Get the next live webinar in your inbox

One email a month: the upcoming live event + free recording access for subscribers. No spam, unsubscribe anytime.

Lesson 8 of 10 ~16 min
Course progress
0%

Creating Custom GitHub Actions

Build reusable custom actions to simplify workflows and share automation logic

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:

  1. JavaScript/TypeScript Actions - Run directly on the runner
  2. Docker Container Actions - Run in a container
  3. 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:

  1. Create a public repository
  2. Add action metadata (action.yml)
  3. Tag releases (e.g., v1.0.0)
  4. 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

  1. Use semantic versioning for action tags
  2. Provide clear documentation in README
  3. Handle errors gracefully with core.setFailed()
  4. Use inputs for configuration not hardcoded values
  5. Set useful outputs for downstream jobs
  6. Test actions thoroughly before publishing
  7. 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