Monorepo CI in GitHub Actions: Why Path Filters Break When Services Share Code
You push a one-line fix to a README in a monorepo with fourteen services. GitHub Actions lights up like a Christmas tree: every lint job, every unit test, every container build, every deploy-preview, all of them, for a change that touched exactly zero lines of code. Six minutes and a fistful of runner credits later, everything is green, and nothing was ever at risk.
The usual reaction is to reach for paths and paths-ignore at the top of the workflow. That helps for about a week, until the day you change a file in a shared package and CI cheerfully skips the three services that import it. Now you have shipped a break and CI told you it was fine.
Workflow-level path filters are the wrong tool for a monorepo the moment services share code. They gate whole workflows, they have no idea which job needs which files, and they see paths as strings, not as a dependency graph. The pattern that actually works is a single detect-changes job that computes what changed once, and downstream jobs that decide for themselves whether to run.
Do not gate jobs with the workflow-level paths key. Add one early job that runs dorny/paths-filter, expose its results as outputs, and put an if: on every downstream job that reads those outputs. Two things people miss:
- Change detection needs history. Set
fetch-depth: 2(or more) inactions/checkout, because the defaultfetch-depth: 1has no previous commit to diff. - Path filters cannot follow imports. A change to a shared package must be wired to fan out to every consumer, either by listing the shared path in each consumer's filter or by using a real dependency-graph tool.
Why paths and paths-ignore cannot do per-job filtering
The on.push.paths and on.pull_request.paths keys answer exactly one question: should this workflow file trigger at all? They are evaluated once, before any job exists, and the answer is a single boolean for the whole run. There is no per-job version of them, and there never has been. If any path matches, the entire workflow runs. If none match, none of it runs.
That is fine for a repository with one deployable. In a monorepo it forces a bad choice. Split every service into its own workflow file and you lose shared setup, shared concurrency groups and a single required status check, and you get fourteen near-identical YAML files to keep in sync. Keep one workflow and use workflow-level paths, and it is all-or-nothing: touch anything and everything runs, or narrow the paths and start silently skipping jobs that should have run.
The second choice is the dangerous one. A paths list is a denylist you maintain by hand, and it drifts out of sync with the code the instant someone adds a directory. When it drifts, CI does not error. It just quietly stops testing something.
The fix: a detect-changes job that outputs signals
Step 1: check out enough history to diff
Change detection compares your commit against a base. If the checkout only fetched one commit, there is nothing to compare against and the filter either errors or reports everything as changed. Fix it at the source.
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # push events: HEAD plus its parent to diff
For pull requests, dorny/paths-filter diffs against the PR base branch automatically and does not need extra depth. It is push events, and workflow_dispatch, where fetch-depth: 1 bites you.
Step 2: run the filter and name your services
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
- 'packages/shared/**'
web:
- 'services/web/**'
- 'packages/shared/**'
shared:
- 'packages/shared/**'
Notice that packages/shared/** appears under both api and web. That is the manual dependency edge: a change to the shared package sets both api and web to true, so both get tested. The filter still does not understand imports, but you have told it the relationship explicitly.
Step 3: gate downstream jobs on the outputs
test-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "running api tests"
build-web:
needs: detect-changes
if: needs.detect-changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "building web"
Each job now decides independently. A README-only change sets every filter to false, every downstream job is skipped, and the whole run finishes in the few seconds the detect job takes. This is the same discipline I describe in Ship to Your Own VPS: GitHub Actions CI/CD Without the Cloud Bill: do the cheap check first, do the expensive work only when it is warranted.
Step 4 (optional): drive a dynamic matrix from one job
Once you have more than a handful of services, per-job if: blocks get repetitive. Emit a JSON array of changed services and feed it straight into a matrix.
detect-changes:
outputs:
services: ${{ steps.filter.outputs.changes }}
# dorny/paths-filter exposes a `changes` output:
# a JSON array of the filter keys that matched
build:
needs: detect-changes
if: needs.detect-changes.outputs.services != '[]'
strategy:
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
runs-on: ubuntu-latest
steps:
- run: echo "building ${{ matrix.service }}"
One detect job now decides exactly which services build, with no per-job duplication. The changes output is a built-in feature of the action, not something you assemble by hand.
How to verify it actually filters
Do not trust it because the YAML looks right. Prove it with two pushes.
- Change only a README. Open the run and confirm the
detect-changesjob ran and every service job shows the grey Skipped state, not green. Green means it ran and did nothing useful. - Change one file under
packages/shared/. Confirm that both the api and web jobs ran, and any service that does not import shared stayed skipped.
You can inspect the exact outputs the detect job produced by expanding its log, or add a debug step:
- run: |
echo "api=${{ steps.filter.outputs.api }}"
echo "web=${{ steps.filter.outputs.web }}"
echo "changes=${{ steps.filter.outputs.changes }}"
What people get wrong
The ever-growing per-job paths list. The most common bad pattern is hand-maintaining a long paths array for each service and trusting it forever. It is a denylist that silently rots. The day someone adds services/api/v2/ and forgets to add it to the filter, the api tests stop running for that directory and nothing warns you. Keep the filters as coarse as you can (services/api/**, not a list of files) so new files are covered by default.
Assuming path filters understand imports. They do not. packages/shared/** changing has no automatic effect on services/api unless you wrote that edge yourself. If your dependency graph is complex enough that maintaining these edges by hand is error-prone, stop fighting it and adopt a tool that computes the graph from your actual imports, such as Turborepo's --filter with ... or Nx's affected commands. Path filters are for simple, mostly-independent services.
Forgetting the first-push edge case. On the very first push to a new default branch there is no previous commit, so the base SHA is all zeros and the diff is undefined. Filters can report nothing changed, or everything changed, depending on the tool. If you bootstrap branches programmatically, guard for an empty or zero before SHA and treat it as run everything.
When it is still broken
- Every job runs no matter what. Your checkout probably still has
fetch-depth: 1, or the filter path patterns do not match your real directory layout. Add the debug step above and read the actualchangesoutput. - Nothing ever runs. Check that downstream jobs have
needs: detect-changes. Withoutneeds, theif:references an output that does not exist yet and evaluates to false. - A skipped job blocks your required status check. A skipped job reports neutral, not success, which can leave a required check pending forever. Either make the check a single aggregate job that always runs, or mark the deploy gate to treat skipped as passed.
- PR diff looks wrong. If you rebased or force-pushed, the base comparison can include commits you did not touch. Confirm the action is diffing against the PR base and not a stale merge base.
The principle is simple: compute what changed exactly once, early, with real history to diff against, and let every job downstream make its own decision. That is a monorepo CI you can trust when it says a job was safe to skip.
Frequently asked questions
- Can I use paths and paths-ignore to skip individual jobs in a workflow?
- No. The workflow-level paths and paths-ignore keys only decide whether the entire workflow triggers. They cannot skip individual jobs. To gate specific jobs you need a detect-changes job that outputs values and downstream jobs that read those outputs in an if: condition.
- Why does my change detection say nothing changed even after I edited a file?
- The default actions/checkout uses fetch-depth: 1, which fetches only the latest commit, so there is no previous commit to diff against. Set fetch-depth: 2 for push events, or fetch the base branch for pull requests, so the diff has something to compare to.
- How do I make a change in a shared library rebuild every service that uses it?
- Path filters cannot see import relationships, only literal paths. Either add the shared path to every consuming service's filter so a shared change lights them all up, or build a real dependency graph with a tool like Turborepo or Nx that knows which packages depend on which.
- Why does path filtering behave strangely on the first push to a new branch?
- On the very first push to a brand new default branch there is no before commit to compare against, so the diff is undefined and filters can behave unexpectedly. Guard for this case by treating an empty or all-zero before SHA as run everything, rather than trusting the filter output blindly.