Fixing Slow Monorepo CI with Affected Builds
Every commit to your monorepo rebuilds and retests all packages, so CI time grows with the repo instead of with the change. This page shows how to switch CI to affected-only execution so a small change runs a small pipeline.
Exact symptoms and error messages
CI logs show every package building on a one-file change, and total time climbs as packages are added:
• Packages in scope: 42
• Running build in 42 packages
web:build: cache miss, executing 3f9a...
ui:build: cache miss, executing 8c11...
... (42 tasks) ...
Time: 18m 44s
Root cause analysis
Without a base to diff against, the task runner has no way to know what changed, so it runs the full set. The fix is to give CI (a) full git history and (b) an explicit comparison ref so the runner can compute the changed packages and their dependents from the workspace graph, exactly as described in CI/CD Pipeline Optimization for Monorepos.
The reason the runner defaults to 'everything' is that it is failing safe. If it cannot reliably compute what changed — because there is no base commit to diff against — running all packages is the only choice that guarantees correctness. So a slow, everything-builds pipeline is usually not a configuration bug in the runner but a missing precondition: the git history and base ref the diff needs. Supplying them is what unlocks the narrowing.
Accuracy of the dependency graph is the second precondition. Affected detection marks a changed package dirty and then walks its dependents — every package that imports it — so those get retested too. If a cross-package import is undeclared (a deep relative path into another package's source, say), the runner cannot see that edge, and a real dependent is skipped. Fast CI and a correct graph are therefore the same problem: the graph that makes builds fast is the graph that makes them correct.
Resolution and configuration patch
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pnpm install --frozen-lockfile --ignore-scripts
# Only build/test packages affected since the base branch
- run: pnpm turbo run build test --filter='...[origin/main]'
The ...[origin/main] selector expands to every package changed since origin/main plus everything that depends on them, which is the correct set to validate.
Layer remote caching on top of affected so even the changed set replays unchanged work. The two compose: affected removes packages the change cannot reach, and the cache replays any survivor whose inputs already have a stored result.
- run: pnpm turbo run build test --filter='...[origin/main]'
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
With both in place, a one-line change to a leaf package builds only that package and re-tests only its dependents, and anything already built on another runner is downloaded rather than recomputed.
CLI validation and debug commands
# Print the affected set without running anything
pnpm turbo run build --filter='...[origin/main]' --dry=json | jq '.tasks[].package'
# Compare against a known base locally
git fetch origin main && pnpm turbo run test --filter='...[origin/main]'
Prevention and CI guardrails
- Set
fetch-depth: 0on checkout so the merge-base always exists. - Pass an explicit base ref rather than relying on the runner's guess.
- Keep the workspace graph accurate — undeclared imports hide real dependents.
- Add remote caching so even the affected set replays unchanged work.
What forces a full rebuild even with affected on
Even a correctly-configured affected pipeline runs everything sometimes, and it is important to recognize when that is right. A change to a truly global input — the root lockfile, a shared tsconfig.base.json, an .npmrc, or the CI workflow itself — can influence every package's output, so a full run is the safe result, not a bug. The fix is not to suppress it but to keep those global files stable and change them deliberately.
The pathological case is different: a full rebuild on a change that touches nothing global. That points at a shallow clone (no merge-base), an over-broad input glob that folds unrelated files into every package's hash, or a base ref that does not resolve. Distinguishing the two — legitimate global invalidation versus an accidental one — is the core skill of keeping affected fast, and the runner's dry-run output tells you which you are looking at.
Proving the affected set before you trust it
Before relying on affected in CI, confirm it selects what you expect, because a silently-too-small set ships untested code. Every runner can print the plan without executing it, and reading that plan is the cheapest way to catch a graph problem.
# Turborepo: the exact tasks and packages that would run
pnpm turbo run test --filter='...[origin/main]' --dry=json | jq '.tasks[].package'
Compare the printed set against the files you changed. If you edited a shared library and its consumers are absent from the list, the dependency edge is missing — usually an undeclared import — and you have found a correctness bug the fast path would otherwise hide. Making this dry-run a reviewable artifact on large changes keeps the graph honest over time.
Frequently Asked Questions
Why does affected sometimes still run everything?
Usually a shallow clone (no merge-base) or a change to a root-level file that every package depends on — such as the lockfile or a shared tsconfig. Both correctly invalidate the whole graph.
Does affected risk skipping a package that should have run?
Only if the dependency graph is wrong. Affected walks declared dependents, so an undeclared cross-package import is the one thing that can hide a real dependent.
Why does a lockfile change rebuild everything?
Because the resolved dependency graph is an input to every package's build hash. A lockfile change can alter any package's dependencies, so the runner conservatively — and correctly — treats the whole graph as affected.
How do I know affected isn't silently skipping a package?
Print the plan with a dry-run and compare it to your diff. If a package that imports your changed code is missing, the dependency edge is undeclared. Fix the import so the graph — and therefore affected — sees it.
Does affected replace the need for caching?
No — they compose. Affected removes packages the change cannot reach; caching replays any remaining package whose inputs already have a stored result. Use both for the fastest correct pipeline.
Related
- CI/CD Pipeline Optimization for Monorepos — the overview for fast monorepo pipelines.