Skip to content

build: verify the published tarball instead of grepping the bundle - #1578

Open
thymikee wants to merge 3 commits into
mainfrom
claude/publishing-setup-simplify-grda5u
Open

build: verify the published tarball instead of grepping the bundle#1578
thymikee wants to merge 3 commits into
mainfrom
claude/publishing-setup-simplify-grda5u

Conversation

@thymikee

@thymikee thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #1577. That PR added a grep for one specifier prefix (@agent-device/*) in dist/, and taught CI to run one more command (devices --json) that happened to touch the broken path. Both are shaped like the specific incident. This replaces them with a gate shaped like the question: does the tarball npm would publish actually work when installed?

pnpm check:package (scripts/check-package.ts) packs the tarball, then verifies it from outside the workspace — where no pnpm link can mask an unresolvable specifier:

  1. publint --strict on the tarball — packaging metadata (exports/bin/types conditions, file existence).
  2. attw --profile esm-only on the tarball — declaration resolution for the module systems this package actually supports.
  3. dependency-closure audit, both directions: every bare specifier in the shipped .js/.d.ts is a Node builtin or a declared dependencies entry, and every declared entry is imported by something.
  4. resolution smoke: installs the tarball into a clean project in a temp dir, imports all 12 exports subpaths, and runs the CLI (--version, help, devices --json, doctor --remote --json) — the last two being the lazily-loaded daemon and remote-config halves where the 0.20.4 failure actually surfaced.

Net: one script instead of one script plus ~15 lines of bespoke CI shell, and it is runnable locally rather than only on a runner.

The build no longer emits a publishable bundle in the first place. 0.20.4 shipped because rolldown warned about unresolved workspace imports and exited 0, and prepack packed the result. tsdown.config.ts now escalates UNRESOLVED_IMPORT to a build failure — the cheapest gate, at the source, with a message naming the fix.

Two real defects the established tools found

  • All 12 exports entries listed types after import. Conditions are order-sensitive: TypeScript matched import first and only found declarations because a sibling .d.ts happened to sit next to each .js. Resolution by accident, not by condition. publint reported all 12 as errors; the order is now types first and publint --strict is clean.
  • pngjs was a runtime dependency that no shipped code imports. tsdown inlines it via deps.alwaysBundle, so every user installed a package nothing resolved. It moves to devDependencies (still needed to build). The closure audit is what surfaced this, and it now keeps the manifest and the bundle in agreement in both directions.

Where the gate runs

  • package:npmlast, after the Apple/Android payloads exist, so prepack/npm publish verifies the complete real tarball. Publishing can no longer skip it. (Inner pack uses --ignore-scripts, so it cannot re-enter prepack.)
  • check:tooling — replaces check:bundle-dependencies.
  • CI Packaged CLI Node 22.12 — build on the toolchain Node, verify on the engines.node floor. The hand-rolled pack/global-install/run shell is gone.
  • pnpm check:affected — new package check id, selected when a public exports entry changes.

Validation

Both new gates were proven red before green, against the actual 0.20.4 failure mode rather than a synthetic one. Reproducing it required removing the workspace links (node_modules/@agent-device) — the config migration in #1577 alone does not reproduce it, since these are devDependencies that tsdown bundles by default:

  • build guard: with links removed, pnpm build fails with UNRESOLVED_IMPORT (previously: warned, exit 0, publishable dist/).
  • check:package: against a dist/ built with links removed, it fails naming every leaked specifier and file (@agent-device/ad-replay in dist/src/session.js, …) plus yaml as newly-unimported. Restoring the links returns it to green.
  • publint layer: reverting one exports entry to import-before-types fails the gate; restored, clean.

Green on the branch: check:package (~11s end to end, leaves no temp dir and no running daemon), pnpm build, typecheck, lint, format:check, check:layering, depgraph:test, check:production-exports, check:mcp-metadata, check:bundle-owner-files, check:affected:test (35/35), and vitest related over the changed files (580 files / 5023 tests). Also confirmed the exports reordering does not disturb packageEntryFiles, which reads entry.import by key rather than positionally.

Not covered locally: the macOS Build npm package job, which is where prepack exercises the gate against a tarball that includes the Swift and Android payloads.

Tradeoffs and gaps

  • check:tooling now touches the network. check:package installs the tarball from the registry, so pnpm check is no longer fully hermetic. Unavoidable for a check whose whole point is resolving the package the way a user does — and it is the same registry pnpm install already needs. It adds ~11s.
  • Steps 3 and 4 overlap deliberately. A specifier reachable only through a lazy dynamic import is invisible to the runtime import; a require computed at runtime is invisible to the static audit. Neither alone closes the class.
  • attw runs with --profile esm-only, which ignores the node10 and CJS-consumer rules. Those fail for this package by design (type: module, engines.node >= 22.12); every ESM and bundler resolution stays enforced.
  • Native payload presence in the tarball is still only covered by the macOS prepack job — the gate verifies the JS/type contract and dependency closure, which are deterministic in both lanes.

11 files touched. Scope stayed on packaging; the one adjacent edit is registering the new gate in the affected-check catalog, which that catalog's completeness assertion requires.

Docs

No docs or skills changes: no command behavior changes, and the gate is reachable through the existing aggregate scripts.


Generated by Claude Code

Replaces the bundle-dependency grep with one gate that packs the tarball npm
would publish and proves it sound from a clean consumer install: publint and
attw on the tarball, a two-way dependency-closure audit, an import of every
`exports` subpath, and the CLI smoke run — all from outside the workspace,
where no pnpm link can mask an unresolvable specifier.

Also stops the build from emitting a publishable bundle in the first place: a
missing workspace link now fails `pnpm build` instead of warning and exiting 0,
which is how 0.20.4 shipped an unresolvable `@agent-device/ad-script` import.

publint found 12 real defects in the current package — every `exports` entry
listed `types` after `import`, so TypeScript resolved declarations by accident
rather than by condition. The dependency audit found `pngjs` declared as a
runtime dependency while tsdown inlines it, an install every user paid for and
no shipped code reached; it moves to devDependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-1578/

Built to branch gh-pages at 2026-08-03 22:16 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.96 MB 1.96 MB 0 B
JS gzip 626.6 kB 626.6 kB 0 B
npm tarball 746.3 kB 746.3 kB +11 B
npm unpacked 2.61 MB 2.61 MB +18 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 30.8 ms 30.5 ms -0.3 ms
CLI --help 69.8 ms 70.8 ms +0.9 ms

Top changed chunks: no changes in the largest emitted chunks.

pnpm 11.17 requires Node >= 22.13, so `pnpm check:package` could not start on
the 22.12 floor the Packaged CLI job exists to cover. The gate needs only `node`
and `npm`, so the job invokes the script directly.

Splits the dependency-closure audit into a collector and a message builder to
clear Fallow's complexity threshold, and classifies both packaging linters in
ignoreDependencies: they are subprocess CLIs with no importable API here, which
dependency analysis cannot follow to an import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD
@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Blocking — the new “all shipped bare specifiers” guarantee is incomplete and has no behavioral regression pin.

scripts/check-package.ts:110-124 derives dependency usage only from OXC’s ESM module records (static import/export and literal import()). Literal require('pkg') and createRequire(import.meta.url)('pkg') produce none of those records. A lazy createRequire('@agent-device/...') can therefore avoid Rolldown’s UNRESOLVED_IMPORT, the closure audit, all-export imports, and the two exercised CLI paths—reintroducing the same published-package failure class for another command.

Please traverse/reject literal CommonJS resolution forms (including aliased createRequire where supported), or narrow the guarantee explicitly. Add a behavioral fixture test that runs the gate against malformed packed output and proves an undeclared/private lazy specifier fails. The current tests at src/__tests__/npm-package-scripts.test.ts:62-95 only pin wiring; deleting the audit/import probe would still leave them green. Required validation: demonstrate the fixture red before the fix and green after, while retaining the existing clean-tarball and Node 22.12 package smokes.

The exact 0.20.4 ESM artifact is caught and CI is green, but the generalized guarantee is not yet durable.

The dependency-closure audit derived shipped imports from the ESM module
record alone, so it could not see a package resolved through `require` or a
`createRequire` result: neither produces a module-record entry. A lazy
`createRequire('@agent-device/…')` would therefore clear the audit, the
all-export probe and the exercised CLI paths, reintroducing the 0.20.4
published-install failure class for another command.

Measuring the built bundle turned up a second, larger hole in the same
reader. The shipped files are minified, and the minifier rewrites every
string literal to a no-substitution template literal, so the dynamic-import
extraction — which accepted quoted strings only — matched 0 of the 99
dynamic imports the bundle contains. The lazy `import()` path that broke
0.20.4 was reported as covered while checking nothing.

Specifiers now come from the module record plus an AST walk over every
literal runtime-resolution form: `import()`, `require()`,
`require.resolve()`, an immediately-invoked `createRequire(...)`, and calls
through a `createRequire` result under any import or minified alias. Both
spellings of a string literal count everywhere, and `.cjs` joins the
scanned extensions.

Computed specifiers stay explicitly out of scope, and are pinned as such.
Rejecting them is not available: minifiers reuse short identifiers across
scopes, and the packed bundle really does contain an unrelated
`a(h[t],f,g,l,e,m)` that no name-based match can distinguish from a require
call. Those are covered by the gate's runtime half instead, which resolves
them for real. Bare-identifier calls need the one-string-argument shape for
the same reason.

The audit moves to scripts/lib/shipped-imports.ts so fixture packages can
exercise it. The gate needs a real `npm pack` behind minutes of Swift and
Android builds, so every check that runs it can only watch a healthy
package pass — which is how a reader that matched nothing looked covered.
The new fixtures assert the failure direction per resolution form: 16 of
the 22 fail against the previous reader, and the 6 that pass are the
quoted-spelling and pinned-limitation cases. A wiring assertion keeps the
audit and both runtime probes attached to the gate, since fixtures alone
would stay green if the call were deleted.

Verified against the real built bundle: the closure resolves to exactly the
two declared dependencies, so the stricter reader adds no false positives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Fixed in e72e156. You were right, and measuring the built bundle turned up a second hole in the same reader that was worse than the one you named.

Your finding. require('pkg') and createRequire(import.meta.url)('pkg') produce no module-record entries, so a lazy createRequire('@agent-device/…') cleared the audit, the all-export probe, and both exercised CLI paths. Confirmed.

The larger one. The shipped files are minified, and the minifier rewrites every string literal to a no-substitution template literal. The dynamic-import extraction accepted quoted strings only, so it matched 0 of the 99 dynamic imports in the built bundle. The lazy import() path that broke 0.20.4 read as covered while checking nothing.

Specifiers now come from the module record plus an AST walk over every literal resolution form — import(), require(), require.resolve(), immediately-invoked createRequire(...), and calls through a createRequire result under any import or minified alias. Both literal spellings count everywhere, and .cjs joins the scanned extensions.

Narrowed explicitly, per your second option. Computed specifiers stay out of scope and are pinned as such. Rejecting them isn't available: minifiers reuse short identifiers across scopes, and the packed png-worker-contract.js really does contain a(h[t],f,g,l,e,m) in a scope where a is not that file's createRequire result — no name-based match can tell it from a require call, so rejection would fail the gate on a sound package. Bare-identifier calls need the one-string-argument shape for the same reason. Computed resolution stays covered by the runtime half, which resolves it for real.

Required validation. The audit moved to scripts/lib/shipped-imports.ts so fixture packages can exercise it — the gate needs a real npm pack behind minutes of Swift and Android builds, so every check that runs it can only watch a healthy package pass, which is exactly how a reader matching nothing looked covered.

scripts/__tests__/package-closure-audit.test.ts asserts the failure direction per resolution form, with fixtures spelled the way the minifier spells them. Against the previous reader, 16 of 22 fail; the 6 that pass are the quoted-spelling cases and the two pinned limitations:

× undeclared private specifier fails via dynamic import, minified backtick spelling
✓ undeclared private specifier fails via dynamic import, quoted spelling
× undeclared private specifier fails via bare require
× undeclared private specifier fails via require.resolve
× undeclared private specifier fails via aliased createRequire result
× undeclared private specifier fails via immediately invoked createRequire
× undeclared private specifier fails via createRequire through a namespace import
× undeclared private specifier fails via require alias declared below its use
✓ a computed specifier is out of scope for the static audit
✓ a minified name collision is not mistaken for a require call
   Tests  16 failed | 6 passed (22)

All 22 green after. On your point that deleting the audit would leave the wiring tests green: a wiring assertion now pins auditDependencyClosure and both runtime probes to the gate, so behavior is covered by fixtures and attachment by the wiring test.

Both existing smokes are retained untouched — the clean-tarball path and the Node 22.12 packaged-CLI job. I also ran the new reader against the real built bundle: the closure resolves to exactly @limrun/api and yaml across 8 resolution sites, so the stricter reader adds no false positives. Local gates green (typecheck, oxlint, oxfmt, layering, Fallow audit).


Generated by Claude Code

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed head e72e156: the prior blocker is resolved.

The package audit now covers literal/minified-template import(), require(), require.resolve(), and direct/aliased/namespace createRequire forms across shipped JS/CJS/declarations. Malformed-package fixtures prove each undeclared private specifier fails and the declared form passes; the wiring test keeps the closure audit and both runtime probes attached to the publish gate.

All CI is green, including the Node 22.12 clean-install package verification and full macOS Package Smoke. The documented residual limit—genuinely computed specifiers and deeper alias/assignment chains—is reasonable and backed by runtime export/CLI probes. No remaining blocking findings; ready for human merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants