Thuta Learning
IntermediateDevOps & Toolsbeginner

Matrix Builds: Testing Across Versions

What you'll walk away with

  • Explain the core ideas behind Matrix Builds: Testing Across Versions
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

A matrix lets you write one job definition and have Actions expand it into many concrete jobs, one per combination of the variables you list. You declare strategy.matrix with named arrays, say os and node-version, and every step in that job can read the current combination through the matrix context. Six combinations become six jobs running in parallel on six separate runners, each with its own cache, checkout, and result line in the checks list.

include and exclude let you bend the grid rather than accept the full cross product. exclude removes combinations that make no sense: a Node version that never shipped for Windows, or a runtime you support only on Linux. include does two quite different things. If every key in the entry matches an expanded job, its extra keys are added to that job; if they do not all match, a brand-new job is appended instead. That dual behaviour surprises people constantly. Adding coverage: true to one existing combination decorates it; adding an unmatched os value silently creates a seventh job.

fail-fast defaults to true, which cancels every sibling the moment one combination fails. That is the right default for saving minutes and the wrong default for learning something. A failure on Node 18 with fail-fast on tells you Node 18 is broken. With fail-fast off you also learn whether 20 and 22 are broken, which is the difference between a version-specific bug and a genuinely broken commit. Set fail-fast: false whenever you are debugging.

Cost is multiplicative. Adding a third OS to a 2x3 matrix does not add three jobs, it adds three more per Node version, and every one of them bills minutes.

text
MATRIX EXPANDING INTO PARALLEL JOBS
-----------------------------------
  strategy.matrix
    os:           [ubuntu-latest, windows-latest]
    node-version: ['18', '20', '22']

                node 18       node 20       node 22
             +-------------+-------------+-------------+
  ubuntu     |    job 1    |    job 2    |    job 3    |
             |             |             | +coverage   |
             +-------------+-------------+-------------+
  windows    |  EXCLUDED   |    job 4    |    job 5    |
             +-------------+-------------+-------------+

  2 x 3 = 6 combinations, minus 1 exclude = 5 parallel jobs
  the include entry decorated job 3, it did not add a job 6

  fail-fast: true    job 3 fails -> jobs 1,2,4,5 CANCELLED
  fail-fast: false   job 3 fails -> all five still report

Connect it to a real scenario

Suppose you ship a library that claims to support Node 18, 20, and 22 on Linux and Windows. Without a matrix you either test on one version and hope, or you copy-paste the same job five times and let the copies drift. With strategy.matrix you write the job once.

runs-on: ${{ matrix.os }} is the piece that makes each expanded job land on a different runner image. The name: expression matters more than it looks: without it every expanded job appears in the checks list under the same name and you cannot tell which one went red, which is precisely the information a matrix exists to give you.

The exclude entry drops Windows on Node 18 because you never promised that combination and paying for it teaches you nothing. The include entry attaches coverage: true to a single combination, so the coverage step runs once rather than five times and you avoid merging five conflicting reports.

While debugging, set fail-fast: false and read the whole grid before drawing a conclusion. Once the pipeline is stable on a busy repository, add max-parallel so that one pull request cannot occupy every concurrent runner your plan allows and stall everyone else's builds behind it.

Try the working example

yaml
name: Matrix tests

on:
  pull_request:
  workflow_dispatch:

jobs:
  test:
    name: node ${{ matrix.node-version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      max-parallel: 4
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: ['18', '20', '22']
        exclude:
          - os: windows-latest
            node-version: '18'
        include:
          - os: ubuntu-latest
            node-version: '22'
            coverage: true
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Collect coverage on one combination only
        if: matrix.coverage
        run: npm run coverage
You should see
This definition expands into five jobs: Ubuntu with Node 18, 20, and 22, and Windows with Node 20 and 22, which is the 2x3 grid minus the excluded Windows and Node 18 entry. Each runs on its own runner in parallel and appears as its own check named by the name: expression. Only the Ubuntu and Node 22 job has matrix.coverage set, so the coverage step runs there and is skipped in the other four. Because fail-fast is false, a failure in any one combination does not cancel the others; each reports its own pass or fail, and the workflow as a whole is red if any of the five failed. With fail-fast left at its default, the first failure would cancel every sibling still running, and those combinations would report as cancelled rather than as passed or failed, so you would not learn whether the problem was version-specific.

5-minute try-it

Add macos-latest to the os array of your 2x3 matrix. Before pushing, write down how many jobs you expect. Then set fail-fast: true, deliberately break a test only on Node 18, and compare which combinations report results against the same run with fail-fast: false. Finally, move an existing combination's extra flag into an include entry and confirm it decorates the existing job rather than appending a new one.

One important caution

Leaving fail-fast at its default while debugging a version-specific failure: the first red combination cancels the rest, so you never find out whether the other versions are broken too.

Adding an include entry whose keys do not all match an existing combination: instead of adding a flag to that job it silently appends an extra job, and the matrix quietly grows along with the bill.

GitHub Docs - Running variations of jobs in a workflowCI/CD with GitHub Actions

Easy traps

  • Leaving fail-fast at its default while debugging a version-specific failure: the first red combination cancels the rest, so you never find out whether the other versions are broken too.
  • Adding an include entry whose keys do not all match an existing combination: instead of adding a flag to that job it silently appends an extra job, and the matrix quietly grows along with the bill.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Add macos-latest to the os array of your 2x3 matrix. Before pushing, write down how many jobs you expect. Then set fail-fast: true, deliberately break a test only on Node 18, and compare which combinations report results against the same run with fail-fast: false. Finally, move an existing combination's extra flag into an include entry and confirm it decorates the existing job rather than appending a new one.

You'll know it worked when: This definition expands into five jobs: Ubuntu with Node 18, 20, and 22, and Windows with Node 20 and 22, which is the 2x3 grid minus the excluded Windows and Node 18 entry. Each runs on its own runner in parallel and appears as its own check named by the name: expression. Only the Ubuntu and Node 22 job has matrix.coverage set, so the coverage step runs there and is skipped in the other four. Because fail-fast is false, a failure in any one combination does not cancel the others; each reports its own pass or fail, and the workflow as a whole is red if any of the five failed. With fail-fast left at its default, the first failure would cancel every sibling still running, and those combinations would report as cancelled rather than as passed or failed, so you would not learn whether the problem was version-specific.

Matrix Builds: Testing Across Versions | Thuta Learning