main has been red for four consecutive CI runs with nothing wrong in the tree.
The failure
src/lib/docs-client-loader.tsx(1,32): error TS2307: Cannot find module 'collections/browser'
src/lib/docs-client-loader.tsx(17,24): error TS7031: Binding element 'MDXContent' implicitly has an 'any' type
src/lib/docs-source.server.ts(1,22): error TS2307: Cannot find module 'collections/server'
npm error workspace @loopover/ui@0.0.0
npm error command sh -c tsc --noEmit
It hits validate-code on every PR regardless of what the PR changes — including a test-only PR that touches no UI file at all.
Root cause
.github/actions/setup-workspace restores a node_modules cache and, on a hit, skips npm ci entirely. Skipping npm ci skips every workspace lifecycle script — and apps/loopover-ui has "postinstall": "fumadocs-mdx", which generates .source/. That directory is what tsconfig.json maps collections/* onto:
"collections/*": ["./.source/*"]
.source/ is gitignored and is not among the cached paths (node_modules, apps/*/node_modules, packages/*/node_modules). So on a cache hit the UI typechecks against modules that do not exist.
Reproduced locally by simulating the cache-hit state — rm -rf apps/loopover-ui/.source with node_modules intact gives exactly the three errors above, and npm run postinstall --workspace @loopover/ui clears them.
Why it started when it did
The cache key includes the lockfile. A lockfile change mints a fresh key: the first run MISSES, installs, generates .source, passes, and saves the cache. Every run afterwards HITS and fails. That is the observed pattern — green through 507960134, then red from 40133d789 onward with no relevant change in between.
This is the same shape as the @eslint/js hoist bug the cache step's own comment already documents: "a failure that reproduces on no developer machine, because a real npm ci always creates those directories."
Fix
Run the codegen unconditionally, after the conditional install.
Regenerate rather than cache .source. Caching it looks like the smaller fix and is wrong: the cache key derives from manifests + lockfile, while .source derives from source.config.ts and content/docs/**. A cached copy would serve a docs collection generated from different MDX than the commit under test — a stale pass, which is worse than an honest failure.
Acceptance
- A cache-hit run typechecks the UI successfully.
- Adding a workspace lifecycle script in future does not silently reintroduce this.
mainhas been red for four consecutive CI runs with nothing wrong in the tree.The failure
It hits
validate-codeon every PR regardless of what the PR changes — including a test-only PR that touches no UI file at all.Root cause
.github/actions/setup-workspacerestores anode_modulescache and, on a hit, skipsnpm cientirely. Skippingnpm ciskips every workspace lifecycle script — andapps/loopover-uihas"postinstall": "fumadocs-mdx", which generates.source/. That directory is whattsconfig.jsonmapscollections/*onto:.source/is gitignored and is not among the cached paths (node_modules,apps/*/node_modules,packages/*/node_modules). So on a cache hit the UI typechecks against modules that do not exist.Reproduced locally by simulating the cache-hit state —
rm -rf apps/loopover-ui/.sourcewithnode_modulesintact gives exactly the three errors above, andnpm run postinstall --workspace @loopover/uiclears them.Why it started when it did
The cache key includes the lockfile. A lockfile change mints a fresh key: the first run MISSES, installs, generates
.source, passes, and saves the cache. Every run afterwards HITS and fails. That is the observed pattern — green through507960134, then red from40133d789onward with no relevant change in between.This is the same shape as the
@eslint/jshoist bug the cache step's own comment already documents: "a failure that reproduces on no developer machine, because a real npm ci always creates those directories."Fix
Run the codegen unconditionally, after the conditional install.
Regenerate rather than cache
.source. Caching it looks like the smaller fix and is wrong: the cache key derives from manifests + lockfile, while.sourcederives fromsource.config.tsandcontent/docs/**. A cached copy would serve a docs collection generated from different MDX than the commit under test — a stale pass, which is worse than an honest failure.Acceptance