Every commit to a busy repository can trigger a full pipeline: every service built, every suite executed, every cache warmed. In a monorepo with a dozen modules, that means most of your CI spend goes to code that hasn’t changed. The smarter approach in 2026 is to generate a dynamic test matrix from changed files and feed it straight into GitHub Actions. With a few lines of shell and YAML, you can turn a monolithic CI run into a focused, fast, and cost-effective workflow.
Why Static Matrices Waste Pipeline Time
Most teams start with a static matrix that lists every component explicitly:
strategy:
matrix:
service: [api, web, worker, scheduler, admin]
That simple approach runs all five services on every push. It does not matter if the push only touched a README file or changed one line inside web/. The workflow still spins up five runners, checks out the full repository, installs dependencies, and executes tests. As your product grows, the matrix expands, and your waiting room gets longer.
Path filters—such as paths: on pull_request events—help only when a complete directory must be skipped. They do not help you select which of several directories to test when multiple changed paths land in one commit. For that, you need a dynamic matrix determined at runtime.
How to Build a Change-Detection Job
Dynamic matrices are made possible by two GitHub Actions features: job outputs and fromJSON. The idea is simple. A first job, changes, inspects the list of changed files, extracts the directories or service names that were touched, and outputs a JSON array. A second job uses that array as the source for its matrix.
Using git diff and Outputting a JSON Matrix
The first step is to check out the repository and compute a diff against the base branch. For pull requests, you can diff against origin/main or origin/master. For pushes, you may want to diff between the previous commit and the current one.
jobs:
changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.diff.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed services
id: diff
run: |
CHANGED=$(git diff --name-only origin/main...HEAD)
SERVICES=$(echo "$CHANGED" | cut -d/ -f1 | sort -u | jq -Rsc 'split("\n")[:-1]' )
echo "matrix=$SERVICES" >> "$GITHUB_OUTPUT"
This works when every service lives in its own top-level directory, such as api/, web/, and worker/. The cut command takes the first path segment, sort -u removes duplicates, and jq converts the lines into a JSON array. If no files changed, the output is an empty array.
Filtering by Namespace or Directory
Not every repository is flat. You may have a nested structure like src/services/api or packages/ui. You can adjust the detection command to use a deeper segment:
CHANGED=$(git diff --name-only origin/main...HEAD)
SERVICES=$(echo "$CHANGED" | grep -E '^(src/services|packages)/' | cut -d/ -f3 | sort -u | jq -Rsc 'split("\n")[:-1]' )
This filters out files that do not belong to any monitored service and extracts the service folder name from the third path segment. You can extend the same idea to support multiple namespace levels, or you can map paths to human-readable labels in a small lookup file.
Wiring the Dynamic Matrix into Your Workflow
Once the changes job produces a JSON array, the test job needs to consume it. Set up a second job that depends on changes and uses fromJSON in its matrix definition:
test:
needs: changes
if: needs.changes.outputs.matrix != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
service: ${{ fromJSON(needs.changes.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Run tests for ${{ matrix.service }}
run: |
cd ${{ matrix.service }}
make test
The if condition prevents the entire job from running when no relevant files changed. Inside the job, matrix.service is named exactly as it appeared in the directory structure. You can also pass the service name as an environment variable to a custom script, or use it as a key in a CI configuration file.
Avoiding a Hard Dependency on Git Diff
Using git diff directly is transparent and fast, but it requires a full clone with history. If you want to avoid fetching all commits, you can use the dorny/paths-filter action, which is designed for this purpose. It returns a JSON object of matched paths and can run entirely with the default event payload. The output can then be converted into a matrix array. The trade-off is an extra action dependency, but for many teams it is worth it because the logic is more readable and easier to maintain.
Handling Edge Cases: No Changes, Multiple Changes, and Paths
Dynamic matrices need to behave correctly in three common scenarios.
- No changed services: A documentation update produces an empty matrix. The
if: needs.changes.outputs.matrix != '[]'guard skips the test job entirely. You can also add a final “all clear” notification job that runs after the condition is evaluated. - Multiple services changed: If a commit touches both
apiandweb, the matrix includes both. If you need to test affected pairs—for example, an integration test that combines two services—your detection script can output an array of combinations using a cartesian product command. - Path prefixes and root files: Files in the repository root, such as
.github/workflows/deploy.yml, may not belong to any service. Decide whether such files should trigger all services. Many teams want end-to-end tests when CI configuration changes, so you can add a special condition: if.githubis in the changed file list, output the full service list.
Dynamic Matrices in a Monorepo: A 2026 Perspective
The shift toward dynamic, change-aware CI has accelerated as monorepos have become the default for both startups and large enterprises. GitHub Actions now supports deeper job concurrency controls, and with the ability to generate a matrix at runtime, you can make CI as narrow as a single commit. This is not a niche optimization for multi-player teams; it is a practical way to keep developer feedback cycles under five minutes, even as the codebase expands to dozens of components.
One important nuance in 2026 is cache management. A dynamic matrix means that a service may not run for several pushes. When it finally does run, its dependency cache may be stale. You should pair your dynamic matrix strategy with a well-designed cache key that uses the matrix value and the lockfile hash. This way, you retain the speed of caching even for occasionally executed jobs.
Another modern improvement is to combine dynamic matrices with reusable workflows. You can place the change-detection logic in a reusable workflow and call it from every repository in your organization. Each repo can define its own map of paths to test commands, while the detection logic stays consistent. This pattern is particularly useful for platform teams that manage multiple application repositories and want to enforce a single CI standard.
Finally, consider the user experience. A dynamic matrix means that a pull request from a contributor will show only the tests relevant to their changes. The status checks will be short and meaningful. Instead of waiting for forty minutes to see that the scheduler service passed, they see three jobs complete in under five minutes. That is a better developer experience, and it is the primary reason to adopt dynamic matrices in the first place.
Conclusion
Generating a dynamic test matrix from changed files is one of the highest-impact workflow optimizations available in GitHub Actions today. It removes redundant work, shortens feedback loops, and makes your CI pipeline scale gracefully as your repository grows. By using a change-detection job, outputting a JSON array, and feeding it into fromJSON, you can turn a full-suite pipeline into a focused, precise testing engine. A few lines of bash now will save your team hours every single week.
