Build Matrices for Multiple Versions and Platforms
A build matrix runs the same job multiple times with different inputs — different language versions, operating systems, or configuration flags — all in parallel. Instead of writing a separate job for "test on Node 18" and another for "test on Node 20," you declare the versions once and let GitHub Actions generate every combination automatically. This lesson assumes you already know how to write a basic workflow with jobs and steps; here we focus specifically on the strategy.matrix feature and the design decisions around it.
Overview / How it works
A matrix is defined under strategy.matrix inside a job. Each key you list becomes a dimension, and GitHub Actions computes the Cartesian product of all dimensions to create one job run per combination. If you list three Node.js versions and three operating systems, you get nine job runs, each with its own log, status check, and pass/fail result.
Two settings control matrix behavior beyond the raw combinations:
- fail-fast — when
true(the default), GitHub Actions cancels every other in-progress matrix job as soon as one combination fails. This can hide real failures on other platforms because their logs never finish. - max-parallel — caps how many matrix jobs run concurrently, useful when you have a large matrix and limited runner capacity or want to avoid overwhelming a shared resource like a test database.
You can also add or remove specific combinations with include and exclude, which is how you handle exceptions — for example, skipping an unsupported OS/version pairing or adding one extra experimental combination without expanding every dimension.
Syntax or workflow structure
The matrix lives under jobs.<job_id>.strategy, and its values are referenced inside the job with the matrix context, for example ${{ matrix.node-version }}. A minimal shape looks like this:
jobs:
test:
strategy:
fail-fast: false
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18.x, 20.x, 22.x]
runs-on: ${{ matrix.os }}
steps:
- run: echo "Testing on ${{ matrix.os }} with Node ${{ matrix.node-version }}"
exclude entries must match every key in a combination to remove it; a partial match (say, specifying only os when the matrix also has node-version) removes every combination with that os, not just one. include entries either augment an existing combination (when all listed keys match one exactly) by adding new keys to it, or, if no exact match exists, they are added as an entirely new combination.
Examples
Example 1: A single-dimension version matrix
Start with the simplest case: testing a Node.js project against three runtime versions on one operating system.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Expected behavior: the Actions tab shows three parallel jobs named test (18.x), test (20.x), and test (22.x). Because fail-fast is false, if the Node 18 run fails on a syntax feature only supported in newer runtimes, the 20.x and 22.x jobs still run to completion and report their own pass/fail status.
Example 2: Crossing versions with operating systems
Add a second dimension so the matrix covers both the runtime version and the host platform — important for packages with native dependencies or platform-specific file path handling.
name: Cross-Platform CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x, 22.x]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Expected behavior: nine jobs run, named test (ubuntu-latest, 18.x) through test (macos-latest, 22.x). max-parallel: 4 means only four of the nine run at once; the rest queue and start as slots free up. This keeps a large matrix from consuming your entire concurrent-job quota at once.
Example 3: Exceptions with include and exclude
Real projects rarely need every combination. Here we drop an unsupported pairing, mark one combination for coverage reporting, and add a single experimental version that is allowed to fail without breaking the required status check.
name: Extended Matrix CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x, 22.x]
exclude:
- os: windows-latest
node-version: 18.x
include:
- os: ubuntu-latest
node-version: 22.x
coverage: true
- os: ubuntu-latest
node-version: 23.x
experimental: true
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
- name: Upload coverage
if: matrix.coverage == true
run: npm run coverage
Expected behavior: the windows-latest + 18.x combination never runs. The ubuntu-latest + 22.x job gains a coverage flag (an exact match on both existing keys, so it augments rather than duplicates the combination) and runs the coverage step. A brand-new job, ubuntu-latest + 23.x, is added with experimental: true; because no combination previously had node-version: 23.x, this is treated as an added combination, not a match. Its continue-on-error means a failure there shows as a yellow warning, not a red X, so it cannot block a required status check.
Step by step
- Identify your real support matrix — the language versions and operating systems you actually promise to support, not every version that exists.
- Declare each as a matrix dimension with explicit, pinned version strings (avoid
latestaliases for language runtimes; they change silently over time). - Set
fail-fast: falsewhenever you want visibility into every platform’s result, which is almost always true for a support matrix. - Add
excludefor combinations you know are unsupported, andincludefor one-off additions like a coverage run or an experimental preview version. - Reference
matrix.<key>inruns-on,with, and conditionalifexpressions to drive setup steps. - Confirm in the Actions run view that the job count and names match what you expect before merging the workflow change.
Common Mistakes
Mistake 1: leaving the default fail-fast behavior on a support matrix. With the default fail-fast: true, one failing combination cancels every other in-progress job. If Windows fails first, you never learn whether Linux and macOS would have passed — you have to fix Windows, push again, and wait for the next run to find out.
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x, 22.x]
Correction: add fail-fast: false so every combination finishes and reports independently.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x, 22.x]
Mistake 2: expecting an include entry to always augment an existing job. An include entry only merges into an existing combination when every key it specifies matches that combination exactly. A common error is assuming a partial match is enough:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18.x, 20.x]
include:
- node-version: 20.x
coverage: true
Here the include entry has no os key, so it does not match either existing combination exactly — it is added as a brand-new job with only node-version and coverage set, and no os value at all, which breaks runs-on: ${{ matrix.os }}. Correction: list every key that identifies the target combination.
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18.x, 20.x]
include:
- os: ubuntu-latest
node-version: 20.x
coverage: true
Mistake 3: writing OS-specific shell commands without accounting for the default shell. windows-latest runners default to PowerShell for run steps, while Linux and macOS default to bash. A step written for Unix syntax silently breaks on Windows:
- run: rm -rf dist && mkdir dist
Correction: either pin the shell explicitly, or branch on runner.os so each platform uses commands it understands.
- name: Clean dist directory
shell: bash
run: rm -rf dist && mkdir dist
Best Practices
- Keep matrix dimensions to versions and platforms you genuinely support in production; a huge matrix multiplies both minutes billed and time spent triaging unrelated flaky failures.
- Use
fail-fast: falsefor support matrices so you get a complete picture of what broke where, but keep the defaultfail-fast: truefor matrices that only vary an internal test shard, where an early exit is a reasonable time-saver. - Give artifacts unique names derived from matrix values (for example
coverage-${{ matrix.os }}-${{ matrix.node-version }}) so parallel jobs don’t overwrite each other’s uploads. - Mark speculative or preview versions with
continue-on-errorvia anexperimentalflag rather than leaving them out entirely; you get early warning without blocking merges. - Pin dimension values (exact version numbers, specific runner images) instead of floating tags, so a matrix result means the same thing today as it did last month.
- Set the job’s
permissionsto the minimum required, typicallycontents: readfor a build-and-test matrix; only add write scopes to a separate job if you need to publish results or comment on a pull request.
Practice Exercises
- Take a workflow that tests a single Node.js version and convert it into a matrix covering Node 18.x, 20.x, and 22.x with
fail-fast: false. Confirm three separate job entries appear in the Actions run. - Add a second dimension for
os: [ubuntu-latest, windows-latest]to the same workflow. Predict the resulting job count before running it, then verify. - Add an
excludeentry to remove one specific os/version pairing, and anincludeentry that adds acoverage: trueflag to exactly one existing combination. Confirm in the run view that the excluded combination is gone and the included one shows the extra coverage step. - Introduce one command in a step that only works on Unix shells, run the matrix, observe the Windows job fail, then fix it with an explicit
shell:or arunner.osconditional.
Summary
A build matrix turns one job definition into many parallel runs across the versions and platforms you actually support, using strategy.matrix plus optional include/exclude exceptions. Set fail-fast: false when you need every platform’s result, use max-parallel to bound concurrency, and remember that include only merges into an existing combination on an exact key match — otherwise it creates a new one. Keep the matrix scoped to real support targets, give matrix jobs minimal permissions, and name artifacts uniquely per combination so results from one platform never silently overwrite another’s.
