Courses / CI/CD & GitHub Actions
Lesson 6 of 16
Building Custom GitHub Actions: JavaScript, Docker & Composite
Create your own GitHub Actions from scratch using JavaScript, Docker containers, and composite steps. Includes publishing to the marketplace.
What you'll learn
- ✓The three types of GitHub Actions and when to use each
- ✓Building a JavaScript action with inputs, outputs, and error handling
- ✓Creating a Docker action for language-agnostic tooling
- ✓Composing existing actions into reusable composite actions
- ✓Testing and publishing your action to the GitHub Marketplace
Prerequisites
- •Basic GitHub Actions workflow experience
- •JavaScript or Docker fundamentals
When the 20,000+ actions on the GitHub Marketplace do not cover your use case, you can build your own. Custom actions encapsulate reusable logic that any workflow can call with a simple uses: reference. GitHub supports three types: JavaScript actions, Docker container actions, and composite actions. Each serves different needs.
When to Build a Custom Action
Build a custom action when you find yourself copying the same multi-step logic across workflows or repositories. Common examples include:
- Posting deployment notifications to Slack with your org’s specific format
- Running a proprietary linting or compliance tool
- Parsing test results and commenting on pull requests
- Interacting with internal APIs that no public action supports
JavaScript Actions
JavaScript actions run directly on the runner without a container, making them the fastest option. They have access to the GitHub Actions toolkit packages.
Project Structure
my-action/
action.yml
index.js
package.json
node_modules/ # Must be committed or use ncc to bundle
action.yml
Every action needs a metadata file:
# action.yml
name: 'PR Size Labeler'
description: 'Adds size labels to pull requests based on lines changed'
author: 'your-org'
inputs:
github-token:
description: 'GitHub token for API access'
required: true
small-threshold:
description: 'Max lines for small label'
required: false
default: '50'
medium-threshold:
description: 'Max lines for medium label'
required: false
default: '200'
outputs:
label:
description: 'The size label that was applied'
lines-changed:
description: 'Total lines changed in the PR'
runs:
using: 'node20'
main: 'dist/index.js'
branding:
icon: 'tag'
color: 'blue'
Implementation
Install the toolkit packages:
npm init -y
npm install @actions/core @actions/github
Write the action logic:
// index.js
const core = require('@actions/core');
const github = require('@actions/github');
async function run() {
try {
const token = core.getInput('github-token', { required: true });
const smallThreshold = parseInt(core.getInput('small-threshold'));
const mediumThreshold = parseInt(core.getInput('medium-threshold'));
const octokit = github.getOctokit(token);
const { context } = github;
if (!context.payload.pull_request) {
core.setFailed('This action only works on pull request events');
return;
}
const { data: pr } = await octokit.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
const linesChanged = pr.additions + pr.deletions;
let label;
if (linesChanged <= smallThreshold) {
label = 'size/S';
} else if (linesChanged <= mediumThreshold) {
label = 'size/M';
} else {
label = 'size/L';
}
// Remove existing size labels
const existingLabels = pr.labels
.filter(l => l.name.startsWith('size/'))
.map(l => l.name);
for (const existingLabel of existingLabels) {
await octokit.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: existingLabel,
});
}
// Add the new label
await octokit.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label],
});
core.setOutput('label', label);
core.setOutput('lines-changed', linesChanged.toString());
core.info(`Applied label "${label}" (${linesChanged} lines changed)`);
} catch (error) {
core.setFailed(`Action failed: ${error.message}`);
}
}
run();
Bundling with ncc
Instead of committing node_modules, bundle everything into a single file:
npm install -D @vercel/ncc
npx ncc build index.js -o dist
This creates dist/index.js with all dependencies inlined. Commit the dist directory.
Using Your JavaScript Action
# In any workflow
jobs:
label-pr:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- uses: your-org/pr-size-labeler@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
small-threshold: '100'
Docker Actions
Docker actions run inside a container, so they can use any language or tool. They are slower to start (container build + pull overhead) but provide complete environment control.
Project Structure
docker-action/
action.yml
Dockerfile
entrypoint.sh
action.yml for Docker
name: 'Database Migration Check'
description: 'Validates SQL migration files for common issues'
inputs:
migrations-path:
description: 'Path to migration files'
required: false
default: './migrations'
dialect:
description: 'SQL dialect (postgres, mysql, sqlite)'
required: false
default: 'postgres'
outputs:
issues-found:
description: 'Number of issues detected'
runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.migrations-path }}
- ${{ inputs.dialect }}
Dockerfile
FROM python:3.12-slim
RUN pip install sqlfluff sqlparse
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint Script
#!/bin/bash
set -euo pipefail
MIGRATIONS_PATH="$1"
DIALECT="$2"
ISSUES=0
echo "Checking migrations in $MIGRATIONS_PATH for $DIALECT dialect..."
for file in "$MIGRATIONS_PATH"/*.sql; do
if [ ! -f "$file" ]; then
echo "No migration files found"
echo "issues-found=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Checking: $file"
# Check for missing transaction wrappers
if ! grep -qi "BEGIN\|START TRANSACTION" "$file"; then
echo "::warning file=$file::Migration is not wrapped in a transaction"
ISSUES=$((ISSUES + 1))
fi
# Check for destructive operations without IF EXISTS
if grep -qi "DROP TABLE\|DROP COLUMN" "$file" && ! grep -qi "IF EXISTS" "$file"; then
echo "::error file=$file::Destructive operation without IF EXISTS"
ISSUES=$((ISSUES + 1))
fi
# Lint SQL syntax
sqlfluff lint "$file" --dialect "$DIALECT" --format github-annotation || true
done
echo "issues-found=$ISSUES" >> "$GITHUB_OUTPUT"
if [ "$ISSUES" -gt 0 ]; then
echo "::warning::Found $ISSUES issue(s) in migration files"
fi
The ::warning and ::error prefixes are GitHub Actions workflow commands that create annotations on the pull request.
Using the Docker Action
jobs:
check-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: your-org/migration-checker@v1
id: check
with:
migrations-path: './db/migrations'
dialect: 'postgres'
- if: steps.check.outputs.issues-found != '0'
run: echo "${{ steps.check.outputs.issues-found }} migration issues found"
Composite Actions
Composite actions combine multiple steps (including calls to other actions) into a single reusable unit. They require no build step and are the simplest type to create.
action.yml for Composite
name: 'Setup and Test Node App'
description: 'Checks out code, sets up Node, installs deps, and runs tests'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '22'
test-command:
description: 'Test command to run'
required: false
default: 'npm test'
outputs:
coverage-percent:
description: 'Code coverage percentage'
value: ${{ steps.coverage.outputs.percent }}
runs:
using: 'composite'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- shell: bash
run: npm ci
- shell: bash
run: npm run lint
- shell: bash
run: ${{ inputs.test-command }}
- id: coverage
shell: bash
run: |
COVERAGE=$(npx nyc report --reporter=text-summary 2>/dev/null | grep 'Statements' | awk '{print $3}' | tr -d '%')
echo "percent=${COVERAGE:-unknown}" >> "$GITHUB_OUTPUT"
Key differences from other action types:
- Every
runstep must specifyshell. - The
usingvalue iscomposite, notnode20ordocker. - Steps can mix
uses(calling other actions) andrun(shell commands).
Using the Composite Action
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: your-org/setup-and-test@v1
id: test
with:
node-version: '22'
test-command: 'npm run test:ci'
- run: echo "Coverage is ${{ steps.test.outputs.coverage-percent }}%"
Testing Your Action
Local Testing with act
The act tool runs GitHub Actions locally:
# Install act
brew install act
# Run a workflow that uses your action
act pull_request -W .github/workflows/test.yml
CI Testing
Create a test workflow in your action’s repository:
# .github/workflows/test-action.yml
name: Test Action
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Test the action from the current commit
- uses: ./
id: action-test
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Verify outputs
run: |
if [ -z "${{ steps.action-test.outputs.label }}" ]; then
echo "Expected label output to be set"
exit 1
fi
Publishing to the Marketplace
- Ensure your
action.ymlhasname,description,author, andbrandingfields. - Create a GitHub release with a semver tag.
- On the release page, check “Publish this Action to the GitHub Marketplace.”
- Create a moving major version tag:
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
# Create/update the v1 major version tag
git tag -f v1 v1.0.0
git push --force origin v1
Consumers reference @v1 and automatically get patch and minor updates when you move the tag forward.
Choosing the Right Type
| Type | Startup | Language | Use Case |
|---|---|---|---|
| JavaScript | Fast | JavaScript/TypeScript | API interactions, label management, notifications |
| Docker | Slow | Any language | Custom tools, complex dependencies, binary requirements |
| Composite | Fast | Shell + other actions | Combining existing actions, standardizing workflows |
Start with composite actions for simple orchestration. Use JavaScript for anything needing GitHub API interaction. Use Docker when you need a specific runtime or system-level tools.
Summary
Custom GitHub Actions let you package reusable CI/CD logic that any repository can consume with a single uses line. JavaScript actions are fast and ideal for GitHub API work. Docker actions provide full environment control at the cost of startup time. Composite actions combine existing steps without any build process. Test locally with act, test in CI with self-referencing workflows, and publish with semver tags for stable consumption across your organization.
Progress is saved locally to your browser.