Fan Out CI with a GitHub Actions Matrix
Run the same job across supported runtimes in parallel, fail fast on the first broken combination, and keep one required status check.
Task
Test every supported language version and operating system without duplicating the workflow file for each combination.
When to use this
A pull request passes CI on Node 20 but breaks on Node 22; the regression ships because the workflow only tested the default version.
Prerequisites
- A GitHub Actions workflow with a test job
- A short list of supported runtime versions and operating systems
- Branch protection that requires a status check
Steps
1. Add a matrix and disable fail-fast so one broken combination does not hide the rest.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
Expected output: Four jobs appear in the run, one per operating system and Node version.
2. Exclude a combination that is explicitly unsupported instead of letting it fail forever.
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
exclude:
- os: windows-latest
node: 20
Expected output: The unsupported pair is skipped and the run reports three jobs.
3. Scope the dependency cache per matrix entry so versions do not overwrite each other.
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
Expected output: Each job restores its own cache key and installs dependencies without cross-version reuse.
4. Add an aggregate job and require only that job in branch protection.
required:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- run: test "${{ needs.test.result }}" = "success"
Expected output: The required check passes only when every matrix job succeeds, including skipped exclusions.
Troubleshooting
Problem
Branch protection waits forever after the workflow changes
Solution
Required checks are matched by job name; matrix-expanded names change. Require the aggregate job instead of each combination.
Problem
Windows jobs fail on shell commands that work on Linux
Solution
Use cross-platform commands or set shell: bash, and avoid path separators and line-ending-sensitive assertions.
Limitations
- Every matrix entry consumes a full runner, so a wide matrix increases queue time on busy repositories.
- Matrix values must be literal YAML; dynamic lists need a discovery job and fromJSON.
A matrix is a test contract
Supporting Node 20 and 22 is a promise, and a promise is only real if CI tests both. A matrix turns one job definition into many jobs, one per combination, and runs them in parallel. The common mistake is leaving fail-fast at its default: with true, one failing combination cancels the others, so the log shows a single error and hides the fact that three other combinations are also broken. Set fail-fast: false while you are stabilizing the matrix, then decide deliberately whether early cancellation is acceptable.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
Name checks for humans
Matrix-expanded job names include the values, which makes the checks list readable but breaks branch protection whenever the matrix changes. Add one aggregate job that needs the matrix job, runs with if: always(), and fails when the upstream result is not success. Branch protection then requires only the aggregate check, and adding a Node version no longer requires editing protection rules. Keep the aggregate job tiny so it costs almost nothing.
Cache and exclusions
Cache keys must include the matrix values, otherwise the Windows job restores a Linux dependency store and installs from scratch anyway. Use exclude for combinations that will never be supported, and document why; an unsupported pair that fails on every run trains people to ignore red builds. Finally, pin action versions by commit SHA, as with any other workflow step, so the matrix cannot change behavior underneath a release.