fix(exe): restore @pnpm/exe startup on Node.js v25.7+#11330
Conversation
Node.js v25.7 added a ModuleFormat byte to the SEA blob header (nodejs/node#61813). SEA blobs carry no version marker, so a blob written by a pre-25.7 builder fails deserialization at startup when embedded in a 25.7+ runtime — and vice versa — with a native `CHECK_LE(format_value, kModule)` assertion. @pnpm/exe@11.0.0-rc.4 hit this because the release CI host Node (25.6.1) built a blob for an embedded runtime pinned to 25.9.0. resolveBuilderBinary now only reuses the running Node when its version exactly matches the target, and otherwise downloads a host-arch Node of the target version via the existing ensureNodeRuntime path. Also upgrade each platform artifact's prepublishOnly from `test -f pnpm` to `node ../verify-binary.mjs <os> <arch>`, which additionally runs `pnpm -v` when the publish host can execute the target. On the Linux CI, that would have caught the rc.4 SEA crash before it shipped.
There was a problem hiding this comment.
Pull request overview
This PR fixes a regression in pnpm pack-app / @pnpm/exe where SEA executables could crash at startup when the Node.js version used to build the SEA blob didn’t match the embedded Node.js runtime.
Changes:
- Pin the SEA “builder” Node.js to the exact embedded runtime version (download host-arch Node when needed) and add an explicit error when the requested runtime is below the minimum
--build-seaversion. - Strengthen artifact publishing gates by adding a
verify-binary.mjsprepublish check that (when runnable) executespnpm -vand validates the output. - Add a test that actually runs the hardlinked
pnpmbinary with-vin the@pnpm/exetest suite.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| releasing/commands/src/pack-app/packApp.ts | Pin builder Node to the resolved embedded runtime version; improve help/error behavior around SEA compatibility. |
| pnpm/artifacts/verify-binary.mjs | New prepublish verifier that checks binary existence and (when executable) validates pnpm -v output. |
| pnpm/artifacts/win32-x64/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/win32-arm64/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/linux-x64/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/linux-x64-musl/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/linux-arm64/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/linux-arm64-musl/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/darwin-x64/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/darwin-arm64/package.json | Swap prepublish check to use verify-binary.mjs. |
| pnpm/artifacts/exe/test/setup.test.ts | Add a test that executes pnpm -v to catch SEA/runtime regressions. |
| .changeset/pack-app-builder-version-pin.md | Changeset documenting the crash fix and version pinning behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…mples - verify-binary.mjs now takes an optional <libc> arg and detects host libc via `process.report.getReport().header.glibcVersionRuntime`. When the target's libc doesn't match the host's (e.g. publishing a musl artifact from a glibc CI), the -v execution is skipped with a log line instead of trying to exec a non-runnable binary. - The four linux-* package.jsons now pass their libc to the verifier: linuxstatic-* pass "musl", linux-* (default variant) pass "glibc". - packApp.ts help usages/descriptions referenced node@22 and node@lts, both of which are now rejected by PACK_APP_RUNTIME_TOO_OLD. Examples now use node@25 / node@25.5.0 derived from MIN_BUILDER_VERSION. Addresses Copilot review on #11330.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… in verify-binary The pnpm CJS SEA entry shim failed on Node.js v25.7+ with: ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: file:///.../dist/pnpm.mjs because the ambient `require` and `import()` inside a CJS SEA entry are replaced with embedder hooks in 25.7+ that only know how to resolve built-in module names. An explicit `Module.createRequire(process.execPath)` returns a normal CJS loader that skips the embedder hooks, and the pnpm bundle has no top-level await so Node 22+'s synchronous require-of-ESM loads it fine. The per-platform prepublish check now stages a `dist -> ../exe/dist` symlink before executing `./pnpm -v` (and removes it after), so the SEA binary can resolve its sibling bundle from inside a platform package dir that ships only the binary. A pre-existing symlink from an aborted run is replaced; a real `dist/` directory is preserved and left untouched. Verified end-to-end on darwin-arm64: `./pnpm -v` now prints a semver from each platform package dir.
- resolveBuilderBinary no longer threads fetch and nodeDownloadMirrors; ensureNodeRuntime runs pnpm add directly and doesn't need them. - verify-binary.mjs and setup.test.ts accept a SemVer 2 "+<metadata>" suffix so future builds like 11.0.0-rc.4+sha.<hash> aren't rejected. - verify-binary.mjs formats caught errors via String(err) in case a non-Error is thrown. - setup.test.ts pnpm -v invocation gets a 30s execFileSync timeout so a hung binary fails the test instead of stalling the suite.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Before staging the dist/ symlink, verify-binary.mjs now runs the SEA binary *without* dist/ alongside it and asserts the resulting error mentions the runtime cwd path — not a build-time path. A pnpm.cjs that accidentally captured __filename or a cwd-relative path during packaging would keep working on the build machine but silently ignore dist/ on any user machine, shipping a broken executable. Running this negative case first catches that regression at publish time instead of after. Skipped when a real dist/ directory is already present (developer layout); in that case we can't distinguish a correctly-resolved dist from a hardcoded one, and the main verification still exercises the success path. Verified locally against both ./artifacts/darwin-arm64 and a relocated copy under /tmp — the guard silently passes when pnpm.cjs is relocatable and would fail loudly if it ever regressed.
- PACK_APP_RUNTIME_TOO_OLD hint no longer contains an unquoted '>=' that shells would interpret as redirection. The hint now recommends a concrete version (`node@25.5.0`) instead. - verify-binary.mjs passes 'junction' instead of 'dir' as the symlink type on win32. Directory symlinks require elevated privileges on Windows; junctions don't. Non-win32 hosts keep 'dir'.
…ction branching The repo already depends on symlink-dir through the pnpm CLI, which resolves cleanly from pnpm/artifacts/verify-binary.mjs. It handles the Windows "need Developer Mode / elevated privileges for dir symlinks" case internally (falls back to junctions), so the verifier can drop its own symlinkType switch. The symlink is still guarded by !distPreexists — symlink-dir atomically renames away any existing destination, which would silently drop a real dist/ directory staged by a developer. All three scenarios re-verified locally: fresh run, stale symlink from aborted run, and a populated real dist/ dir. All pass, and the real dir is preserved untouched after the run.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- setup.test.ts: gate the `pnpm -v` test on both hasPlatformBinary AND the presence of exeDir/dist/pnpm.mjs. The bundle is staged by build-artifacts, not by the ordinary compile step, so test runs that haven't built the CLI bundle would spuriously fail without this skip. - verify-binary.mjs: when the pre-dist relocation check fails for a reason other than "missing runtime dist path" (spawn error, signal, timeout, crash), surface the raw diagnostic — status/signal/code plus stderr — instead of claiming pnpm.cjs regressed. The operator can then tell a real relocation regression from an unrelated failure.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
## Summary `@pnpm/exe@11.0.0-rc.4` aborts on every invocation with: ``` node::sea::(anonymous namespace)::SeaDeserializer::Read() at ../src/node_sea.cc:174 Assertion failed: (format_value) <= (static_cast<uint8_t>(ModuleFormat::kModule)) ``` Two independent Node.js v25.7+ SEA regressions are responsible, both surfaced by the rc.4 bump of the embedded runtime from 25.6.1 to 25.9.0. This PR fixes both and adds a prepublish smoke test so a broken binary can't reach npm again. ## Root cause **1. SEA blob format changed in Node.js v25.7.0** ([nodejs/node#61813](nodejs/node#61813) added ESM-entry-point support and inserted a new `ModuleFormat` byte into the blob header). SEA blobs carry no version marker, so a blob written by one Node.js version can only be deserialized by a matching one. In rc.4, the CI host Node.js (25.6.1, pre-change) wrote the blob and it was embedded in a 25.9.0 runtime (post-change) — the deserializer reads a misaligned byte as `format_value`, exceeds `kModule`, `CHECK_LE` fires, `SIGABRT`. `resolveBuilderBinary()` was preferring `process.execPath` whenever the running Node supported `--build-sea`, never checking that its version matched the embedded runtime. **2. Node.js v25.7+ replaces the ambient `require` and `import()` inside a CJS SEA entry with embedder hooks** that only resolve built-in module names. The `pnpm.cjs` shim loaded `dist/pnpm.mjs` via `await import(pathToFileURL(...).href)`, which after the fix to (1) reached the CJS entry and then blew up with: ``` ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: file:///.../dist/pnpm.mjs at loadBuiltinModuleForEmbedder at importModuleDynamicallyForEmbedder ``` ## Changes - **`releasing/commands/src/pack-app/packApp.ts`** — `resolveBuilderBinary` now takes the resolved target runtime version and only reuses `process.execPath` when `process.version` exactly matches; otherwise it downloads a host-arch Node of the target version via the existing `ensureNodeRuntime` path. Added `PACK_APP_RUNTIME_TOO_OLD` for runtimes older than v25.5 (no `--build-sea`). Removed the now-unused `DEFAULT_BUILDER_SPEC` and the stale `fetch`/`nodeDownloadMirrors` args on the builder resolver. Help text / examples refreshed to drop `node@22` / `node@lts` references that would now be rejected. - **`pnpm/pnpm.cjs`** — loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(fileURL)`. `createRequire` returns a regular CJS loader that bypasses the SEA embedder hooks, and the pnpm bundle has no top-level await so synchronous `require` of ESM (Node 22+) loads it cleanly. No build-time paths are baked in — `process.execPath` is evaluated at runtime, verified by relocation-testing the darwin-arm64 SEA under `/tmp/`. - **`pnpm/artifacts/verify-binary.mjs`** (new) + `prepublishOnly` on every platform artifact — replaces the existence-only `test -f pnpm` gate with: 1. A **relocation-sensitivity check**: run the binary without `dist/` staged and confirm the failure mentions a path derived from `process.execPath`, not a build-time constant. Catches any future regression of (2). 2. A **smoke test**: stage a `dist → ../exe/dist` symlink (using `symlink-dir` so Windows junctions are handled transparently), exec `./pnpm -v`, assert the output is a SemVer 2 string. - Cross-platform targets (darwin/win32 artifacts on a Linux CI, or a libc mismatch) skip the exec with a log line and fall back to existence-only, so a musl artifact published from a glibc host still goes through. - Real `dist/` dirs (developer layout) are preserved; stale symlinks from aborted runs are replaced; created symlinks are cleaned up on exit. - **`pnpm/artifacts/exe/test/setup.test.ts`** — new `pnpm -v` execution test gated on both the platform binary and the staged bundle being present, so ordinary `pn compile` test runs skip cleanly instead of failing on a missing `dist/`.
Summary
@pnpm/exe@11.0.0-rc.4aborts on every invocation with:Two independent Node.js v25.7+ SEA regressions are responsible, both surfaced by the rc.4 bump of the embedded runtime from 25.6.1 to 25.9.0. This PR fixes both and adds a prepublish smoke test so a broken binary can't reach npm again.
Root cause
1. SEA blob format changed in Node.js v25.7.0 (nodejs/node#61813 added ESM-entry-point support and inserted a new
ModuleFormatbyte into the blob header). SEA blobs carry no version marker, so a blob written by one Node.js version can only be deserialized by a matching one. In rc.4, the CI host Node.js (25.6.1, pre-change) wrote the blob and it was embedded in a 25.9.0 runtime (post-change) — the deserializer reads a misaligned byte asformat_value, exceedskModule,CHECK_LEfires,SIGABRT.resolveBuilderBinary()was preferringprocess.execPathwhenever the running Node supported--build-sea, never checking that its version matched the embedded runtime.2. Node.js v25.7+ replaces the ambient
requireandimport()inside a CJS SEA entry with embedder hooks that only resolve built-in module names. Thepnpm.cjsshim loadeddist/pnpm.mjsviaawait import(pathToFileURL(...).href), which after the fix to (1) reached the CJS entry and then blew up with:Changes
releasing/commands/src/pack-app/packApp.ts—resolveBuilderBinarynow takes the resolved target runtime version and only reusesprocess.execPathwhenprocess.versionexactly matches; otherwise it downloads a host-arch Node of the target version via the existingensureNodeRuntimepath. AddedPACK_APP_RUNTIME_TOO_OLDfor runtimes older than v25.5 (no--build-sea). Removed the now-unusedDEFAULT_BUILDER_SPECand the stalefetch/nodeDownloadMirrorsargs on the builder resolver. Help text / examples refreshed to dropnode@22/node@ltsreferences that would now be rejected.pnpm/pnpm.cjs— loadsdist/pnpm.mjsthroughModule.createRequire(process.execPath)instead ofawait import(fileURL).createRequirereturns a regular CJS loader that bypasses the SEA embedder hooks, and the pnpm bundle has no top-level await so synchronousrequireof ESM (Node 22+) loads it cleanly. No build-time paths are baked in —process.execPathis evaluated at runtime, verified by relocation-testing the darwin-arm64 SEA under/tmp/.pnpm/artifacts/verify-binary.mjs(new) +prepublishOnlyon every platform artifact — replaces the existence-onlytest -f pnpmgate with:dist/staged and confirm the failure mentions a path derived fromprocess.execPath, not a build-time constant. Catches any future regression of (2).dist → ../exe/distsymlink (usingsymlink-dirso Windows junctions are handled transparently), exec./pnpm -v, assert the output is a SemVer 2 string.dist/dirs (developer layout) are preserved; stale symlinks from aborted runs are replaced; created symlinks are cleaned up on exit.pnpm/artifacts/exe/test/setup.test.ts— newpnpm -vexecution test gated on both the platform binary and the staged bundle being present, so ordinarypn compiletest runs skip cleanly instead of failing on a missingdist/.Test plan
releasing/commands— 53 pack-app unit tests passpnpm run lint:tscleanpnpm run spellcheckcleanpack-app --runtime node@25.9.0 --target darwin-arm64builds a SEA that runspnpm -vcleanly (previouslySIGABRT)./tmp/relocation-success/pkg/pnpmwithdistsymlinked →-vprints the expected version from a path unrelated to the build tree.verify-binary.mjsvalidated in three scenarios — fresh run, stale symlink from aborted run, pre-existing realdist/dir. All pass and leave no trailing symlink.11.0.0-rc.5from this branch and verify@pnpm/exe@11.0.0-rc.5runspnpm -vcleanly on macOS + Linux + Windows via a live npm install.