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: trueexclude 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 productionConfigure 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 lsConfigure 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 listAzure:
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 listOIDC 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.shSecurity 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.