GitHub Actions in Depth
Topic 54

Matrix Builds

CI/CD

A matrix turns one job definition into many. Declare a few axes — operating systems and language versions, say — and Actions runs the Cartesian product as separate, parallel jobs: three OSes times four versions is twelve jobs from a dozen lines of YAML. It is how you prove a library works everywhere it claims to, without copy-pasting a job per combination.

The controls around the grid are what make it practical: include and exclude trim or extend the set, fail-fast decides whether one failure cancels the rest, and max-parallel caps how many run at once.

The Matrix Grid

List multiple keys under strategy.matrix and Actions expands every combination into its own job. Reference the values with ${{ matrix.<key> }} — most often matrix.os in runs-on: so a single block targets every platform.

A matrix across OS and Node versions
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

include and exclude

These two read as opposites and are constantly confused. exclude is the filter: it removes specific combinations from the generated grid, such as a runtime that does not build on an old OS. include does the reverse — it adds variables to combinations that already match, or appends entirely new combinations the product would not have produced.

A variable that only an include entry sets will be empty in every other job. If you read matrix.extra in a step, make sure every combination defines it, or guard the step with an if:.

fail-fast

The default is fail-fast: true, which cancels every in-progress matrix job the instant one fails. That is exactly wrong when you are trying to learn which versions broke, because the first failure hides the rest. Set fail-fast: false on test matrices so every combination reports its own result.

max-parallel

A wide matrix can spawn dozens of jobs at once, exhausting your concurrency limit or hammering an external API's rate limit. max-parallel caps how many matrix jobs run simultaneously, trading some wall-clock time for staying inside self-hosted capacity or third-party quotas.

Consuming Matrix Values

Beyond runs-on:, matrix values flow into any step expression. For data-driven sets, a prior job can emit a JSON array as an output and a later job can build its matrix from it with fromJSON() — so the combinations are computed at runtime rather than hardcoded.

Keeping the varying values in matrix.* and referencing them everywhere is what lets one job definition cover the whole grid instead of duplicating near-identical jobs.

Common Mistakes
  • Leaving fail-fast at its default true on a test matrix, so the first failing combination cancels the others and hides which versions broke.
  • Defining a 50-job matrix with no max-parallel, exhausting the concurrency limit and leaving jobs queued for a long time.
  • Using include expecting it to remove combinations — it adds them; exclude is the filter.
  • Hardcoding runtime versions in the matrix and never updating them, so the build quietly stops covering current releases.
  • Reading matrix.x in a step where x is only set by an include that does not apply, getting an empty value.
Best Practices
  • Set fail-fast: false on test matrices when you want the complete picture of which combinations fail.
  • Use max-parallel to stay within self-hosted runner capacity or external API rate limits.
  • Use exclude to drop known-incompatible pairs, such as an old runtime on a newer OS.
  • Generate the matrix dynamically from a prior job's JSON output with fromJSON() when the set is data-driven.
  • Keep varying values in matrix.* and reference them in runs-on: and steps to avoid duplicating job definitions.
Comparable toolsGitLab CI/CD parallel:matrixCircleCI matrix jobsAzure Pipelines strategy: matrixJenkins declarative matrix directive

Knowledge Check

What is the difference between include and exclude in a matrix?

  • exclude removes combinations; include adds variables to matches or appends new ones
  • Both filter the grid down to fewer combinations; include is just the newer spelling of exclude
  • include removes combinations from the grid and exclude appends new ones
  • They only reorder the sequence jobs run in, not the set of combinations

You want to see every failing version, not just the first. What do you set?

  • fail-fast: false
  • fail-fast: true
  • max-parallel: 1
  • continue-on-error: true on the matrix

Why does max-parallel matter with self-hosted runners?

  • It caps simultaneous matrix jobs so the grid stays within your finite runner capacity
  • It speeds the grid up by running more matrix combinations at once than the default
  • It is mandatory for any matrix that defines more than two separate axes
  • It changes which runner labels in the pool are eligible to pick up jobs

How is a dynamic matrix built from another job's output?

  • A job emits a JSON array output and a later job passes it through fromJSON() into matrix
  • The matrix automatically inherits any prior job's exported environment variables at runtime
  • You list every combination by hand under the include key in the matrix
  • Dynamic matrices computed at runtime are not supported in Actions

You got correct