GitHub Actions Matrix include/exclude Produces Wrong Combinations
The matrix expands into unexpected jobs because include and exclude follow specific rules: include can either extend an existing combination or add a brand-new one, and exclude must match every key of a combination to remove it.
What this error means
You get more or fewer matrix jobs than intended - an include entry created a whole new job instead of adding a field, or an exclude removed nothing because its keys did not fully match.
matrix:
os: [ubuntu-latest]
node: [18, 20]
include:
- node: 22 # adds a NEW job (no os), total 3 not 2
exclude:
- node: 18 # only matches if it pins all original keysDiagnose it: print the context before you change anything
Most workflow-expression bugs are not syntax errors, they are an expression reading something that is empty. GitHub resolves a missing property to an empty string instead of failing the run, so a wrong reference looks like a logic bug rather than a mistake. Dump the contexts first and you will usually see the answer immediately.
- name: Dump contexts
run: |
echo '--- github ---' ; echo '${{ toJSON(github) }}'
echo '--- needs ---' ; echo '${{ toJSON(needs) }}'
echo '--- steps ---' ; echo '${{ toJSON(steps) }}'
echo '--- matrix ---' ; echo '${{ toJSON(matrix) }}'
echo '--- inputs ---' ; echo '${{ toJSON(inputs) }}'Check the context is allowed where you used it
Contexts are not available everywhere. The same expression can be valid in a step if and invalid in a job if, which is why an expression that works in one workflow fails when moved.
| Where you wrote it | Contexts available there |
|---|---|
run-name | github, inputs, vars |
concurrency | github, inputs, vars |
Top-level env | github, secrets, inputs, vars |
jobs.<id>.if | github, needs, vars, inputs |
jobs.<id>.steps.if | github, needs, strategy, matrix, job, runner, env, vars, steps, inputs |
jobs.<id>.outputs | Full access, including secrets |
Reusable workflow outputs | github, jobs, vars, inputs |
Common causes
include extends vs adds depending on keys
If an include entry only sets keys already in the matrix, it expands a matching combination. If it introduces a new value not in the base, it adds a separate job.
exclude must match a full combination
exclude removes a combination only when its keys match an existing generated combination. A partial key set may not match the way you expect.
How to fix it
Be explicit about include entries
Specify all base keys in an include entry when you want to extend a specific combination, not create a new bare one.
matrix:
os: [ubuntu-latest]
node: [18, 20]
include:
- os: ubuntu-latest
node: 20
coverage: true # extends the existing 20 jobVerify the expansion
- Print the matrix context with a debug job to see the real combinations.
- Pin every relevant key in exclude entries so they match exactly.
- Use fail-fast: false while tuning so one leg does not cancel the rest.
Catch it before it reaches CI
Every failure in this cluster is statically detectable. actionlint parses workflow expressions, checks context availability against the same rules above, and validates needs references, so these bugs never need to cost you a run.
# one-off
docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color
# as a job, before anything expensive runs
- uses: actions/checkout@v4
- run: |
bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
./actionlint -colorHow to prevent it
- Treat include as add/extend and exclude as full-match removal.
- Echo the matrix combinations when changing include/exclude.
- Keep the base matrix small and layer specifics via include deliberately.