GitHub Actions: Practical Patterns Beyond the Basics
Past "lint, test, build" lies a set of patterns that actually matter in production pipelines: reusable workflows, matrix builds, caching, and OIDC-based cloud auth with no stored secrets.
Reusable workflows vs. composite actions
Once you have more than a couple of repos, copy-pasted CI YAML becomes its own maintenance burden — exactly the Terraform DRY problem, just in pipeline form. GitHub Actions gives you two mechanisms to avoid it, and picking the right one matters:
- Composite actions — bundle a sequence of steps into a single reusable action, called from within a job. Good for "these five steps always go together" (e.g. checkout + setup + cache restore).
- Reusable workflows — an entire workflow called via
workflow_call, with its own inputs/secrets, and its own jobs. Good for "every service repo should run this exact CI pipeline shape."
# .github/workflows/reusable-build.yml
on:
workflow_call:
inputs:
node-version: { type: string, default: '20' }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: ${{ inputs.node-version }} }
- run: npm ci && npm run build
# calling-repo/.github/workflows/ci.yml
jobs:
build:
uses: my-org/.github/.github/workflows/reusable-build.yml@main
with: { node-version: '22' }
Matrix builds for real coverage
Testing across multiple versions or platforms shouldn't mean duplicated job definitions. The matrix strategy fans a single job definition out across every combination automatically, including failing fast or continuing on error per-combination as needed.
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with: { node-version: ${{ matrix.node }} }
Caching: the single biggest speed lever
Dependency installation (npm, pip, Go modules) is usually the slowest, most repetitive part of a pipeline. actions/cache keyed on a lockfile hash means cache hits skip the slow install entirely, and cache misses only happen when dependencies actually change.
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
cache: input that wraps this pattern for you — check before hand-rolling it.OIDC: cloud auth without stored secrets
The biggest security upgrade most pipelines are still missing: instead of storing long-lived AWS/GCP/Azure credentials as repo secrets, GitHub Actions can present a short-lived OIDC token that your cloud provider trusts directly — no static credential sitting in secrets storage waiting to leak.
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-deploy
aws-region: eu-west-1
The IAM role trust policy restricts which repo/branch can assume it — meaning a leaked workflow log can't be used to exfiltrate a standing credential, because there isn't one.
Concurrency control: stop wasting runner minutes
Without concurrency settings, every push to a PR branch queues a new full pipeline run, even if three are already in flight for the same branch. Cancelling superseded runs automatically saves real runner minutes (and real money on hosted runners).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Final thoughts
Most teams get the "run tests on push" basics right immediately and never revisit the pipeline again. The patterns that actually save time and reduce risk at scale — reusable workflows instead of copy-paste, caching, OIDC instead of stored secrets, and concurrency cancellation — tend to get adopted only after the pain of their absence becomes obvious. Worth doing earlier than that.