Excluding and Including Matrix Combinations Without Duplication

Three operating systems by four Node versions by two browsers is twenty-four jobs, of which maybe fifteen are combinations you actually support. exclude and include trim that down β€” but they are applied in an order that surprises people, and include can silently add jobs rather than modify them.

When to use this pattern

  • Your matrix expands to combinations that cannot work β€” a browser that does not exist on a platform, a runtime your oldest dependency does not support.
  • You need one combination to differ slightly (a longer timeout, an extra flag) without copying the whole entry.
  • The matrix has grown past the point where anyone can say what it runs without expanding it by hand.

Prerequisites

How the expansion is actually computed

The order is fixed and it is the source of every surprise: build the full cross product, remove everything matching an exclude entry, then apply include entries β€” which may decorate a surviving job or create a brand new one.

Cross Product, Then Exclude, Then Include Stage one shows a grid of twelve jobs from three operating systems by four Node versions. Stage two removes four cells matched by exclude rules, leaving eight. Stage three shows one include entry adding a field to an existing cell and a second include entry creating a new ninth job that was not in the original grid. 1 Β· CROSS PRODUCT β€” 3 os Γ— 4 node = 12 2 Β· EXCLUDE β€” 4 removed, 8 remain dashed = matched an exclude rule 3 Β· INCLUDE decorates ubuntu+20 β†’ adds coverage: true no match β†’ creates a 9th job final: 9 jobs, not 8 The rule to remember: exclude only ever subtracts; include can either decorate or add. An include entry decorates when every key it shares with a matrix dimension matches a surviving combination. If any shared key disagrees, it is treated as a new job specification instead. This is why a matrix that "should" shrink sometimes grows after an apparently harmless include.

Complete working example

# .github/workflows/compat.yml β€” a trimmed cross-product matrix.
name: Compatibility
on: [pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os:      [ubuntu-latest, windows-latest, macos-latest]
        node:    [18, 20, 22, 24]

        exclude:
          # Partial match: every key listed must match, unlisted keys are wildcards.
          # This one line removes ALL four macOS jobs on old runtimes (2 of them).
          - { os: macos-latest, node: 18 }
          - { os: macos-latest, node: 20 }
          # Windows + Node 24 is not supported by our native dependency yet.
          - { os: windows-latest, node: 24 }

        include:
          # DECORATES an existing combination β€” adds a field, creates no job,
          # because os+node here match a surviving cell exactly.
          - { os: ubuntu-latest, node: 20, coverage: true, timeout: 20 }
          # DECORATES every remaining windows job: only `os` is shared with a
          # dimension, and it matches, so `shell` is added to both of them.
          - { os: windows-latest, shell: pwsh }

    timeout-minutes: ${{ matrix.timeout || 10 }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ matrix.node }} }

      - name: Show what this job actually resolved to
        run: echo '${{ toJSON(matrix) }}'      # auditable expansion, in the log

      - run: npm ci --ignore-scripts
      - run: npm test
      - if: matrix.coverage
        run: npm run coverage:upload           # only the one decorated job does this

Step-by-step walkthrough

exclude matches partially, and that is the feature. An exclude entry naming only { os: macos-latest } removes every macOS job regardless of Node version. Listing more keys narrows the match. Reaching for one exclude per unwanted cell is the most common way a matrix becomes unreadable β€” look for the shared key first.

include decorates when the shared keys match. { os: windows-latest, shell: pwsh } shares exactly one key with a dimension, and it matches two surviving jobs, so both get matrix.shell. No new jobs appear. This is the behaviour you almost always want and the reason include beats duplicating a whole entry.

include creates a job when they do not. Add { os: ubuntu-latest, node: 16 } and you get a ninth job, because no surviving combination has node: 16 β€” it was never in the dimension. That is occasionally useful for a one-off legacy check, and otherwise it is a bug that presents as β€œthe matrix got bigger when I trimmed it”.

Order the file to match the evaluation order. Dimensions, then exclude, then include, top to bottom. It costs nothing and means the next reader can follow the same three stages the runner does.

Echo toJSON(matrix). Every job then records exactly what it resolved to, which turns a class of confusing failures β€” β€œwhy did this job run with Node 18?” β€” into a log line rather than a reconstruction exercise.

Verification

Reproduce the expansion locally before pushing, so the job count is a prediction rather than a discovery:

# Expand the cross product, apply excludes, and count what remains.
jq -n '
  [ {os:"ubuntu-latest"},{os:"windows-latest"},{os:"macos-latest"} ] as $os
  | [18,20,22,24] as $node
  | [ $os[] as $o | $node[] as $n | { os: $o.os, node: $n } ] as $full
  | [ {os:"macos-latest",node:18},{os:"macos-latest",node:20},{os:"windows-latest",node:24} ] as $ex
  | ($full | map(select(. as $c | $ex | any(. == $c) | not))) as $kept
  | { full: ($full|length), excluded: ($ex|length), kept: ($kept|length), jobs: $kept }
'

Expected:

{ "full": 12, "excluded": 3, "kept": 9, "jobs": [ … ] }

Then confirm the real run agrees:

gh run view --json jobs --jq '[.jobs[].name] | length, .[]'

If the live count exceeds the predicted one, an include entry created a job β€” compare the names against the predicted list to find which.

Explicit Entries vs Dimensions Plus Excludes The left approach lists nine complete include entries, taking about 27 lines and requiring a new entry whenever a Node version is added. The right approach declares two dimensions and three exclude rules in about 9 lines, and adding a Node version is a one-word change. Both produce nine jobs. 9 explicit include entries ~27 lines of YAML adding Node 26 β†’ 3 new entries by hand unsupported pairs invisible β€” nothing records why 2 dimensions + 3 excludes ~9 lines of YAML adding Node 26 β†’ one value in the list each exclude documents an unsupported pair both expand to the same 9 jobs Prefer the right-hand form: the excludes are the documentation of what you do not support.

Common pitfalls

Using include where you meant exclude. Adding entries to narrow a matrix does the opposite. If the job count went up, an include created rather than decorated.

Excluding one cell at a time. Nine excludes to remove nine cells usually means a shared key was missed. Look for the dimension value they have in common and exclude on that alone.

Requiring matrix job names as status checks. The names encode the matrix values, so trimming the matrix renames or removes checks and branch protection blocks on a check that no longer exists. Require a fixed-name aggregate job instead, as set out in generating a dynamic job matrix from JSON.

Where this stops helping

Trimming keeps a matrix honest, but it does not make a wide matrix cheap. Past a certain width the constraint moves from YAML clarity to runner supply: twenty-four jobs on a ten-runner pool run in three waves, so the wall-clock is set by the queue rather than by the slowest job. At that point the productive move is to shrink the dimensions, not to add more excludes.

The usual reduction is to ask what each dimension is actually testing. An operating-system dimension is worth its cost only if you ship platform-specific code or native modules; otherwise a single Linux runner plus a nightly cross-platform run gives the same signal at a fraction of the width. A runtime-version dimension is worth it at the boundaries β€” oldest supported and newest β€” with the middle versions covered nightly rather than on every pull request. Applying both reductions typically takes a 24-job pull-request matrix to 6, which is a bigger win than any amount of exclude-tuning, and it moves the deleted coverage to a schedule rather than deleting it outright.

Reducing Matrix Width Instead of Adding Excludes Three stages: the full pull-request matrix of twenty-four jobs, then twelve after collapsing the operating-system dimension to Linux only, then six after keeping only the oldest and newest runtime versions. A note shows the removed combinations moving to a nightly scheduled run rather than being dropped. 24 jobs 3 os Γ— 4 node Γ— 2 browser os β†’ 1 12 jobs linux Γ— 4 node Γ— 2 browser node β†’ 2 6 jobs boundary versions only the other 18 combinations run nightly, not never Width reduction beats exclude-tuning past ~10 jobs.

← Back to Managing Environment Matrices in GitHub Actions