open atlas
↑ Back to track
CI/CD pipelines CICD · 06 · 01

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.

CICD Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

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.

StrategyWhat runs on a small PRWall-clockMain risk
Build & test everythingAll 180 projects~40 min, every PRNone — just slow and costly
Affected onlyChanged + dependents (often 5–25%)MinutesA root file marks everything affected
Affected + remote cacheAffected minus cache hitsSeconds–minutesA 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 --parallel

One 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.

Pick the best fit

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.

Quiz

A PR changes only a leaf docs package, yet nx affected rebuilds all 180 projects. What is the most likely cause?

Quiz

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?

Recall before you leave
  1. 01
    Walk through how a tool computes the affected set from a PR, and why a stale --base or a lockfile change breaks it.
  2. 02
    What exactly goes into a task's cache key, and how does an incomplete key produce a stale 'cache hit' that ships to production?
Recap

“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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.