Skip to content

Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing

FixDevs ·

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 expand

Or 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 expanded

Or all matrix jobs fail when one fails, even with fail-fast: false:

strategy:
  fail-fast: false   # Set, but jobs still cancel when one fails

Or 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 only

Why This Happens

GitHub Actions matrix strategy has several behaviors that aren’t obvious:

  • Matrix variable access — use ${{ matrix.variable }} in YAML context, not $VARIABLE shell syntax (which only works for env: variables).
  • fail-fast: true is the default — all in-progress and queued matrix jobs are cancelled when any job in the matrix fails. Set fail-fast: false explicitly to let all jobs complete.
  • include extends the matrixinclude adds or augments combinations. A standalone entry in include (no existing matrix keys) adds a new combination. An entry that matches existing keys adds properties to that combination.
  • exclude must match exactly — if an exclude entry 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 test

Allow 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 set

Fix 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: 18

Result:

  • 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 windows

Fix 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 job

Proper 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 once

Job-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 mistakes

Check 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 valuesmatrix: { 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles