Types of Custom Actions
GitHub Actions supports three runtime types for custom actions: Composite, JavaScript, and Docker. Each trades off portability, startup speed, and flexibility differently. Understanding these trade-offs is essential before building your own action.
flowchart TD
W[Workflow Step: uses] --> L{Local Path?}
L -->|Yes ./path| LOCAL[Local Action
in repository]
L -->|No| R{Remote Reference?}
R -->|owner/repo@ref| REMOTE[GitHub Repository]
R -->|No| M{Marketplace?}
M -->|Published action| MP[GitHub Marketplace]
REMOTE --> RESOLVE[Download action.yml]
MP --> RESOLVE
LOCAL --> RESOLVE
RESOLVE --> TYPE{using: ?}
TYPE -->|composite| COMP[Run composite steps]
TYPE -->|node20| JS[Run Node.js dist/index.js]
TYPE -->|docker| DOCK[Build/Pull Docker image]
style W fill:#132440,color:#fff
style RESOLVE fill:#3B9797,color:#fff
style COMP fill:#16476A,color:#fff
style JS fill:#16476A,color:#fff
style DOCK fill:#16476A,color:#fff
| Feature | Composite | JavaScript (Node.js) | Docker |
|---|---|---|---|
| Runtime | Runner shell (bash/pwsh) | Node.js 20 | Container |
| Startup Speed | Instant (no overhead) | Fast (~1-2s) | Slow (build/pull image) |
| Runner Compatibility | All (Linux, macOS, Windows) | All (Linux, macOS, Windows) | Linux only |
| Complexity | Low (just YAML) | Medium (Node.js + bundling) | High (Dockerfile + entrypoint) |
| Language | Shell commands + other actions | JavaScript/TypeScript | Any (Python, Go, Rust, etc.) |
| Dependencies | Whatever runner provides | Bundled via ncc | Full control (Dockerfile) |
| Access to Runner | Full filesystem access | Full filesystem access | Isolated (mounted workspace) |
| Best For | Orchestrating existing actions | API calls, GitHub integrations | Custom toolchains, any language |
When to Use Each Type
- Composite: Wrapping a sequence of shell commands and existing actions into a reusable unit. Perfect for team-specific "setup" or "deploy" actions.
- JavaScript: Interacting with the GitHub API, processing webhook payloads, making HTTP requests, complex string/JSON manipulation. The
@actions/githubtoolkit gives authenticated Octokit access out of the box. - Docker: Running tools not available on GitHub runners, using languages other than JavaScript, or when you need a reproducible environment with specific system packages.
Creating Composite Actions
Composite actions are the simplest type — they're pure YAML that combine multiple run commands and uses steps into a single reusable action. They execute directly on the runner with no additional runtime overhead.
# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Install Node.js, restore cache, and install dependencies'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '20'
working-directory:
description: 'Directory containing package.json'
required: false
default: '.'
outputs:
cache-hit:
description: 'Whether the cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Cache node_modules
id: cache
uses: actions/cache@v4
with:
path: ${{ inputs.working-directory }}/node_modules
key: node-${{ runner.os }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
restore-keys: |
node-${{ runner.os }}-
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Show installed version
shell: bash
run: |
echo "Node: $(node --version)"
echo "npm: $(npm --version)"
echo "Cache hit: ${{ steps.cache.outputs.cache-hit }}"
Key rules for composite actions:
- Every
runstep must specifyshell:(bash, pwsh, python, etc.) - You can use
uses:to call other actions inside a composite action - Inputs are accessed via
${{ inputs.name }}, not${{ github.event.inputs.name }} - Outputs must reference a step ID:
value: ${{ steps.step-id.outputs.key }} - Composite actions cannot use
secretscontext directly — pass secrets as inputs
Sharing Composite Actions Between Repos
To share a composite action across repositories, publish it in a dedicated repo or a shared .github repository:
# In your workflow, reference the shared action:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Reference action from another repository
- uses: your-org/shared-actions/setup-node-project@v1
with:
node-version: '20'
# Or reference a local action in the same repo
- uses: ./.github/actions/setup-node-project
with:
node-version: '18'
your-org/actions/setup-node, your-org/actions/deploy-k8s). Reference them as your-org/actions/setup-node@v1. This centralizes maintenance while keeping actions logically separate.
Building JavaScript Actions
JavaScript actions run on Node.js 20 and have access to the full @actions toolkit — a set of packages for interacting with the runner, GitHub API, filesystem, and process execution. They start fast (no container overhead) and work on all runner OSes.
The core toolkit packages:
@actions/core— Inputs, outputs, logging, annotations, secrets masking, summary@actions/github— Authenticated Octokit client for the GitHub API@actions/exec— Execute shell commands with streaming output@actions/io— File operations (cp, mv, rmRF, mkdirP, which)@actions/tool-cache— Download and cache tools@actions/artifact— Upload/download workflow artifacts@actions/cache— Save/restore runner cache
// src/index.js — A JavaScript action that labels PRs by file paths
const core = require('@actions/core');
const github = require('@actions/github');
async function run() {
try {
// Get inputs defined in action.yml
const token = core.getInput('github-token', { required: true });
const configPath = core.getInput('config-path') || '.github/labeler.yml';
// Create authenticated GitHub client
const octokit = github.getOctokit(token);
const { context } = github;
// Only run on pull requests
if (!context.payload.pull_request) {
core.info('Not a pull request event, skipping.');
return;
}
const prNumber = context.payload.pull_request.number;
core.info(`Processing PR #${prNumber}`);
// Get changed files
const { data: files } = await octokit.rest.pulls.listFiles({
...context.repo,
pull_number: prNumber,
});
const changedPaths = files.map(f => f.filename);
core.info(`Changed files: ${changedPaths.join(', ')}`);
// Determine labels based on paths
const labels = [];
if (changedPaths.some(p => p.startsWith('src/'))) labels.push('source');
if (changedPaths.some(p => p.startsWith('docs/'))) labels.push('documentation');
if (changedPaths.some(p => p.startsWith('test/'))) labels.push('tests');
if (changedPaths.some(p => p.endsWith('.yml') || p.endsWith('.yaml'))) labels.push('config');
if (labels.length > 0) {
await octokit.rest.issues.addLabels({
...context.repo,
issue_number: prNumber,
labels,
});
core.info(`Added labels: ${labels.join(', ')}`);
}
// Set outputs for downstream steps
core.setOutput('labels-added', labels.join(','));
core.setOutput('files-changed', changedPaths.length.toString());
// Write to job summary
await core.summary
.addHeading('PR Labeler Results')
.addTable([
[{ data: 'Metric', header: true }, { data: 'Value', header: true }],
['Files Changed', changedPaths.length.toString()],
['Labels Added', labels.join(', ') || 'None'],
])
.write();
} catch (error) {
core.setFailed(`Action failed: ${error.message}`);
}
}
run();
The corresponding action.yml:
# action.yml
name: 'PR Path Labeler'
description: 'Automatically labels pull requests based on changed file paths'
inputs:
github-token:
description: 'GitHub token for API access'
required: true
default: ${{ github.token }}
config-path:
description: 'Path to labeler configuration file'
required: false
default: '.github/labeler.yml'
outputs:
labels-added:
description: 'Comma-separated list of labels that were added'
files-changed:
description: 'Number of files changed in the PR'
runs:
using: 'node20'
main: 'dist/index.js'
Bundling with ncc and TypeScript Support
JavaScript actions must ship with all dependencies bundled — you cannot rely on npm install at runtime. Use @vercel/ncc to compile everything into a single file:
{
"name": "pr-path-labeler",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "ncc build src/index.js -o dist --source-map --license licenses.txt",
"test": "jest",
"lint": "eslint src/",
"all": "npm run lint && npm run test && npm run build"
},
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.1",
"jest": "^29.7.0",
"eslint": "^8.56.0"
}
}
# Build the action — produces dist/index.js with all deps bundled
npm run build
# The dist/ directory MUST be committed to the repository
git add dist/
git commit -m "build: compile action"
For TypeScript, use @vercel/ncc with tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist", "__tests__"]
}
# For TypeScript actions, ncc handles compilation automatically:
npx ncc build src/index.ts -o dist --source-map --license licenses.txt
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 (the SHA for v4.1.0) instead of uses: actions/checkout@v4. Tools like pinact and Dependabot can automate SHA pinning across your workflows.
Building Docker-based Actions
Docker actions run inside a container, giving you complete control over the execution environment. Use them when you need specific system packages, languages other than JavaScript, or reproducible toolchains. The trade-off is startup time (building/pulling images) and Linux-only runner support.
# Dockerfile
FROM python:3.12-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
jq \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt /requirements.txt
RUN pip install --no-cache-dir -r /requirements.txt
# Copy action code
COPY entrypoint.sh /entrypoint.sh
COPY src/ /src/
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
# action.yml for Docker action
name: 'Python Code Analyzer'
description: 'Runs static analysis on Python code using custom rules'
inputs:
source-path:
description: 'Path to Python source files'
required: false
default: 'src/'
severity:
description: 'Minimum severity level to report (info, warning, error)'
required: false
default: 'warning'
config-file:
description: 'Path to analyzer configuration'
required: false
outputs:
issues-found:
description: 'Number of issues found'
report-path:
description: 'Path to the generated report file'
runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.source-path }}
- ${{ inputs.severity }}
env:
ANALYZER_CONFIG: ${{ inputs.config-file }}
Entrypoint Scripts
The entrypoint script receives inputs via positional arguments (args) or environment variables (env). It communicates outputs back to GitHub via workflow commands written to $GITHUB_OUTPUT:
#!/bin/bash
# entrypoint.sh
set -euo pipefail
SOURCE_PATH="${1:-src/}"
SEVERITY="${2:-warning}"
echo "::group::Running Python Analyzer"
echo "Source: ${SOURCE_PATH}"
echo "Severity: ${SEVERITY}"
echo "Config: ${ANALYZER_CONFIG:-default}"
# Run the analysis
REPORT_FILE="/tmp/analysis-report.json"
python /src/analyzer.py \
--source "${SOURCE_PATH}" \
--severity "${SEVERITY}" \
--config "${ANALYZER_CONFIG:-}" \
--output "${REPORT_FILE}"
# Count issues
ISSUES=$(jq '.issues | length' "${REPORT_FILE}")
echo "Found ${ISSUES} issues"
echo "::endgroup::"
# Copy report to workspace (mounted at /github/workspace)
cp "${REPORT_FILE}" "${GITHUB_WORKSPACE}/analysis-report.json"
# Set outputs using GITHUB_OUTPUT file
echo "issues-found=${ISSUES}" >> "${GITHUB_OUTPUT}"
echo "report-path=analysis-report.json" >> "${GITHUB_OUTPUT}"
# Fail if critical issues found
CRITICAL=$(jq '[.issues[] | select(.severity == "error")] | length' "${REPORT_FILE}")
if [ "${CRITICAL}" -gt 0 ]; then
echo "::error::Found ${CRITICAL} critical issues!"
exit 1
fi
You can also use a pre-built image instead of building from a Dockerfile:
# action.yml using pre-built image (faster startup)
runs:
using: 'docker'
image: 'docker://ghcr.io/your-org/python-analyzer:1.2.0'
args:
- ${{ inputs.source-path }}
- ${{ inputs.severity }}
image: 'docker://ghcr.io/org/image:tag' in production. Reserve image: 'Dockerfile' for development and testing.
Adding Inputs and Outputs to Custom Actions
Inputs and outputs are the public API of your action. Well-designed inputs have sensible defaults, clear descriptions, and explicit required flags. Outputs communicate results to downstream steps.
# action.yml — Complete inputs/outputs example
name: 'Deploy to Environment'
description: 'Deploy application to a specified environment with rollback support'
inputs:
environment:
description: 'Target environment (staging, production)'
required: true
version:
description: 'Version tag to deploy'
required: true
dry-run:
description: 'Simulate deployment without making changes'
required: false
default: 'false'
timeout:
description: 'Deployment timeout in seconds'
required: false
default: '300'
rollback-on-failure:
description: 'Automatically rollback if health check fails'
required: false
default: 'true'
outputs:
deployment-id:
description: 'Unique identifier for this deployment'
deployment-url:
description: 'URL of the deployed application'
previous-version:
description: 'Version that was running before this deployment'
status:
description: 'Deployment status (success, failed, rolled-back)'
Setting Outputs from Each Action Type
From Composite Actions — reference step outputs:
# Composite action setting outputs
runs:
using: 'composite'
steps:
- name: Deploy
id: deploy
shell: bash
run: |
DEPLOY_ID=$(uuidgen)
echo "deployment-id=${DEPLOY_ID}" >> $GITHUB_OUTPUT
echo "deployment-url=https://${{ inputs.environment }}.example.com" >> $GITHUB_OUTPUT
echo "status=success" >> $GITHUB_OUTPUT
# In outputs section, reference the step:
outputs:
deployment-id:
description: 'Deployment ID'
value: ${{ steps.deploy.outputs.deployment-id }}
deployment-url:
description: 'Deployment URL'
value: ${{ steps.deploy.outputs.deployment-url }}
From JavaScript Actions — use core.setOutput():
const core = require('@actions/core');
// Set outputs that consumers can reference
core.setOutput('deployment-id', deployId);
core.setOutput('deployment-url', `https://${environment}.example.com`);
core.setOutput('previous-version', previousVersion);
core.setOutput('status', 'success');
From Docker Actions — write to $GITHUB_OUTPUT:
#!/bin/bash
# Docker entrypoint — set outputs via GITHUB_OUTPUT
DEPLOY_ID=$(uuidgen)
echo "deployment-id=${DEPLOY_ID}" >> "${GITHUB_OUTPUT}"
echo "deployment-url=https://${ENVIRONMENT}.example.com" >> "${GITHUB_OUTPUT}"
echo "status=success" >> "${GITHUB_OUTPUT}"
Consuming outputs in a workflow:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
id: deploy
uses: ./
with:
environment: staging
version: ${{ github.sha }}
- name: Report
run: |
echo "Deployed: ${{ steps.deploy.outputs.deployment-id }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Previous: ${{ steps.deploy.outputs.previous-version }}"
Using Official GitHub Templates
GitHub provides starter templates that include project structure, CI workflows for testing the action itself, and proper TypeScript/Docker configurations. Use these to bootstrap new actions quickly:
| Template | Language | Description |
|---|---|---|
actions/javascript-action |
JavaScript | Minimal JS action with @actions/core, Jest tests, ncc build |
actions/typescript-action |
TypeScript | Full TypeScript setup, ESLint, Jest, ncc, source maps |
actions/docker-action |
Shell/Any | Dockerfile-based action with entrypoint.sh |
actions/container-action |
Any | Pre-built container image action template |
# Create a new action from the TypeScript template
gh repo create my-custom-action --template actions/typescript-action --public --clone
cd my-custom-action
# Install dependencies
npm install
# Develop your action
# Edit src/main.ts with your logic
# Build and test
npm run all
# Commit the dist/ folder (required for actions)
git add -A
git commit -m "feat: initial action implementation"
git push
The TypeScript template includes:
src/main.ts— Entry point with try/catch andcore.setFailed()__tests__/main.test.ts— Jest test setup with mocked inputs.github/workflows/ci.yml— CI that builds and tests the action.github/workflows/check-dist.yml— Ensuresdist/is up to datetsconfig.json,.eslintrc.json— Proper TypeScript/linting configaction.yml— Starter metadata with example input/output
Publishing Actions to GitHub Marketplace
To publish an action to the GitHub Marketplace, your action.yml needs specific metadata fields for branding and discoverability. The action must live in a public repository at the root level (not in a subdirectory for Marketplace listing).
# action.yml with full Marketplace metadata
name: 'Deploy Notify'
author: 'Your Organization'
description: |
Send deployment notifications to Slack, Teams, or Discord.
Supports custom templates, thread replies, and status updates.
branding:
icon: 'bell'
color: 'blue'
inputs:
webhook-url:
description: 'Webhook URL for the notification service'
required: true
status:
description: 'Deployment status (success, failure, cancelled)'
required: true
environment:
description: 'Target environment name'
required: false
default: 'production'
message-template:
description: 'Custom message template (supports $VARS)'
required: false
outputs:
message-id:
description: 'ID of the sent notification message'
thread-ts:
description: 'Thread timestamp for reply threading (Slack)'
runs:
using: 'node20'
main: 'dist/index.js'
The branding field controls how your action appears in Marketplace:
- icon: Any Feather icon name (e.g.,
bell,cloud,zap,shield) - color: One of
white,yellow,blue,green,orange,red,purple,gray-dark
Versioning Strategy
Actions follow semantic versioning with a major version tag pattern that consumers expect:
# Create a specific version release
git tag -a v1.2.3 -m "Release v1.2.3: Add Discord support"
git push origin v1.2.3
# Update the major version tag (consumers use this)
# Delete and recreate the v1 tag pointing to latest v1.x.x
git tag -fa v1 -m "Update v1 tag to v1.2.3"
git push origin v1 --force
# Consumers reference the major version:
# uses: your-org/deploy-notify@v1
# This always gets the latest v1.x.x release
v1.2.3) and major (v1) tags. Consumers pin to major version (@v1) for automatic non-breaking updates. When you make breaking changes, create v2 — existing consumers stay on v1 until they explicitly upgrade.
Publishing steps:
- Ensure
action.ymlhasname,description, andbrandingfields - Create a GitHub release with a semantic version tag
- On the release page, check "Publish this action to the GitHub Marketplace"
- Select a primary and secondary category
- Publish — the action becomes searchable on
github.com/marketplace
Automating Action Releases
Manually managing tags and releases doesn't scale. Use release-please or similar tools to automate versioning based on conventional commits, and a separate workflow to update major version tags.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
major: ${{ steps.release.outputs.major }}
minor: ${{ steps.release.outputs.minor }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: node
update-major-tag:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update major version tag
env:
MAJOR: ${{ needs.release-please.outputs.major }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Delete the existing major tag (locally and remotely)
git tag -d "v${MAJOR}" 2>/dev/null || true
git push origin ":refs/tags/v${MAJOR}" 2>/dev/null || true
# Create new major tag pointing to this release
git tag -a "v${MAJOR}" -m "Update v${MAJOR} tag to ${TAG}"
git push origin "v${MAJOR}"
echo "Updated v${MAJOR} tag to point to ${TAG}"
CI for Actions — Testing the Action Itself
Your action needs its own CI pipeline. Test it by actually running it in a workflow:
# .github/workflows/ci.yml — Test the action itself
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
# Unit tests
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
# Lint and type-check
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
# Verify dist/ is up to date
check-dist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Check for uncommitted changes
run: |
if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then
echo "::error::dist/ is out of date. Run 'npm run build' and commit."
git diff dist/
exit 1
fi
# Integration test — run the action for real
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run the action
id: test-action
uses: ./
with:
environment: 'test'
version: 'v0.0.0-test'
dry-run: 'true'
- name: Verify outputs
run: |
echo "Status: ${{ steps.test-action.outputs.status }}"
if [ "${{ steps.test-action.outputs.status }}" != "success" ]; then
echo "::error::Expected status 'success', got '${{ steps.test-action.outputs.status }}'"
exit 1
fi
# Cross-platform test
cross-platform:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: ./
with:
environment: 'test'
version: 'v0.0.0-test'
dry-run: 'true'
- Unit tests: Mock
@actions/coreand@actions/github— test logic in isolation - check-dist: Ensure the compiled
dist/matches source — catches forgotten builds - Integration tests: Use
uses: ./to test the action in the same repo - Cross-platform: Test on all supported OSes if using composite or JS actions
Exercises
Build a "Setup and Lint" Composite Action
Create a composite action at .github/actions/setup-lint/action.yml that accepts a language input (python, javascript, go) and runs the appropriate linter. For Python use ruff, for JavaScript use eslint, for Go use golangci-lint. The action should output the number of issues found and set a failure annotation if issues exceed a configurable threshold.
Build a PR Size Labeler in TypeScript
Create a TypeScript action that labels PRs based on the total lines changed: size/XS (<10), size/S (10-49), size/M (50-199), size/L (200-499), size/XL (500+). Use @actions/github to fetch PR files and add labels. Include Jest tests that mock the GitHub API. Bundle with ncc and verify the check-dist workflow catches stale builds.
Build a Python Security Scanner Docker Action
Create a Docker-based action that runs bandit (Python security linter) on a configurable source path. Accept inputs for severity level, confidence level, and exclusion patterns. Output a JSON report as an artifact and set the step to failed if high-severity issues are found. Use a multi-stage Dockerfile to minimize image size. Publish the image to GHCR and reference it with docker://ghcr.io/... for faster runs.
Automate Action Releases with release-please
Take any action from exercises 1-3 and add the full release automation pipeline: (1) Configure release-please with conventional commit parsing, (2) Add a workflow that updates the major version tag on each release, (3) Add a check-dist workflow that fails if dist/ is stale, (4) Create a release that publishes to Marketplace with proper branding. Verify that consumers referencing @v1 get your latest patch release.
Next in the Series
In Module 9: Security and Hardening, we'll cover securing your workflows — permissions, OIDC for cloud authentication, secret management, supply chain hardening with artifact attestations, and defending against injection attacks in untrusted inputs.