Skip to content
OpsAtlas

GitHub Actions Matrix Jobs and Required Status Checks

By OpsAtlas · · 2 min read

Table of contents

You add Node 24 to the test matrix, open a pull request, and it sits there forever:

test (ubuntu-latest, 22)   Expected — Waiting for status to be reported

Nothing is running and nothing failed. The branch protection rule requires a check that no longer exists. Matrices and required status checks are a common source of stuck pull requests. A small change to how the workflow is structured avoids the problem.

Why it happens

Branch protection (and repository rulesets) require checks by name. A matrix job’s name includes its values: test (ubuntu-latest, 22). When you change the matrix, by adding a version, removing an OS or adding an include, the job names change too.

  • A removed combination leaves a required check that will never be reported. The PR stays blocked.
  • An added combination runs, but nothing requires it. If it fails, the PR can still be merged.

Both are bad. The first blocks everyone, and the second lets failures through without anyone noticing.

Fix: require one aggregate job

Don’t require the matrix jobs. Add one job that depends on the whole matrix and fails if any part of it failed, and require only that job:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [20, 22, 24]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

  tests-passed:
    if: always()
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: exit 1
        if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}

if: always() matters. Without it, a failed matrix leaves tests-passed skipped, and branch protection treats a skipped required check as passing. With it, the job runs every time and fails explicitly.

Now you can change the matrix as much as you like. The required check stays tests-passed.

Keep pull request matrices small

A full compatibility matrix is useful on main, but slow on every push to a pull request. A common split is a small matrix for PRs and the full one on main and on a schedule. Generate the matrix in a setup job and pass it on with fromJSON:

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.m.outputs.matrix }}
    steps:
      - id: m
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            echo 'matrix={"os":["ubuntu-latest"],"node":[22]}' >> "$GITHUB_OUTPUT"
          else
            echo 'matrix={"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[20,22,24]}' >> "$GITHUB_OUTPUT"
          fi

  test:
    needs: setup
    strategy:
      fail-fast: false
      matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
    runs-on: ${{ matrix.os }}
    steps:
      - run: echo "testing ${{ matrix.os }} / node ${{ matrix.node }}"

Required checks are where this pays off: the matrix changes depending on the event, but tests-passed is always there.

Know what the matrix will produce

include and exclude are where matrices get confusing. An include entry that matches existing combinations adds keys to them. One that doesn’t match creates a brand new job. An exclude with a single key removes a whole row. Getting this wrong means you test fewer combinations than you think, or more than you pay for. A single matrix is also limited to 256 jobs.

Paste the strategy block, or the whole workflow, into the GitHub Actions matrix expander. It shows the exact list of jobs GitHub will create, with their names, and which entries each exclude removed and each include added. The names it shows are the check names branch protection would see, so you can tell before merging which required checks a matrix change will break.

Checklist

  • Don’t require matrix jobs by name. Require one aggregate job with if: always().
  • Fail the aggregate job on failure and cancelled.
  • Set fail-fast: false on compatibility matrices so you see every failure.
  • Use a small matrix on PRs and the full one on main and on a schedule.
  • Expand the matrix before you merge to see the exact job and check names.

Try it yourself

  • GitHub Actions matrix expander

    Preview every job a GitHub Actions strategy.matrix creates, with include and exclude applied exactly as GitHub does, before you push.

    CI/CD