GitHub Actions Matrix Builds: Test Across Versions
Use the matrix strategy in GitHub Actions to test across Node versions, operating systems, and dependency sets without duplicating workflow files.
What you'll learn
- ✓What a matrix strategy is and when to use one
- ✓How to test across Node versions and operating systems
- ✓How to include and exclude specific combinations
- ✓How to use fail-fast and max-parallel
- ✓How to fan out and fan in with needs
Prerequisites
- •Comfortable with a basic GitHub Actions workflow — see What is CI/CD
A matrix build runs the same job many times with different inputs. Instead of writing five copies of your test job for five Node versions, you declare the matrix once and GitHub Actions fans out the jobs in parallel. Done well, matrix builds catch version-specific bugs before users do. Done badly, they balloon your CI bill and slow down merges. This post covers both halves.
The basic shape
A workflow job becomes a matrix job by adding strategy.matrix. Each key in the matrix produces a dimension.
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
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
Push this and you get three parallel jobs: test (18), test (20), and test (22). The web UI groups them under one matrix.
Multiple dimensions
Add more keys to multiply the matrix. Be careful — the count is the product.
strategy:
matrix:
node: [20, 22]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
That is six jobs (2 nodes times 3 OSes). Across pull requests, this adds up. Match your matrix to actual support promises: if you only ship Linux server software, do not run macOS in CI.
Include and exclude
include adds extra combinations and lets you attach extra variables. exclude removes specific cells from the grid.
strategy:
matrix:
node: [20, 22]
os: [ubuntu-latest, windows-latest]
include:
- node: 22
os: ubuntu-latest
coverage: true
exclude:
- node: 20
os: windows-latest
The included row enables coverage only on one cell. The exclude removes a combination you do not care about. Use include to define one-off edge cases without duplicating the whole matrix.
Failing fast
By default, if any matrix job fails the others are cancelled. That keeps CI fast but hides parallel failures.
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
Set fail-fast: false when you want to see every failure in one run. It is the right default for matrix builds — you want to know if Node 18 and 22 both broke for different reasons.
Limiting parallelism
max-parallel caps how many matrix jobs run concurrently. Useful when each job uses a shared resource like a test database.
strategy:
max-parallel: 2
matrix:
shard: [1, 2, 3, 4]
The matrix still has four cells, but only two run at a time. Combine with sharding to keep total wall-clock time low without overwhelming downstream services.
Sharding tests
A matrix is the easiest way to split a slow test suite. Pass the index into your test runner.
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx jest --shard=${{ matrix.shard }}/4
Four parallel jobs each run a quarter of the suite. Total CPU time is the same; wall-clock time drops by roughly four. Most modern runners (Jest, Vitest, Playwright) support sharding natively.
Fan out, fan in
Often you want a single job to depend on all matrix jobs passing. Use needs with the matrix job name.
jobs:
test:
strategy:
matrix:
node: [18, 20, 22]
runs-on: ubuntu-latest
steps:
- run: npm test
release:
needs: test
runs-on: ubuntu-latest
steps:
- run: npm publish
release waits for every cell of test to succeed. Combined with branch protection requiring test as a status check, this keeps broken versions out of production. It is the same fan-out, fan-in pattern Docker users see with multi-stage pipelines — see What is Docker for related concepts.
Conditional steps inside the matrix
Steps can branch on matrix values.
- name: Upload coverage
if: matrix.coverage == true
uses: codecov/codecov-action@v4
Combine with include to attach the coverage: true flag only to the cell you want.
Dynamic matrices
You can compute the matrix at runtime by setting a job output and reading it in strategy. This is powerful for monorepos where you want to run only affected packages.
jobs:
setup:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.changed.outputs.packages }}
steps:
- id: changed
run: echo "packages=$(./scripts/changed.sh)" >> $GITHUB_OUTPUT
test:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJson(needs.setup.outputs.packages) }}
steps:
- run: npm test --workspace=${{ matrix.package }}
The script outputs a JSON array, fromJson parses it, and the matrix only runs against changed packages.
Cost and signal hygiene
A few habits keep matrix builds healthy:
- Keep the matrix small on pull requests, full on the main branch. Use
if: github.event_name == 'push'to gate expensive cells. - Cache dependencies.
actions/setup-nodehas caching built in; use it. - Treat flaky cells as bugs. A matrix amplifies flakiness because more cells means more chances to fail.
- Name your cells clearly. The job name shown in the UI comes from the matrix values.
When not to use a matrix
If a dimension is only ever one value, it is not a matrix. If two cells share no code paths, write two separate jobs. Matrices are for true variation across a single workflow, not for fitting unrelated work into one block.
Wrap up
The matrix strategy is one of the highest-leverage features in GitHub Actions. A few lines of YAML turn one job into a grid that exercises every supported version, OS, and shard your project cares about. Pair it with fail-fast: false, sharding, and fan-in via needs, and you have a CI pipeline that surfaces real compatibility issues without doubling the workflow file count.