GitHub Actions Tutorial — Dynamic Matrix
Dynamic Matrix
Instead of hard-coding matrix values in YAML, you can generate them at runtime. This is essential for monorepos, conditional testing, and pipelines that adapt to what changed.
fromJSON() Pattern
Use fromJSON() to convert a JSON string output into a matrix:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- id: set
run: |
echo 'matrix={"node":[18,20,22],"os":["ubuntu-latest","windows-latest"]}' >> "$GITHUB_OUTPUT"
test:
needs: setup
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: node --version
Generate Matrix from Changed Files
Build a matrix based on which packages changed in a monorepo:
jobs:
detect:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.changes.outputs.packages }}
has-changes: ${{ steps.changes.outputs.has-changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed packages
id: changes
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep '^packages/' | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)
if [ "$CHANGED" = "[]" ]; then
echo "has-changes=false" >> "$GITHUB_OUTPUT"
echo 'packages=[]' >> "$GITHUB_OUTPUT"
else
echo "has-changes=true" >> "$GITHUB_OUTPUT"
echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"
fi
test:
needs: detect
if: needs.detect.outputs.has-changes == 'true'
strategy:
matrix:
package: ${{ fromJSON(needs.detect.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
cd packages/${{ matrix.package }}
npm ci && npm test
Only changed packages get tested — no wasted CI minutes.
Generate Matrix from a Config File
Read matrix configuration from a JSON file in the repository:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.read.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Read matrix config
id: read
run: |
MATRIX=$(cat .github/matrix-config.json)
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
build:
needs: setup
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- run: echo "Building for ${{ matrix.os }} with ${{ matrix.compiler }}"
.github/matrix-config.json:
{
"os": ["ubuntu-latest", "windows-latest"],
"compiler": ["gcc", "clang"]
}
Conditional Matrix Expansion
Include different versions based on the branch:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- name: Generate matrix
id: set
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo 'matrix={"node":[18,20,22],"os":["ubuntu-latest","windows-latest","macos-latest"]}' >> "$GITHUB_OUTPUT"
else
echo 'matrix={"node":[20],"os":["ubuntu-latest"]}' >> "$GITHUB_OUTPUT"
fi
test:
needs: setup
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
- main branch: Full 9-job matrix (thorough testing)
- Feature branches: Single-job matrix (fast feedback)
Common Mistakes
| Mistake | Fix |
|---|---|
fromJSON() fails |
Output must be valid JSON — use jq to construct it |
| Empty matrix | Dynamic matrix with empty array creates 0 jobs and doesn’t fail — add if: guard |
| Output truncated | GitHub outputs have a 1 MB limit — keep matrix JSON small |
| Matrix keys don’t match | Output JSON keys must match what the matrix: block expects |
Quick Reference
| Pattern | Use Case |
|---|---|
| fromJSON() | Convert step output to matrix |
| Changed files | Test only affected packages |
| Config file | Centralize matrix in .json file |
| Branch-conditional | Full matrix on main, minimal on feature |
| jq | Build JSON arrays from shell commands |
← Previous: Fail-Fast & Parallel
Next: Matrix Patterns →