Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
Quick Answer
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
The Problem
A GitHub Actions matrix strategy doesn’t generate the expected jobs:
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
# Expected: 6 jobs (3 × 2)
# Actual: Only some jobs run or the matrix fails to expandOr a matrix variable is not accessible in a step:
matrix:
version: ['1.0', '2.0']
steps:
- run: echo "Version is ${{ matrix.version }}"
# Outputs: "Version is " — variable not expandedOr all matrix jobs fail when one fails, even with fail-fast: false:
strategy:
fail-fast: false # Set, but jobs still cancel when one failsOr an include to add a specific combination doesn’t work as expected:
matrix:
os: [ubuntu-latest, windows-latest]
include:
- os: macos-latest
node: 18 # Should add macOS with node 18 onlyWhy This Happens
GitHub Actions matrix strategy has several behaviors that aren’t obvious:
- Matrix variable access — use
${{ matrix.variable }}in YAML context, not$VARIABLEshell syntax (which only works forenv:variables). fail-fast: trueis the default — all in-progress and queued matrix jobs are cancelled when any job in the matrix fails. Setfail-fast: falseexplicitly to let all jobs complete.includeextends the matrix —includeadds or augments combinations. A standalone entry ininclude(no existing matrix keys) adds a new combination. An entry that matches existing keys adds properties to that combination.excludemust match exactly — if anexcludeentry doesn’t exactly match a generated combination’s values, it’s silently ignored.- Large matrix limits — free GitHub Actions accounts have concurrency limits. A 10×10 matrix (100 jobs) may queue most jobs.
- Dynamic matrix — generating a matrix from a previous job output requires specific JSON formatting.
Fix 1: Access Matrix Variables Correctly
Matrix variables use the ${{ matrix.* }} expression syntax everywhere in the job definition — steps, if conditions, job names, service definitions:
jobs:
test:
runs-on: ${{ matrix.os }} # ← matrix variable in runs-on
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
- os: ubuntu-latest
node: 20
experimental: true # Extra property for specific combination
name: Test on ${{ matrix.os }} — Node ${{ matrix.node }} # Job name
steps:
# CORRECT — use ${{ matrix.* }} in GitHub Actions expressions
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
# CORRECT — works in run: steps too
- run: echo "Testing on Node ${{ matrix.node }} / ${{ matrix.os }}"
# WRONG — $MATRIX_NODE is not set by default (it's not in env:)
- run: echo "Node is $MATRIX_NODE" # Outputs empty string
# If you need it as an env var, set it explicitly
- name: Set matrix vars as env
env:
NODE_VERSION: ${{ matrix.node }}
run: echo "Node is $NODE_VERSION" # Now works
# Conditional step using matrix value
- if: ${{ matrix.experimental == true }}
run: echo "This is an experimental build"Fix 2: Disable fail-fast to Run All Matrix Jobs
By default, GitHub cancels all remaining matrix jobs when one fails. Disable fail-fast to see results across all configurations:
jobs:
test:
strategy:
fail-fast: false # ← All jobs run even if one fails
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 testAllow specific combinations to fail without failing the whole matrix:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
- os: windows-latest
node: 20
allow-failure: true # Custom property
steps:
- run: npm test
continue-on-error: ${{ matrix.allow-failure == true }}
# This step won't fail the job if allow-failure is setFix 3: Understand include and exclude Behavior
include and exclude have specific semantics that differ from most templating systems:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
# include: Adds new combinations or extra properties to existing ones
include:
# Adds NEW combination (macos + node 18) — not in the base matrix
- os: macos-latest
node: 18
# Adds 'experimental: true' to the EXISTING ubuntu + node 20 combination
- os: ubuntu-latest
node: 20
experimental: true
# exclude: Removes specific combinations from the base matrix
exclude:
# Removes the windows + node 18 combination
- os: windows-latest
node: 18Result:
- ubuntu + 18
- ubuntu + 20 (with
experimental: true) - windows + 20 (node 18 excluded)
- macos + 18 (added by include)
# Common mistake — using include to REPLACE instead of ADD
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
# include with NO existing matrix keys creates an additional combination
include:
- os: macos-latest # macOS IS added as a new combination
# But it doesn't REPLACE ubuntu or windowsFix 4: Create Dynamic Matrices from Previous Jobs
A matrix can be generated dynamically from a previous step’s output — useful for generating test suites from file discovery:
jobs:
# Job 1: Generate the matrix
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Generate matrix
id: set-matrix
run: |
# Generate a JSON array of values
VERSIONS=$(node -e "console.log(JSON.stringify(['18', '20', '22']))")
echo "matrix={\"node\":${VERSIONS}}" >> $GITHUB_OUTPUT
# Or from file discovery:
# TESTS=$(find tests -name "*.spec.ts" | jq -R -s -c 'split("\n")[:-1]')
# echo "matrix={\"test\":${TESTS}}" >> $GITHUB_OUTPUT
# Job 2: Use the dynamic matrix
test:
needs: setup
strategy:
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Running on Node ${{ matrix.node }}"Generate matrix from a JSON file in the repo:
- name: Set matrix from file
id: set-matrix
run: echo "matrix=$(cat .github/test-matrix.json)" >> $GITHUB_OUTPUT// .github/test-matrix.json
{
"include": [
{ "os": "ubuntu-latest", "node": "18" },
{ "os": "windows-latest", "node": "20" },
{ "os": "macos-latest", "node": "22" }
]
}Fix 5: Use Matrix Outputs in Dependent Jobs
Getting results from matrix jobs into a subsequent job requires collecting all outputs:
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
outputs:
# Matrix jobs can set outputs — but only one value survives (last write wins)
artifact-path: ${{ steps.build.outputs.artifact-path }}
steps:
- id: build
run: |
ARTIFACT="build-${{ matrix.os }}-${{ github.sha }}.tar.gz"
echo "artifact-path=$ARTIFACT" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
# WARNING: Only the LAST matrix job's output is available here
- run: echo "Artifact: ${{ needs.build.outputs.artifact-path }}"
# This is a GitHub Actions limitation — all matrix jobs write to same output key
# WORKAROUND: Upload artifacts from each matrix job and download in deploy jobProper pattern — use artifacts for matrix job outputs:
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }} # Unique name per matrix job
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
# Downloads all uploaded artifacts into artifacts/ directory
- name: List artifacts
run: ls -la artifacts/
# artifacts/build-ubuntu-latest/
# artifacts/build-windows-latest/Fix 6: Limit Matrix Concurrency
In large matrices or with limited runners, control concurrency to avoid overwhelming infrastructure:
jobs:
test:
strategy:
fail-fast: false
max-parallel: 3 # At most 3 jobs run simultaneously (from any matrix)
matrix:
node: [16, 18, 20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
# 12 total jobs, but max 3 run at onceJob-level concurrency group — cancel in-progress runs when new ones start:
jobs:
test:
concurrency:
group: test-${{ matrix.os }}-${{ matrix.node }}-${{ github.ref }}
cancel-in-progress: true # Cancel previous run for same combination
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]Fix 7: Debug Matrix Expansion
When you’re unsure what combinations the matrix generates, inspect the workflow before running it:
jobs:
debug-matrix:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
- os: macos-latest
node: 22
runs-on: ${{ matrix.os }}
steps:
- name: Print matrix context
run: |
echo "OS: ${{ matrix.os }}"
echo "Node: ${{ matrix.node }}"
echo "Full matrix: ${{ toJSON(matrix) }}"GitHub’s matrix visualization — the GitHub Actions UI shows all matrix job combinations in the workflow run view. Each combination appears as a separate job in the job list on the left side.
Validate YAML syntax locally:
# Install actionlint — GitHub Actions linter
brew install actionlint # macOS
# Or: go install github.com/rhysd/actionlint/cmd/actionlint@latest
# Validate the workflow file
actionlint .github/workflows/test.yml
# Catches matrix syntax errors, invalid contexts, and common mistakesCheck effective matrix with GitHub CLI:
# View recent workflow runs and their jobs
gh run list --workflow=test.yml
# View jobs in a specific run
gh run view <run-id> --json jobs --jq '.jobs[].name'Still Not Working?
Matrix with empty values — an empty array in a matrix dimension (node: []) creates zero combinations for that dimension, resulting in no jobs. Validate that your dynamic matrix generation produces non-empty arrays.
String vs number matrix values — matrix: { node: [18, 20] } creates integer values. Some actions expect strings. If you see type mismatch errors, quote the values: node: ['18', '20'].
Secrets not available in matrix jobs — secrets are available in all matrix jobs by default. If a secret appears empty, check it’s defined in the repository/environment settings and the job has access to the environment.
if condition on matrix job — to conditionally run matrix jobs, the if expression is evaluated per job:
jobs:
test:
if: ${{ github.event_name != 'pull_request' || matrix.os != 'windows-latest' }}
# Skip Windows tests on pull requests — run all on push
strategy:
matrix:
os: [ubuntu-latest, windows-latest]For related GitHub Actions issues, see Fix: GitHub Actions Docker Build Push and Fix: GitHub Actions Permission Denied.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.
Fix: GitHub Actions Environment Variables Not Available Between Steps
How to fix GitHub Actions env vars and outputs not persisting between steps — GITHUB_ENV, GITHUB_OUTPUT, job outputs, and why echo >> $GITHUB_ENV is required.