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.
A monorepo CI pipeline that rebuilds and retests every package on every commit gets slower with each package added, because its cost is proportional to the repository rather than to the change. The reason it runs everything is usually that the runner has no way to compute what changed — either there is no base to diff against, or no task runner deriving the affected set from the dependency graph. Affected builds fix this by running only the packages a change can reach: the changed packages plus their dependents.
The precondition is git history and an accurate dependency graph. The runner diffs the working tree against a base branch to find changed packages, then walks the graph to include dependents, so a shallow clone that lacks the base commit forces a fallback to running everything, and an undeclared cross-package import hides a real dependent. Fast, correct affected builds depend on both being right — full history for the diff, and honest dependency declarations for the traversal.
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.
Give CI full history and run only the affected set:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm turbo run build test --filter='...[origin/main]'
The ...[origin/main] selector expands to every package changed since the base plus everything that depends on them — the correct set to validate. Layer remote caching on top so even the affected survivors replay unchanged work rather than rebuilding, which compounds the speed-up.
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.
- 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 dependency 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.
What forces a full rebuild even with affected on
Even a correctly-configured affected pipeline runs everything sometimes, and recognizing when that is right prevents the mistake of suppressing a legitimate full run. 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. 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 with 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. A full set with a global file in the diff is expected; a full set with only leaf changes is a configuration bug to fix at its source rather than work around.
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, because a regression that shrinks the affected set incorrectly is caught in review rather than by a production incident. The affected set is a claim about what a change can reach, and like any claim it is worth verifying against the actual change before trusting a scoped CI run to have covered everything.
Composing affected with caching and sharding
Affected detection is the first of three composable levers, and its speed-up compounds when layered with the other two. Affected removes packages a change cannot reach; caching replays any survivor whose inputs are unchanged, on this runner or another through a remote cache; and sharding parallelizes whatever genuinely-new work remains. Applied in that order, a one-line change runs only its package and dependents, replays the ones that did not truly change, and spreads the rest across runners — so the pipeline's cost tracks the change rather than the repository.
The order matters because each lever operates on what the previous one left. Affected first, because there is no point caching or parallelizing work a change cannot reach; caching second, replaying built results; sharding last, on the remaining serial work. Reaching for sharding before affected is the common mistake — you then pay for parallelism on tests affected would have skipped. Composing the three correctly is what takes a monorepo CI from cost-proportional-to-the-repo to cost-proportional-to-the-change, which is the difference between a pipeline that slows with every package added and one that stays fast as the repository grows.
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.
Why does my monorepo CI rebuild everything on a one-line change?
Usually no base to diff against — a shallow clone lacking the merge-base — or no task runner computing the affected set. Set fetch-depth: 0, pass an explicit base, and run with --filter='...[origin/main]' so only changed packages and their dependents run.
How do I know affected isn't silently skipping a package?
Print the plan with a dry-run (--dry=json) 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.
Why does a lockfile change still rebuild everything?
Because the resolved dependency graph is an input to every package's build hash, so a lockfile change can affect any package. The runner conservatively — and correctly — treats the whole graph as affected. This is a legitimate full run, not a bug.
In what order should I combine affected, caching, and sharding?
Affected first (it removes work a change can't reach), caching second (it replays built results), sharding last (it parallelizes the remaining serial work). Sharding before affected wastes parallelism on tests affected would have skipped.
Related
- CI/CD Pipeline Optimization for Monorepos — the overview for fast monorepo pipelines.