Skip to content

H-6762: Stop @hashintel/ds-helpers publishing empty tarballs - #9190

Open
claude[bot] wants to merge 5 commits into
mainfrom
H-6762-ds-helpers-empty-tarball
Open

H-6762: Stop @hashintel/ds-helpers publishing empty tarballs#9190
claude[bot] wants to merge 5 commits into
mainfrom
H-6762-ds-helpers-empty-tarball

Conversation

@claude

@claude claude Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Requested via Slack thread

🌟 What is the purpose of this PR?

@hashintel/ds-helpers has been publishing near-empty tarballs to npm since March.

Before. Installing the design system from the registry gave you a package whose every entry point pointed at a file that was not in the tarball. 0.1.1, 0.2.0 and 0.2.1 each contain six files — package.json, CHANGELOG.md, three licence files and README.md — and no code at all. 0.2.1 is still the latest tag.

$ tar -tzf hashintel-ds-helpers-0.2.1.tgz
package/package.json
package/CHANGELOG.md
package/LICENSE-APACHE.md
package/LICENSE-MIT.md
package/LICENSE.md
package/README.md

After. The tarball always contains the generated payload — 126 files — and if it ever does not, the pack fails loudly instead of publishing an empty package. Panda keeps --clean, so stale generated CSS is still swept.

Reviewers: this has been reworked at the reviewer's request. The first version fixed the race by dropping --clean, which traded an empty-tarball race for stale artifacts accumulating in developers' trees. That is gone. Panda now writes to a ds-components-private outdir with --clean, and ds-helpers copies that directory into place atomically. The root cause and evidence sections are unchanged; what does this change is entirely new.

Root cause

libs/@hashintel/ds-helpers/styled-system/ is the entire published payload of @hashintel/ds-helpers, but it was written by a different package: @hashintel/ds-components' Panda codegen, whose panda.config.ts set outdir: "../ds-helpers/styled-system". That codegen ran as panda codegen --clean, and --clean reaches @pandacss/node's ctx.output.empty()fsExtra.emptyDirSync() on the outdir, followed by an asynchronous rewrite. Nothing else in the repo deletes that directory.

changeset publish publishes packages concurrently@changesets/cli@2.30.0 runs Promise.all over a queue with NPM_PUBLISH_CONCURRENCY_LIMIT = 10, and only drops to serial for interactive 2FA (process.stdin.isTTY, false in CI). Each concurrent npm publish <dir> runs that package's own prepublishOnlyprepackprepare.

Several of those hooks re-run the deleter:

Package Hook
@hashintel/ds-components prepublishOnly: turbo run build
@hashintel/petrinaut prepublishOnly: turbo run build (depends on ds-components)
@hashintel/ds-helpers prepack: turbo run codegen --filter @hashintel/ds-helpers

Root turbo.json has build.dependsOn: ["codegen"], so all three reach @hashintel/ds-components#codegen. Turbo does not de-duplicate across processes — in a two-process run both logged the same task hash as a cache miss and executed it simultaneously:

race-helpers.log:     @hashintel/ds-components:codegen: cache miss, executing 31a963db97bea16e
race-components.log:  @hashintel/ds-components:codegen: cache miss, executing 31a963db97bea16e

So a panda codegen --clean fires while npm pack is reading the directory, and the tarball is written with it emptied.

Evidence

Reproduction. Repeatedly npm pack ds-helpers (30 iterations) while ds-components' codegen runs concurrently in a loop:

short tarballs
pre-fix config (negative control, run on this branch) 5 / 30 in one run and 1 / 30 in a later one, including packs of exactly 6 files — the same file count and file list as the published 0.2.1
this branch 0 / 30, min = max = 126 files

The negative control is the same harness with only outdir pointed back at ../ds-helpers/styled-system, so the harness demonstrably detects the bug it is claiming to have fixed.

The preconditions held in the actual release. npm publish timestamps for the 2026-05-22 run:

@hashintel/refractive      0.0.4   18:00:06.898Z
@hashintel/petrinaut-core  0.0.1   18:00:21.563Z
@hashintel/ds-helpers      0.2.1   18:00:24.269Z   <-- 6 files
@hashintel/ds-components   0.2.1   18:01:02.684Z
@hashintel/petrinaut       0.0.15  18:01:11.482Z

ds-helpers finished packing at 18:00:24Z while ds-components and petrinaut — both running turbo run buildpanda codegen --clean — were still in flight, i.e. two codegens were live when 0.2.1 packed.

Directly observed, pre-fix. A 50 ms sampler on the directory during concurrent npm publish --dry-run of both packages (cold turbo cache, 5 iterations) saw the directory reach zero files after being fully populated in 2 of 5 runs: 0 → 41 → 120 → 0 → 120.

⚠️ What is NOT proven

  • No CI logs survive from the 2026-05-22 run, so the wipe cannot be shown happening in that specific job. The evidence is the mechanism plus the exactly-matching symptom, not a log line from that job.
  • 0.2.0 (also 6 files) has a second, fully deterministic cause on top of this: its published package.json has no scripts key at all — the "thin artifact package" refactor left ds-helpers with no generation hook, so on a fresh CI checkout styled-system/ simply never existed.
  • 0.1.1 (5 files) predates the current layout entirely; not reproduced, no claim made about it.
  • The dist race described under commit 3 is suspected by analogy and has never been reproduced.

🔗 Related links

🚫 Blocked by

  • Nothing

🔍 What does this change?

Panda no longer writes across a package boundary, and it keeps --clean. ds-helpers obtains its payload by copying.

The design

ds-components/src (preset, components, scripts)
        │
        │ panda codegen --clean      ← sweeps stale artifacts, as before
        ▼
ds-components/styled-system          ← private to ds-components, git-ignored,
        │                              never packed, never read by consumers
        │ sync-styled-system.mjs     ← copy, gated on expected-payload.json
        ▼
ds-helpers/styled-system             ← the published payload

libs/@hashintel/ds-helpers/scripts/sync-styled-system.mjs (new, dependency-free) is the whole mechanism. The source is shared mutable state — a concurrent panda codegen --clean empties it and rewrites ~120 files asynchronously, so any read of it can land mid-rewrite. Two invariants make that harmless, and both are load-bearing:

  • A tree is installed only if it is complete, judged against expected-payload.json — a checked-in list of all 120 files Panda generates here — and confirmed by a second identical read. Checking only the entry points named in exports is not enough, and neither is a stability check on its own; see the second review round for why, with a reproduction. A checked-in expectation is the one authority a racing process cannot move.
  • The payload directory is never unlinked. POSIX cannot rename a directory onto a populated one, so files are staged outside styled-system/ and moved onto their targets individually with rename(2), which is atomic, and extras are pruned last. styled-system/ therefore always exists and always holds a complete set of entry points, whenever a packer reads it.

The common case — a publish, where the payload already matches the source because Panda's output is byte-for-byte deterministic (verified: three consecutive panda codegen runs produce an identical tree hash, and no timestamps appear anywhere in the output) — writes nothing at all, which is what keeps a concurrent npm pack undisturbed.

Where the copy runs, and why not only in prepack

prepack alone would have broken every consumer, because ds-helpers' subpath exports are consumed at dev and build time, not just at publish time:

Consumer files importing @hashintel/ds-helpers/*
@hashintel/ds-components (itself) 444
@hashintel/petrinaut 107
@apps/hash-frontend 74
@apps/petrinaut-website 2

By subpath: /css (428 imports), /jsx (205), /types (43), /tokens (20). Three of those packages have a vercel.json and build on Vercel (ds-componentsturbo run build:ladle, hash-frontend, petrinaut-website).

So the copy runs from ds-components' codegen as well as from ds-helpers' own codegen. The reason is a structural fact about the existing graph: @hashintel/ds-components#codegen is the one task that every one of those consumers already reaches — directly for ds-components' own tasks, and transitively through ^build for the other three. Verified mechanically across 5 packages × 8 tasks that a producer of ds-helpers/styled-system is present in each graph. Keeping ds-components#codegen a producer therefore means no consumer can lose its styled system on a turbo cache hit, and no new edges were needed anywhere. ds-helpers#codegen independently runs the same copy so that prepack works standalone.

File by file

  • libs/@hashintel/ds-components/panda.config.tsoutdir: "../ds-helpers/styled-system"outdir: "styled-system", with a comment recording why it must never point outside the package. Already covered by ds-components/.gitignore (styled-system, under # PandaCSS), so no new ignore rule is needed; confirmed with git check-ignore.
  • libs/@hashintel/ds-components/panda.local.config.ts — drops its duplicate outdir override so it inherits coreConfig's. Both configs must target the same directory (as they did before), and inheriting means they cannot drift apart.
  • libs/@hashintel/ds-components/package.jsoncodegen:panda is panda codegen --clean again, and a new codegen:ds-helpers step (node ../ds-helpers/scripts/sync-styled-system.mjs) is appended to the codegen chain.
  • libs/@hashintel/ds-components/turbo.json — the old "never re-add --clean" comment is replaced. codegen.outputs gains the new private styled-system/** and keeps ../ds-helpers/styled-system/**, with a comment explaining that the second entry is load-bearing for consumer cache hits.
  • libs/@hashintel/ds-helpers/turbo.jsoncodegen is no longer a no-op: inputs gains the copy script and ../ds-components/panda.local.config.ts; outputs stays styled-system/**; dependsOn stays @hashintel/ds-components#codegen. build.dependsOn: [] is kept — it is what prevents ds-helpers#codegen → ds-components#codegen → ^build → ds-helpers#build → ds-helpers#codegen. Verified with turbo run <task> --dry=json across 10 task names: exit 0, no cycles.
  • libs/@hashintel/ds-helpers/package.jsoncodegen goes from "true" to node scripts/sync-styled-system.mjs. prepack is unchanged: it still runs codegen and then the verifier.
  • libs/@hashintel/ds-helpers/expected-payload.json (new, committed) — the list of all 120 generated files. Asserted by both the copy and the pack-time verifier. Deliberately committed rather than derived at runtime: a record written by the step that might have truncated the tree cannot detect the truncation. Panda's generated file set is driven by its config (JSX elements, patterns), not by how many components ds-components has, so it is stable — adding a component does not change it. When it does change, both scripts fail and name the difference, and it is regenerated as a reviewed diff.
  • libs/@hashintel/ds-helpers/README.md — documents the copy, why Panda must not write here directly, and how to regenerate expected-payload.json.
  • libs/@hashintel/ds-helpers/.gitignore — ignores the styled-system.tmp-* staging directory (removed in a finally, so it only survives a kill -9 mid-copy). expected-payload.json is deliberately not ignored. Verified that neither it nor the staging directory can enter the tarball.
  • libs/@hashintel/ds-*/AGENTS.md — both files documented the old outdir mechanism in several places (architecture diagrams, boundary rules, the inlined panda.config.ts sample, the script table). Updated to match. Also corrects the pre-existing claim that styled-system/ is "(committed)" — it is git-ignored.

Unchanged from the first review round

Still present, still as reviewed:

  • libs/@hashintel/ds-helpers/scripts/verify-package-contents.mjs — the pack-time backstop, still run from prepack after the copy. It resolves every literal target reachable from main/module/types/exports and asserts each exists and is non-empty. Strengthened in the second review round to also assert the whole tree against expected-payload.json, so a truncated payload cannot be packed whatever produced it. Not itself published (files does not include scripts).
  • The exports cleanups./recipes removed (styled-system/recipes/ is never generated: no defineRecipe/defineSlotRecipe in ds-components/src/, and the generated tree contains only css jsx patterns tokens types helpers.mjs; the only in-repo references are the legacy import string a codemod migrates away from), and the import/require conditions dropped from ./types (Panda emits types/ as .d.ts only, so they could never resolve — they also dangled in the last good release, 0.1.2).
  • The changesetpatch for both packages, text rewritten to describe this approach.
    • Judgement call a reviewer may want to overrule: removing two exports entries would normally be breaking, but neither has ever resolved to a real file in any published version, so nothing can regress by losing them. Hence patch, not major.

Second review round: the copy could still truncate the payload

Bugbot filed a High Severity finding against the first version of the copy. It was right, it was the same hazard as the original bug, and it is fixed in the second commit. Recording it here because the reasoning should outlive the review thread.

The copy validated only the nine entry points named in exports. But a concurrent panda codegen --clean empties the shared private source and then rewrites ~120 files asynchronously, so a read can land on a tree that holds those handful of index files and none of the other 111. That tree passed the check, then failed the byte-comparison against the existing payload, and so took the "replace" path — which renamed the payload directory out of the way first, reopening the exact window this PR exists to close, and left a truncated tree behind. The code comment asserting that path was unreachable during a publish was simply wrong: the trees are only identical if the source is complete at read time.

Reproduced deterministically rather than waiting on a timing lottery. With the source cut down to its nine entry points:

payload before:  120 files
sync:            styled-system/ replaced (9 files)      <-- accepted it
payload after:     9 files
verifier:        verified 9 entry-point target(s)       <-- exit 0, passed
npm pack:        total files: 15                        <-- would have shipped

Two further things worth flagging, because they are the traps in this area:

  • The first race harness could not have caught this. It ran panda codegen --clean and the copy sequentially in one shell, so the copy always observed a finished source and always took the no-op path. Its clean 0/30 was real but tested the wrong interleaving. The harness now runs a generator process, a copy process and a packer concurrently — 458 and then 697 copy invocations under that harness still never hit the bad path by timing alone, which is precisely why the deterministic reproduction above was necessary.
  • A stability check is not a completeness proof, and a self-written manifest is not an independent check. An intermediate attempt read the source twice and required identical bytes; a statically truncated source passes that, and the payload still went 120 → 9. Recording what the copy installed and checking against that record cannot help either, since the record is written by the step that did the truncating. Hence the checked-in expected-payload.json.

On the two directions suggested in review: moving the copy earlier and leaving prepack to verify only is not sufficient on its ownds-components' and @hashintel/petrinaut's prepublishOnly: turbo run build both reach ds-components#codegen, which runs the copy, so a copy runs during the publish window regardless of what ds-helpers' prepack does. And "rename the new tree over the old in one operation" is not implementable: POSIX cannot rename a directory onto a populated one. Never unlinking the payload and moving files onto their targets individually achieves the stated goal — styled-system/ is never absent — strictly better than a directory swap could.

Commit 3 — tsup clean reverted to true

The previous round set clean: false on ds-components' tsup config, on the theory that dist/ is exposed to the same race one level up (@hashintel/petrinaut's concurrent prepublishOnly: turbo run build rebuilding this package while it is being packed).

That is reverted. clean: false reintroduces exactly the stale-artifact hazard that ruled out dropping --clean, and it is worse here than for ds-helpers: the "./*" entry in ds-components' exports maps straight into dist/components/, so a deleted component's stale artifact would stay importable and would still be published.

Applying this PR's pattern to dist is the consistent fix, but it is not a config flag: dist/ is assembled by three independent steps that each write into it directly — build:lib:js (tsup), build:lib:dts (tsc -p tsconfig.dts.json plus scripts/generate-flat-dts.ts) and build:buildinfo (panda ship --outfile dist/...). Staging it means restructuring all three behind a single promote step. Given the dist race is suspected but has never been reproduced, that is left as a follow-up rather than half-fixed here, and a comment in tsup.config.ts records the hazard and the reasoning. Happy to do it in this PR if you'd rather.

⚠️ Known issues

What this deliberately does not do

A pre-publish tarball-content assertion in release.yml would not have caught 0.2.1. Such a gate runs serially, and a serial pack always succeeds — 126 files every time. The failure only exists during the concurrent publish, after the gate has already passed. A gate placed after changeset publish would detect it but cannot un-publish; npm has already moved latest. That is why the fix removes the cross-boundary wipe rather than adding a check.

The prepack verifier is second-line defence for the deterministic failure modes — missing generation hook (the real 0.2.0 cause), codegen failure, wrong outdir — not for the race.

Residual sharp edge

Two, both deliberate:

  • expected-payload.json has to be regenerated when Panda's generated file set legitimately changes — a new pattern, a different jsxFramework, a Panda upgrade. That is a real maintenance cost, accepted because the alternative is a completeness check that a racing process can move. Drift is loud, not silent: both scripts fail and name the difference, and the README says how to regenerate.
  • A turbo cache hit on ds-helpers#codegen restores styled-system/** over whatever is on disk without emptying it first, so a hit cannot sweep stale files the way a cache miss (which runs the copy) does. Inputs being unchanged means the output set should be identical anyway, and it is the same behaviour turbo has always had here. The pack-time whole-tree check would catch it regardless.

🐾 Next steps

Follow-ups found while investigating, not addressed here:

  • Stage ds-components' dist/ and promote it atomically, as described under commit 3 — the same pattern, applied to the suspected-but-unreproduced race one level up.
  • @hashintel/ds-components lists @hashintel/ds-helpers in both dependencies and peerDependencies, so registry consumers of ds-components@0.2.1/0.2.2 pull the broken ds-helpers twice over.
  • 0.2.1 is still latest on npm for @hashintel/ds-helpers. Merging this fixes future publishes but does not fix the registry — someone still needs to cut and promote a good release.

🛡 What tests cover this?

No automated test — a concurrency race is not something to assert on in CI. The prepack verifier is itself the runtime check, and it fails the publish rather than a test run. Verified by hand on turbo 2.6.3 / node 22.22.2 / npm 10.9.7:

########## COLD CACHE (turbo cache cleared, both styled-system dirs absent) ##########
@hashintel/ds-components:codegen: cache miss, executing
@hashintel/ds-components:codegen: styled-system/ synced (120 files; 120 written, 0 removed)
@hashintel/ds-helpers:codegen:    cache miss, executing
@hashintel/ds-helpers:codegen:    styled-system/ already up to date (120 files)
 Tasks: 3 successful, 3 total ; Time: 16.7s
verified 9 entry-point target(s) and all 120 generated file(s).
npm notice total files: 126        files in pack: 126 | targets: 9 | DANGLING: 0

########## WARM CACHE (both dirs deleted first) ##########
 Cached: 3 cached, 3 total ; Time: 621ms >>> FULL TURBO
verified 9 entry-point target(s) and all 120 generated file(s).
npm notice total files: 126        files in pack: 126 | targets: 9 | DANGLING: 0

########## ds-components (from-scratch build, clean: true) ##########
dist files: 248 ; files in pack: 254 | targets: 7 | DANGLING: 0
styled-system entries in ds-components' tarball: 0   (private dir is not published)

########## THE RACE — generator + copier + packer, three concurrent processes ##########
short tarballs: 0 / 30      min pack count: 126     max pack count: 126
payload samples: 16370  ->  16370 at 120 files
  samples with payload ABSENT/EMPTY: 0
  samples with payload PARTIAL:      0
copy invocations: 697  ->  697 "already up to date", 0 writes, 0 failures

########## STRESS — 3 concurrent `panda codegen --clean` loops vs 1 copier, 40s ##########
copy invocations: 257  ->  257 "already up to date", all exit 0, 0 spurious failures
payload samples: 6219  ->  6219 at 120 files ; ABSENT 0 ; PARTIAL 0

########## NEGATIVE CONTROL — same harness, pre-fix `outdir` restored ##########
pack 16 -> 6 files   <<<< SHORT TARBALL
CONTROL short tarballs: 1 / 30      (an earlier run of the same control: 5 / 30,
                                     three of them exactly 6 files)

########## REGRESSION — the truncation Bugbot found ##########
source truncated to its 9 entry points:
  copy     -> exit 1, "111 of 120 expected file(s) missing, including css/conditions.mjs..."
  payload  -> untouched, still 120 files
payload truncated behind the copy's back:
  verifier -> exit 1, "111 of 120 expected generated file(s) are missing"

########## `--clean` STILL SWEEPS (the point of the rework) ##########
before: private sentinel yes ; stale recipes/ yes ; payload sentinel yes
after:  all three swept ; private 120 files ; payload 120 files

########## FAIL-LOUD GUARDS ##########
source missing       -> exit 1 ; payload untouched (120)
source empty         -> exit 1 ; payload untouched (120)
source unrecognised  -> exit 1 ; payload untouched (120)
payload truncated    -> verifier exit 1
staging dirs left behind by any of the above: 0
expected-payload.json / staging dirs present in tarball: no / no

########## CONCURRENT PUBLISH SIMULATION (both hooks in flight, cold cache) ##########
iter 1: ds-helpers 126 (verifier ok) ; ds-components 254 ; 18964 samples ; EMPTY 0 ; PARTIAL 0
iter 2: ds-helpers 126 (verifier ok) ; ds-components 254 ; 19090 samples ; EMPTY 0 ; PARTIAL 0
iter 3: ds-helpers 126 (verifier ok) ; ds-components 254 ; 19074 samples ; EMPTY 0 ; PARTIAL 0
  (payload only ever sampled at 0 — before first generation — or 120; never partial)

########## GRAPH ##########
turbo run <task> --dry=json for codegen, build, lint:tsc, lint:eslint, fix:eslint,
dev, test:unit, build:ladle, build:lib, build:buildinfo -> exit 0, 0 cycle messages
producer-reachability check across 5 packages x 8 tasks -> a producer in every graph

########## REPO CHECKS ##########
turbo run lint:tsc    --filter @hashintel/ds-components -> 3 successful, 3 total
turbo run lint:eslint --filter @hashintel/ds-components -> 3 successful, 3 total (exit 0)
yarn constraints                          -> exit 0
oxfmt --check <touched files>             -> All matched files use the correct format.
markdownlint-cli2 <touched markdown>      -> 0 issues
eslint on the new private styled-system/  -> ignored via .gitignore, as intended
turbo prune @local/petrinaut-arch-docs    -> exit 0 (was exit 1 before merging main)
@local/petrinaut-arch-docs test:unit      -> 7 files, 75 tests passed

❓ How to test this?

  1. Check out the branch, yarn install.
  2. rm -rf libs/@hashintel/ds-components/styled-system libs/@hashintel/ds-helpers/styled-system
  3. cd libs/@hashintel/ds-helpers && npm pack --dry-runprepack generates the private directory, copies it here (styled-system/ created (120 files)), the verifier prints verified 9 entry-point target(s) present., and npm reports 126 files.
  4. Check --clean still sweeps: touch libs/@hashintel/ds-components/styled-system/STALE.mjs, then yarn workspace @hashintel/ds-components codegen. STALE.mjs is gone from both directories, and the copy reports replaced.
  5. Check it fails loudly: rm -rf libs/@hashintel/ds-components/styled-system && node libs/@hashintel/ds-helpers/scripts/sync-styled-system.mjs — exits 1, names the missing source, and leaves the existing payload alone.
  6. Check it cannot be truncated: delete everything but the index.* files from libs/@hashintel/ds-components/styled-system, then run the copy — it exits 1 naming the 111 missing files and the payload stays at 120. Delete the same files from libs/@hashintel/ds-helpers/styled-system instead and run node scripts/verify-package-contents.mjs — it exits 1.
  7. See the original failure: in one shell loop npm pack --dry-run on ds-helpers; in another loop panda codegen --clean inside ds-components with outdir temporarily pointed back at ../ds-helpers/styled-system. Short packs (some at exactly 6 files) appear only with the old outdir.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

The two AGENTS.md files are updated in-place, since they documented the outdir mechanism this PR changes.

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • affected the execution graph, and the turbo.json's have been updated to reflect this

@hashintel/ds-helpers#codegen changes from the no-op "true" to a real task that copies the payload, so its inputs gain the copy script, expected-payload.json and ds-components' local Panda config. @hashintel/ds-components#codegen gains a script step and declares the new private styled-system/** alongside the existing ../ds-helpers/styled-system/** output. No task or dependency edge was added or removed, and ds-helpers#build's empty dependsOn is retained to keep the graph acyclic — verified with --dry=json across 10 task names (exit 0, no cycles).

claude added 2 commits August 10, 2026 13:34
libs/@hashintel/ds-helpers/styled-system/ is the entire published payload
of @hashintel/ds-helpers, but it is generated by @hashintel/ds-components
(panda outdir "../ds-helpers/styled-system"). That codegen ran with
--clean, which calls fs-extra emptyDirSync() on the directory.

changeset publish publishes packages concurrently (Promise.all, queue
concurrency 10), and ds-components' and petrinaut's prepublishOnly hooks
both re-run that codegen -- so a `panda codegen --clean` fires while npm
is packing ds-helpers, and the tarball ships with only the six metadata
files. That is what 0.1.1, 0.2.0 and 0.2.1 look like on npm; 0.2.1 is
still `latest`.

- drop --clean from ds-components' codegen:panda, so nothing empties the
  published directory (turbo does not clean outputs before restoring a
  cache hit, so freshness is unaffected in CI)
- record why in ds-components/turbo.json so it is not re-added
- add a dependency-free prepack verifier to ds-helpers that fails the
  pack when any exports/main/types target is missing, turning the
  remaining deterministic failure modes into a failed release instead of
  a silent empty publish
- drop the ./recipes export and the import/require conditions on ./types:
  panda never generates styled-system/recipes/ (no recipes in the preset)
  and emits types/ as .d.ts only, so those targets have never resolved
- add a changeset bumping both packages as a patch. Patch rather than
  major on the grounds that the two removed export entries never resolved
  to real files in any published version, so no consumer can regress by
  losing them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N9k8hWgVbTi81ufCMpAWv
Same bug class as the previous commit, one level up, and deliberately kept
as a separate commit so it can be dropped independently.

libs/@hashintel/ds-components/tsup.config.ts had `clean: true`, which
empties `dist/` -- the entire published payload of @hashintel/ds-components
-- while @hashintel/petrinaut's concurrent `prepublishOnly: turbo run build`
rebuilds this package during `changeset publish`. That is the identical
mechanism as panda's `--clean` emptying ds-helpers' styled-system/.

THIS IS NOT REPRODUCED. The only supporting evidence is that the published
file counts are non-monotonic for a growing component library:

  0.1.2   24 files
  0.2.0  163 files
  0.2.1   58 files
  0.2.2   86 files  (latest)
  HEAD   254 files  (local pack)

which is consistent with a truncating race but does not prove one -- HEAD's
source is not 0.2.2's source, so the file-count diff is confounded. It is
changed here because `clean: false` costs nothing: CI always builds from an
absent dist, and a stale local dist is fixed with `rm -rf dist`.

Verified after the change: a from-scratch build (dist removed first) still
produces 254 files in `npm pack --dry-run`, with all 7 main/types/exports
targets resolving.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N9k8hWgVbTi81ufCMpAWv
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview Aug 13, 2026 3:19pm
hashdotdesign-tokens Ready Ready Preview Aug 13, 2026 3:19pm
petrinaut Error Error Aug 13, 2026 3:19pm

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team area/apps > hash.design Affects the `hash.design` design site (app) labels Aug 10, 2026
@claude
claude Bot marked this pull request as ready for review August 10, 2026 14:10
@claude
claude Bot requested a review from alex-e-leon August 10, 2026 14:10
@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes publish-time behavior for two public npm packages and export map entries; the primary risk is release/pack failures if codegen is incomplete, not runtime app logic.

Overview
Fixes a release race where @hashintel/ds-helpers could ship to npm with only metadata and no styled-system/** payload.

@hashintel/ds-components no longer runs panda codegen --clean, so concurrent changeset publish jobs cannot wipe ds-helpers’s published directory mid-pack. turbo.json documents that constraint. tsup clean is set to false for the same class of risk against dist/ (not reproduced here).

@hashintel/ds-helpers prepack now runs verify-package-contents.mjs, which fails the pack if any exports / files target is missing or empty. package.json drops the never-generated ./recipes export and import/require on ./types (types-only).

Patch changeset for both packages.

Reviewed by Cursor Bugbot for commit 37582a5. Bugbot is set up for automated code reviews on this repo. Configure here.

… place

Reworks the fix at Alex's request. Dropping `--clean` traded an empty-tarball
race for stale generated CSS accumulating in developers' trees, so `--clean` is
restored and the cross-package write is removed instead.

`@hashintel/ds-components`' Panda `outdir` is now `styled-system`, private to
that package and already git-ignored, and `codegen:panda` runs with `--clean`
again. `@hashintel/ds-helpers` materialises its published payload by copying
that directory, via a new dependency-free `scripts/sync-styled-system.mjs`:

- the copy is staged in a temporary sibling directory, so a concurrent
  `panda codegen --clean` emptying the source cannot produce a partial payload;
- the staged tree is validated against ds-helpers' own `exports` map, and a
  missing, empty or incomplete source exits non-zero with an actionable message
  rather than leaving an empty tree (a good existing payload is left untouched);
- it is moved in with `rename(2)` and never written in place, and when the
  staged tree is byte-identical to what is on disk — the case during a
  concurrent publish, since Panda's output is deterministic — nothing is
  written at all, so a packer can never observe a partial `styled-system/`.

The copy runs from ds-components' `codegen` as well as from ds-helpers' own
`codegen`, because ds-helpers' six subpath exports are consumed at dev and
build time, not just at publish time: 444 files in ds-components itself, 107 in
@hashintel/petrinaut, 74 in @apps/hash-frontend and 2 in
@apps/petrinaut-website, including three Vercel builds. `ds-components#codegen`
is the one task every one of those graphs already reaches, so keeping it a
producer means no consumer loses its styled system on a cache hit; a
pack-time-only copy would have broken all of them. `ds-helpers#codegen` is no
longer the no-op `"true"` — it runs the copy, which is what `prepack` uses.
`ds-helpers#build` keeps its empty `dependsOn` to avoid the documented cycle.

Also restores tsup's `clean: true` for ds-components' `dist`. `clean: false`
reintroduced exactly the stale-artifact hazard that ruled out dropping
`--clean`, and worse here: the `"./*"` entry in ds-components' `exports` maps
into `dist/components/`, so a deleted component's stale artifact would stay
importable and would still be published. Applying this commit's pattern to
`dist` means restructuring the three steps that each write into it directly
(`build:lib:js`, `build:lib:dts`, `build:buildinfo`) behind one promote step;
that is left as a follow-up rather than half-fixed, since the `dist` race is
suspected but has never been reproduced.

Verification (turbo 2.6.3, node 22.22.2, npm 10.9.7):

- cold cache: ds-helpers packs 126 files, 9/9 export targets resolve, 0 dangling
- warm cache (>>> FULL TURBO, 577ms): same 126 files, 0 dangling
- ds-components: 254 files, 7/7 targets resolve, 0 dangling; its private
  `styled-system/` is correctly absent from the tarball
- race repro, 30 concurrent packs against a looping `codegen --clean` + copy:
  0 short tarballs, min = max = 126; 2096 samples of ds-helpers'
  `styled-system/` were all at 120 files — never empty, never partial
- negative control, same harness with the pre-fix config restored: 5/30 short
  tarballs including three exact 6-file packs, so the harness does detect it
- `--clean` verified to sweep a stale sentinel file and a stale `recipes/`
  directory from the private outdir, and the copy propagates that sweep
- concurrent publish simulation (both publish hooks in flight, cold cache,
  3 iterations): 126 / 254 every time, 0 post-population empty samples
- ds-components `lint:tsc` and `lint:eslint` pass; `yarn constraints` exits 0;
  `oxfmt --check` and `markdownlint-cli2` clean on every touched file

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 146b229. Configure here.

Comment thread libs/@hashintel/ds-helpers/scripts/sync-styled-system.mjs Outdated
claude added 2 commits August 13, 2026 15:03
The previous revision of `sync-styled-system.mjs` could still truncate the
published payload, and the pack-time verifier would accept the result. Both
holes are closed here.

The copy validated only the nine entry points named in `exports`. A concurrent
`panda codegen --clean` empties the shared private source and then rewrites ~120
files asynchronously, so a read can land on a tree that holds those index files
and none of the other 111. `promote` then saw a tree differing from the payload
and took its two-`rename` replace path -- which unlinked `styled-system/` first,
reopening the exact window this PR exists to close, and left a truncated tree
behind. Demonstrated: with a source cut down to its nine entry points, the
payload went from 120 files to 9, `verify-package-contents.mjs` exited 0, and
`npm pack` produced 15 files instead of 126.

The comment claiming the replace path was unreachable during a publish was
wrong: the trees are only identical when the source is complete at read time.
The first race harness missed it because it ran panda and the copy sequentially
in one shell, so the copy always observed a finished source.

Two invariants now, both load-bearing:

- A tree is installed only if it matches `expected-payload.json`, a checked-in
  list of all 120 files Panda generates for this package, confirmed by a second
  identical read. Completeness is judged against something a racing process
  cannot move; deriving it from disk (or from a manifest the copy writes itself)
  cannot detect a truncation the copy just performed. A stability check alone is
  also insufficient -- a statically truncated source passes it -- which an
  intermediate attempt got wrong.
- The payload directory is never unlinked. POSIX cannot rename a directory onto
  a populated one, so files are staged outside `styled-system/` and moved onto
  their targets individually with `rename(2)`, which is atomic, and extras are
  pruned last. `styled-system/` always exists with a full set of entry points.

`verify-package-contents.mjs` now asserts the whole tree against the same
checked-in list rather than just the entry points, so a truncated payload cannot
be packed whatever produced it. When Panda's generated file set legitimately
changes, both scripts fail and name the difference; the README explains how to
regenerate the list, and the diff is reviewed like any other change.

Verification:

- regression, source truncated to its nine entry points: copy exits 1 naming the
  111 missing files, payload untouched at 120; verifier on a payload truncated
  behind its back exits 1 naming the same
- race repro with a separate generator process, syncer process and packer (the
  interleaving the first harness could not produce): 0/30 short tarballs, min =
  max = 126; 16,370 payload samples all at 120 files, never absent, never
  partial; 697 copy runs, all "already up to date", zero writes
- stress, 3 concurrent `panda codegen --clean` loops against one syncer for 40s:
  257 copy runs, all "already up to date", exit 0, no spurious failures; 6,219
  payload samples all at 120
- negative control, pre-fix `outdir` restored: 1/30 short tarballs including a
  6-file pack, so the harness still detects the original bug
- cold cache 126 files / 0 dangling; warm cache >>> FULL TURBO in 621ms, same;
  ds-components 254 files / 0 dangling
- concurrent publish simulation, cold cache, 3 iterations: 126 and 254 every
  time, 0 post-population empty samples, 0 partial samples
- guards: source missing / empty / unrecognised all exit 1 and leave the payload
  at 120; no staging directories left behind
- `expected-payload.json` and the staging directories stay out of the tarball
- oxfmt clean; `yarn constraints` exits 0; no cycles across 8 turbo task graphs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash.design Affects the `hash.design` design site (app) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants