This repo demonstrates how to build a dynamic matrix strategy in GitHub Actions using only tools that are pre-installed on the runner. No third-party actions required beyond actions/checkout. It is appropriate for monorepos and other situations where you want to discover work dynamically instead of hardcoding it.
The 2026 edition turns this into a shootout — five different approaches to matrix generation, each running as its own job, so you can compare simplicity, speed, and overhead directly in the Actions UI.
The pattern has two jobs:
- Generate — discover directories and output a JSON array
- Consume — use
strategy.matrixwithfromJsonto fan out into parallel jobs
The recommended approach uses ls and jq, both pre-installed on ubuntu-24.04:
echo "matrix=$(ls -d example_*/ | jq -Rnc '[inputs]')" >> $GITHUB_OUTPUTBreaking this down:
ls -d example_*/lists directories matching the globjq -Rnc '[inputs]'reads each line as raw text (-R), collects them into an array via null input +inputs(-n), and outputs compact JSON (-c)
The result is a JSON array sent to $GITHUB_OUTPUT for downstream jobs:
["example_1/","example_2/"]This is then declared as a job output:
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}Matrix strategy is implemented at the job level. The consumer job unpacks the JSON array with fromJson and fans out — one job per array item:
use_matrix_jq:
runs-on: ubuntu-24.04
needs: [createMatrix_jq]
strategy:
matrix:
paths: ${{ fromJson(needs.createMatrix_jq.outputs.matrix) }}
env:
MATRIX_PATH: ${{ matrix.paths }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Echo the output
run: echo "${MATRIX_PATH}"
- name: Cat the file
run: cat "${MATRIX_PATH}file.txt"You can reference ${{ matrix.paths }} anywhere in the job context — environment variables, step inputs, etc.
Notice that run: steps use the shell variable "${MATRIX_PATH}" rather than directly interpolating ${{ matrix.paths }}. This avoids script injection — a common pitfall where expressions are interpolated into the shell script before the shell executes, allowing crafted values to break out of the intended command.
Setting the value as an env: variable at the job level and referencing it as "${MATRIX_PATH}" in run: steps is the recommended pattern.
The workflow implements five different approaches to matrix generation. Each runs as its own job with its own consumer, so you can compare them end-to-end in the Actions UI.
Pre-installed on ubuntu-24.04. One line. Purpose-built for JSON.
echo "matrix=$(ls -d example_*/ | jq -Rnc '[inputs]')" >> $GITHUB_OUTPUTZero external tools — shell builtins only. Works well for controlled directory names but fragile with special characters.
dirs=(example_*/)
printf -v items ',"%s"' "${dirs[@]}"
echo "matrix=[${items:1}]" >> $GITHUB_OUTPUTPre-installed on ubuntu-24.04 (Node 20). More verbose but familiar to JavaScript teams.
matrix=$(node -e "
const fs = require('fs');
const dirs = fs.readdirSync('.')
.filter(f => fs.statSync(f).isDirectory() && f.startsWith('example_'))
.map(f => f + '/');
console.log(JSON.stringify(dirs));
")
echo "matrix=${matrix}" >> $GITHUB_OUTPUTNot pre-installed — requires the oven-sh/setup-bun action. This demonstrates the overhead of adding a non-pre-installed tool: the setup step downloads and installs Bun before the generation command can run.
matrix=$(bun -e "
import { readdirSync, statSync } from 'fs';
const dirs = readdirSync('.')
.filter(f => statSync(f).isDirectory() && f.startsWith('example_'))
.map(f => f + '/');
console.log(JSON.stringify(dirs));
")
echo "matrix=${matrix}" >> $GITHUB_OUTPUTNot pre-installed — requires apt-get install. ripgrep is a content search tool, not a directory lister, so this is deliberately using the wrong tool for the job. It still needs jq for JSON construction, and the install step adds significant overhead.
echo "matrix=$(rg --files | grep '^example_' | sed 's|/.*|/|' | sort -u | jq -Rnc '[inputs]')" >> $GITHUB_OUTPUT| Approach | Pre-installed | Lines of Shell | Command Time | Job Duration | Notes |
|---|---|---|---|---|---|
| jq | Yes | 1 | 5ms | 3s | Recommended. Purpose-built for JSON |
| bash | Yes (builtin) | 3 | 2ms | 5s | Zero dependencies, fragile with special chars |
| Node.js | Yes | ~5 | 175ms | 4s | Familiar to JS teams |
| Bun | No | ~5 + setup | 23ms | 5s | Requires oven-sh/setup-bun action |
| ripgrep | No | ~3 + install | 6ms | 15s | Content search tool; still needs jq for JSON |
Command Time = just the matrix generation command. Job Duration = total job wall time including checkout, setup/install, and generation. Measured on
ubuntu-24.04runners, March 2026.
dorny/paths-filter is a popular, feature-rich action for filtering changed paths. It's a great tool for complex filtering scenarios, but it adds the overhead of downloading and executing a third-party action. For simple directory enumeration like this example, pre-installed CLI tools are faster and have zero external dependencies.
- Using a matrix for your jobs — GitHub's official matrix strategy documentation
- Security hardening for GitHub Actions — expression injection and other security considerations
- How to Make Dynamic Matrix in GitHub Actions — original inspiration (2020, may reference deprecated syntax like
::set-output)