Skip to content

fix(build): fail the build when tsup emits no declarations, so a DTS-less dist cannot be cached as a full build - #12076

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-11907-turbo-skip-dts-cache-key
Aug 25, 2026
Merged

fix(build): fail the build when tsup emits no declarations, so a DTS-less dist cannot be cached as a full build#12076
yinlianghui merged 2 commits into
mainfrom
claude/issue-11907-turbo-skip-dts-cache-key

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#11907

The card recorded the signature and said the mechanism was unestablished, with two
candidates needing opposite repairs. This measures which one it is.

Triage's leading hypothesis is falsified

The hypothesis was that the build task's turbo hash does not include OS_SKIP_DTS,
so a skip-DTS artifact is cache-valid for a non-skip run. Measured on origin/main
before any change, via turbo run build --filter @objectstack/plugin-auth --dry=json:

OS_SKIP_DTS@objectstack/plugin-auth#build hashglobalCacheInputs.environmentVariables.configured
unsetce8fa0947ab2f8f8[]
1a6411042c68db782["OS_SKIP_DTS=6b86b273ff34..."]

globalEnv does exactly what the reader assumes. The two runs already hash
differently, so a skip-DTS artifact can never be served to a run that wants
declarations. There is nothing to repair in turbo.json, and declaring the env
again would change nothing.

The actual mechanism: a build that reports success having emitted nothing

tsup 8.5.1 runs DTS generation in a worker_threads.Worker and settles the build
promise only from that worker's message events (node_modules/tsup/dist/index.js,
the dtsTask in the Promise.all([dtsTask(), mainTasks()]) at line 1703):

awaitnewPromise((resolve,reject)=>{constworker=newWorker(path.join(__dirname,'./rollup.js'));worker.postMessage({ ... });worker.on('message',(data)=>{if(data==='error'){terminateWorker();reject(...);}elseif(data==='success'){terminateWorker();resolve();}
...
});// no worker.on('error'), no worker.on('exit')});

No error handler, no exit handler. A worker that dies without posting a message
(OOM under memory pressure is the shape the card cites) settles neither branch: the
promise never settles, the event loop drains, and node exits 0 with the esbuild
pass having already written dist/. Reduced to the same handler set in isolation,
the parent prints its main-pass line and exits 0 while the "resolved" line never
runs.

So the bad artifact is produced by a run that legitimately hashes as an ordinary full
build — because it is one. turbo sees exit 0 and caches a DTS-less dist/** under
that ordinary hash. That is why a plain pnpm build does not clear it: the rebuild is
a cache hit that restores the same artifact, and only --force replaces the entry.

Reproduced end to end on this tree, OS_SKIP_DTS unset throughout, by making the DTS
worker die:

  • build exit 0, turbo 26 successful, 26 total
  • dist/ = index.js, index.mjs, rate-limit-storage.*, maps — zero.d.ts,
    the card's step 1 exactly
  • cached under ce8fa0947ab2f8f8, the same hash a healthy build produces
  • next plain run: cache.status: HIT, restores the DTS-less dist — the card's step 3
  • the cached entry's own manifest: 9 files, 0 declaration files

The repair

A build that was supposed to emit declarations and did not must exit non-zero. turbo
caches only successful tasks, so a failing build never becomes a cache entry and the
fault cannot outlive the run that produced it. Nothing here loosens a hash, busts a
cache, or weakens a gate.

scripts/check-dts-emitted.mjs runs as the last step of the package's build and
asserts that every declaration path the manifest promises (types, typings, and
types conditions inside exports) is present and non-empty. It is a no-op when
OS_SKIP_DTS is set, because those declarations are absent on purpose and that run
already hashes differently.

Before / after, against a genuinely cold cache

A cache-correctness fix is easy to "verify" against a warm cache that no longer holds
the bad entry, so the entry was evicted from the cache directory by hash and the probe
confirmed cache.status: MISS before the run, with turbo logging cache miss, executing for the task.

DTS worker dies, cold cachebuild exitturbocache entry written
beforedist/ JS only, 0 declarations026 successfulyes — 9 files, 0 declarations, replayed on every later run
afterdist/ JS only, 0 declarations125 successful, 26 totalnone — next run MISSes and re-executes

Healthy path after the fix: check-dts-emitted: @objectstack/plugin-auth - 2/2 declared declaration file(s) present., build exit 0, four declaration files on disk, and the
package's own turbo run typecheck green at 27 successful, 27 total.

Worth knowing: the turbo cache is shared across worktrees

Found while controlling for cache warmth. turbo resolves the repo root through the git
common directory, so a linked worktree's .git file resolves to the primary
checkout and every worktree in the container shares one cache directory. The
per-task-worktree isolation AGENTS.md requires does not extend to it. That is what lets
this defect travel: one worktree hitting memory pressure caches a DTS-less dist under an
ordinary hash, and every other worktree at the same commit gets it on a first, clean
run — which is how a fresh worktree with a clean install can start out broken. CI is
immune because it has no warm cache. Filed separately rather than changed here.

Scope

Wired into @objectstack/plugin-auth only, the package the card measured. Every other
package that builds through tsup with dts: !process.env.OS_SKIP_DTS has the same
exposure and the guard is written package-agnostic for them, but rolling it across ~50
build scripts is a different-sized change and is filed separately rather than ridden in
here.

Gates

Derived at the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-written path list) — 17 families, all green, run at
044638016. check:entry-guard was genuinely red first: the guard exports its
predicates and ran process.exit at the top level, which would have ended an importer
mid-import with status 0 — the same "exit 0 reads as success" failure one level up. Now
behind isEntrypoint.

Build orchestration only; nothing published changes behaviour, so skip-changeset.


Generated by Claude Code

…less dist cannot be cached as a full build
tsup runs DTS generation in a worker thread and settles its promise only from
that worker's `message` events -- it registers no `error` and no `exit`
handler. A worker that dies without posting a message leaves the promise
unsettled; the event loop drains and node exits 0 with the JS pass already
written. turbo caches successful tasks, so the DTS-less dist lands in the cache
under the hash of an ordinary full build and every later run replays it.
Assert the declarations the manifest promises before the build may succeed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
…rd cannot exit the importer
check:entry-guard: a scripts/** file that exports bindings must not run on
import. Fitting for this one -- an unguarded process.exit here would end an
importer mid-import with status 0, the same 'exit 0 reads as success' failure
the guard itself exists to catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@github-actionsgithub-actionsBot added size/m dependencies Pull requests that update a dependency file labels Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f7b25c546b64880f37dcf6e023e62ee9b142407dpackageMentionDocs.

@yinlianghuiyinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026 — with Claude
@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 08:01
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 40225e9Aug 25, 2026
36 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-11907-turbo-skip-dts-cache-key branch August 25, 2026 08:15
yinlianghui pushed a commit that referenced this pull request Aug 25, 2026
… build
#11907 established that tsup 8.5.1 can report build success having emitted zero
declarations: DTS generation runs in a `worker_threads.Worker` whose promise is
settled only from that worker's `message` events (no `error`, no `exit`
handler), so a worker that dies without posting one settles neither branch,
node exits 0, and esbuild has already written `dist/`. turbo caches only
successful tasks — so the declaration-less `dist/**` is cached under the hash of
an ordinary full build and replays until `--force`.
PR #12076 landed the package-agnostic guard and wired it into
`@objectstack/plugin-auth` only. This rolls it across the whole population:
every workspace package whose `build` script invokes tsup — 67 in total, 66
newly wired — now ends its build with
`node <root>/scripts/check-dts-emitted.mjs`, joined with `&&` so the guard's
status is the build's status.
The diff is confined to `build` script lines (66 files, 66 insertions, 66
deletions) so it stays trivially resolvable against the open PR population.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filesize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@yinlianghui@claude