Actio
Macros

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):

.actio.yml
jobs:
  test:
    runs-on: ubuntu-latest
    dynamic-matrix:
      script: echo '["1", "2", "3"]'
      alias: shard
    steps:
      - run: ./run-shard.sh ${{ matrix.shard }}
generated .yml
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 }}
.actio.yml
jobs:
  test:
    runs-on: ubuntu-latest
    dynamic-matrix:
      script: echo '["1", "2", "3"]'
      alias: shard
    steps:
      - run: ./run-shard.sh ${{ matrix.shard }}
generated .yml
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:

.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 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).

.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:

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

generated .yml
  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):

.actio.yml
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.

On this page