0154 — Each change builds and tests only what it touches: workspace packages plus Turborepo

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:

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):

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

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.

  1. 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 one turbo.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.

  2. 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 transit task (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's outputs is empty and the cache holds logs only.

  3. An app that reads files outside its folder must list them as inputs. Turbo cannot see a tsconfig paths target, a ../../ import or a stylesheet @import. apps/internal reads src and agents this 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/telemetry through src/lib/traffic), config/*.json, lambda/, evals/, scripts/lib, by ../ and by path.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.ts holds 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.

  4. 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 under src/ belongs to the root, so the filter deselects apps/internal exactly when it must run (checked on the slice-1 branch with --dry=json: a src/lib/utils edit selected no task; a packages/phone edit selected phone, brand, marketing, site and internal). So each app lane keeps its trigger list and runs turbo 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.

  5. 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 in pull_request jobs 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 with remoteCache.signature, and PR jobs read-only), decided when that 19% is worth it.

  6. 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 exclude apps/**, and the app lanes run only the apps' own tests, so slice 1 changes nothing in ci.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.
  7. 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:

    1. src/lib/brand into @propflow/brand (no imports out, already a package to land in).
    2. src/lib/platform/hooks (cut its one edge to platform/auth), anonymize, domain/rent-history, integrations/client.
    3. The logging hub: cut logging.ts's two edges (request-context-accessor, security/sanitize) so it stops reaching ~646 files, then move logging, security/sanitize and utils into a @propflow/core package.
    4. data/types.ts and data/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 makes apps/internal cacheable, because it is the bulk of internal's type graph. Every slice re-measures with split_graph.py before it starts, and stops to re-plan if the measured coupling is larger than this list says.

Consequences

Alternatives considered

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):

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":

  1. Cut the two big type edges: the decision-line model's types into decision-line-model.ts, the comms snapshot's into domain/maintenance/maintenance-comms-snapshot.ts. Pure type moves, no forwarding re-exports.
  2. Narrow apps/internal/turbo.json's inputs from the whole repo to its type graph, with turbo-inputs.drift.test.ts walking 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.
  3. 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:

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.