CI runs too often
Monorepo CI: why every change runs everything
By Keith Mazanec, Founder, CostOps ยท Updated January 30, 2026
A developer fixes a typo in a CSS file inside packages/frontend. GitHub Actions kicks off the full pipeline: backend tests, SDK builds, E2E suites, documentation generation, and a Docker image push. The frontend change takes 2 minutes to validate. The full pipeline takes 45. You're billed for all of it. This is the default behavior for any monorepo that doesn't explicitly scope CI to changed paths, and it's one of the most expensive CI misconfigurations in production.
Symptoms
How to tell if your monorepo CI is running too much
Open your repository's Actions tab and compare what changed in each PR against what jobs actually ran. If there's no correlation, you have this problem.
-
No correlation between changed paths and executed jobs. A PR that touches only packages/docs triggers the same 12 jobs as a PR that changes packages/api. Every job runs regardless of what actually changed.
-
CI time scales with repo size, not change size. Adding a new package to the monorepo increases CI time for every PR, including PRs that never touch the new package. A 5-package monorepo takes 20 minutes per run. After adding a sixth package, it takes 25.
-
Identical build/test steps repeated across jobs. Every job runs npm ci or installs the same dependencies, even when those dependencies are irrelevant to the change. Setup time dominates the pipeline. See reducing CI setup and install overhead for ways to speed this up.
Metrics
What unscoped monorepo CI actually costs
Consider a monorepo with 5 services, each taking 8 minutes to test on Linux runners. A team of 20 developers opens 15 PRs/day, averaging 4 pushes per PR. Without path filtering, every push tests all 5 services. With filtering, the average push affects 1.5 services.
Before: all services, every push
60 pushes × 5 jobs × 8 min × 22 days × $0.006/min
After: only affected services
Save $222/mo · $2,664/year · 70% reduction
That's Linux at $0.006/min. On macOS at $0.062/min, the same scenario goes from $2,453/mo to $736/mo, saving $1,717/mo. And this is a modest example. Monorepos with 10+ packages or complex matrix builds multiply these numbers further.
Fix 1
Use path-based job selection with a change detection step
The most common approach for monorepos under 10 packages is the dorny/paths-filter action. It runs as a lightweight first job, diffs the changed files against a filter map, and exports boolean outputs that downstream jobs use in if: conditions. Skipped jobs report as “Success” to branch protection, which solves the required checks deadlock that breaks native paths: filters.
The key insight: put the path filter inside the workflow as a job, not on the on: trigger. Workflow-level paths: filters skip the entire workflow, which leaves required status checks in a permanent “Pending” state. Job-level filtering lets the workflow always run but skips irrelevant jobs.
name: CI on: push: branches: [main] pull_request: jobs: detect-changes: runs-on: ubuntu-latest permissions: pull-requests: read outputs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} sdk: ${{ steps.filter.outputs.sdk }} steps: - uses: dorny/paths-filter@v3 id: filter with: filters: | shared: &shared - 'packages/shared/**' - 'packages/config/**' frontend: - *shared - 'packages/frontend/**' backend: - *shared - 'packages/backend/**' sdk: - *shared - 'packages/sdk/**' test-frontend: needs: detect-changes if: ${{ needs.detect-changes.outputs.frontend == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: cd packages/frontend && npm test test-backend: needs: detect-changes if: ${{ needs.detect-changes.outputs.backend == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: cd packages/backend && npm test test-sdk: needs: detect-changes if: ${{ needs.detect-changes.outputs.sdk == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: cd packages/sdk && npm test
The YAML anchor &shared / *shared ensures shared libraries are included in every package’s filter. Without this, a change to packages/shared/utils.ts wouldn’t trigger tests for packages that depend on it.
One caveat: this approach requires you to manually maintain the filter map. When you add a new package or change dependency relationships, you need to update the YAML. For monorepos with stable structures under 10 packages, this is manageable. Beyond that, consider dependency-graph-aware tooling (Fix 2 or Fix 3).
Fix 2
Add a gate job so skipped checks don't block merges
When using path-based job selection, individual test jobs will be skipped for unrelated changes. If those jobs are required status checks, the PR merges fine because GitHub reports skipped jobs as “Success.” But if you want a single check to gate merges, add a gate job that aggregates results from all test jobs. Make this the only required check.
# Add this job after test-frontend, test-backend, test-sdk ci-gate: if: always() needs: [test-frontend, test-backend, test-sdk] runs-on: ubuntu-latest steps: - name: Check results run: | if [[ "${{ needs.test-frontend.result }}" == "failure" ]] || [[ "${{ needs.test-backend.result }}" == "failure" ]] || [[ "${{ needs.test-sdk.result }}" == "failure" ]]; then echo "One or more checks failed" exit 1 fi echo "All checks passed (or were skipped)"
The if: always() ensures this job runs even when upstream jobs are skipped. It checks for failure explicitly, and since skipped jobs report as skipped rather than failure, the gate passes. Set ci-gate as your only required status check in branch protection, and merges flow smoothly regardless of which packages were affected.
Fix 3
Use dependency-graph tools to detect affected targets
For larger monorepos, manually maintaining path filters breaks down. You add a new dependency between packages/sdk and packages/auth, forget to update the YAML, and a breaking change slips through. Dependency-graph-aware tools solve this by reading your actual package relationships.
Nx and Turborepo both provide an affected command that compares a base git ref to the current HEAD, walks the dependency graph, and runs tasks only on impacted projects. The key difference from static path filters: if you change packages/shared, only projects that actually depend on shared are tested, rather than everything.
steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: nrwl/nx-set-shas@v4 - run: npm ci - run: npx nx affected -t lint test build --parallel=3
steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - uses: pnpm/action-setup@v3 - run: pnpm install - run: pnpm turbo run lint test build --affected
Critical detail: both tools need git history to compute diffs. Nx requires fetch-depth: 0 (full clone) and the nrwl/nx-set-shas action to find the last successful CI run’s commit. Turborepo needs at minimum fetch-depth: 2 and automatically reads GITHUB_BASE_REF in PR workflows. If you leave the default fetch-depth: 1 (shallow clone), both tools fall back to running all tasks, silently defeating the purpose.
Fix 4
Map shared dependencies so changes propagate correctly
The most common mistake in monorepo path filtering is forgetting about shared code. Your frontend and backend both import from packages/shared. You set up path filters for each package. A developer changes packages/shared/validators.ts. Neither the frontend nor backend tests run. The broken validator ships to production.
filters: | frontend: - 'packages/frontend/**' backend: - 'packages/backend/**' # Change to packages/shared? # Nothing runs. Bug ships.
filters: | shared: &shared - 'packages/shared/**' - 'packages/config/**' frontend: - *shared - 'packages/frontend/**' backend: - *shared - 'packages/backend/**'
YAML anchors (&shared / *shared) keep the filter DRY. When you add a new shared package, update the anchor once and all downstream filters inherit it. This is the manual equivalent of what Nx and Turborepo do automatically via the dependency graph.
One caveat: also include root-level config files that affect all packages. Changes to tsconfig.base.json, .eslintrc.js, or package.json at the root should trigger all jobs. Add these to the shared anchor.
Reference
Which approach fits your monorepo
The right solution depends on your monorepo’s size and how often its dependency graph changes. Here’s a quick decision matrix:
| Approach | Best for | Tracks deps |
|---|---|---|
| dorny/paths-filter | 2–10 packages, stable structure | Manual (YAML anchors) |
| Nx affected | JS/TS monorepos, 10+ packages | Automatic (dep graph) |
| Turborepo --affected | JS/TS monorepos, Vercel ecosystem | Automatic (dep graph) |
| Bazel rdeps / bazel-diff | Polyglot, large-scale monorepos | Automatic (build graph) |
| Separate workflow files | 2–3 packages, simplest setup | None |
All approaches work on every GitHub plan, including Free. The dorny/paths-filter action, Nx, and Turborepo are free and open-source. The only tier-dependent consideration is larger runners (Team plan or higher) if you want to speed up builds further after reducing unnecessary work.
Reference
Common mistakes that silently break filtering
These are the failure modes that make monorepo CI filtering silently revert to “run everything”:
| Mistake | What happens |
|---|---|
| Shallow clone (fetch-depth: 1) | Nx/Turborepo can't compute diffs, runs all tasks |
| Missing nrwl/nx-set-shas | Nx compares against wrong base, runs too many targets |
| Workflow-level paths: filter | Required checks stay “Pending” on unmatched PRs |
| Shared deps not in filter map | Shared library changes don't trigger dependent tests |
| Root config files not included | tsconfig.base.json change breaks builds undetected |
Related guides
Stop Docs Changes Triggering Full CI
Use path filters to skip CI on README, changelog, and config-only commits.
Reduce CI Setup and Install Overhead
Cut repeated npm ci and dependency install time across monorepo jobs.
Dependency Cache Not Working
Fix broken caching that forces full installs on every CI run.
Stop CI Running on Every Push
Concurrency groups, scoped triggers, and path filters for any repo.