# dynamic-matrix (/docs/macros/dynamic-matrix)



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`](/docs/macros/for-each), or unroll it into individually addressable
named jobs with [`expand-matrix`](/docs/macros/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):

<CodeCompare>
  ```yaml title=".actio.yml"
  jobs:
    test:
      runs-on: ubuntu-latest
      dynamic-matrix:
        script: echo '["1", "2", "3"]'
        alias: shard
      steps:
        - run: ./run-shard.sh ${{ matrix.shard }}
  ```

  ```yaml title="generated .yml"
  jobs:
    actio_setup_test: # [!code highlight:13]
      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] # [!code highlight]
      strategy: # [!code highlight:4]
        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 != '' # [!code highlight]
      steps:
        - run: ./run-shard.sh ${{ matrix.shard }}
  ```
</CodeCompare>

## Example: one job per open PR [#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:

```yaml title=".actio.yml"
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 test
```

[`include` mode](#heterogeneous-fan-out-include-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 [#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) [#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:[...]}`](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow#using-a-matrix-strategy)
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).

```yaml title=".actio.yml"
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:

```json
{"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`:

```yaml title="generated .yml"
  test:
    runs-on: ${{ matrix.runs-on }}
    needs: [actio_setup_test] # [!code highlight]
    strategy: # [!code highlight:3]
      matrix: ${{ fromJSON(needs.actio_setup_test.outputs.matrix) }}
      fail-fast: false
    if: needs.actio_setup_test.outputs.matrix != '[]' && needs.actio_setup_test.outputs.matrix != '' # [!code highlight]
    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.

<Callout type="warn">
  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`.
</Callout>

### Limits [#limits]

A generated matrix still fans out &#x2A;*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`](/docs/syntax#for-each) or separate jobs if you need
structurally different work.

Matrix is also capped at
[256 jobs per run](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow#using-a-matrix-strategy).

## Inline scripts [#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):

```yaml title=".actio.yml"
dynamic-matrix:
  alias: pkg
  checkout: true
  script: |
    ls -d packages/*/ | xargs -n1 basename | jq -Rcn '[inputs]'
```

## Shells [#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.


## Sitemap

Browse the full documentation: [Markdown sitemap](https://austenstone.github.io/actio/sitemap.md) · [XML sitemap](https://austenstone.github.io/actio/sitemap.xml)