Two features make GitHub Actions scale beyond toy pipelines: matrix strategies (parallelism across versions) and reusable workflows (sharing pipelines across repos). This lesson teaches both.
Matrix Strategy Basics
Suppose you need to test a library on Node 18, 20, and 22, on both Linux and Windows. Writing six near-duplicate jobs would be painful. The matrix strategy fans out one job definition into multiple variants:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This produces 6 jobs (2 OSes × 3 Node versions). Each runs in parallel on its own runner.
Include and Exclude
Add specific combinations or trim the matrix:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
include:
- os: ubuntu-latest
node: 20
coverage: true # extra variable only on this variant
exclude:
- os: windows-latest
node: 18 # skip this combination
Failure Behaviour
strategy:
fail-fast: false # default is true
max-parallel: 4 # cap concurrency
matrix: ...
- fail-fast: true (default) cancels remaining variants the moment one fails — saves minutes
- fail-fast: false lets every variant run to completion — better for "show me all the failures" CI
- max-parallel throttles concurrency — useful when matrix variants compete for a shared resource (DB, API quota)
Dynamic Matrices
You can generate the matrix at runtime from JSON:
jobs:
list-targets:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- id: set
run: echo "matrix=$(./scripts/list-targets.js)" >> $GITHUB_OUTPUT
build:
needs: list-targets
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.list-targets.outputs.matrix) }}
steps:
- run: ./build.sh ${{ matrix.target }}
This pattern powers monorepo CI — list the changed packages dynamically, then fan a build job across them.
Reusable Workflows
A reusable workflow is a workflow file with on: workflow_call: as its trigger. It can be invoked from other workflows — including across repositories — with typed inputs and secrets.
Defining a reusable workflow
# .github/workflows/reusable-build.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
node-version:
required: false
type: number
default: 20
secrets:
NPM_TOKEN:
required: true
outputs:
image-tag:
description: 'Built image tag'
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm ci
- id: tag
run: echo "tag=ghcr.io/acme/app:${{ github.sha }}" >> $GITHUB_OUTPUT
Calling it from another workflow
jobs:
build:
uses: acme-org/shared-workflows/.github/workflows/reusable-build.yml@v3
with:
environment: staging
node-version: 22
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh "${{ needs.build.outputs.image-tag }}"
You can pass secrets: inherit to forward all the caller's secrets verbatim — convenient but blunt. For sensitive cases, name each secret explicitly so the called workflow's authors can audit what it has access to.
Composite Action vs Reusable Workflow
| Concern | Composite action | Reusable workflow |
|---|---|---|
| Scope | Steps inside one job | Entire workflow (multiple jobs) |
| Runs on | The caller's runner | Its own runner |
| Can use other actions | Yes | Yes |
| Can call other reusable workflows | No | Yes (up to 4 nesting levels) |
| Best for | "Set up project" sub-recipes | "Build and deploy" full pipelines |
The Internal Platform Pattern
A common shape at large orgs: a platform-workflows repo holds reusable workflows for "build a Node service", "build a Go service", "deploy to k8s", etc. Every application repo invokes those reusable workflows from a tiny CI file. Centralising the pipelines means upgrades, security improvements, and policy changes happen once.
Versioning Reusable Workflows
Pin reusable workflows the same way you pin actions — to a tag or SHA, not @main. The reusable workflow can break your pipeline if it changes; pinning gives you control over when to absorb the change.