Monorepo CI: affected-only builds and remote caching
In a large monorepo, "build & test everything on every PR" stops scaling. Run only the affected subset from the git diff, restore prior outputs from a content-hashed remote cache, and shard what's left — but a cache key that misses an input ships a stale build.
A one-line copy fix in a docs package opens a PR. CI spins up, and forty minutes later it goes green — having rebuilt and tested all 180 projects in the monorepo, including three services and a mobile app that import nothing from the package you touched. The pipeline does this on every PR. The team has quietly accepted a forty-minute feedback loop as the cost of a monorepo, and one contributor has started batching unrelated changes into a single PR just to amortise the wait. Nothing is broken. Everything is slow. The build is answering a question nobody asked: what could have changed? — when the only question that matters is what actually did?
Affected: from a git diff to a subset of the task graph
By the end of this lesson you’ll know exactly why the 40-minute build keeps happening and the three levers that eliminate it — or at least shrink it to match the diff, not the repo.
“Build and test everything on every PR” is correct and, in a large monorepo, ruinous. Its cost grows with the size of the repository while the value of any single PR depends only on what that PR changed. Affected-only builds break that coupling. The tooling — Nx, Turborepo, Bazel — models the repo not as a flat list of packages but as a project graph: nodes are projects, edges are import/dependency relationships. On top of it sits a task graph: build, test, and lint for each project, wired so a project’s build depends on its dependencies’ build.
Computing the affected set is two steps. First, the tool asks git which files changed between two commits — a --base and a --head — and maps each touched file back to the project that owns it. That gives the directly changed projects. Second, it walks the project graph in reverse: any project that transitively depends on a changed project is also affected, because a change upstream can break it. The union is the affected set, and the tool runs the requested tasks for exactly that subset.
# In CI, base = last successful main SHA, head = the PR tip.
# nrwl/nx-set-shas exports these so you don't diff against a stale point.
nx affected -t build,test,lint --base=origin/main --head=HEAD
# Turborepo expresses the same idea via the package graph + git:
turbo run build test lint --filter='...[origin/main]'The base matters more than it looks. Set it to the last successful commit on main, not a fixed branch point — otherwise a long-lived PR diffs against an old base, recomputes a huge affected set, and you lose the win. In CI you wire this with a helper like nrwl/nx-set-shas.
Remote cache: hash the inputs, restore the outputs
Affected tells you what to run. The cache tells you whether you even need to. Before executing a task, the tool computes a hash over everything that can influence the output: the project’s source files, the resolved hashes of its dependencies, the task’s configuration, the tool versions, and any declared environment variables. That hash is the cache key. If a task with that exact key was run before — by anyone, on any machine — its recorded output (the dist/, the test result, the logs) is downloaded and replayed instead of recomputed.
The leverage is that the cache is shared and content-addressed. Turborepo splits this into a global hash and a per-task task hash; if either changes, the task misses. A teammate who already built main locally, or a previous CI run, populates a remote cache (Nx Cloud, Turborepo Remote Cache) that your run reads from. A cache hit that turns a multi-minute build into a sub-second artifact restore is the difference between a CI run that scales with the diff and one that scales with the repo.
| Strategy | What runs on a small PR | Wall-clock | Main risk |
|---|---|---|---|
| Build & test everything | All 180 projects | ~40 min, every PR | None — just slow and costly |
| Affected only | Changed + dependents (often 5–25%) | Minutes | A root file marks everything affected |
| Affected + remote cache | Affected minus cache hits | Seconds–minutes | A missing input → a wrong “cache hit” |
▸Why this works
The cache is only as correct as its inputs. The hash must capture every input — and the one people forget is environment variables. Turborepo is blunt about this: a value read at build time but absent from the hash is a silent poison. You split env vars into env/globalEnv (affect the hash) and passThroughEnv/globalPassThroughEnv (available at runtime, do not change the key). Put NEXT_PUBLIC_API_URL in the wrong bucket and two builds with different API URLs share a key: the second gets a “hit” and ships the first one’s baked-in URL. The bug looks impossible — same commit, wrong build — because the input that differed was never hashed.
Sharding: split what’s left across runners
Affected and caching shrink which tasks run. Sharding shrinks how long the survivors take by running them in parallel across N runners. The classic form is a CI matrix: split a 2,000-case test suite into shards 1/4 … 4/4, give each its own runner, and a 40-minute suite finishes in ~10 (four-way ≈ 75% faster); a small suite can drop from 3 minutes to 30 seconds. Nx Cloud and Turborepo go further and distribute the task graph itself across agents, respecting dependency order so a downstream build waits for its upstream.
# GitHub Actions matrix: 4 parallel test shards.
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx jest --shard=${{ matrix.shard }}/4 # or: nx affected -t test --parallelOne rule separates a fast matrix from a slow one: balance by execution time, not test count. Four shards of equal file count but lopsided runtime leave three runners idle while one grinds — the slowest shard sets your wall-clock, so the win is only as good as your worst shard.
A 180-project monorepo runs ~40 min of build+test on every PR, where most PRs touch 1–3 projects. Pick the primary scaling strategy.
The honest tradeoff: affected + remote cache is the highest-leverage move, but its entire value rests on the hash being complete and the affected computation being tight. Miss an input and you ship stale; let a root file balloon the affected set and you’ve paid the setup cost for nothing.
The failure modes that erase the win
Three failures recur, and each turns a scaling win into a liability. When you see any of them in your repo, the fix is always the same: tighten the input set, not the tooling.
The root-file blast radius. Change tsconfig.base.json, nx.json, or — the classic — the lockfile, and the tool marks everything affected. The lockfile case is a deliberate failsafe: a dependency bump could affect any project, so by default Nx plays it safe and rebuilds all. The result is that a routine package-lock.json update detonates the full 40-minute build, and a team that doesn’t understand why concludes “affected doesn’t work.” It works; you tripped the global-input wire. You tune it with namedInputs so only genuinely global files are global — but mis-scope a {workspaceRoot} glob and you re-create the blast radius yourself.
The stale hit from an unhashed input. Covered above: an env var, a tool version, or a config file that influences the output but isn’t in the hash. The cache returns a build that is wrong for the current inputs, and because a hit is silent and fast, it can pass review and reach production. This is the most dangerous failure because it doesn’t fail — it succeeds with the wrong answer.
Remote-cache poisoning / trust. A remote cache is a shared write surface. If an untrusted actor (a fork PR, a compromised token) can write artifacts, they can plant a malicious dist/ under a key a trusted build will later read, and your “cache hit” runs their code. This is why Turborepo signs artifacts with HMAC-SHA256 and verifies them on download, treating any artifact that fails the signature as a miss — and why you scope cache write access tightly and never let untrusted PRs populate the cache trusted runs consume.
A PR changes only a leaf docs package, yet nx affected rebuilds all 180 projects. What is the most likely cause?
Two builds of the same commit produce different bundles, but the second is reported as a remote cache HIT and ships the wrong API URL. What went wrong?
- 01Walk through how a tool computes the affected set from a PR, and why a stale --base or a lockfile change breaks it.
- 02What exactly goes into a task's cache key, and how does an incomplete key produce a stale 'cache hit' that ships to production?
“Build and test everything on every PR” is correct and, past a certain repo size, unaffordable — its cost scales with the monorepo while a PR’s value scales only with its diff. Affected-only builds break that coupling: the tool models the repo as a project graph and a task graph, reads the files changed between a —base and —head, maps them to the projects that own them, then walks the graph in reverse to add every transitive dependent — and runs tasks only for that affected set. A content-hashed remote cache (Nx Cloud, Turborepo) goes further, hashing each task’s full inputs — source, dependency hashes, config, tool versions, env vars — into a key and restoring a prior output instead of recomputing, shared across CI and developer machines so a multi-minute build becomes a sub-second restore. Sharding then parallelises the survivors across N runners (balance by runtime, not test count) for roughly 75% off at four-way. Three failures erase the win: a root file (lockfile, tsconfig.base.json) marking everything affected — by design for the lockfile, tunable via namedInputs; an input missing from the hash turning a ‘cache hit’ into a silently stale build that succeeds with the wrong answer; and remote-cache poisoning, which is why artifacts are HMAC-SHA256-signed and verified on download and why write access stays tightly scoped. Scale with the change, not the repo — and keep the hash complete and the base tight, because that is where the correctness lives. Now when you see a PR that rebuilds everything, your first question is: which global input was touched — the lockfile, a root config, or a mis-scoped glob?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.