Building Custom GitHub Actions
Custom actions allow you to encapsulate workflow logic into reusable components that can be shared across repositories and with the community.
Types of Actions
GitHub supports three types of custom actions:
- JavaScript/TypeScript Actions - Run directly on the runner
- Docker Container Actions - Run in a container
- Composite Actions - Combine multiple workflow steps
Composite Actions
Simplest to create, perfect for grouping common steps:
# .github/actions/setup-node-cache/action.yml
name: 'Setup Node with Cache'
description: 'Setup Node.js with dependency caching'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '20'
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
shell: bash
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
Usage:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-cache
with:
node-version: '20'
- name: Run tests
run: npm test
Blog Deployment Action
Custom action for deploying the blog:
# .github/actions/deploy-blog/action.yml
name: 'Deploy Blog'
description: 'Build and deploy the blog to Vercel'
inputs:
vercel-token:
description: 'Vercel deployment token'
required: true
production:
description: 'Deploy to production'
required: false
default: 'false'
outputs:
deployment-url:
description: 'Deployment URL'
value: ${{ steps.deploy.outputs.url }}
runs:
using: 'composite'
steps:
- name: Install Vercel CLI
shell: bash
run: npm install -g vercel
- name: Build site
shell: bash
run: npm run build
- name: Deploy to Vercel
id: deploy
shell: bash
env:
VERCEL_TOKEN: ${{ inputs.vercel-token }}
run: |
if [ "${{ inputs.production }}" == "true" ]; then
URL=$(vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN)
else
URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN)
fi
echo "url=$URL" >> $GITHUB_OUTPUT
- name: Comment deployment URL
shell: bash
if: github.event_name == 'pull_request'
run: |
echo "🚀 Deployed to ${{ steps.deploy.outputs.url }}" >> $GITHUB_STEP_SUMMARY
JavaScript Action
More powerful, can use npm packages:
// .github/actions/test-reporter/index.js
const core = require('@actions/core');
const github = require('@actions/github');
const fs = require('fs');
async function run() {
try {
const reportPath = core.getInput('report-path', { required: true });
const token = core.getInput('github-token', { required: true });
// Read test results
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
// Create GitHub comment
const octokit = github.getOctokit(token);
const { context } = github;
if (context.eventName === 'pull_request') {
const comment = `
## 🧪 Test Results
- **Total:** ${report.total}
- **Passed:** ✅ ${report.passed}
- **Failed:** ❌ ${report.failed}
- **Duration:** ⏱️ ${report.duration}s
${report.failed > 0 ? '### Failed Tests\n' + report.failures.map(f => `- ${f.name}`).join('\n') : ''}
`;
await octokit.rest.issues.createComment({
...context.repo,
issue_number: context.payload.pull_request.number,
body: comment
});
}
core.setOutput('passed', report.passed);
core.setOutput('failed', report.failed);
} catch (error) {
core.setFailed(error.message);
}
}
run();
Action metadata:
# .github/actions/test-reporter/action.yml
name: 'Test Reporter'
description: 'Report test results in PR comments'
inputs:
report-path:
description: 'Path to test report JSON'
required: true
github-token:
description: 'GitHub token'
required: true
outputs:
passed:
description: 'Number of passed tests'
failed:
description: 'Number of failed tests'
runs:
using: 'node20'
main: 'index.js'
Package.json:
{
"name": "test-reporter-action",
"version": "1.0.0",
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0"
}
}
Screenshot Comparison Action
Custom action for visual regression testing:
# .github/actions/visual-regression/action.yml
name: 'Visual Regression Test'
description: 'Compare screenshots and report differences'
inputs:
baseline-dir:
description: 'Directory with baseline screenshots'
required: true
current-dir:
description: 'Directory with current screenshots'
required: true
threshold:
description: 'Pixel difference threshold (0-1)'
required: false
default: '0.1'
outputs:
differences-found:
description: 'Whether differences were found'
value: ${{ steps.compare.outputs.has-differences }}
runs:
using: 'composite'
steps:
- name: Install pixelmatch
shell: bash
run: npm install -g pixelmatch
- name: Compare screenshots
id: compare
shell: bash
run: |
DIFF_COUNT=0
for baseline in ${{ inputs.baseline-dir }}/*.png; do
filename=$(basename "$baseline")
current="${{ inputs.current-dir }}/$filename"
diff="diffs/$filename"
if [ -f "$current" ]; then
pixelmatch "$baseline" "$current" "$diff" ${{ inputs.threshold }}
if [ $? -ne 0 ]; then
DIFF_COUNT=$((DIFF_COUNT + 1))
echo "Difference found: $filename"
fi
fi
done
if [ $DIFF_COUNT -gt 0 ]; then
echo "has-differences=true" >> $GITHUB_OUTPUT
echo "## ⚠️ Visual Regression Detected" >> $GITHUB_STEP_SUMMARY
echo "Found $DIFF_COUNT screenshot differences" >> $GITHUB_STEP_SUMMARY
else
echo "has-differences=false" >> $GITHUB_OUTPUT
echo "## ✅ No Visual Differences" >> $GITHUB_STEP_SUMMARY
fi
- name: Upload diff images
if: steps.compare.outputs.has-differences == 'true'
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: diffs/
E2E Test Runner Action
Reusable E2E testing action:
# .github/actions/run-e2e-tests/action.yml
name: 'Run E2E Tests'
description: 'Execute Playwright E2E tests with retries and reporting'
inputs:
test-pattern:
description: 'Test file pattern'
required: false
default: 'tests/**/*.spec.ts'
browser:
description: 'Browser to test'
required: false
default: 'chromium'
retries:
description: 'Number of retries'
required: false
default: '2'
outputs:
test-status:
description: 'Test execution status'
value: ${{ steps.test.outcome }}
runs:
using: 'composite'
steps:
- name: Setup test environment
uses: ./.github/actions/setup-node-cache
- name: Install Playwright
shell: bash
run: npx playwright install --with-deps ${{ inputs.browser }}
- name: Run tests
id: test
shell: bash
run: |
npx playwright test \
${{ inputs.test-pattern }} \
--project=${{ inputs.browser }} \
--retries=${{ inputs.retries }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ inputs.browser }}
path: |
playwright-report/
test-results/
- name: Generate summary
if: always()
shell: bash
run: |
if [ "${{ steps.test.outcome }}" == "success" ]; then
echo "## ✅ E2E Tests Passed (${{ inputs.browser }})" >> $GITHUB_STEP_SUMMARY
else
echo "## ❌ E2E Tests Failed (${{ inputs.browser }})" >> $GITHUB_STEP_SUMMARY
fi
Action with Conditional Steps
Handle different scenarios:
# .github/actions/smart-deploy/action.yml
name: 'Smart Deploy'
description: 'Deploy with environment detection'
inputs:
branch:
description: 'Branch name'
required: true
runs:
using: 'composite'
steps:
- name: Determine environment
id: env
shell: bash
run: |
if [ "${{ inputs.branch }}" == "main" ]; then
echo "environment=production" >> $GITHUB_OUTPUT
echo "url=https://thinkdifferent.blog" >> $GITHUB_OUTPUT
elif [ "${{ inputs.branch }}" == "staging" ]; then
echo "environment=staging" >> $GITHUB_OUTPUT
echo "url=https://staging.thinkdifferent.blog" >> $GITHUB_OUTPUT
else
echo "environment=preview" >> $GITHUB_OUTPUT
echo "url=https://preview-${{ inputs.branch }}.thinkdifferent.blog" >> $GITHUB_OUTPUT
fi
- name: Deploy to production
if: steps.env.outputs.environment == 'production'
shell: bash
run: ./deploy-prod.sh
- name: Deploy to staging
if: steps.env.outputs.environment == 'staging'
shell: bash
run: ./deploy-staging.sh
- name: Deploy preview
if: steps.env.outputs.environment == 'preview'
shell: bash
run: ./deploy-preview.sh ${{ inputs.branch }}
- name: Update status
shell: bash
run: |
echo "Deployed to ${{ steps.env.outputs.environment }}" >> $GITHUB_STEP_SUMMARY
echo "URL: ${{ steps.env.outputs.url }}" >> $GITHUB_STEP_SUMMARY
Publishing Actions
Share your action with the community:
- Create a public repository
- Add action metadata (
action.yml) - Tag releases (e.g.,
v1.0.0) - Publish to GitHub Marketplace
# action.yml in root of repository
name: 'Blog Test Runner'
description: 'Run Playwright tests for Astro blogs'
author: 'Your Name'
branding:
icon: 'check-circle'
color: 'green'
inputs:
astro-version:
description: 'Astro version'
required: false
test-pattern:
description: 'Test pattern'
required: false
default: 'tests/**/*.spec.ts'
runs:
using: 'composite'
steps:
# ... action steps
Usage by others:
- name: Test blog
uses: your-username/blog-test-runner@v1
with:
astro-version: '5.0.0'
Best Practices
- Use semantic versioning for action tags
- Provide clear documentation in README
- Handle errors gracefully with
core.setFailed() - Use inputs for configuration not hardcoded values
- Set useful outputs for downstream jobs
- Test actions thoroughly before publishing
- Keep actions focused - one responsibility
Action Testing
Test custom actions in workflow:
name: Test Custom Actions
on: [push]
jobs:
test-setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test setup action
uses: ./.github/actions/setup-node-cache
with:
node-version: '20'
- name: Verify setup
run: |
node --version
npm --version
test -d node_modules
Key Takeaways
✅ Composite actions group multiple steps
✅ JavaScript actions enable complex logic
✅ Use inputs for flexibility
✅ Provide outputs for downstream jobs
✅ Handle errors with core.setFailed()
✅ Document actions thoroughly
✅ Test before publishing to marketplace