GitHub Actions workflows are powerful, but as your CI/CD pipelines grow, you find yourself repeating the same logic across dozens of repositories. Custom actions solve this: they let you encapsulate reusable workflow logic into a single, versioned, shareable unit — either within your organization or published publicly to the GitHub Marketplace.
This guide covers all three types of custom actions (JavaScript, Docker, and composite), when to use each, best practices for building them with the Actions Toolkit, and how to test, version, and publish them.
The Three Types of Custom Actions
GitHub Actions supports three action types, each with different trade-offs:
- JavaScript actions: Run directly on the runner VM using Node.js. Fast startup, no container overhead. Best for most custom actions.
- Docker container actions: Run in a Docker container. Full control over the execution environment, but slower startup. Best when you need a specific OS or toolchain.
- Composite actions: Combine multiple workflow steps into a single action. No code required — just YAML. Best for grouping existing actions and shell scripts.
Composite Actions: The Easiest Starting Point
Composite actions let you group workflow steps without writing any JavaScript. If you find yourself copying the same 5-step setup sequence across every repository, a composite action eliminates the duplication:
# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Install Node, cache dependencies, and run build'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '20'
package-manager:
description: 'Package manager (npm, pnpm, yarn)'
required: false
default: 'npm'
outputs:
cache-hit:
description: 'Whether the dependency 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: Setup pnpm
if: inputs.package-manager == 'pnpm'
uses: pnpm/action-setup@v3
- name: Get cache directory
id: cache-dir
shell: bash
run: |
if [ "${{ inputs.package-manager }}" = "pnpm" ]; then
echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
elif [ "${{ inputs.package-manager }}" = "yarn" ]; then
echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
else
echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
fi
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.cache-dir.outputs.dir }}
key: ${{ runner.os }}-${{ inputs.package-manager }}-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ inputs.package-manager }}-
- name: Install dependencies
shell: bash
run: |
if [ "${{ inputs.package-manager }}" = "pnpm" ]; then
pnpm install --frozen-lockfile
elif [ "${{ inputs.package-manager }}" = "yarn" ]; then
yarn install --frozen-lockfile
else
npm ci
fi
# Usage in any workflow
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-project
with:
node-version: '20'
package-manager: 'pnpm'
JavaScript Actions: Full Control with TypeScript
Project Setup
mkdir my-action && cd my-action
npm init -y
npm install @actions/core @actions/github @actions/exec @actions/io
npm install -D typescript @types/node @vercel/ncc
# ncc bundles your action into a single file — no node_modules needed in the repo
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"strict": true,
"outDir": "./lib",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
// package.json scripts
{
"scripts": {
"build": "ncc build src/index.ts -o dist --source-map --minify",
"package": "npm run build",
"test": "jest"
}
}
Action Metadata (action.yml)
# action.yml — defines inputs, outputs, and runtime
name: 'Semantic PR Checker'
description: 'Validates PR titles follow Conventional Commits specification'
author: 'Your Name'
inputs:
github-token:
description: 'GitHub token for API access'
required: true
types:
description: 'Comma-separated list of allowed commit types'
required: false
default: 'feat,fix,docs,style,refactor,test,chore,perf,ci,build,revert'
require-scope:
description: 'Whether a scope is required (e.g., feat(ui): ...)'
required: false
default: 'false'
outputs:
valid:
description: 'Whether the PR title is valid'
type:
description: 'The commit type extracted from the PR title'
scope:
description: 'The commit scope (if present)'
runs:
using: node20
main: dist/index.js
Action Implementation
// src/index.ts
import * as core from '@actions/core';
import * as github from '@actions/github';
interface ParsedTitle {
type: string;
scope?: string;
breaking: boolean;
description: string;
}
function parsePRTitle(title: string): ParsedTitle | null {
// Conventional Commits: type(scope)!: description
const regex = /^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/;
const match = title.match(regex);
if (!match) return null;
return {
type: match[1]!,
scope: match[2],
breaking: match[3] === '!',
description: match[4]!,
};
}
async function run(): Promise<void> {
try {
const token = core.getInput('github-token', { required: true });
const allowedTypes = core.getInput('types').split(',').map(s => s.trim());
const requireScope = core.getInput('require-scope') === 'true';
const context = github.context;
const octokit = github.getOctokit(token);
// Get PR title from context or fetch it
let prTitle: string;
if (context.eventName === 'pull_request') {
prTitle = context.payload.pull_request?.title as string;
} else {
// Fetch from API if triggered differently
const { data: pr } = await octokit.rest.pulls.get({
...context.repo,
pull_number: context.payload.number as number,
});
prTitle = pr.title;
}
core.info(`Checking PR title: "${prTitle}"`);
const parsed = parsePRTitle(prTitle);
if (!parsed) {
core.setOutput('valid', 'false');
core.setFailed(
`PR title does not follow Conventional Commits format.\n` +
`Expected: type(scope): description\n` +
`Got: "${prTitle}"\n` +
`Examples: feat(auth): add OAuth2 support, fix: resolve memory leak`
);
return;
}
if (!allowedTypes.includes(parsed.type)) {
core.setOutput('valid', 'false');
core.setFailed(
`PR type "${parsed.type}" is not allowed.\n` +
`Allowed types: ${allowedTypes.join(', ')}`
);
return;
}
if (requireScope && !parsed.scope) {
core.setOutput('valid', 'false');
core.setFailed(`PR title must include a scope: ${parsed.type}(scope): description`);
return;
}
// All checks passed
core.setOutput('valid', 'true');
core.setOutput('type', parsed.type);
if (parsed.scope) core.setOutput('scope', parsed.scope);
if (parsed.breaking) {
core.warning('This PR introduces a breaking change (!)');
}
core.info(`✅ PR title is valid: type=${parsed.type}${parsed.scope ? `, scope=${parsed.scope}` : ''}`);
} catch (error) {
core.setFailed(error instanceof Error ? error.message : String(error));
}
}
run();
Working with the GitHub API
// Common patterns with @actions/github
const octokit = github.getOctokit(core.getInput('github-token'));
const { owner, repo } = github.context.repo;
// Comment on a PR
await octokit.rest.issues.createComment({
owner, repo,
issue_number: github.context.payload.pull_request?.number!,
body: '## Analysis Results\n\nAll checks passed ✅',
});
// Add labels to a PR
await octokit.rest.issues.addLabels({
owner, repo,
issue_number: github.context.payload.pull_request?.number!,
labels: ['ready-for-review'],
});
// List changed files in a PR
const { data: files } = await octokit.rest.pulls.listFiles({
owner, repo,
pull_number: github.context.payload.pull_request?.number!,
per_page: 100,
});
const changedFiles = files.map(f => f.filename);
Testing Your Action
Unit Testing with Jest
// src/__tests__/index.test.ts
jest.mock('@actions/core');
jest.mock('@actions/github');
import * as core from '@actions/core';
import { parsePRTitle } from '../index'; // Export for testing
describe('parsePRTitle', () => {
it('parses standard conventional commit', () => {
const result = parsePRTitle('feat(auth): add OAuth2 support');
expect(result).toEqual({
type: 'feat',
scope: 'auth',
breaking: false,
description: 'add OAuth2 support',
});
});
it('parses breaking change', () => {
const result = parsePRTitle('feat!: remove deprecated API');
expect(result).toEqual({
type: 'feat',
scope: undefined,
breaking: true,
description: 'remove deprecated API',
});
});
it('returns null for invalid format', () => {
expect(parsePRTitle('update some stuff')).toBeNull();
expect(parsePRTitle('WIP: in progress')).toBeNull();
});
});
Integration Testing with act
act is a CLI tool that runs GitHub Actions locally using Docker:
# Install act
brew install act # macOS
# Or: https://github.com/nektos/act
# Create a test workflow
# .github/workflows/test-action.yml
name: Test Action
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./ # Uses the action in the current directory
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Run locally
act push -s GITHUB_TOKEN=your_token
Versioning and Publishing
Semantic Versioning for Actions
# Tag your releases
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
# Also create/update a major version tag (allows users to pin to v1)
git tag -fa v1 -m "Update v1 tag"
git push origin v1 --force
# Users can reference any of these:
uses: your-org/your-action@v1 # Latest v1.x (recommended for users)
uses: your-org/your-action@v1.2.3 # Exact version (most stable)
uses: your-org/your-action@main # Latest (risky — never for production)
Release Workflow
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Build
run: npm run package
- name: Verify dist is up to date
run: |
git diff --exit-code dist/ || (echo "dist/ is not up to date. Run 'npm run package' and commit." && exit 1)
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
dist/index.js
action.yml
Security Best Practices
-
Pin third-party actions to full commit SHA:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683instead of@v4. Prevents supply chain attacks via tag mutation. -
Set minimum permissions: Request only the permissions your action needs in the workflow's
permissionsblock. -
Never log secrets:
core.setSecret()masks values in logs, but avoid logging anything that might contain sensitive data at all. - Validate all inputs: Inputs come from workflow files and could be influenced by untrusted sources (PR titles, issue bodies). Validate and sanitize before use in shell commands.
// Always validate inputs before using in shell commands
const branchName = core.getInput('branch-name');
if (!/^[a-zA-Z0-9_/-]+$/.test(branchName)) {
core.setFailed(`Invalid branch name: ${branchName}`);
return;
}
// Use @actions/exec instead of string interpolation in shell
import * as exec from '@actions/exec';
await exec.exec('git', ['checkout', '-b', branchName]); // Safe
// NOT: await exec.exec(`git checkout -b ${branchName}`); // Injection risk
Publishing to the GitHub Marketplace
To list your action in the Marketplace, add branding to your action.yml:
# action.yml — add branding block
branding:
icon: 'check-circle' # Feather icon name
color: 'green' # Background color
Then create a GitHub Release with a version tag. GitHub automatically prompts you to publish to the Marketplace when you create a release from a repository containing a top-level action.yml.
Conclusion
Custom GitHub Actions are one of the highest-leverage investments in your CI/CD infrastructure. A well-built action can save hours of workflow duplication across dozens of repositories and enforce organizational standards automatically.
- Start with composite actions for grouping existing actions and shell scripts — no code required
- Use JavaScript actions with TypeScript for complex logic, API calls, and reusable behavior
- Reserve Docker actions for when you need a specific toolchain or OS environment
- Always bundle with
ncc, pin your dependencies, and test before publishing
For more on CI/CD automation, see our guide on GitHub Actions vs GitLab CI and the best CI/CD pipeline for small teams.
Free Developer Tools
If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.
Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder
🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.
Top comments (0)