Two scenes.
Scene one. An engineer edits one file in core/types/. Opens a PR. CI spends 12 minutes building the whole monorepo, then another 4 minutes running integration tests for apps that don't even import this package. Sixteen minutes later, green check. Everyone's used to it — "we have 80 packages, what can you do."
Scene two. Same engineer puts the preview label on the PR. CI builds three docker images, pushes them to the registry, brings up web/api/docs containers on the VPS, renders three subdomains. The reviewer opens a URL — it shows the same page as main, because the change was in core/, which the frontend doesn't touch. RAM eaten, preview stack idling until the PR closes.
Both scenes are about the same gap.
The usual pitch: affected speeds up the build
Most monorepo tools support affected detection: diff against a known point, find changed packages, expand through the reverse-dependency graph, rebuild only those. Turbo, Nx, kb-devkit — they all do this.
The usual pitch: "affected speeds up the build." True. But that's the cheapest of its effects — build-skip saves seconds. If you stop at the build, you miss everything else.
It's actually a signal
Affected isn't "don't rebuild unnecessary things." It's the answer to: which part of the world does this change actually touch?
And that answer is just as useful at every layer of the stack.
Layer 1: the build
Decision: what to rebuild. The answer drives the compiler. Saves CPU seconds.
kb-devkit run build --affectedLayer 2: artifacts
Decision: which docker images to build and push. The answer drives CI and network traffic. Saves tens of seconds, registry space, and keeps production rollouts from triggering for no reason.
# .kb/deploy.yaml
targets:
kb-gateway:
watch: [plugins/gateway/**, core/**, shared/**]
image: kb-gatewaykb-deploy compares watch: globs against the git diff. Nothing matched — target skipped. No build, no push, no SSH.
Layer 3: environments
Decision: whether to spin up a preview at all. The answer moves a whole docker compose: containers, RAM, ports, nginx routes, subdomains. Saves minutes, ~1 GB of RAM, and keeps the PR thread from filling up with "here's the preview" comments that nobody opens.
Vercel and Render pitch "preview on every PR" as a feature. It works for them — you pay for more runtime. But a PR that only touches server-side logic with no user-facing change doesn't need three containers spun up for it.
In our CI it looks like this:
CHANGED=$(git diff --name-only origin/main...HEAD)
AFFECTED=()
[[ "$CHANGED" =~ ^sites/web/apps/web/ ]] && AFFECTED+=(web)
[[ "$CHANGED" =~ ^sites/web/apps/docs/ ]] && AFFECTED+=(docs)
[[ "$CHANGED" =~ ^plugins/gateway/|^core/|^shared/ ]] && AFFECTED+=(gateway)
[[ ${#AFFECTED[@]} -eq 0 ]] && { echo "no UI affected — skipping preview"; exit 0; }Eight lines. Something visible to a human — spin it up. Nothing — don't. Nobody loses anything.
When the layers stack
PR with one commit in sites/web/apps/web/components/Header.tsx:
- Layer 1: rebuild only
kb-labs-weband its dependents. - Layer 2: push only
kb-site-web. Gateway and docs — untouched. - Layer 3: spin up a preview with the web container only. No gateway, no docs.
One question, three savings. Each one an order of magnitude larger than the last: seconds → minutes → minutes + RAM + UX.
How to tell when it's missing
A good signal: a "do this or not" decision that you can't answer cleanly. You usually paper over it with a flag: if: env.BRANCH == 'main', --all, a manual approval. Flags accumulate, the platform stops deciding on its own.
If you catch yourself reaching for one of those flags — ask: what does the system already know about this change? If there's an answer — that's the input. If not — that's what's missing.
Implementation note
watch: globs need to be expressed the same way at every layer. Ours are path-globs: plugins/gateway/**, sites/web/apps/web/**. Same syntax in kb-devkit, kb-deploy, and the preview pipeline.
When one signal is used at three layers, it needs one representation. Otherwise the layers drift and you get bugs like "build skipped X but deploy built X — why?" The fix is always the same: collapse to one form.
For us, affected ended up as three independent implementations — at the build, artifact, and environment layers. All three grew at different times from different problems. Wiring them together for preview environments, it became clear it was the same move applied three times. One signal, three uses, three classes of savings.
Affected isn't a build optimization. It's the question "does this even need to happen?" asked at every layer.