2.2.4. Matrix Strategies
💡 First Principle: A matrix is a loop over job definitions — GitHub multiplies out the axes you declare and generates one independent job per combination, which is how you test five Node versions on three operating systems by writing one job instead of fifteen.
The base syntax multiplies every axis together, and include/exclude reshape the product:
strategy:
fail-fast: false # default is TRUE
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18 # drop one combination
include:
- os: ubuntu-latest
node: 22
experimental: true # add a variable to an existing combination
- os: ubuntu-latest
node: 23 # add an entirely new combination
runs-on: ${{ matrix.os }}
The include/exclude semantics are precisely the kind of detail the exam likes: exclude removes combinations from the product; include either adds new variables to combinations that already match its other keys, or appends a whole new job if it doesn't match any. exclude is applied before include, so an include can re-add something excluded. Matrices are capped at 256 jobs per workflow run.
Two controls govern behavior under stress. fail-fast (default true) cancels all in-progress and pending matrix jobs the moment any variant fails — great for fast feedback, terrible when you need the full compatibility picture, so compatibility testing almost always sets it to false. max-parallel throttles concurrent variants, which matters for cost, for rate-limited external services, and for self-hosted fleets with limited capacity.
Matrices can also be generated dynamically: a prior job emits a JSON array as an output and the consumer does matrix: ${{ fromJSON(needs.setup.outputs.list) }} — the standard answer to "how do we test only the packages that changed?"
⚠️ Exam Trap: fail-fast defaults to true. If a scenario reports that "most variants show as cancelled after one failed," nothing is broken — that's the default, and fail-fast: false is the fix. Also note runner labels like ubuntu-latest shift to new major versions over time, so pinning matters for reproducibility.
Reflection Question: You want fast feedback on PRs but a complete compatibility report on nightly runs, from the same matrix. Which single key would you set differently between the two triggers, and how would you express it?