Skip to content

Add prepublishOnly guard to prevent publishing incomplete packages - #277

Merged
amritk merged 1 commit into
mainfrom
claude/mini-lynx-native-dist-exports-bv3iux
Aug 5, 2026
Merged

Add prepublishOnly guard to prevent publishing incomplete packages#277
amritk merged 1 commit into
mainfrom
claude/mini-lynx-native-dist-exports-bv3iux

Conversation

@amritk

@amritk amritk commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

release:publish prepares three things no checked-in manifest carries — concrete versions in place of workspace:/catalog:, the development condition stripped, a LICENSE copied into each package — and bun run build produces the dist/ every entry point names. All four happen in the ephemeral publish job, so all four are skipped by an npm publish run anywhere else, and nothing caught that.

@amritk/mjst@0.7.15 and @amritk/generate-parsers@0.12.3 are why this is worth a gate rather than a convention. Both shipped dead on arrival — tsc-alias corrupted a regex literal in the compiled JS, so the CLI threw a SyntaxError before doing any work — and both are deprecated on npm rather than fixable, because a published version is forever. The sibling repo took the same class of miss further: @amritk/mini-lynx-native@0.2.0 was hand-published with no dist/ at all, and its surviving development condition pointed at the src the tarball also shipped, so anything honouring that condition resolved raw TypeScript and the package looked healthy.

This adds the check at the one point that catches it regardless of who is publishing or from where.

Changes

  • scripts/check-publishable.mjs — fails a publish when the manifest points at a dist/ file that is not on disk, when a development condition is still present, or when the package directory has no LICENSE. Plain node and node builtins only, so no environment can skip it.
  • prepublishOnly in all 12 publishable packagesprepublishOnly rather than prepack deliberately: it does not fire on npm pack, which is what dist-smoke and cli-e2e use, so the repo's own checks are untouched.
  • scripts/check-publishable.test.ts — 11 cases, including both workspace-specific shapes below and the vacuous-pass case (a manifest with no dist target anywhere, which is how a check like this goes quiet).
  • .changeset/publish-guard.md — empty changeset; this touches no published surface.

Two shapes in this workspace made the obvious version of this check wrong, and both are covered:

  • @amritk/mjst is a CLI. Its only entry is bin.mjst and its exports map carries nothing but ./package.json, so a check reading exports alone called it unreachable — and would have had nothing to say if the executable went missing. It reads exports, bin, main, module and types.
  • @amritk/helpers exports ./* as ./dist/*.js. A target can be a pattern rather than a path, so existsSync on it always fails. A pattern is satisfied by any one file of that extension, which is all the manifest itself promises.

Testing

Verified both directions against the real pipeline, not just the unit tests. After resolve-workspace-protocolstrip-development-exportscopy-license, all twelve packages pass. With packages/cli/dist moved aside the CLI fails naming ./dist/cli.js; before the strip runs, @amritk/helpers fails on both the surviving condition and the missing LICENSE.

  • bun run test passes (13 workspaces)
  • bun run check passes (the 22 warnings are pre-existing — confirmed by stashing; these files add none)
  • bun run build passes
  • READMEs regenerated if config.schema.json changed (bun run generate-readmes) — n/a, unchanged
  • Changeset added (bunx changeset) if this affects a published package

Also run: bun run versions:check, bun run types:check (12 workspaces), and bun run test:dist (96 tests across 9 files).

Related issues

Companion to amritk/mini#34, which fixes the mini-lynx-native release described above and removes the development condition from that repo entirely. The condition is deliberately left in place here — see that PR for why the tradeoff differs.

⚡ Benchmark delta vs main (c3863a980cdcc7)

Suite Case Metric main PR Δ
parsers User · safe parse ops/s 19.60M ±2% 19.53M ±5% -0.4% ⚪
parsers Order · safe parse ops/s 8.54M ±1% 8.98M ±1% +5.1% 🟢
parsers Assert · safe parse ops/s 137.25M ±12% 137.16M ±12% -0.1% ⚪~
parsers User · strict parse ops/s 16.56M ±4% 16.55M ±3% -0.1% ⚪
parsers Order · strict parse ops/s 8.61M ±1% 8.89M ±1% +3.2% ⚪
parsers Assert · strict parse ops/s 79.34M ±6% 79.66M ±7% +0.4% ⚪
validators small (4 fields) valid ops/s 62.93M ±2% 62.46M ±1% -0.7% ⚪
validators small (4 fields) invalid ops/s 24.00M ±1% 23.79M ±1% -0.9% ⚪
validators order (nested + array) valid ops/s 11.81M ±2% 11.81M ±1% +0.0% ⚪
validators order (nested + array) invalid ops/s 12.52M ±1% 12.54M ±1% +0.1% ⚪
validators assert-loose valid ops/s 174.44M ±3% 173.93M ±3% -0.3% ⚪
validators assert-loose invalid ops/s 37.40M ±0% 37.00M ±1% -1.1% ⚪
validators assert-strict valid ops/s 157.78M ±3% 158.00M ±2% +0.1% ⚪
validators assert-strict invalid ops/s 15.95M ±2% 15.84M ±2% -0.7% ⚪
api static GET (runtime) req/s 4.24M ±1% 3.79M ±1% -10.5% 🔴
api dynamic GET, params+query validated (runtime) req/s 715.3k ±1% 700.5k ±2% -2.1% ⚪
api POST, body validated (runtime) req/s 1.61M ±1% 1.58M ±1% -1.9% ⚪
api dynamic GET, 500-route table, last match (runtime) req/s 1.96M ±1% 1.91M ±1% -2.3% ⚪
api unroutable path, 500-route table (runtime) req/s 1.25M ±1% 1.24M ±0% -0.7% ⚪
api static GET (compiled) req/s 530.4k ±1% 531.1k ±1% +0.1% ⚪
api POST, body validated (compiled) req/s 183.8k ±2% 185.9k ±4% +1.2% ⚪
codegen User · safe ms/parser 0.05ms ±20% 0.05ms ±14% +12.2% 🔴~
codegen Order · safe ms/parser 0.20ms ±14% 0.22ms ±10% +11.9% 🔴~
codegen Assert · safe ms/parser 0.09ms ±12% 0.08ms ±12% -7.0% 🟢~
codegen User · strict ms/parser 0.05ms ±17% 0.06ms ±17% +6.4% 🔴~
codegen Order · strict ms/parser 0.19ms ±11% 0.19ms ±6% +4.1% ⚪~
codegen Assert · strict ms/parser 0.09ms ±8% 0.09ms ±5% -5.3% 🟢
yaml small (tree) parse ops/s 446.3k ±2% 447.9k ±1% +0.4% ⚪
yaml small (data) parse ops/s 311.4k ±2% 314.0k ±1% +0.8% ⚪
yaml medium (tree) parse ops/s 41.3k ±1% 41.7k ±1% +0.9% ⚪
yaml medium (data) parse ops/s 22.1k ±1% 21.6k ±1% -2.2% ⚪
yaml large (tree) parse ops/s 866.2 ±1% 874.9 ±1% +1.0% ⚪
yaml large (data) parse ops/s 565.1 ±1% 564.4 ±0% -0.1% ⚪
runtime small (guard) valid ops/s 5.35M ±2% 5.17M ±1% -3.4% ⚪
runtime small (guard) invalid ops/s 9.48M ±2% 9.09M ±4% -4.1% ⚪
runtime small (errors) valid ops/s 4.70M ±4% 4.64M ±3% -1.3% ⚪
runtime small (errors) invalid ops/s 5.04M ±3% 4.60M ±3% -8.7% 🔴
runtime wide (40 props) (guard) valid ops/s 574.4k ±1% 582.3k ±2% +1.4% ⚪
runtime wide (40 props) (guard) invalid ops/s 10.24M ±2% 10.22M ±4% -0.2% ⚪
runtime wide (40 props) (errors) valid ops/s 498.2k ±1% 503.0k ±2% +1.0% ⚪
runtime wide (40 props) (errors) invalid ops/s 475.9k ±1% 480.8k ±1% +1.0% ⚪
runtime deep ($ref + arrays) (guard) valid ops/s 49.8k ±2% 50.0k ±2% +0.4% ⚪
runtime deep ($ref + arrays) (guard) invalid ops/s 3.05M ±1% 2.98M ±3% -2.4% ⚪
runtime deep ($ref + arrays) (errors) valid ops/s 47.0k ±1% 47.8k ±1% +1.8% ⚪
runtime deep ($ref + arrays) (errors) invalid ops/s 227.9k ±1% 230.2k ±0% +1.0% ⚪

mjst only, all suites (shared or bench-harness changes); each timed number is the better of two order-balanced (ABBA) runs, each the median of 21 isolated-process trials (±n% = coefficient of variation). ⚪ within ±5% · 🟢 improvement · 🔴 regression · ~ marks an unstable sample (CV > 10%) · ⚠parity marks a correctness disagreement. On shared CI runners, deltas within ±10% are usually noise — trust direction only when it persists across pushes.

`release:publish` prepares three things no checked-in manifest carries —
concrete versions in place of `workspace:`/`catalog:`, the `development`
condition stripped, a LICENSE copied into each package — and `bun run build`
produces the `dist/` every entry point names. All four happen in the ephemeral
publish job, so all four are skipped by an `npm publish` run anywhere else,
and nothing caught that.

`@amritk/mjst@0.7.15` and `@amritk/generate-parsers@0.12.3` are why this is
worth a gate rather than a convention. Both shipped dead on arrival, and both
are deprecated on npm rather than fixable, because a published version is
forever. The sibling repo took the same class of miss further:
`@amritk/mini-lynx-native@0.2.0` was hand-published with no `dist/` at all,
and its surviving `development` condition pointed at the `src` the tarball
also shipped, so anything honouring that condition resolved raw TypeScript and
the package looked healthy.

`scripts/check-publishable.mjs` runs from the package root as
`prepublishOnly`. Two shapes in this workspace made the obvious version of it
wrong, and both are covered:

- `@amritk/mjst` is a CLI. Its only entry is `bin.mjst` and its exports map
  carries nothing but `./package.json`, so a check reading `exports` alone
  called it unreachable — and would have had nothing to say if the executable
  went missing. It reads `exports`, `bin`, `main`, `module` and `types`.
- `@amritk/helpers` exports `./*` as `./dist/*.js`, so a target can be a
  pattern rather than a path. A pattern is satisfied by any one file of that
  extension, which is all the manifest itself promises.

Verified both ways against the real pipeline: after `resolve-workspace-protocol`,
`strip-development-exports` and `copy-license`, all twelve packages pass; with
`packages/cli/dist` moved aside the CLI fails naming `./dist/cli.js`, and
before the strip runs `@amritk/helpers` fails on the condition and the LICENSE.

Nothing about the repo's own checks changes: `prepublishOnly` does not fire on
`npm pack`, which is what dist-smoke and cli-e2e use.
@amritk
amritk merged commit e92e745 into main Aug 5, 2026
4 checks passed
@amritk
amritk deleted the claude/mini-lynx-native-dist-exports-bv3iux branch August 5, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants