Advanced GitHub Actions Patterns#

Once you understand the basics of GitHub Actions, these patterns solve the real-world problems: testing across multiple environments, authenticating to cloud providers without static secrets, building reusable action components, and scaling runners.

Matrix Builds#

Test across multiple OS versions, language versions, or configurations in parallel:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        go-version: ['1.22', '1.23']
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
      - run: go test ./...

This creates 4 jobs (2 OS x 2 Go versions) running in parallel. Set fail-fast: false so a failure in one combination does not cancel the others – you want to see all failures at once.

Include and exclude for fine-grained control:

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
      - os: windows-latest
        node: 18
    include:
      - os: ubuntu-latest
        node: 22
        experimental: true

exclude removes specific combinations. include adds extra combinations or properties. In this example, the experimental property is accessible as ${{ matrix.experimental }} in steps.

Conditional Execution#

Control when jobs and steps run using if expressions:

jobs:
  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: [test, lint]
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        if: success()
        run: ./deploy.sh

      - name: Notify on failure
        if: failure()
        run: curl -X POST "$SLACK_WEBHOOK" -d '{"text":"Deploy failed"}'

Useful conditions:

  • success() – default, runs if all previous steps succeeded.
  • failure() – runs only if a previous step failed.
  • always() – runs regardless of status (cleanup steps).
  • cancelled() – runs if the workflow was cancelled.
  • contains(github.event.pull_request.labels.*.name, 'deploy') – check PR labels.

Job dependencies with needs:

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]

  lint:
    runs-on: ubuntu-latest
    steps: [...]

  deploy:
    needs: [test, lint]       # Waits for both to succeed
    runs-on: ubuntu-latest
    steps: [...]

  notify:
    needs: [deploy]
    if: always()               # Runs even if deploy failed
    runs-on: ubuntu-latest
    steps: [...]

Environment Protection Rules#

Environments add approval gates, wait timers, and scoped secrets:

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://myapp.com
    steps:
      - run: ./deploy.sh production

Configure environment protection rules in GitHub settings:

  • Required reviewers: one or more people must approve before the job runs.
  • Wait timer: delay execution by N minutes (useful for canary deployments).
  • Deployment branches: restrict which branches can deploy to this environment.

Environment-scoped secrets override repository secrets of the same name, so production can have a different DEPLOY_TOKEN than staging.

Composite Actions#

Package multiple steps into a reusable action. Unlike reusable workflows, composite actions run within the calling job – same runner, same filesystem.

# .github/actions/setup-and-test/action.yml
name: 'Setup and Test'
description: 'Install deps, run lint and test'
inputs:
  go-version:
    description: 'Go version'
    required: true
    default: '1.23'
runs:
  using: 'composite'
  steps:
    - uses: actions/setup-go@v5
      with:
        go-version: ${{ inputs.go-version }}
    - name: Install dependencies
      shell: bash
      run: go mod download
    - name: Lint
      shell: bash
      run: golangci-lint run
    - name: Test
      shell: bash
      run: go test -race -coverprofile=coverage.out ./...

Use it in a workflow:

steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-and-test
    with:
      go-version: '1.23'

Every run step in a composite action must specify shell. This is required by GitHub Actions for composite actions and is easy to forget.

OIDC for Cloud Authentication#

OpenID Connect lets GitHub Actions authenticate to cloud providers without storing static credentials. GitHub issues a short-lived JWT, and the cloud provider verifies it.

AWS:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions
      aws-region: us-east-1
  - run: aws s3 ls

Configure the AWS IAM role trust policy to accept tokens from GitHub:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"},
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
      },
      "StringLike": {
        "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
      }
    }
  }]
}

GCP:

steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/github'
      service_account: 'github-actions@myproject.iam.gserviceaccount.com'
  - uses: google-github-actions/setup-gcloud@v2
  - run: gcloud compute instances list

Azure:

steps:
  - uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
  - run: az webapp list

OIDC eliminates the risk of leaked static credentials. The sub claim in the condition restricts which repositories and branches can assume the role.

Path Filtering for Monorepos#

Trigger different workflows based on which files changed:

on:
  push:
    paths:
      - 'services/api/**'
      - 'shared/lib/**'
      - '.github/workflows/api-ci.yml'

For matrix-based monorepo CI, use dorny/paths-filter:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'services/api/**'
            web:
              - 'services/web/**'

  api-test:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing API"

Self-Hosted Runners#

For workloads that need specific hardware, network access to private resources, or faster startup:

# Download and configure a runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/myorg/myrepo --token AXXXX
./run.sh

Security considerations: self-hosted runners persist between jobs. A malicious workflow in a fork can access the runner’s filesystem, network, and credentials. Never use self-hosted runners with public repositories that accept PRs from forks.

Autoscaling with actions-runner-controller (ARC): deploy runners as Kubernetes pods that scale based on workflow demand:

apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: ci-runners
spec:
  replicas: 1
  template:
    spec:
      repository: myorg/myrepo
      labels:
        - self-hosted
        - linux
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: ci-runners-autoscaler
spec:
  scaleTargetRef:
    name: ci-runners
  minReplicas: 1
  maxReplicas: 10
  scaleUpTriggers:
    - githubEvent:
        workflowJob: {}
      duration: "30m"

ARC spins up runners when jobs queue and scales down when idle. This avoids paying for idle self-hosted infrastructure while keeping job startup fast.