GitHub Actions Advanced Patterns#

Once you move past single-file workflows that run npm test on every push, GitHub Actions becomes a platform for building serious CI/CD infrastructure. The features covered here – reusable workflows, composite actions, matrix strategies, OIDC authentication, and caching – are what separate a working pipeline from a production-grade one.

Reusable Workflows#

A reusable workflow is a complete workflow file that other workflows can call like a function. Define it with the workflow_call trigger:

# .github/workflows/build.yml
name: Build
on:
  workflow_call:
    inputs:
      go-version:
        required: true
        type: string
    secrets:
      DEPLOY_TOKEN:
        required: true
    outputs:
      image-tag:
        description: "The built image tag"
        value: ${{ jobs.build.outputs.tag }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ inputs.go-version }}
      - run: go build -o app ./cmd/server
      - id: meta
        run: echo "tag=sha-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"

Call it from another workflow with uses:

# .github/workflows/ci.yml
jobs:
  build:
    uses: ./.github/workflows/build.yml
    with:
      go-version: "1.22"
    secrets:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

You can reference workflows in the same repo (./.github/workflows/build.yml), another repo (org/repo/.github/workflows/build.yml@main), or a specific tag. GitHub enforces a maximum nesting depth of four levels – a workflow calling a workflow calling a workflow calling a workflow is the limit.

Composite Actions#

Composite actions bundle multiple steps into a single reusable action. Unlike reusable workflows (which define entire jobs), composite actions are steps you embed within a job:

# .github/actions/setup-and-lint/action.yml
name: "Setup and Lint"
description: "Install dependencies and run linters"
inputs:
  node-version:
    required: false
    default: "20"
runs:
  using: "composite"
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
    - run: npm ci
      shell: bash
    - run: npm run lint
      shell: bash

Use composite actions when you want to reuse a sequence of steps across jobs within the same workflow or across repos. Use reusable workflows when you need to encapsulate entire jobs with their own runner selection, permissions, and output handling.

Matrix Strategies#

Matrix builds let you test across combinations of versions, operating systems, and configurations without duplicating workflow definitions:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        go-version: ["1.21", "1.22", "1.23"]
        exclude:
          - os: macos-latest
            go-version: "1.21"
        include:
          - os: ubuntu-latest
            go-version: "1.23"
            experimental: true
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.experimental || false }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
      - run: go test ./...

Setting fail-fast: false is critical for CI that tests compatibility. The default (true) cancels all matrix jobs when any single one fails, which means you only learn about one failure per push. With fail-fast: false, you see all failures at once.

The include key adds specific combinations with extra variables. The exclude key removes specific combinations from the generated matrix. Together they let you handle edge cases without resorting to conditional logic inside steps.

OIDC for Cloud Authentication#

OpenID Connect (OIDC) eliminates the need to store cloud credentials as repository secrets. Instead, your workflow requests a short-lived token from GitHub’s OIDC provider, and your cloud provider trusts that token based on a pre-configured trust policy.

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
      - run: aws ecs update-service --cluster prod --service api --force-new-deployment

The permissions: id-token: write line is essential. Without it, the workflow cannot request a token from GitHub’s OIDC provider. On the AWS side, you create an IAM role with a trust policy that allows GitHub’s OIDC provider (token.actions.githubusercontent.com) and restricts it to your specific repo and branch.

Azure and GCP follow the same pattern with their respective credential actions. The benefit is significant: no long-lived secrets to rotate, no risk of leaked credentials in logs, and fine-grained access control scoped to specific repos and branches.

Caching#

Caching dependencies between workflow runs prevents re-downloading and re-compiling packages on every push:

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

The cache key strategy matters. Hash the lockfile (go.sum, package-lock.json, Pipfile.lock) so the cache invalidates when dependencies change. The restore-keys prefix provides a fallback – if the exact key does not match, the most recent cache with the matching prefix is restored. This means you get partial cache hits even when dependencies change slightly.

Cache limits are 10 GB per repository. Least-recently-used entries are evicted first. For monorepos, scope cache keys by package path to avoid one project’s cache evicting another’s.

Artifacts and Job Dependencies#

Artifacts pass data between jobs in the same workflow run:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: go build -o myapp ./cmd/server
      - uses: actions/upload-artifact@v4
        with:
          name: binary
          path: myapp
          retention-days: 5

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: binary
      - run: chmod +x myapp && ./myapp --version

The needs keyword declares job dependencies. Jobs without needs run in parallel. Use if: needs.build.result == 'success' for conditional execution based on upstream job results. You can also use if: always() to run a job regardless of upstream failures – useful for notification or cleanup jobs.

Concurrency Control#

Prevent redundant workflow runs with the concurrency key:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

Applied at the workflow level, this cancels any in-progress run for the same branch when a new commit is pushed. This is essential for PR workflows where rapid pushes would otherwise stack up queued runs. For deployment workflows to production, set cancel-in-progress: false to avoid interrupting an active deployment.

Environment Protection Rules#

GitHub environments add approval gates and environment-specific secrets:

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

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

Configure the production environment in repository settings to require manual approval from specific reviewers before the job runs. You can also add wait timers, restrict which branches can deploy, and define environment-specific secrets that are only available to jobs targeting that environment.

Self-Hosted Runners#

Use self-hosted runners when you need private network access (deploying to internal infrastructure), special hardware (GPUs, ARM64), or cost control at scale (GitHub-hosted runners bill per minute). Register runners with labels and target them in workflows:

runs-on: [self-hosted, linux, arm64, gpu]

Never use self-hosted runners for public repositories. Anyone who can open a pull request can execute arbitrary code on your runner. For public repos, use GitHub-hosted runners exclusively.

Common Gotchas#

The GITHUB_TOKEN default permissions changed to read-only. Workflows that previously worked may break silently when they cannot push, create releases, or write to packages. Set explicit permissions at the job or workflow level.

Secrets are not available in workflows triggered by pull requests from forked repositories. This is a deliberate security measure – a fork could modify the workflow to exfiltrate secrets. Use pull_request_target cautiously if you need secrets in fork PRs, and never run untrusted code in that context.

Production Example#

A complete CI/CD workflow combining these patterns:

name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read
  id-token: write

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        go-version: ["1.22", "1.23"]
    uses: ./.github/workflows/test.yml
    with:
      go-version: ${{ matrix.go-version }}

  build:
    needs: test
    uses: ./.github/workflows/build.yml
    with:
      go-version: "1.23"

  deploy-staging:
    if: github.ref == 'refs/heads/main'
    needs: build
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: us-east-1
      - run: ./deploy.sh staging

  deploy-production:
    needs: deploy-staging
    environment:
      name: production
      url: https://api.example.com
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: us-east-1
      - run: ./deploy.sh production

This workflow tests across Go versions, builds once, deploys to staging automatically on main, and waits for manual approval before production. OIDC handles authentication. Concurrency prevents pileups on rapid pushes to PRs.