Generating a Dynamic Job Matrix from JSON in GitHub Actions

Your matrix lists eleven workspaces, a pull request touches one, and CI builds all eleven anyway. A dynamic matrix fixes that: a small setup job decides what needs to run, prints it as JSON, and the matrix expands to exactly those entries β€” nothing hard-coded, nothing stale when someone adds a workspace.

When to use this pattern

  • The set of things to build changes over time β€” workspaces, packages, target environments.
  • Most pull requests touch a small subset, and you are paying to build the rest.
  • The matrix currently duplicates a list that already exists somewhere else in the repository.

Prerequisites

How the expansion works

strategy.matrix is evaluated before the job starts, from an expression. That expression can reference a previous job’s output, and fromJSON turns a string into the array or object the matrix needs. The sequencing is the part worth internalising, because every failure mode is a sequencing failure.

From Changed Files to an Expanded Matrix A setup job diffs the pull request, filters the workspace list to those affected, and writes a JSON array to its output. The matrix job references that output through fromJSON and expands into one job per entry. A final aggregate job with a fixed name depends on all of them and reports a single status. setup job diff β†’ filter β†’ sort ~8 seconds GITHUB_OUTPUT ["web","api","emails"] fromJSON expands the matrix build (web) build (api) build (emails) aggregate job β€” fixed name the one thing branch protection can require The matrix job names are "build (web)", "build (api)" … β€” they change per run, which is why the aggregate exists.

Complete working example

# .github/workflows/build.yml β€” matrix computed from what the pull request touched.
name: Build
on: [pull_request]

jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.plan.outputs.targets }}
      count:   ${{ steps.plan.outputs.count }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }        # full history: the diff below needs the merge base

      - id: plan
        run: |
          BASE="origin/${{ github.base_ref }}"
          # Every workspace directory that has at least one changed file under it.
          changed=$(git diff --name-only "$BASE"... \
            | awk -F/ '$1=="packages" && NF>2 {print $2}' | sort -u)

          # Shared code invalidates everything β€” build the full set instead.
          if git diff --name-only "$BASE"... | grep -qE '^(package-lock.json|tsconfig.base.json)$'; then
            changed=$(ls packages)
          fi

          # jq -c produces ONE line, which is what GITHUB_OUTPUT requires.
          json=$(printf '%s\n' $changed | jq -R . | jq -sc .)
          echo "targets=$json"            >> "$GITHUB_OUTPUT"
          echo "count=$(echo "$json" | jq 'length')" >> "$GITHUB_OUTPUT"
          echo "matrix will be: $json"

  build:
    needs: plan
    if: needs.plan.outputs.count != '0'   # a zero-length matrix is a hard error, so skip instead
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.plan.outputs.targets) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci --ignore-scripts
      - run: npm run build --workspace "packages/${{ matrix.target }}"

  # Fixed name, always runs β€” this is the required status check.
  build-complete:
    needs: [plan, build]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Assert the matrix succeeded or was legitimately skipped
        run: |
          # "skipped" is success here: it means nothing needed building.
          case "${{ needs.build.result }}" in
            success|skipped) echo "ok (${{ needs.plan.outputs.count }} target(s))" ;;
            *) echo "build matrix failed: ${{ needs.build.result }}"; exit 1 ;;
          esac

Step-by-step walkthrough

fetch-depth: 0 is not optional. The default shallow checkout has no merge base, so git diff origin/main... compares against nothing useful and either errors or returns the whole tree. The extra fetch costs a few seconds and is the difference between a correct plan and a plan that silently builds everything.

Emit compact JSON on one line. GITHUB_OUTPUT is a key-value file parsed line by line; pretty-printed JSON spanning several lines is truncated at the first newline and produces a parse error in fromJSON that names neither the job nor the value. jq -sc guarantees a single line.

Handle the β€œshared file changed” case explicitly. A lockfile or base tsconfig change affects every workspace, and a diff-based plan will happily report zero targets because no packages/* path changed. Widening to the full set on those paths is the cheap, safe answer; the alternative is a green pull request that never built anything.

Guard the empty matrix. GitHub rejects a zero-length matrix with Matrix must define at least one vector β€” a job-level error, not a skip. The if: on count != '0' turns β€œnothing to do” into a clean skip.

Know which JSON shape you are emitting. fromJSON accepts three different shapes and each one means something different to the matrix, which is where most confusing expansions come from.

Three JSON Shapes and What Each Expands To A flat array of three strings assigned to one matrix key produces three jobs read as matrix.target. An array of three objects assigned to include produces three jobs whose fields are read individually. A full matrix object with two keys of three and two values produces six jobs from the cross product. JSON EMITTED JOBS CREATED READ IN STEPS AS ["web","api","emails"] matrix: { target: fromJSON(…) } 3 jobs matrix.target [{"name":"web","node":20}, …] matrix: { include: fromJSON(…) } 3 jobs matrix.name, matrix.node {"os":[…3…],"node":[…2…]} matrix: fromJSON(…) 6 jobs cross product matrix.os, matrix.node The third shape multiplies. Emit it only when you mean a cross product β€” otherwise prefer the first two.

The practical advice is to keep the flat array as the default and reach for the object form only when a target genuinely needs per-entry settings, such as a different Node version or a longer timeout. The full-matrix form is powerful and easy to get wrong, because a computed cross product can quietly grow past the concurrency budget discussed in optimizing pipeline concurrency and queue limits.

Add an aggregate job for branch protection. Matrix job names embed the matrix values, so build (web) exists on one pull request and not the next; you cannot list them as required checks. A fixed-name job that inspects needs.build.result gives branch protection something stable to require, and treating skipped as success is what stops a documentation-only pull request from being blocked forever.

Verification

# 1. The plan step's JSON is valid and single-line, before you trust it in CI.
BASE=origin/main
git diff --name-only "$BASE"... | awk -F/ '$1=="packages" && NF>2 {print $2}' | sort -u \
  | jq -R . | jq -sc . | tee /tmp/targets.json | jq 'type, length'
# β†’ "array"
# β†’ 3

# 2. The workflow expression parses the same way GitHub will.
jq -e 'type == "array" and (map(type == "string") | all)' /tmp/targets.json \
  && echo "matrix-safe"

# 3. After a run, confirm only the expected jobs were created.
gh run view --json jobs --jq '.jobs[].name' | sort

Expected from the third command on a single-workspace pull request:

build (web)
build-complete
plan

If you see every workspace listed, the diff fell back to the full set β€” check the merge base and the shared-file rule before assuming the filter is wrong.

Runner Minutes: Fixed Matrix vs Dynamic Matrix Bars for eight sample pull requests. The fixed matrix consumes a constant 44 runner-minutes for every pull request. The dynamic matrix consumes between 4 and 44 minutes depending on how many workspaces the change actually touched, averaging about 11. 0 22 44 min fixed matrix β€” always 44 min, 11 workspaces lockfile change β€” full set, correctly PR 1 PR 4 PR 7 Mean drops from 44 to about 11 runner-minutes per pull request; the plan job itself costs 8 seconds.

Common pitfalls

Building the matrix from a stale list. If the setup job reads a hard-coded array, you have moved the duplication rather than removed it. Derive the list from the filesystem or from the tool that already owns it, as in configuring Nx affected commands.

Forgetting fail-fast: false. With the default, the first failing target cancels the rest, so a developer fixes one workspace, pushes, and finds another β€” turning one red run into several. The exception is a matrix used purely for compatibility smoke tests, where a single failure genuinely does invalidate the run and cancelling saves real minutes.

Emitting objects when you meant strings. matrix.target is whatever the array element was. An array of objects gives you matrix.target.name, and forgetting the extra field produces the literal string [object Object] in a path. Keep the array flat unless you genuinely need several dimensions, which is covered in excluding and including matrix combinations.

← Back to Managing Environment Matrices in GitHub Actions