expand-matrix
Unroll a compile-time matrix into discrete named jobs you can `needs:` a single leg of.
A native GitHub Actions matrix fans out one anonymous job — there's no way for a
downstream job to needs: a single leg (e.g. "deploy only after the linux/x64
build"). expand_matrix unrolls a compile-time-known strategy.matrix into one
real, named job per leg, so you can target legs individually with a needs: selector.
It's a pure compile-time transform: the strategy/matrix and the expand_matrix
key never reach the generated workflow. Every ${{ matrix.* }} reference is rewritten
to the concrete leg value.
jobs:
build:
expand_matrix: true
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [linux, windows]
arch: [x64, arm64]
steps:
- run: ./build --arch ${{ matrix.arch }}
deploy:
needs: build(os=linux, arch=x64)
runs-on: ubuntu-latest
steps:
- run: ./deploy.shjobs:
build-linux-x64:
runs-on: linux
steps:
- run: ./build --arch x64
build-linux-arm64:
runs-on: linux
steps:
- run: ./build --arch arm64
build-windows-x64:
runs-on: windows
steps:
- run: ./build --arch x64
build-windows-arm64:
runs-on: windows
steps:
- run: ./build --arch arm64
deploy:
needs: build-linux-x64
runs-on: ubuntu-latest
steps:
- run: ./deploy.shjobs:
build:
expand_matrix: true
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [linux, windows]
arch: [x64, arm64]
steps:
- run: ./build --arch ${{ matrix.arch }}
deploy:
needs: build(os=linux, arch=x64)
runs-on: ubuntu-latest
steps:
- run: ./deploy.shjobs:
build-linux-x64:
runs-on: linux
steps:
- run: ./build --arch x64
build-linux-arm64:
runs-on: linux
steps:
- run: ./build --arch arm64
build-windows-x64:
runs-on: windows
steps:
- run: ./build --arch x64
build-windows-arm64:
runs-on: windows
steps:
- run: ./build --arch arm64
deploy:
needs: build-linux-x64
runs-on: ubuntu-latest
steps:
- run: ./deploy.shWhen to reach for it
Use expand_matrix when a later job depends on a specific leg, not the whole
fan-out — staged deploys, leg-specific gates, collecting one leg's artifact. If you
just want N identical runs and a single barrier, a plain native matrix is simpler;
keep using that. To loop a known list into inline steps within a single job (no
extra jobs), use for-each. For a matrix whose shape isn't
known until the run starts, use dynamic-matrix —
that stays a native runtime matrix and is out of scope here (see
Limits).
Slugs
Each leg becomes a job named ${jobId}-${values…}, joining the axis values in
declared key order, lowercased with non-alphanumerics collapsed to _:
| matrix | job id |
|---|---|
os: linux, arch: x64 | build-linux-x64 |
os: windows, arch: arm64 | build-windows-arm64 |
node: 20 | build-20 |
Ordering is the cartesian product in declared key order, values in declared order, so
slugs are stable across builds. Only axis values form the slug —
include-injected extra props don't. If two legs slug to the
same id (or a slug collides with another job), Actio hard-errors
(expand-matrix-slug-collision) rather than silently dropping a job.
needs: selectors
Target a leg with jobId(key=value, …):
needs: build(os=linux, arch=x64) # → build-linux-x64 (exactly one leg)- Full selector (all axes pinned) → exactly one slug.
- Partial selector (a subset of axes) → every matching leg.
build(os=linux)on anos × archmatrix expands to[build-linux-x64, build-linux-arm64]. - Multiple selectors in a
needs:array are each resolved and the results unioned (order preserved, deduped). - A plain job id with no
(passes through untouched, so ordinaryneeds:still work.
Values may be unquoted (run up to ,/)) or single/double-quoted to allow spaces and
commas. Whitespace around tokens is ignored.
needs:
- build(os=linux) # all linux legs
- build(os=windows, arch=x64) # one specific legSelectors are validated against the expanded set: an unknown base job, an unknown axis
key, or a selector that matches zero legs is a hard error
(expand-matrix-unknown-selector / -unknown-key / -no-match) — a typo never
silently compiles to a missing dependency.
matrix.* rewriting
After expansion there's no matrix context, so every ${{ matrix.<path> }} is replaced
with that leg's value, everywhere it appears (runs-on, env, if, name, step
run/uses/with/env/if, container, services, outputs, …):
- A field that is a sole reference keeps the value's type —
node-version: ${{ matrix.node }}withnode: [18, 20]emits the number18, not"18". - Inside larger strings the value is interpolated as text —
name: build on ${{ matrix.os }}→build on linux. - Inside a compound expression each ref becomes an expression literal —
if: ${{ matrix.os == 'linux' }}→${{ 'linux' == 'linux' }}(objects/arrays becomefromJSON('…')).
include / exclude
Standard GitHub matrix semantics, evaluated at compile time:
strategy:
matrix:
os: [linux, windows]
arch: [x64, arm64]
exclude:
- os: windows
arch: arm64 # drop just that leg → 3 jobs
include:
- os: linux
sku: pro # adds matrix.sku=pro to the linux legs
- os: mac # no matching leg → appended as a new build-mac jobexclude drops every base leg containing all the entry's key=value pairs (a
partial exclude like {os: windows} drops the whole windows column). include merges
into every compatible leg without overwriting an axis value; an entry that matches no
leg is appended as a standalone leg (and later includes see earlier additions) — exactly
as native GitHub Actions resolves it.
Limits
The matrix must be compile-time known. A runtime matrix —
matrix: ${{ fromJSON(...) }} or any ${{ }} in an axis value — is rejected
(expand-matrix-runtime); that's what dynamic-matrix
is for, and it stays native.
GitHub caps a run at
256 jobs.
Because expand_matrix emits one real job per leg, more than 256 legs is caught at
compile time (expand-matrix-too-many-legs) instead of failing at runtime.
Every expanded leg is a distinct job in the run graph. A 4-leg build is 4 jobs; a
big matrix can crowd the Actions UI fast. Reach for expand_matrix when you need to
address legs individually — not as a default replacement for a native matrix.