Uh oh!
There was an error while loading. Please reload this page.
fix(build): fail the build when tsup emits no declarations, so a DTS-less dist cannot be cached as a full build - #12076
Merged
Conversation
…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
Contributor
📓 Docs Drift CheckNothing 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
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Aug 25, 2026
yinlianghui
marked this pull request as ready for review
August 25, 2026 08:01
Uh oh!
There was an error while loading. Please reload this page.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/mainbefore any change, via
turbo run build --filter @objectstack/plugin-auth --dry=json:OS_SKIP_DTS@objectstack/plugin-auth#buildhashglobalCacheInputs.environmentVariables.configuredce8fa0947ab2f8f8[]1a6411042c68db782["OS_SKIP_DTS=6b86b273ff34..."]globalEnvdoes exactly what the reader assumes. The two runs already hashdifferently, 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 envagain 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.Workerand settles the buildpromise only from that worker's
messageevents (node_modules/tsup/dist/index.js,the
dtsTaskin thePromise.all([dtsTask(), mainTasks()])at line 1703):No
errorhandler, noexithandler. 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
0while the "resolved" line neverruns.
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/**underthat ordinary hash. That is why a plain
pnpm builddoes not clear it: the rebuild isa cache hit that restores the same artifact, and only
--forcereplaces the entry.Reproduced end to end on this tree,
OS_SKIP_DTSunset throughout, by making the DTSworker die:
26 successful, 26 totaldist/=index.js,index.mjs,rate-limit-storage.*, maps — zero.d.ts,the card's step 1 exactly
ce8fa0947ab2f8f8, the same hash a healthy build producescache.status: HIT, restores the DTS-less dist — the card's step 3The 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.mjsruns as the last step of the package's build andasserts that every declaration path the manifest promises (
types,typings, andtypesconditions insideexports) is present and non-empty. It is a no-op whenOS_SKIP_DTSis set, because those declarations are absent on purpose and that runalready 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: MISSbefore the run, with turbo loggingcache miss, executingfor the task.dist/JS only, 0 declarations26 successfuldist/JS only, 0 declarations25 successful, 26 totalHealthy 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 thepackage's own
turbo run typecheckgreen at27 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
.gitfile resolves to the primarycheckout 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-authonly, the package the card measured. Every otherpackage that builds through tsup with
dts: !process.env.OS_SKIP_DTShas the sameexposure 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 at044638016.check:entry-guardwas genuinely red first: the guard exports itspredicates and ran
process.exitat the top level, which would have ended an importermid-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