0154 — Each change builds and tests only what it touches: workspace packages plus Turborepo
- Status: Accepted (Gera chose this direction on the CI big-picture page, bet 1; slice 1 is the first PR)
- Date: 2026-11-02
- Deciders: Gera
Context
The CI big picture (Nov 1) found that CI is 84% of the AWS bill, about $100 a day, and that each merged change runs CI about 5.6 times, mostly re-checking code that did not change. Its bet 1: a build tool that knows what each change affects and caches the result, so the same code is never checked twice. Gera picked it.
Three things already decide what CI runs, each on its own:
- Test selection in
ci.yml: the affected unit tests on a PR, the full set in the merge queue. - Guard pins: the ~1,069 always-run drift tests.
- The per-test-file result cache (#24377, dry run): a test file whose recorded inputs hash the same as a passing run on the same day is reported as skippable. It measured 37–46% of files cacheable a few merges after a recording.
None of them covers the type check or the build, and none can see across apps. ADR-0153 split the repo into apps (apps/site, apps/internal) and packages (@propflow/brand, marketing, telemetry, phone, ui), which is what a task-graph tool needs to work with.
What the code looks like today (measured, origin/main a3365c11b44, the app-split import graph re-run on it):
- 79% of changes touch shared core; over the last 7 days 1,277 of 1,438 merges to main (89%) changed a non-test file under
src/oragents/. src/lib's hubs are not leaves.platform/logging.ts(1,772 importers),utils/(515),data/statuses/(274) anddata/types.ts(2,046 code importers, 2,875 test importers) each reach about 646 other files when you follow their imports.data/types.tschanged in 127 of the 1,438 merges.logging.tsgets there through two edges,request-context-accessor.tsandsecurity/sanitize.ts;data/types.tsimports 30 domain modules for their types.- The true leaves are small. Directories under
src/libwith no import of any other repo code:
| Group | Files | Importers outside it | Changed in last 7 days (of 1,438 merges) |
|---|---|---|---|
src/lib/brand |
7 | 29 code, 5 tests | 5 |
src/lib/anonymize |
3 | 8 | 1 |
src/lib/integrations/client |
3 | 8 | 0 |
src/lib/domain/rent-history |
5 | 5 | 0 |
src/lib/platform/hooks |
5 | 45 (one edge out, to platform/auth) |
0 |
- The two app lanes, Nov 2 (all their runs so far):
ci-site.yml63 successful runs, median job 276 s (npm ci 82, typecheck 26, lint 27, test 38, build 51);ci-internal.yml112 runs, median 267 s (npm ci 87, typecheck 57, lint 6, test 3, build 64). Of the site's 68 finished runs, 34 tested exactly the inputs of an earlier run: 21 an earlier run on the same PR or on main, 13 only another PR's. Of internal's 116, 5 repeated, all same-PR or main, because internal reads product code by path and almost every merge changes some of it.
Decision
We adopt Turborepo as the one task graph and cache for the workspace, and move shared core out of src/lib into workspace packages, leaf-most first. Every slice is a vertical one that works on its own.
Turborepo, not Nx. Our repo is npm workspaces of TypeScript packages, and the tasks are four scripts per package (
typecheck,lint,test,build). Turborepo runs package scripts as they are, needs oneturbo.json, and its remote cache is part of Vercel, which we already use: free on every plan under fair use (Vercel changelog, "Free Vercel Remote Cache"; upload caps 1 TB a month on Pro). Nx's extras (generators, project-level code ownership, its own executors) are things we do not need, and its remote cache is a separate Nx Cloud account. Choosing by data: everything we measured above is per-package hashing and per-package selection, which both tools do the same way.What turbo caches, and how. One task per package script. A task's hash is the package's own files, the lockfile entries it resolves, its declared env, and, through a no-op
transittask (dependsOn: ["^transit"]), the files of every workspace package it depends on. A pass replays its log; a failure is never cached. Nothing in CI consumes.next(each Vercel project builds its own deploy), so every task'soutputsis empty and the cache holds logs only.An app that reads files outside its folder must list them as inputs. Turbo cannot see a tsconfig
pathstarget, a../../import or a stylesheet@import.apps/internalreadssrcandagentsthis way (its type graph reaches ~3,400 product files), and that product code reads further files on its own: workspace packages internal never declares (@propflow/telemetrythroughsrc/lib/traffic),config/*.json,lambda/,evals/,scripts/lib, by../and bypath.join. No hand-kept list follows that, so an app that reads root code hashes the whole repo (minus docs, markdown and the other apps); only a leaf data file (a.json, a font) may be listed on its own.src/__tests__/turbo-inputs.drift.test.tsholds that rule. A missing input would be a stale cache hit, which is a false green. The cost is that internal's cache hits only when nothing but docs or the site changed, until slice 4 takes its type graph out of the root.Selection stays with the workflows'
paths:triggers until the product code is in packages.turbo run … --filter=...[origin/main]maps a changed file to the package that owns its folder. A change undersrc/belongs to the root, so the filter deselectsapps/internalexactly when it must run (checked on the slice-1 branch with--dry=json: asrc/lib/utilsedit selected no task; apackages/phoneedit selected phone, brand, marketing, site and internal). So each app lane keeps its trigger list and runsturbo run typecheck lint test build --filter=@propflow/<app>. The[origin/main]filter becomes the selector for a lane once nothing in it reads root code by path.The cache store: GitHub's Actions cache now; Vercel's remote cache once it has a cache-only credential. The only Vercel credential in the repo is
VERCEL_TOKEN, which creates production deployments. Putting it inpull_requestjobs that run the PR's own code is a security change, not a cache setting. GitHub's cache scoping is the trust model #8279 and #24377 already chose: a PR reads its own entries and main's, never another PR's. Measured cost of that choice on the site lane: 21 of 68 runs hit inside that scope, 13 more would hit only with a shared remote cache. Moving to Vercel's remote cache is a later, separate step: a token that can only read and write the cache (or signed artifacts withremoteCache.signature, and PR jobs read-only), decided when that 19% is worth it.How it composes with what exists, so nothing runs twice.
- A test file belongs to exactly one runner. Today
ci.yml's root vitest, eslint and tsconfig excludeapps/**, and the app lanes run only the apps' own tests, so slice 1 changes nothing inci.yml. When a module and its tests move into a package (slice 2 on), they leave the root's selection and the guard-pin list in the same PR, and turbo owns them. - The per-test-file cache (#24377) stays the cache for the product root. It is finer than turbo (per test file, keyed by the inputs recorded while that file ran), and the root stays one package. Turbo is the cache for packages and apps. A file is never under both.
- Guard pins stay always-run in the product root. A pin that moves into a package with its subject becomes an ordinary package test; turbo reruns it whenever anything it reads changes, which is what "always-run" protected.
- A test file belongs to exactly one runner. Today
The carve-out order: leaf-most first, measured, one package per slice. Each slice moves one group into a package (a new one or the closest existing one), rewrites its importers to the package specifier, and moves its tests. Order:
src/lib/brandinto@propflow/brand(no imports out, already a package to land in).src/lib/platform/hooks(cut its one edge toplatform/auth),anonymize,domain/rent-history,integrations/client.- The logging hub: cut
logging.ts's two edges (request-context-accessor,security/sanitize) so it stops reaching ~646 files, then movelogging,security/sanitizeandutilsinto a@propflow/corepackage. data/types.tsanddata/statuses: invert the 30 type imports from domain modules (the domain types move down into the package) before the move. This is the carve that makesapps/internalcacheable, because it is the bulk of internal's type graph. Every slice re-measures withsplit_graph.pybefore it starts, and stops to re-plan if the measured coupling is larger than this list says.
Consequences
- Slice 1 (this ADR's first PR) is small on purpose and saves little. Estimate from the Nov 2 runs: each hit saves the four task steps (~142 s on the site, ~128 s on internal) but still pays checkout and
npm ci. With 21 site and 5 internal same-scope repeats a day that is about 60 runner-minutes a day, well under $1 at our spot prices (estimate). Its value is the plumbing: the task graph, the input pins and the cache step, proven on real runs, before a singlesrc/libfile moves. - The real saving comes from slices 2 to 4, when product code sits in packages and a PR that touches only a product route stops re-checking them. We do not put a dollar figure on it yet: the big picture's $17–25 a day is for the per-test cache alone; each slice measures its own before and after on the app and package lanes.
- Skipping
npm cion a full hit is the next step inside the app lanes (it is 30% of each run). Turbo can tell whether every task will hit before installing anything; that is a follow-up, not slice 1. - The root
package.jsongains apackageManagerfield. Turbo 2 treats a repo without one as a single package and silently finds no workspaces (seen on the slice-1 branch:monorepo: false). npm ignores the field unless corepack is turned on. - Harder: every app or package that reads a file by a relative path outside its folder has to list it, and the drift test is what keeps that honest.
Alternatives considered
- Nx. Same per-package hashing; heavier config and a second vendor for the remote cache. See Decision 1.
- Vercel remote cache from day one with
VERCEL_TOKEN. Rejected for slice 1: it hands a production-deploy token to code running in every PR. Revisit with a cache-only credential (Decision 5). --filter=...[origin/main]as the app lanes' selector now. Rejected: it missesapps/internalon anysrc/change (Decision 4).- Extending the homegrown per-test cache to type check and build. The type check is one whole-program pass per tsconfig, so there is nothing per file to key; it only splits by package, which is what turbo does.
- Carving the big hubs first (
data/types.ts,logging.ts). They are what everything imports, but each reaches ~646 files, so moving one drags most ofsrc/libwith it. Leaves first proves the move recipe on code that cannot break much.
Amendment 2026-11-02 — re-measured before slice 3: internal's type graph enters through type-only edges, not through data/types.ts
Decision 7 says every slice re-measures and re-plans if the coupling differs from the list. It does, for apps/internal.
Measured (origin/main 7b6c7a3d0e0, a walk of every import from apps/internal's own files with its tsconfig paths, type-only edges included, because tsc and next build read them; a one-off script using TypeScript's own resolveModuleName, which step 2 below brings into the repo as the drift test's walk):
- Internal reaches 3,600 files (type graph) but only 563 by value imports, the set
internal-app-import-graph.drift.test.tsalready keeps free of the data layer. The other 3,037 come in through 52 type-only edges from value-graph files into server code. data/types.tsis already inside the 563. Two edges carry almost all the rest:decision-line.ts(the model's types sat beside its builder, which value-imports the tools catalog and through it the Temporal client) andwo-unified-timeline.ts(viawo-progress.ts's type import of the comms workflow's snapshot). Cutting just those two takes the closure to ~1,450.- The remaining ~880 files hang off a shared core of ~580 that about 30 edges reach independently (five client components type-import a
src/app/api/**/route.ts, plusdata/types.ts's type imports ofrenewal-batch-pointer,intake-attachment-contentand similar domain modules). None of them moves the number alone; all of them together take internal to ~580 files.
What that buys, measured on the last 400 merges to main (a merge "misses" if it changes any input):
| Internal's inputs | Merges that miss | Possible hit rate |
|---|---|---|
| Whole repo minus docs (today) | 393 | 2% |
| Its type graph as it is (3,600 files) | 297 | 26% |
| After the two cuts (~1,450 files) | 172 | 57% |
| After all cuts (~580 files) | 99 | 75% |
(src/components/**, packages/** and the lockfile count as inputs in every row: Tailwind scans the components for class names. They alone miss 66 of 400.)
New order for internal's part of slice 4, replacing "move data/types.ts first":
- Cut the two big type edges: the decision-line model's types into
decision-line-model.ts, the comms snapshot's intodomain/maintenance/maintenance-comms-snapshot.ts. Pure type moves, no forwarding re-exports. - Narrow
apps/internal/turbo.json's inputs from the whole repo to its type graph, withturbo-inputs.drift.test.tswalking type edges as well as value ones and failing on any file the walk reaches that the inputs miss. This is the slice that changes internal's cache numbers; measure it. - Cut the ~30 edges into the shared core in batches (route response types into
src/lib/api-schemas,data/types.ts's domain imports inverted as Decision 7.4 says). Each batch narrows the inputs again.
The leaf moves of Decision 7.2 and the logging hub (7.3) stay in the order given; they do not move internal's number.
The same walk exposes a gap in today's selection: ci-internal.yml's paths: covers only internal's value graph, so a type-only change in decision-line.ts could break internal's type check without starting its lane. Step 2's walk closes it for turbo; step 2 also widens the paths: list to the type graph.
Amendment 2026-11-05 — Decision 3: an app that reads root code lists, as turbo inputs, a walk of its reads written in CI
Decision 3 said no hand-kept list can follow what root code reads, so apps/internal hashed the whole repo, and its cache could hit only on a docs-only change (3 of the last 400 merges, 1%). That reasoning holds for a hand-kept list. It does not hold for a list a program writes from the tree being built.
Why written in CI, not committed. The first version of this amendment (2026-11-03, #25318) committed the generated list and failed CI when it differed from the walk. That PR (#25318, closed and split) was kicked from the merge queue twice and got one blocking review for the same cause: any merge that changed what internal reads (its own follow-up cuts, an unrelated product change) made the committed snapshot stale, and after merge, every such PR would have bounced once to regenerate. A list written per run has no snapshot to go stale.
The rule now. For apps/internal:
- The walk (
scripts/lib/internal-inputs.ts) starts at every file internal's tsconfig includes, tests too, and follows: every import TypeScript reads, type-only included, resolved with TypeScript's own resolver and internal's tsconfig (JSON imports included; an import that resolves to nothing fails the walk); workspace packages reached by name, aspackages/<name>/**(so@propflow/phone, reached through the pipeline lab's product components and never declared, is hashed); the stylesheet's@import,@source(a whole tree: Tailwind scans it) andurl(); and, in the files that RUN (reached over value edges), every string literal that names a tracked repo path, alone or as the literal arguments of apath.join/resolve/URL/fscall. A file reached only through a type import is read by the type checker and never executed, so its runtime reads do not count. - Written per run.
ci-internal.ymlrunsnpm run generate:internal-inputsafternpm ciand before turbo. It overwritesapps/internal/turbo.jsonwith the walk's reads, which sit once on a script-lessoutside-readstask;typecheck,lint,testandbuilddepend on it, and a task's hash includes its dependencies' hashes. The walk is deterministic for a tree, so an unchanged tree writes an identical list and an identical hash. Verified withturbo run lint build --filter=@propflow/internal --dry=json: editing a listed file changesoutside-readsand both task hashes, and editing an unlisted one (a doc) changes none. A folder at least half read is folded intodir/**, and folding only adds files. - The committed file is the fallback.
apps/internal/turbo.jsonin git keeps the whole-repo inputs, so a turbo run that skips the generator (a laptop, a future caller) can never replay a stale pass. Vercel does not build through turbo (vercel.jsonpinsnext build), and the task cache is CI's ownactions/cache, scoped per PR and main. - The trigger was unchanged by this amendment except for the walk's own two files (the Decision 4 amendment below widens it).
ci-internal.yml'spaths:still list internal's value graph (kept complete byinternal-app-import-graph.drift.test.ts), so a type-only change in product code still does not start the lane. That gap predates this amendment; widening the trigger to everything the walk reads is the next part (folder-widepaths:). - The check.
turbo-inputs.drift.test.tschecks that the list the generator writes covers every walked read; that the workflow runs the generator afternpm ciand before turbo; and that the committed file stays whole-repo. It has positive controls for each kind of read, planted edges for an undeclared package and for a runtime read beyond the app's own files, and a mutation control (TURBO_INPUTS_MUTATION=drop-one).
What the walk cannot see, and why that is accepted: a path built entirely at runtime from non-literal parts (path.join(root, entry.file) where entry.file comes from data) in a file that runs in internal's tasks. The literal scan catches the folder part when it is a literal. Each such read found later is a walk fix plus a control, not a return to the whole repo.
Not changed: apps/site keeps the hand-checked rule (it reads no root code). Decision 4 (selection by paths:) stands.
Amendment 2026-11-05 — Decision 4: apps/internal's lane starts on the folders it reads from
ci-internal.yml's paths: listed internal's value graph module by module (494 lines), so a type-only change in product code could break internal's type check without starting its lane. The trigger is now hand-kept and folder-wide: src/app, src/components, src/lib, agents/clara/lib, packages, config and public, plus the app, the lockfile, the task graph, this workflow and the input walk's two scripts. It is wrong only in the safe direction (the lane runs; turbo replays a cached pass when none of the inputs changed), and a new import inside those folders never makes it stale. internal-app-import-graph.drift.test.ts still checks that every module the app imports is covered.
Measured on the last 400 merges: the folder-wide trigger starts the lane on 81% of merges, against 80% for a list folded from the full type-graph walk, so it costs almost nothing over an exact list. Deploys follow it: deploy runs on every passing main push the trigger starts, cache hit or not (a hit is not proof that production serves these inputs: a revert back to an earlier input set would hit and leave the reverted change live). That is roughly 4x the old trigger's 290 deploys a week, fewer after the concurrency group collapses bursts. Skipping a deploy safely needs the hash production actually serves, which is a separate change.