Guides / Monorepo: every change runs everything

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

Pushes/day 60
Jobs/push 5
Minutes/job 8
Monthly minutes 52,800
Monthly cost $317/mo

60 pushes × 5 jobs × 8 min × 22 days × $0.006/min

After: only affected services

Pushes/day 60
Avg jobs/push 1.5
Minutes/job 8
Monthly minutes 15,840
Monthly cost $95/mo

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.

.github/workflows/ci.yml
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.

.github/workflows/ci.yml (add to the workflow from Fix 1)
  # 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.

Nx workflow
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
Turborepo workflow
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.

Missing shared deps
filters: |
  frontend:
    - 'packages/frontend/**'
  backend:
    - 'packages/backend/**'

# Change to packages/shared?
# Nothing runs. Bug ships.
Shared deps included
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

Guides / Monorepo: every change runs everything

See which packages are burning CI minutes

CostOps breaks down cost per workflow, per path, per package. See exactly which parts of your monorepo drive the most CI spend.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.