Skip to content

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

Open
claude[bot] wants to merge 2 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 2 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.

Root cause

libs/@hashintel/ds-helpers/styled-system/ is the entire published payload of @hashintel/ds-helpers, but it is written by a different package: @hashintel/ds-components' Panda codegen, whose panda.config.ts:7 sets 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 while ds-components' codegen:panda runs concurrently:

  • before the fix: 3 short tarballs in 30 packs, two of them exactly 6 files — the same file count and file list as the published 0.2.1
  • after the fix: 0 short tarballs in 30 packs

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. 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 second commit (tsup clean: false) is suspected by analogy, not reproduced — see below.

🔗 Related links

🚫 Blocked by

  • Nothing

🔍 What does this change?

Commit 1 — H-6762: Stop @hashintel/ds-helpers publishing empty tarballs

  • libs/@hashintel/ds-components/package.jsoncodegen:panda drops --clean (panda codegen --cleanpanda codegen). This deletes the only code path that empties the published directory, making a 6-file tarball structurally impossible rather than merely unlikely.
    • Tradeoff: stale artifacts are no longer swept when the Panda config changes. CI always starts from an absent directory, so a release cannot pick up stale files; a developer with a stale tree needs rm -rf libs/@hashintel/ds-helpers/styled-system. Verified separately that turbo does not clean outputs before restoring a cache hit (a sentinel file placed in styled-system/ survived a >>> FULL TURBO restore), so cache correctness is unaffected.
    • If you want --clean back, the correct shape is to give Panda a ds-components-private outdir and have ds-helpers' prepack copy it into place. That is a bigger change and is not made here.
  • libs/@hashintel/ds-components/turbo.json — a comment above the codegen task's outputs recording why --clean must not come back.
  • libs/@hashintel/ds-helpers/scripts/verify-package-contents.mjs (new, dependency-free) — resolves every literal target reachable from main/module/types/exports, asserts each exists and is non-empty, and asserts every directory listed in files is non-empty. Exits 1 with a list of what is missing. Not itself published (files does not include scripts).
  • libs/@hashintel/ds-helpers/package.jsonprepack now runs that verifier after codegen; and two exports entries are removed:
    • ./recipesstyled-system/recipes/ is never generated. There are no defineRecipe/defineSlotRecipe calls 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 (ds-components/scripts/migrate-beta-fractal-pilots.ts:365,379 and its test).
    • the import/require conditions on ./types — Panda emits styled-system/types/ as .d.ts only, so those conditions can never resolve. (They also dangled in the last good release, 0.1.2.) The types condition is kept; ./types is consumed only via import type (5 call sites).
  • .changeset/h-6762-ds-helpers-empty-tarball.mdpatch for both @hashintel/ds-helpers and @hashintel/ds-components. Without a changeset, changeset publish cuts no version and the fix does not ship.
    • Judgement call a reviewer may want to overrule: removing two exports entries would normally be a breaking change, but neither has ever resolved to a real file in any published version, so nothing can regress by losing them. Hence patch, not major.

Commit 2 — H-6762: Stop tsup wiping ds-components' dist (SUSPECTED, NOT REPRODUCED)

Kept as a separate commit specifically so it can be dropped if you disagree.

libs/@hashintel/ds-components/tsup.config.ts:13 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 one level up. Set to clean: false.

This one is suspected by analogy, not reproduced. The only supporting evidence is that published file counts are non-monotonic for a growing component library:

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

That 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.

⚠️ 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 — I measured 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 wipe rather than adding a check.

The prepack verifier added here 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.

🐾 Next steps

Follow-ups found while investigating, not addressed here:

  • libs/@hashintel/ds-helpers/AGENTS.md:79 claims styled-system/ is "(committed)" — it is git-ignored (.gitignore:2). Either the comment or the policy is wrong; committing the generated tree would remove this whole class of problem, but that is a repo-policy call.
  • @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. Verification was done by hand:

########## COLD CACHE (turbo cache cleared, styled-system absent) ##########
@hashintel/ds-components:codegen: cache miss, executing e3bc3530099f86ba
@hashintel/ds-helpers:codegen:    cache miss, executing 9b108564571e1d88
@hashintel/ds-helpers: verified 9 entry-point target(s) present.
npm notice total files: 126
DANGLING: none — every target resolves inside the tarball

########## WARM CACHE (styled-system deleted first) ##########
Cached: 3 cached, 3 total   >>> FULL TURBO
@hashintel/ds-helpers: verified 9 entry-point target(s) present.
npm notice total files: 126
DANGLING: none

########## GUARD (payload removed, verifier run directly) ##########
@hashintel/ds-helpers: refusing to pack — the generated payload is missing.
  - missing: styled-system/css/index.d.ts (referenced by "exports../css.types")
  ... 9 targets ...
verifier exit=1

########## RACE (the repro that produced 6-file tarballs), re-run with the fix ##########
short tarballs: 0 / 30   (before fix: 3 / 30, incl. two 6-file packs)

########## CONCURRENT PUBLISH SIMULATION, cold cache, 3 iterations ##########
iter 1-3: ds-helpers 126 (verifier ok) ; ds-components 254 ; post-population empty-dir samples = 0

########## AFTER commit 2 (tsup clean: false), dist removed then rebuilt ##########
ds-components: npm pack --dry-run -> total files: 254
ds-components: 7 main/types/exports targets, 0 dangling

########## repo checks ##########
yarn constraints           -> exit 0
oxfmt --check <new files>  -> All matched files use the correct format.

❓ How to test this?

  1. Check out the branch.
  2. yarn install && rm -rf libs/@hashintel/ds-helpers/styled-system
  3. cd libs/@hashintel/ds-helpers && npm pack --dry-run — the prepack hook regenerates styled-system/, the verifier prints verified 9 entry-point target(s) present., and npm reports 126 files.
  4. Now delete the payload again and run the verifier on its own: rm -rf styled-system && node scripts/verify-package-contents.mjs — it should exit 1 and list every missing target.
  5. To see the original failure, compare against main: repeatedly npm pack ds-helpers in one shell while looping turbo run codegen --filter @hashintel/ds-components --force in another. On main some packs come out at 6 files; on this branch none do.

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

🕸️ 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

scripts changed in two package.jsons (ds-components#codegen:panda lost a flag, ds-helpers#prepack gained a step). No task was added or removed and no dependency edge changed; ds-components/turbo.json gains only a comment on the existing codegen task.

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 10, 2026 1:51pm
hashdotdesign-tokens Ready Ready Preview Aug 10, 2026 1:51pm
petrinaut Ready Ready Preview Aug 10, 2026 1:51pm

@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.

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