GitHub Actions: Advanced Patterns for CI/CD

July 20, 2020

GitHub Actions has evolved from a simple automation tool to a full-featured CI/CD platform. Beyond basic build and test workflows, it supports complex deployment pipelines, matrix builds, reusable workflows, and sophisticated orchestration.

Here are advanced patterns that make GitHub Actions shine.

Workflow Optimization

Matrix Builds

Test across multiple configurations:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [14, 16, 18]
        exclude:
          - os: windows-latest
            node: 14
        include:
          - os: ubuntu-latest
            node: 18
            coverage: true
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test
      - if: matrix.coverage
        run: npm run coverage

Caching

Speed up workflows with caching:

- name: Cache dependencies
  uses: actions/cache@v3
  with:
    path: |
      ~/.npm
      node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

# For Go
- uses: actions/cache@v3
  with:
    path: |
      ~/go/pkg/mod
      ~/.cache/go-build
    key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

# For Python
- uses: actions/cache@v3
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

Conditional Execution

Run steps based on conditions:

steps:
  # Only on main branch
  - if: github.ref == 'refs/heads/main'
    run: npm run deploy

  # Only for pull requests
  - if: github.event_name == 'pull_request'
    run: npm run lint

  # Only when specific files changed
  - uses: dorny/paths-filter@v2
    id: changes
    with:
      filters: |
        src:
          - 'src/**'
        docs:
          - 'docs/**'

  - if: steps.changes.outputs.src == 'true'
    run: npm test

  # Only on success of previous steps
  - if: success()
    run: npm run notify-success

  # Only on failure
  - if: failure()
    run: npm run notify-failure

Reusable Workflows

Creating Reusable Workflows

# .github/workflows/reusable-deploy.yml
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      version:
        required: true
        type: string
    secrets:
      aws-access-key:
        required: true
      aws-secret-key:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.aws-access-key }}
          aws-secret-access-key: ${{ secrets.aws-secret-key }}
          aws-region: us-east-1

      - name: Deploy
        run: ./deploy.sh ${{ inputs.environment }} ${{ inputs.version }}

Using Reusable Workflows

# .github/workflows/deploy-production.yml
name: Deploy Production

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      version: ${{ github.ref_name }}
    secrets:
      aws-access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}
      aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Complex Deployment Patterns

Environment Protection

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - run: ./deploy.sh staging

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - run: ./deploy.sh production

Configure environment protection rules in GitHub:

Canary Deployments

jobs:
  deploy-canary:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to canary
        run: kubectl set image deployment/app app=${{ env.IMAGE }}:${{ github.sha }} --namespace=canary

      - name: Wait and verify
        run: |
          sleep 300  # 5 minutes
          ./scripts/verify-canary.sh

      - name: Check metrics
        id: metrics
        run: |
          ERROR_RATE=$(./scripts/get-error-rate.sh canary)
          echo "error_rate=$ERROR_RATE" >> $GITHUB_OUTPUT

  promote:
    needs: deploy-canary
    if: needs.deploy-canary.outputs.error_rate < 1
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        run: kubectl set image deployment/app app=${{ env.IMAGE }}:${{ github.sha }} --namespace=production

  rollback:
    needs: deploy-canary
    if: failure() || needs.deploy-canary.outputs.error_rate >= 1
    runs-on: ubuntu-latest
    steps:
      - name: Rollback canary
        run: kubectl rollout undo deployment/app --namespace=canary

Multi-Region Deployments

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.build.outputs.image }}
    steps:
      - uses: actions/checkout@v3
      - id: build
        run: |
          IMAGE="registry.example.com/app:${{ github.sha }}"
          docker build -t $IMAGE .
          docker push $IMAGE
          echo "image=$IMAGE" >> $GITHUB_OUTPUT

  deploy:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      matrix:
        region: [us-east-1, eu-west-1, ap-southeast-1]
      max-parallel: 1  # Sequential deployment
      fail-fast: false
    steps:
      - name: Deploy to ${{ matrix.region }}
        run: |
          ./deploy.sh ${{ matrix.region }} ${{ needs.build.outputs.image }}

      - name: Verify deployment
        run: |
          ./verify.sh ${{ matrix.region }}

Custom Actions

Composite Actions

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Setup Node.js, install dependencies, and configure environment'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '18'

runs:
  using: 'composite'
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: ${{ inputs.node-version }}

    - name: Cache dependencies
      uses: actions/cache@v3
      with:
        path: node_modules
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

    - name: Install dependencies
      shell: bash
      run: npm ci

    - name: Setup environment
      shell: bash
      run: cp .env.example .env

Usage:

steps:
  - uses: actions/checkout@v3
  - uses: ./.github/actions/setup-project
    with:
      node-version: '18'

JavaScript Actions

// .github/actions/notify-slack/index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    const webhook = core.getInput('webhook-url');
    const status = core.getInput('status');

    const { context } = github;
    const message = {
      text: `Build ${status}: ${context.repo.owner}/${context.repo.repo}`,
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: `*${context.workflow}* ${status}\n<${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}|View run>`
          }
        }
      ]
    };

    await fetch(webhook, {
      method: 'POST',
      body: JSON.stringify(message)
    });
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

Security Best Practices

Secrets Management

# Use environment secrets for sensitive environments
jobs:
  deploy:
    environment: production
    steps:
      - run: echo "${{ secrets.PROD_API_KEY }}"  # Environment-scoped secret

# Mask sensitive output
steps:
  - run: |
      TOKEN=$(./get-token.sh)
      echo "::add-mask::$TOKEN"
      echo "token=$TOKEN" >> $GITHUB_OUTPUT

Limit Permissions

# Workflow-level permissions
permissions:
  contents: read
  packages: write

# Job-level permissions
jobs:
  build:
    permissions:
      contents: read

  deploy:
    permissions:
      contents: read
      deployments: write

Pin Actions to SHA

# Instead of
- uses: actions/checkout@v3

# Pin to specific SHA
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab

Secure Pull Request Workflows

# For untrusted PR code
on:
  pull_request_target:  # Runs in context of base branch

jobs:
  build:
    permissions:
      contents: read
    steps:
      # Checkout PR code safely
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          persist-credentials: false

      # Don't expose secrets to PR code
      - run: npm test

Testing Workflows

Act for Local Testing

# Install act
brew install act

# Run workflow locally
act push -W .github/workflows/ci.yml

# With secrets
act -s GITHUB_TOKEN=xxx

# Specific job
act -j build

Workflow Debugging

steps:
  - name: Debug context
    env:
      GITHUB_CONTEXT: ${{ toJson(github) }}
    run: echo "$GITHUB_CONTEXT"

  - name: Enable debug logging
    run: echo "ACTIONS_STEP_DEBUG=true" >> $GITHUB_ENV

Cost Optimization

Self-Hosted Runners

jobs:
  build:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v3
      - run: make build

When to use:

Minimize Runner Time

# Cancel in-progress runs
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# Fail fast on matrix
strategy:
  fail-fast: true

# Timeout
jobs:
  build:
    timeout-minutes: 15

Key Takeaways

GitHub Actions is powerful enough for complex CI/CD pipelines. Invest in workflow design for maintainable, secure, and efficient automation.