Monorepo Tooling
How workspace package scripts, dependency installs, publishing, and CI runners are organized.
Workspace layout
The workspace has two product roots, and the split is a single question: does anything import it?
apps/*— what the repo runs.apps/web(Nuxt + Nitro),apps/functions(Azure Functions),apps/infra(the Pulumi program). Nothing imports one, so an app'sdistis deploy payload rather than a dependency, and an app is never a version anyone resolves.packages/*— what the repo imports. Every library, published or private, is here and only here.
That is a boundary the toolchain can address, which is the point of it: every selector names the set it wants rather than the members it does not. --filter "./packages/*", the positional packages for a lint pass, --project "packages/*" for a Vitest run. A library added later is inside all of them and an app added later is outside all of them, with nothing edited either way — where a !@esposter/web exclusion had to learn each new name, in configs where getting it wrong is silent.
Two mechanics make those selectors work. Vitest names a project after its nearest manifest unless the config sets test.name, which would make the names package identities (@esposter/db, azure-mock) that no pattern over the tree can select — so the shared factory names each project after its own workspace-relative directory (getVitestProjectName), and the app's defineVitestProject config spreads the factory's test options, the name among them. --project patterns are wildcards over that name and their * compiles to .*, so a pattern crosses a / like any other character. And a single project needs no filter at all: pnpm -C apps/web build names the directory, leaving --filter for what only it can say — a set (./packages/*) or a dependency closure (@esposter/web^..., which is how pnpm build reaches the app's libraries without naming one).
flowchart LR P["packages/* — built once, cached, uploaded"] --> A[["package-builds artifact"]] A --> CH["lint, typecheck, coverage shards"] A --> W["apps/web — nuxt build, gated on its own key"] A --> F["apps/functions — tsdown, inside its deploy job"] A --> I["apps/infra — tsdown, inside the Pulumi job"]
The artifact carries libraries only, and that follows from the same boundary: nothing imports an app's dist, so shipping it through the artifact would hand unread deploy payload to lint, typecheck, the app build and all eight coverage shards. Each app builds in the workflow that ships it instead, on top of the artifact that workflow already restores and inside the install it already performs — which also means a deploy stops reading its payload out of a cache entry keyed for someone else's purpose. That sequence — restore the entry and verify the libraries on disk (the restore-package-builds action, which the review collector shares), install the app's closure, rebuild the libraries only on a miss, then build the app — is the build-app action, which the Pulumi preview and the Functions deploy call with the app's name. The cost is one line long: a deploy pays its own tsdown build unconditionally rather than inheriting one from a hit. Deploys are the rare path and that build is seconds.
Nothing imports an app's dist, but two suites read one: the Functions package counts the app.* registrations in its bundle — the only check that catches a bundler tree-shaking every trigger away — and apps/functions and apps/infra both snapshot their bundle's size. So the coverage job is a third workflow that ships an app's build, and it builds those two the same way a deploy does, from the source the shard checked out. That is stricter than carrying them in the artifact could be: the artifact's key is a walk over packages/, so a bundle in it would go stale the moment an app's own source changed, and the count would then be asserted against the wrong build. Each shard repeating a tsdown build measured in seconds is the price, and the web app is not among them — nothing reads its output.
Package management
Esposter uses pnpm workspaces as the package manager and workspace script runner. Workspace packages are declared in pnpm-workspace.yaml, and package versions are centralized in the root catalog.
Use the root package.json scripts as the canonical entry points for cross-package work. Package-local scripts should stay small and predictable (build, lint, lint:fix, typecheck, test) so root recursive commands can compose them. Coverage is not a package-local script — it is owned entirely by the root vitest projects run.
The toolchain entry-point scripts run through the virrun sandbox via a virrun -- <cmd> prefix (the prefix is the per-command switch). The committed virrun.config.ts picks the backend per platform — the WSL sandbox on win32, native everywhere else, with the fallbacks — for the reasons virrun configuration gives. Routed today: the read-only lint/lint:packages/typecheck*/test*, the producing build:docs (write-back flushes its output to host), and the mutating dev-loop lint:fix/lint:fix:packages (each underlying oxlint/eslint/pnpm -r step wrapped; the prefix only wraps the root orchestrator, never the per-package scripts it fans out to, so there is no nested sandbox). Native by design: format/format:check are one standalone binary over source files, with no module resolution to isolate and no .nuxt to be wrong about; graph:gen is plain node over the manifests with no toolchain to isolate; build:packages is the bootstrap that builds the virrun bin itself (a circular self-host); the app build (pnpm -C apps/web build, the last step of build) is only ever run in anger by Linux hosts — Railway and CI's build job — where the config resolves native anyway, so the prefix bought it nothing where it ran and charged a win32 developer the mirror tax for an .output nothing local reads; and coverage writes a report the os-backend tmpfs upper would discard; db:gen awaits an equivalence proof (its migration output lands outside the db-schema overlay mount). The mutating scripts are never run in CI (CI runs checks, not fixes). See virrun CI.
TypeScript compiler
Every package's typecheck is plain tsc — vue-tsc where the package holds .vue files — and the typescript package itself is a pnpm overrides alias onto typescript-native-bridge, a fork that keeps the classic TypeScript API surface while running the Go compiler in-process behind it. The override is the whole switch: vue-tsc, @vue/language-core and the editor's tsserver all consume that classic API, so one entry accelerates every one of them without any of them having to move to the TypeScript 7 API they cannot yet read. That is why there is no separate native-compiler dependency and no tsgo invocation left in any script. A run announces itself with ▎ TNB ACTIVE on stderr — the line's absence means stock TypeScript loaded instead, and the override is not taking effect.
The trade is memory for wall-clock: the Go state shares the Node process, so peak RSS is several times a stock run's while the JS heap stays lower, and the app's vue-tsc pass loses roughly a third of its wall-clock. Diagnostics are the Go compiler's, not stock TypeScript's, so an error's wording may differ from what the same code reports under the typescript version the catalog names.
oxlint-tsgolint is unaffected either way — it ships its own Go binaries as optional dependencies and never loads the typescript package, so the type-aware lint pass neither gains nor loses from the override.
Recursive script orchestration
Use pnpm recursive commands instead of Lerna Lite for running scripts across packages.
Common patterns:
pnpm -r build
pnpm -r --parallel lint
pnpm -r --parallel typecheck
pnpm -r --filter "./packages/*" build
pnpm --filter "@esposter/web..." build
pnpm -C apps/web build
Guidelines:
- Tests are the exception:
test/coveragerun through one rootvitest.config.tsprojectsconfig (a singlevitest run), not a recursive fan-out, so the whole suite shares one run, one coverage report, and one--shardaxis. - Use
--parallelfor independent checks such as linting and typechecking. Never forbuild—--parallelis what discards the topological order, and a package would build against a sibling'sdistthat is mid-write or absent. - Output interleaving is settled by environment, not by the scripts.
pnpm -rruns the graph's independent packages concurrently regardless of--parallel, so a failure's stack arrives spliced with another package's output;aggregate-outputbuffers each package into a contiguous block instead. A watched terminal wants the live interleaved default, a log read after the fact wants the blocks, and both run the identical script — soPNPM_CONFIG_AGGREGATE_OUTPUTis set by environment and no script carries the flag. It is set once, bysetup-project-dependenciesintoGITHUB_ENV, rather than by a workflow-levelenvblock per workflow: every job that runs pnpm sets its toolchain up through that action, where a workflowenvreached only the two workflows that remembered it and left the fan-outs insidebuild-appand the review collector interleaved. pnpm reads its settings fromPNPM_CONFIG_<SETTING>; the npm-stylenpm_config_*spelling is not read.--streamis not the counterpart to it and never needs passing: it has been the default since pnpm 12, and--stream,--no-streamand no flag at all produce identical output. - Use filters instead of Lerna scopes/ignores, and only where a filter says something a path cannot: a set, or a dependency closure. One project is
pnpm -C <directory> <script>. build:packagesand the app build are different sets rather than a sequence: the libraries are what CI caches and hands to the checks.pnpm buildderives its one selector instead of naming anything —--filter "@esposter/web...", the app and everything it imports, topologically, so the app builds last and the script knows no library by name. It carries novirrun --, for the reason above. There is nobuild:<app>script: one app ispnpm -C apps/<app> buildat the call site, and a root script that only delegates there is a second definition of the same line, one per app, that says nothing the flag does not.- The bare
buildmeaning the app rather than the workspace is a deviation held on purpose. Railway runspnpm buildas its default build command, so that name is a deploy entrypoint: widening it to everything would have every deploy build the two bundles it does not ship, and the deploy has no way to ask for a narrower one. Every other stage is named, which is what a job reaches for. - Use
--if-presentonly for scripts that are optional across packages. - A job runs the root script, not the binary the script wraps:
pnpm coverage --shard=1/4, neverpnpm exec vitest run --coverage --shard=1/4. The script is where the invocation is defined and reviewed, so a workflow spelling it out again is a second definition that drifts silently — andpnpm execis the form that lets it. Reach forpnpm exec <binary>only where no script owns the invocation at all. - Pass tool flags as direct args, never behind a
pnpm <script> --separator — the trap, and why a flag pnpm itself owns is still forwarded, is.agents/skills/package-scripts/references/pnpm-traps.md. Do not use the separator form: pnpm forwards the literal--into the script's arguments, so the underlying tool treats the trailing flags as post---positionals and silently ignores them (this dropped--shard/--reporterin CI).
Lerna Lite
Lerna Lite is retained for publishing only:
lerna publish --yes
lerna.json repeats the workspace globs (apps/*, packages/*, scripts) that pnpm-workspace.yaml already declares, because lerna-lite reads neither that file nor a workspaces field pnpm does not use: with no packages key it falls back to its own default, packages/*. That default is silent — the release runs green and publishes the libraries while every member outside packages/ stays pinned at the version it held the day it moved there, which is what the move to apps/ did until this key existed. The repeat is enforced rather than trusted (scripts/src/workspace/lernaPackages.test.ts). Versioning is wider than publishing on purpose: every member is versioned and gets a CHANGELOG, and @lerna-lite/publish then filters the private ones, so the apps ride the same version as the libraries without ever reaching npm. The release's own gates stay packages/*-scoped (build:packages, lint:packages, typecheck:packages, test:packages) — nothing in apps/ is published, and CI already gates all three on every push.
Do not use Lerna Lite for recursive script execution or watch orchestration. If a root script is not publishing, prefer pnpm workspace commands. This means @lerna-lite/cli and @lerna-lite/publish remain in dev dependencies, while @lerna-lite/run and @lerna-lite/watch are unnecessary.
pnpm release is the chain in front of it, and every step is a check rather than a fix: format:check, build:packages, lint:packages, typecheck:packages, test:packages, then the publish. format:check is the one check that runs cold, so it is the only one that can precede the build; everything after it reads what the build writes, because the barrels and dist are both generated and gitignored — from a clean checkout lint and typecheck resolve no packages/*/src/index.ts and the size snapshots measure no bundle. The fix variants led this chain once, which made a release rewrite the tree it was about to publish — whatever they changed shipped under the new version and got tagged without anyone reading it. A release is the one moment a dirty tree has to fail loudly rather than be tidied away. The suite is in the chain for the same reason the checks are: CI runs it on every push, but nothing tied a green suite to the tree lerna actually publishes, and a publish is the only step here that a later commit cannot walk back.
Publishing from a developer's machine is the deliberate simplification, not an oversight. The alternative — a local lerna version and a tag-triggered CI job publishing through npm's trusted publishing, which lerna-lite supports out of the box (id-token: write, a per-package token exchange, provenance attached for a public package) — buys an attestation that the published tarball is the one CI built. Nobody here is asking for that attestation, and its price is a release path that lives in two places and a per-package trusted-publisher registration on npmjs.com that fails closed the day a new package is added. One script, run locally, is the whole release: pnpm release gates the tree and hands lerna publish a version, a tag and a dist it just built, and 🚀 Release turns the pushed tag into a GitHub release.
Dependency installs
Use plain pnpm i from the repo root when package manifests change.
Do not set CI=true locally to bypass pnpm prompts, and do not use pnpm install --config.confirmModulesPurge=false or other store override workarounds. Those approaches can create a local .pnpm-store/ in the repository.
When only dependency versions change, follow the dependency update process and refresh the lockfile with:
pnpm refresh:lockfile
An install that dies in the app's postinstall (nuxt prepare) on Cannot find module '@nuxt/devtools-kit' is local node_modules drift, not a bad lockfile. @tresjs/nuxt imports that package without declaring it, so it resolves only through pnpm's hoisted node_modules/.pnpm/node_modules; once those links go missing, every pnpm <script> fails too, because pnpm's deps-status check re-runs the install before running any script. pnpm i reports Already up to date and changes nothing — pnpm i --force relinks. A forced reinstall also clears packages/*/dist, so run pnpm build:packages before the next typecheck, or the app reports missing exports from the workspace packages (@esposter/db, @esposter/configuration) that are really just unbuilt. The same ordering binds the app's own configuration: nuxt prepare loads nuxt.config.ts and everything under apps/web/configuration on install, before any dist exists, so none of it imports a workspace package at the top level — hooks.ts imports @esposter/shared inside the dev-only branch that needs it.
The node version is pinned once, in .node-version: pnpm/setup installs it on a runner, fnm switches to it locally, and Renovate's built-in nodenv manager bumps it. There is no engines.node or devEngines.runtime beside it — nothing in the repo read the root engines.node but the scripts that wrote it, and a second pin is a second number to drift, which is what devEngines.runtime did: read through a Renovate custom manager, which never receives the repo's rangeStrategy, it was never bumped inside its major. Two writers move the pin, each together with the @types/node catalog entry: Renovate's node group in one branch, and pnpm update:node [version], which also installs the version and makes it the fnm default and removes the old version in one call (then refresh the lockfile) — so it is also what a machine runs after pulling a merged bump. Nothing else writes it. Already-open shells keep the old version until reopened. On Windows the bump also invalidates virrun's warm snapshot, and re-provisioning it runs corepack pnpm install inside the WSL guest — whose node is a separate fnm install the script never touches — so a guest node from the releases that stopped bundling corepack has to be given one (npm i -g corepack) before any sandboxed command runs again.
CI job shape
The reusable build-packages workflow gates every package-consuming check, and 🏎️ Bench calls the same definition (uses: ./.github/workflows/build-packages.yaml) — one build and one cache key, so a workflow reaching an already-populated key skips the build entirely. Both workflows trigger on the same push, so the job's concurrency group is keyed on the commit: the second copy waits for the first and restores what it saved, rather than both missing the key together and each building once. It never cancels the waiting job — that job still owes its caller the artifact — and takes queue: max so a third invocation of the same commit, a re-run of either caller while the other waits, is queued rather than replacing the pending one. A reusable workflow shares its caller's run, so the package-builds artifact reaches that caller's other jobs; the actions/cache entry behind it is keyed by content hash and shared repo-wide, which is what carries a build across workflows. Three composite actions split the setup work: setup-project-dependencies exports the aggregate-output setting above and runs a single pnpm/setup step that lays down pnpm (the packageManager version) and the .node-version node, and restores the pnpm store cache, optionally installing bubblewrap behind its bubblewrap-sandbox input. It still pulls pnpm from registry.npmjs.org, deliberately — the registry package is the GitHub release asset byte for byte, and npm signs a checksum over it that the action verifies against a pinned key, which the GitHub-served digest cannot do. What the migration removed is the npm CLI: setup is now one signed binary fetched over a retrying HTTP client rather than a dependency resolution, so registry latency reaches it far less, though a registry outage still would. install-projects is the one install invocation (served by that store cache — the app's postinstall: nuxt prepare generates the Linux .nuxt in place), and setup-packages runs it and then downloads the compiled package-builds artifact. Its filter input is how a job installs only the projects it reads: the app build passes @esposter/web... and so never fetches the Pulumi SDKs, the library build ./packages/*, the review collector ./{packages/*,scripts} so no app's postinstall stands between an event and its cycle, while the repo-wide checks leave it at its default, * — every project, the set a bare pnpm i lays down — because pnpm lint and pnpm typecheck run every project's own script. The action adds the root project and its dependency closure ({.}...) to whatever selector arrives, because the toolchain is declared there — virrun, vitest, oxlint, oxfmt, tsx and the typescript alias are root devDependencies, so a narrowed install without it lays down no virrun bin and the job's first virrun -- ... fails on a missing binary rather than on anything the filter was about. The closure rather than the root alone, because one of those tools is a workspace project of ours: a filtered install builds node_modules for the selected projects only, so selecting the root links virrun in without installing what packages/virrun itself needs, and the bin then dies on Cannot find package 'unconfig' — its single runtime dependency, external by design so its synchronous TS loading can find jiti relative to its own installed file. A repo-wide includeWorkspaceRoot: true would say that once and is refused: it also pushes the root into every recursive run, and the root's own lint ends in pnpm -r lint, which would then include the root and recurse. The platform-branched virrun.config.ts resolves native on the Linux runners, so every virrun -- <cmd> is a passthrough — no bubblewrap, no warm overlay layers, no warm-cache stage on the critical path. Only coverage installs bubblewrap (and pins ubuntu-26.04 for bwrap >= 0.10.0): its shards test virrun's own os backend. → virrun CI
.github/workflows/ is flat because GitHub reads it that way: only files directly inside it are scanned, subdirectories are not, and a reusable workflow has to sit there too to be uses:-able. So a workflow file cannot be filed beside the thing it serves, and the directory grows as a list rather than a tree. What can be nested is .github/actions/, which is the lever that keeps the list short — a group of steps two workflows share becomes a composite action there rather than a third workflow.
flowchart LR F["format — no gate"] BP[build-packages] --> L[lint] BP --> T[typecheck] BP --> C["coverage (sharded)"] --> M[merge coverage] BP --> A[build app]
format is the one check with no needs. oxfmt is a standalone binary over source files — no barrels, no package dist, and no virrun in the command to build one for — so gating it on the package build only made the workflow's quickest check wait out its slowest job. It installs with --filter "{.}" --ignore-scripts for the same reason it skips setup-packages: it wants the lockfile-pinned binary, not the built artifact and not the app's nuxt prepare. The filter is the root project alone rather than the {.}... closure every other job takes — that closure exists to provision virrun, and this is the one check that is native by design, a standalone binary over source with no module resolution to isolate.
Tests run through a single root vitest.config.ts whose projects are the globs pnpm-workspace.yaml declares — apps/*, packages/* and scripts — each member configuring its own project, the app as a Nuxt project via defineVitestProject. Nothing in the root config knows what any member contains, scripts included: it is a member like the rest, and the tooling it holds (the dependency graph, the outdated-dependency report, the oxlint plugins, the sweep scans) is named by its own config rather than by a hand-scoped entry here. So coverage/test are vitest run at the root, not a pnpm -r fan-out, and a coverage shard can land a tooling test as readily as a package one. An agents entry over test files inside .agents/ existed while the code review ran as a workflow script and went out with it; the agent tree holds no executable now, and a check about it lives in scripts like any other.
coverage runs as a matrix over .github/workflows/CI.yaml's matrix.shard: pnpm coverage --reporter=default --reporter=blob --shard=i/n splits all test files across runners (each shard runs a distinct slice and writes the collision-safe .vitest/blob/blob-i-n.json, which carries that shard's coverage data — the directory --merge-reports reads by default, so neither side names it). The default reporter is paired with the blob one on purpose: blob alone writes the file and prints only where it went, so a shard that exits non-zero says nothing about which test failed. A dependent merge coverage job downloads every blob and runs pnpm coverage --merge-reports to recombine them into one unified coverage report — this re-emits the report only, it does not re-run tests. Both are the root coverage script with trailing flags rather than a pnpm exec vitest of their own, so --coverage is stated once; what is banned is the pnpm coverage -- … separator form, which drops the flags after it.
Sharding distributes the coverage work rather than reducing it, and it is kept for faster test-failure feedback and because the root-level run covers every suite, which a per-package coverage fan-out does not. There are no coverage thresholds, so a partial per-shard report cannot false-fail. Matrix shards are isolated runners with no shared filesystem, so each repeats its own setup; the package build is not among it, being downloaded as an artifact.
Shard count is a boundary rather than a preference: it is chosen so the coverage → merge coverage chain lands inside the app build, and halving it roughly doubles each slice, which puts the chain past that build and makes coverage the job everything else waits on. The shard count is the coverage job's matrix in .github/workflows/CI.yaml. What a shard repeats is its setup — about a quarter of its runtime, most of it provisioning the toolchain rather than installing, since the pnpm store cache absorbs the install and the single build-packages job already paid for the artifact. Bench is unsharded — it is a smoke test that every *.bench.ts still executes rather than a walltime measurement, so splitting it only multiplies setup. Changing the coverage count means changing the CoverageShardCount constant developMainStatusChecks reads in the same commit: each shard publishes its own Coverage (n) required check, and a stale count either requires a context that never reports (blocking every PR) or silently stops requiring a live one.
What a CI proposal has to beat
Correctness first, then total consumption. A check exists to be believed, so a proposal that cannot be shown to fail on a broken tree is refused whatever it saves — the failure worth fearing is a job reporting green over work it did not do. What clears that bar is judged on the total a run burns rather than on the moment its slowest job ends, so a saving inside another job's shadow is still a saving. What that counts against is repeated work — rebuilding what a cache holds, installing what a job never reads. Distributing distinct slices across runners is not exempt from that: the slices themselves are never repeated, but each isolated runner repeats its own setup, so a fan-out pays the shard count in setup for the failure it returns sooner. That is a trade to argue rather than assume — the coverage matrix above is where it was argued. Wall-clock returns only as a constraint, that no job be shortened into becoming the one every other job waits on, which is what sizes the coverage matrix above.
A check is widened only for a decision that reads it. Before a trigger grows a path, a gate job or a second content hash for a case it misses, name what waits on the check's verdict — a merge, a deploy, an up. The Pulumi preview on a pull request is the standing example of a check nothing waits on: pulumi up runs locally with its own preview in front of it (pulumi-infra, references/operations.md), so the comment is a courtesy on the pull requests that carry infra source, and a provider bump that automerges from its branch never had a pull request for it to comment on. Its paths: therefore stay the infra tree and the workflow's own files; a gap in a courtesy costs nothing, and the gate that is load-bearing is the one to check the case against (the fallacies skill).
Nor is per-job setup where the time is: a checkout, a toolchain and an install land well inside a minute against an app build measured in several, and the pnpm store cache absorbs the install itself. Caching .nuxt to skip nuxt prepare is a non-starter on top of that — its output derives from app source, so any honest key hashes apps/web and misses on precisely the commits that change it, which is most of them. It would be a cache that hits only when it was not needed. Two larger versions of the same idea are refused for their own reasons: Nuxt build cache and TypeScript build info cache.
What is left is the app build, and it is the whole answer: it is several times the next-longest job and every other job finishes in its shadow. So it is gated on a content hash too, by the same get-build-cache-keys action that keys package-builds — a second key over those same inputs plus apps/web, so a change to a package the app bundles moves both and the two can never disagree about what the tree is.
What the two gates cache is not the same kind of thing. package-builds restores an artifact other jobs read; the app build's .output is read by nothing — no job downloads it, no deploy takes it — so what carries across runs is a marker file holding the key, and the verdict is the whole product. A hit means this exact tree already built green, which is everything a rerun could have said. The install and the artifact download hang off the miss along with the build, because a hit needs neither, which collapses the workflow's critical path to a checkout and a git ls-tree. The marker is written only after the build returns, so a failed build leaves the post-run save nothing to find; and the skip is gated on the marker rather than the restore's cache-hit, as everywhere else here — the packages' side asks verify-package-builds — so a hit that extracted nothing rebuilds instead of reporting green having built nothing. The app inherits the test/bench subtraction — sources and their committed reports alike — for the same reason the packages take it — and one more: nothing under the directories Nuxt and Nitro scan for routes is a test file, and one there would be a route in its own right long before it was a cache question.
The build-packages job builds the packages once and uploads packages/*/dist plus the generated packages/*/src/**/index.ts barrels as a single package-builds artifact (both share the packages/ common ancestor, so consumers download into packages; neither path is a dotfile, so no include-hidden-files). The build itself is gated by a cache entry whose key is a content hash — mode, blob hash and path — computed by the get-build-cache-keys composite action so the save and every restore derive it from one definition. Every restore is the restore-package-builds action — this job's, 🚀 Pulumi's, both Functions deploys' and the review collector's — and this job alone saves, an explicit actions/cache/save after the post-build assertion under the key and paths that action reports, so the entry only ever holds a full set the assertion passed. It hashes every tracked file under packages/ and subtracts three things. Both keys drop *.test.ts / *.test-d.ts / *.bench.ts, which no entrypoint reaches and which change far more often than anything they sit beside; the committed bench reports beside them — *.bench.json, *.bench.md and their platform-suffixed variants — which a pnpm bench run rewrites in apps/web as readily as in virrun, so hashing them spent a whole app build on a number no build reads; and every .md outside the app's content directory. Exactly one thing here reads markdown — the @nuxt/content collection, whose source is docs/**/*.md under that directory — so the subtraction is scoped to what that collection cannot reach rather than to a package, and a README or a CHANGELOG is prose no build opens wherever it sits. Scoped to the package it would have left the app's own pair hashed, and a release rewrites a CHANGELOG there like everywhere else, versioning being separate from publishing — so the release commit would still have paid the app build. Neither key subtracts a directory: this one is the walk over packages/, and the app key is that same walk plus apps/web — the rule a second app would follow with nothing here edited. apps/functions and apps/infra are in neither, each being built inside the workflow that ships it, from the source that workflow checked out. The root manifest, lockfile and catalog are added as blobs — infra bundles the first via import packageJson from "../../../../package.json". virrun.config.ts is not one: neither build runs under the sandbox, so the backend it picks moves nothing either key covers.
.github/ is not hashed into either key, so editing a workflow's build step does not invalidate the artifact that step produces. Every command those workflows run is already hashed — it lives in a manifest under packages/ or in the root one — so what .github/ adds to the key is the YAML around them, and a comment edit in a workflow would then discard both caches and pay the app build, the longest job in CI, for a change no build can observe. The uncovered case is a workflow that changes what a build does without touching a script, which is rare enough to be worth one manual cache eviction rather than a key that moves on every unrelated CI edit.
Everything is an input until proven otherwise, because the two failure directions are not symmetric: subtracting too little costs one rebuild on a push that was already touching a package's internals, while subtracting too much serves a dist that does not match the source it claims to be built from, and every check downstream then passes against the stale artifact. So a build input of a new shape needs nothing done to it — it is already in the key, which is the property an enumerated allowlist could not offer. The trade is the one it buys: a commit touching only a non-app package's lint config rebuilds, where an allowlist would have hit. Test-only, prose-only and app-only commits still hit. Reading git ls-tree keeps generated output (dist, barrels, *.tsbuildinfo, node_modules) out by construction, and hashing content rather than refs is what lets a pull_request event hit an entry a push saved. On a hit pnpm build:packages is skipped and the restored output is uploaded as-is; on a miss the job builds and seeds the cache, installing through install-projects with ./packages/* — the root and its closure for the toolchain, as above — because all three apps' dependency trees — the web app's postinstall: nuxt prepare, the Pulumi SDKs, the Functions host's own deps — are install work this job never reads. The downstream setup-packages composite installs dependencies and downloads the artifact — no build, no per-job cache.
The key is deliberately whole-set rather than per-package: any real input change rebuilds all of them. Per-package keying — what Turborepo and Nx derive automatically from the workspace graph — is rejected in monorepo task runners, because the set builds in a fraction of an app build that gates the workflow either way, so a third content-hash cache beside virrun's and this one buys a fraction of the one job nothing waits on.
The package-builds entry keeps an exact key. Its packages/*/src/**/index.ts glob also catches the hand-written directory barrel a package may keep under src — a tracked file, covered by the key, so an exact hit proves the restored copy is the checked-out copy. Restored by prefix it would be another commit's version of that file, extracted over the checkout and built, green. 🏗️ Pulumi and the Functions deploy restore this same entry for the libraries they link against and build their own app on top of it, so a prefix hit would hand them another commit's libraries to deploy against; the review collector restores it for the same libraries its drain's finishing checks resolve. Both Functions stacks deploy from the one deploy-function-app.yaml, triggered by a push to develop or main, and the branch is the only thing that picks the two resource names — so nothing is left to drift between the stacks.
What a hit is, is decided by the disk. A cache entry has two halves that can drift — its key and its path list — and they fail differently. A key that no longer matches the save's never hits again, loudly enough that someone eventually notices the build nobody expected. A path list that no longer matches extracts part of the entry, or none of it, while actions/cache still reports cache-hit: true; the key is computed from tracked files, so editing that list moves nothing. Both halves therefore have one definition, in get-build-cache-keys, which emits the two globs as an output every save, restore and artifact upload reads — and no job reads cache-hit at all. Each runs the verify-package-builds action first: a few lines of shell asserting that every library with a tsdown.config.ts has a non-empty dist — packages/* because that is what the entry holds, so reading for an app's would report every restore incomplete, so the set is discovered rather than listed and a package added later is covered by nothing. It checks dist alone on purpose — that is the half that gets deployed and published, so its absence is the silent one, where a barrel missing beside it fails typecheck and lint by name. Shell rather than a script, because a hit is designed to need no node_modules and a probe that had to be installed could not answer before the install it gates. build-packages runs it a second time after building, as an assertion rather than a gate, so an incomplete tree fails there instead of reaching the consuming jobs as an artifact.
The shared gate is preferred over per-job caching because the package build then happens at most once per run (no redundant parallel rebuilds on a package-change commit), and on an app-only commit — the common case — the gate's own build is a cache hit, so the gate cost collapses to restore + upload — the toolchain setup and the install hang off the miss too, since a hit uploads straight from the restored paths and needs neither pnpm nor node_modules. The trade is the serial gate wait before the consuming checks start.
The generated barrels must be cached alongside dist because build:packages generates them from a build:prepare hook and the barrel files are not committed — TypeDoc and the package lint/typecheck steps fail without them even when dist is present. Preserve all generated source index files, not just root src/index.ts, since some generators create nested barrels.
CI security
Set persist-credentials: false on every actions/checkout step.
Declare job permissions explicitly and narrowly:
- Read-only jobs:
contents: read. Downloading an artifact produced by the same run needs nothing further —actions: readis for reading another run's artifacts or logs. - OIDC deployment jobs:
id-token: write,contents: read— and on both ends when the job is a call into a reusable workflow. A called workflow's token can only be as permissive as its caller's, andid-token: writeis never a repository default, so declaring it only on the called workflow leavesazure/loginwith no token to exchange. Secrets do not cross that boundary either: a call that needs them issecrets: inherit, and the called workflow readssecrets.*directly. Naming them would narrow nothing —CI.yamlruns on every branch with the whole repository set — and on a call pinned to another ref it is a contract a release lag breaks; the review collector's runner says what that broke. - Release jobs:
contents: write. - PR-commenting previews: minimum scopes for OIDC, repo reads, and PR comments.
Local verification
Run local formatting checks from the repo root with pnpm format:check; run local lint fixing from apps/web with pnpm lint:fix.
Vitest runs on Windows because apps/web/configuration/modules.ts gives Nuxt a minimal module allowlist under process.env.VITEST (no UnoCSS/PWA/security/SEO); loading the full list there crashes the config load on the PWA module's virtual import. If a new test needs an excluded module, add it to the Vitest branch there.
Dependency updates
Renovate writes every version the repo declares — the catalog and overrides: in pnpm-workspace.yaml, both node pins, packageManager, every action under .github/ and every FROM — and a minor or patch merges to main on green with no pull request, while a major opens one for a person, and so does every pnpm bump, since pnpm ships new workspace settings in minors that only its release notes announce. What a person writes is policy, and it lives in one place: a packageRules entry in renovate.json whose description is the reason, for every dependency that is held below a version, disabled, grouped or read before merging. pnpm outdated:dependencies reads the same rules, so a bump by hand and the bot agree on what is held — a cap expressed only as a catalog range, or only as a note in a skill, is one the bot cannot read and will propose past. The rules themselves, what each manager reaches and the local dry run that shows it are the dependency-updates skill's.
GitHub Action versions
Pin actions to full commit SHAs with a trailing version comment:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
To resolve the SHA for a pin, look up the latest stable vX.Y.Z tag via:
git ls-remote --tags --sort='v:refname' https://github.com/<owner>/<repo>.git 'v*'
Ignore broad aliases (v6) and pre-release tags. For annotated tags git ls-remote prints both refs/tags/<version> and refs/tags/<version>^{} — pin the ^{} (dereferenced) SHA.
Use normal zipped artifacts unless there is a measured need for direct artifact uploads. If artifact uploads use archive: false, use actions/download-artifact v8 or newer so direct/non-zipped artifacts are handled correctly.