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.
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 thisStep-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.
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.
Related
- Managing Environment Matrices in GitHub Actions β the parent guide on matrix design.
- Generating a Dynamic Job Matrix from JSON in GitHub Actions β computing the dimensions instead of declaring them.
- Setting Up Matrix Testing for Cross-Browser Frontend Builds β the browser dimension in practice.
- Optimizing Pipeline Concurrency & Queue Limits β what happens when the trimmed matrix is still wider than your runner pool.
β Back to Managing Environment Matrices in GitHub Actions