A GitHub Action that inspects which files changed in a pull request and outputs a dynamic JSON matrix — one entry per unique matched component. Downstream jobs use the matrix to fan out work in parallel, running only for the components that actually changed.
Common use cases:
- Docker image monorepos — build and test only the images whose folders changed
- Schema / data contract repos — validate only the schemas that were modified
- Terraform repos — plan only the infrastructure folders that changed
- Helm / ArgoCD repos — release only the charts or app definitions that were touched
- Fetches the list of changed files from the PR using
gh pr view --json files - Filters files against
paths_include/paths_ignoreglob patterns - Applies
extract_re(a Python regex with named groups) to each surviving file path - Deduplicates extracted values — multiple changed files in the same component produce one matrix entry
- Merges parameters in three layers (lowest → highest priority):
default_params— applied to every entryinject_params[<primary_key_value>]— applied when the extracted primary key matches a key ininject_params- Extracted regex groups — always win; cannot be overridden
| Input | Required | Default | Description |
|---|---|---|---|
github_token |
No | ${{ github.token }} |
Token used to call gh pr view. Use a CI service account token for cross-repo workflows. |
repo |
No | ${{ github.event.repository.full_name }} |
Repository in owner/repo format. Override when the workflow runs in a different repo than the one being inspected. |
pr_number |
No | ${{ github.event.number }} |
PR number to inspect. |
extract_re |
No | (?P<project_name>.*)/.* |
Python regex with one or more named groups (e.g. (?P<project_name>.*)/.*). Each matched file contributes its captured groups as matrix fields. |
paths_include |
No | (all files) | JSON array of glob patterns. Only files matching at least one pattern are considered. Omit or set to '["**"]' to include all files. |
paths_ignore |
No | (none) | JSON array of glob patterns. Files matching any pattern are excluded. Applied after paths_include. |
default_params |
No | '{}' |
JSON object merged into every matrix entry. Useful for common fields like environment or region. |
inject_primary_key |
No | (none) | Name of the extracted regex group used as a lookup key into inject_params. |
inject_params |
No | '{}' |
JSON object where keys are possible values of inject_primary_key and values are parameter objects to merge into matching entries. |
| Output | Type | Description |
|---|---|---|
matrix |
JSON array string | Array of objects. Each object contains the extracted regex groups plus any injected parameters. Pass to fromJson() in a strategy.matrix block. |
matrix-populated |
'true' / 'false' |
Whether matrix contains any entries. Use in if: conditions to skip downstream jobs when nothing changed. |
The default regex (?P<project_name>.*)/.* captures the top-level folder from every changed file. This is the most common pattern for root-level monorepos.
jobs:
pr-changes:
runs-on: ubuntu-latest
outputs:
matrix-params: ${{ steps.matrix-builder.outputs.matrix }}
matrix-populated: ${{ steps.matrix-builder.outputs.matrix-populated }}
steps:
- name: PR Changes Matrix Builder
uses: KyleJamesWalker/pr-changes-matrix-builder@v0
id: matrix-builder
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
paths_ignore: '[".github/**"]'
build:
needs: [pr-changes]
if: needs.pr-changes.outputs.matrix-populated == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
params: ${{ fromJson(needs.pr-changes.outputs.matrix-params) }}
steps:
- run: echo "Building ${{ matrix.params.project_name }}"Changed files: service-a/main.py, service-a/tests/test_main.py, service-b/Dockerfile, .github/workflows/ci.yaml
Matrix output:
[
{"project_name": "service-a"},
{"project_name": "service-b"}
].github/ files are excluded by paths_ignore. service-a appears only once despite two changed files.
When components live under a common prefix, use paths_include to restrict scope and embed the prefix in extract_re to strip it from the captured value.
- uses: KyleJamesWalker/pr-changes-matrix-builder@v0
id: matrix-builder
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
extract_re: "packages/(?P<project_name>.*)/.*"
paths_include: '["packages/**"]'
paths_ignore: '[".github/**", "tools/**", "tests/**", "**/README.md"]'Changed files: packages/auth-service/handler.py, tools/validate.py, packages/billing-service/models.py
Matrix output:
[
{"project_name": "auth-service"},
{"project_name": "billing-service"}
]tools/validate.py is excluded by paths_ignore. The packages/ prefix is stripped by the regex so project_name is just the component name.
Use inject_params to attach component-specific configuration (e.g. different runners, flags, or environment values) without a separate lookup step.
- uses: KyleJamesWalker/pr-changes-matrix-builder@v0
id: matrix-builder
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
extract_re: "(?P<project_name>.*)/.*"
paths_ignore: '[".github/**"]'
default_params: '{"environment": "staging"}'
inject_primary_key: project_name
inject_params: |
{
"gpu-service": {"runner": "gpu-runner", "needs_gpu": true},
"heavy-service": {"runner": "large-runner"}
}Changed files: gpu-service/train.py, api-service/handler.py
Matrix output:
[
{"project_name": "gpu-service", "environment": "staging", "runner": "gpu-runner", "needs_gpu": true},
{"project_name": "api-service", "environment": "staging"}
]api-service has no entry in inject_params, so it gets only default_params.
For release/tag workflows that only run on merged PRs, put the if: condition on the matrix-builder job directly rather than on all downstream jobs.
jobs:
pr-changes:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
outputs:
matrix-params: ${{ steps.matrix-builder.outputs.matrix }}
matrix-populated: ${{ steps.matrix-builder.outputs.matrix-populated }}
steps:
- uses: KyleJamesWalker/pr-changes-matrix-builder@v0
id: matrix-builder
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
extract_re: "packages/(?P<project_name>.*)/.*"
paths_include: '["packages/**"]'
paths_ignore: '[".github/**", "tools/**", "**/README.md"]'
tag-create:
needs: [pr-changes]
if: needs.pr-changes.outputs.matrix-populated == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
params: ${{ fromJson(needs.pr-changes.outputs.matrix-params) }}
steps:
- name: Create release
run: |
gh release create "${{ matrix.params.project_name }}-v${{ github.run_number }}" --generate-notesWhen all matrix entries write to shared state — like committing version bumps to the same branch — use max-parallel: 1 to serialize execution.
version-bump:
needs: [pr-changes]
if: needs.pr-changes.outputs.matrix-populated == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1 # prevent concurrent git pushes to the same branch
matrix:
params: ${{ fromJson(needs.pr-changes.outputs.matrix-params) }}
steps:
- uses: actions/checkout@v4
- run: |
git pull --no-rebase
# your per-component version bump here
git add ${{ matrix.params.project_name }}
git commit -m "Bump ${{ matrix.params.project_name }}"
git pushWhen per-component configuration lives in files inside the repo (e.g. YAML front matter in each component's README.md), use an intermediate job to enrich the matrix before expanding it.
jobs:
pr-changes:
runs-on: ubuntu-latest
outputs:
matrix-params: ${{ steps.matrix-builder.outputs.matrix }}
matrix-populated: ${{ steps.matrix-builder.outputs.matrix-populated }}
steps:
- uses: KyleJamesWalker/pr-changes-matrix-builder@v0
id: matrix-builder
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
paths_ignore: '[".github/**"]'
load-config:
needs: [pr-changes]
if: needs.pr-changes.outputs.matrix-populated == 'true'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.enrich.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Enrich matrix from per-component config
id: enrich
env:
BASE_MATRIX: ${{ needs.pr-changes.outputs.matrix-params }}
run: |
out='[]'
while read -r row; do
name=$(echo "$row" | jq -r '.project_name')
# read any per-component field from a config file in the component folder
runner=$(jq -r '.runner // "ubuntu-latest"' "$name/component.json")
out=$(echo "$out" | jq -c --arg r "$runner" '. + [$row + {runner: $r}]')
done < <(echo "$BASE_MATRIX" | jq -c '.[]')
echo "matrix=$out" >> "$GITHUB_OUTPUT"
build:
needs: [pr-changes, load-config]
if: needs.pr-changes.outputs.matrix-populated == 'true'
runs-on: ${{ matrix.params.runner }}
strategy:
fail-fast: false
matrix:
params: ${{ fromJson(needs.load-config.outputs.matrix) }}
steps:
- run: make build APP=${{ matrix.params.project_name }}This keeps the action focused on change detection while allowing arbitrary per-component metadata to be loaded from the repo at runtime.
extract_re can define multiple named groups. Each group becomes a field in the matrix entry, and the deduplication key is the combination of all groups.
extract_re: "(?P<project_name>[^/]+)/(?P<subpath>[^/]+)/.*"Changed files: service-a/v1/schema.avsc, service-a/v2/schema.avsc, service-b/v1/schema.avsc
Matrix output:
[
{"project_name": "service-a", "subpath": "v1"},
{"project_name": "service-a", "subpath": "v2"},
{"project_name": "service-b", "subpath": "v1"}
]Each unique (project_name, subpath) combination gets its own matrix entry.
paths_ignoreis evaluated afterpaths_include. A file must first survive the include filter before the ignore filter is applied.- Extracted groups always win over
default_paramsandinject_params. You cannot override a captured group name with injected parameters. - The
matrix-populatedcheck is important. GitHub Actions throws an error iffromJson()receives an empty array in astrategy.matrixblock. Always guard downstream jobs withif: needs.<job>.outputs.matrix-populated == 'true'. - The action only reads PR file change metadata. It does not check out your code. If you need per-component config from files in the repo, use the two-stage enrichment pattern above.