Skip to content
C Codeloom
DevOps

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.

·5 min read · By Yash Kesharwani
Intermediate 9 min read

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-node has 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.