dynamic-matrix
The headline feature — generate a matrix at runtime from a script.
When the matrix list isn't known until the run — open PRs, changed packages, a
fan-out computed from an API — a static strategy.matrix can't express it. A
job-level dynamic-matrix block runs a script that prints a JSON array (or
{include:[...]}), and Actio generates the setup job plus all the wiring.
If the list is known at build time you don't need this: loop it inline with
for-each, or unroll it into individually addressable
named jobs with expand-matrix. dynamic-matrix is
specifically for lists computed at runtime.
It splits into a setup job (compact JSON via heredoc) and the matrix job (needs +
fromJSON + fail-fast: false + empty-matrix guard):
jobs:
test:
runs-on: ubuntu-latest
dynamic-matrix:
script: echo '["1", "2", "3"]'
alias: shard
steps:
- run: ./run-shard.sh ${{ matrix.shard }}jobs:
actio_setup_test:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.actio_eval.outputs.matrix }}
steps:
- name: Evaluate dynamic matrix
id: actio_eval
run: |-
{
echo 'matrix<<ACTIO_EOF'
echo '["1", "2", "3"]' | jq -c .
echo ACTIO_EOF
} >> "$GITHUB_OUTPUT"
test:
runs-on: ubuntu-latest
needs: [actio_setup_test]
strategy:
matrix:
shard: ${{ fromJSON(needs.actio_setup_test.outputs.matrix) }}
fail-fast: false
if: needs.actio_setup_test.outputs.matrix != '[]' && needs.actio_setup_test.outputs.matrix != ''
steps:
- run: ./run-shard.sh ${{ matrix.shard }}jobs:
test:
runs-on: ubuntu-latest
dynamic-matrix:
script: echo '["1", "2", "3"]'
alias: shard
steps:
- run: ./run-shard.sh ${{ matrix.shard }}jobs:
actio_setup_test:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.actio_eval.outputs.matrix }}
steps:
- name: Evaluate dynamic matrix
id: actio_eval
run: |-
{
echo 'matrix<<ACTIO_EOF'
echo '["1", "2", "3"]' | jq -c .
echo ACTIO_EOF
} >> "$GITHUB_OUTPUT"
test:
runs-on: ubuntu-latest
needs: [actio_setup_test]
strategy:
matrix:
shard: ${{ fromJSON(needs.actio_setup_test.outputs.matrix) }}
fail-fast: false
if: needs.actio_setup_test.outputs.matrix != '[]' && needs.actio_setup_test.outputs.matrix != ''
steps:
- run: ./run-shard.sh ${{ matrix.shard }}Example: one job per open PR
The pattern dynamic matrices exist for — the list literally doesn't exist until the run starts. Query the GitHub API for open PRs, then fan out one runner per PR that checks out that PR's code:
name: Per-PR checks
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *" # hourly sweep of open PRs
jobs:
check-pr:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
dynamic-matrix:
mode: include
script: |
gh pr list --state open \
--json number,headRefName,headRefOid \
| jq -c '{include: map({pr: .number, branch: .headRefName, sha: .headRefOid})}'
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- run: gh pr checkout ${{ matrix.pr }}
env:
GH_TOKEN: ${{ github.token }}
- run: |
echo "Checking PR #${{ matrix.pr }} (${{ matrix.branch }}) @ ${{ matrix.sha }}"
npm ci
npm testinclude mode lets each entry carry the
pr/branch/sha together, readable as ${{ matrix.pr }} etc. The job-level
permissions and env are carried onto the generated setup job, so gh pr list is
authorized there too. Using gh pr checkout (rather than a bare ref:) means it also
works for fork PRs, whose commits a plain checkout can't reach.
Three things Actio handles that you'd otherwise hand-write: the setup job's
GITHUB_OUTPUT heredoc plumbing, the fail-fast: false default so one bad PR doesn't
cancel the rest, and the empty-matrix guard — with zero open PRs the matrix is
[], which GitHub would otherwise error on; the generated if: skips the job cleanly
instead.
Options
script (required), alias (wrap a scalar array under matrix.<alias>; omit for
raw {include:[...]} mode), mode (include or alias; explicit override of the
alias inference), checkout (default true when script is a local path),
runs-on, shell, compact (default true), fail-fast (default false), id.
Heterogeneous fan-out (include mode)
By default a GitHub Actions matrix fans out one job template, so every shard is
identical. To make each generated entry carry its own attributes — its own
runs-on, env, container, etc. — have your script print a
{include:[...]}
object instead of a scalar array. Each object in include becomes one job, and its
keys are readable as ${{ matrix.<key> }}. This is the include mode (the
default when no alias is set; set mode: include to make it explicit).
jobs:
test:
runs-on: ${{ matrix.runs-on }}
dynamic-matrix:
mode: include
script: ./scripts/plan-shards.sh
steps:
- run: ./run.sh ${{ matrix.shard }}If plan-shards.sh prints:
{"include":[{"shard":"gpu","runs-on":"gpu-runner"},{"shard":"unit","runs-on":"ubuntu-latest"}]}then the gpu shard runs on gpu-runner and the unit shard on ubuntu-latest —
one job definition, heterogeneous runners. The include array passes straight into
strategy.matrix:
test:
runs-on: ${{ matrix.runs-on }}
needs: [actio_setup_test]
strategy:
matrix: ${{ fromJSON(needs.actio_setup_test.outputs.matrix) }}
fail-fast: false
if: needs.actio_setup_test.outputs.matrix != '[]' && needs.actio_setup_test.outputs.matrix != ''
steps:
- run: ./run.sh ${{ matrix.shard }}Reference per-item attributes ergonomically with runs-on: ${{ matrix.runs-on }} on
the job. Actio notices the job's own runs-on is a matrix expression and keeps the
generated setup job on a real runner (runs-on from dynamic-matrix.runs-on,
else ubuntu-latest) so it doesn't inherit the empty expression.
Every entry that omits a key you reference resolves to empty. If you write
runs-on: ${{ matrix.runs-on }}, make sure every include entry sets
runs-on.
Limits
A generated matrix still fans out one job definition with a shared steps: — you
can't print a different step list per item. Heterogeneity is limited to per-item
attributes (runs-on, env, container, inputs), not a different step graph per
entry. Use for-each or separate jobs if you need
structurally different work.
Matrix is also capped at 256 jobs per run.
Inline scripts
script can be a path or an inline command — single- or multi-line. Inline
scripts skip the auto-checkout (set checkout: true if yours reads the repo):
dynamic-matrix:
alias: pkg
checkout: true
script: |
ls -d packages/*/ | xargs -n1 basename | jq -Rcn '[inputs]'Shells
shell: works like a step's shell and picks the interpreter for script.
Supported: bash/sh (heredoc + jq), pwsh/powershell (captured pipeline
output, BOM-free), and python (captured stdout). Actio emits matching plumbing to
publish the matrix output, so a PowerShell or Python generator just prints its JSON.
compact (jq) is bash/sh-only; other shells emit raw multi-line JSON, which
fromJSON() parses fine.