Run one GitHub Actions job across many versions and platforms: matrix syntax, include and exclude, fail-fast, max-parallel and avoiding a runaway matrix.
A matrix runs the same job many times with different inputs. It is how you test across language versions and operating systems without writing the job out repeatedly, and its main hazard is that the number of runs multiplies faster than people expect.
Verified against GitHub Actions as of September 2026.
That is 3 operating systems times 3 Node versions, so 9 jobs. Each consumes minutes, and on a private repository the Windows jobs bill at twice the Linux rate while macOS bills at ten times. This matrix costs roughly 3 + 6 + 30, or 39 Linux-equivalent minutes per run, for what reads as a three-line configuration.
Note that runs-on reads from the matrix. Any key in strategy.matrix becomes available as matrix.<key> throughout the job, including in runs-on, if conditions and step inputs.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm testThe full cross product is rarely what you want. exclude removes specific combinations:
include adds a combination, or adds extra variables to an existing one:
The two behaviours of include are worth separating, because they surprise people. An include entry whose keys all match an existing combination adds variables to it: the node: 22 entry above does not create a tenth job, it gives the existing Node 22 job an experimental variable. An include entry introducing a new value, like node: 23, appends a new job.
A common pattern built on this is marking some combinations as allowed to fail:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
# Only test the oldest Node on Linux.
- os: windows-latest
node: 18
- os: macos-latest
node: 18By default, fail-fast is true: the moment one matrix job fails, all the others are cancelled.
This is the right default for a pull-request gate, because you already know the answer is no and there is no reason to spend nine jobs' worth of minutes confirming it.
It is the wrong default when you want the full picture, for example on a nightly compatibility run where "which versions broke" is the actual question:
With fail-fast: false every combination runs to completion and you get a complete pass/fail grid.
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]max-parallel limits how many matrix jobs run at once:
Two reasons to use it. First, your account has a concurrency limit, and a wide matrix can consume the whole allowance so that other workflows queue behind it. Second, the jobs may contend over a shared external resource: three jobs writing to one test database will produce flakiness that looks like a code problem.
strategy:
max-parallel: 2
matrix:
node: [18, 20, 22]Hardcoding the matrix means editing the workflow to change what is tested. For anything that changes often, generate it:
fromJSON is what makes this work: the matrix must be an object, and a job output is always a string, so the string has to be parsed. The JSON needs to be compact, hence jq -c, because a multi-line value does not survive $GITHUB_OUTPUT without the heredoc delimiter syntax.
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.build.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: build
run: echo "matrix=$(jq -c . .github/versions.json)" >> "$GITHUB_OUTPUT"
test:
needs: setup
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Testing ${{ matrix.node }}"By default a matrix job is named after its combination, which becomes unreadable with several dimensions. name accepts expressions:
This matters beyond tidiness: required status checks are matched by job name, so a matrix whose names change when you add a dimension will break branch protection rules that referenced the old names.
name: test (node ${{ matrix.node }}, ${{ matrix.os }})Three questions worth asking of any matrix.
Does every combination tell you something different? Testing Node 18, 20 and 22 on three operating systems assumes platform-specific breakage is likely. For most pure-JavaScript libraries it is not, and Linux across three Node versions plus one Windows job at the current version catches nearly everything for a third of the cost.
Is this running on every push? A wide compatibility matrix belongs on a schedule or on release branches. The pull-request gate wants to be fast.
Are the slow combinations the ones you need? macOS runners are both the most expensive and usually the slowest to start. If the macOS job exists to catch a class of bug that has never occurred in your project, it is costing time for reassurance rather than information.
How many matrix jobs can one workflow run?
The limit is 256 jobs per workflow run from a matrix. Hitting it usually means the matrix is generated and the generator is producing more combinations than intended.
Why did my other matrix jobs get cancelled?
fail-fast defaults to true, so one failure cancels the rest. Set fail-fast: false when you want every combination to report.
Can matrix jobs share state?
No more than any other jobs. Each matrix combination is a separate job on a separate runner. Passing results out means artifacts for files or job outputs for values, and a matrix job's outputs are awkward to consume because there are several of them; uploading per-combination artifacts and merging afterwards is usually cleaner.
How do I run a step only for one matrix combination?
An if on the step, for example if: matrix.os == 'ubuntu-latest'. This is the normal way to upload coverage exactly once rather than from every combination.
Does the cache work correctly across a matrix?
Only if the cache key includes the matrix dimensions. A key without matrix.node in it will restore the wrong version's dependencies into every job, which is covered in the caching lesson.
Build and push Docker images with GitHub Actions: set up Buildx, log in to GHCR or ECR, use layer caching that works, tag sensibly and build multi-arch.
Deploy to AWS from GitHub Actions without stored keys: create the OIDC provider, write the IAM trust policy claim by claim, scope sub and fix common errors.
Learn what GitHub Actions is and how it runs your build: workflows, jobs, steps and runners, plus a first working pipeline you can commit today.