Skip to content

fix(exe): restore @pnpm/exe startup on Node.js v25.7+#11330

Merged
zkochan merged 9 commits intomainfrom
fix-pack-app-builder-version-pin
Apr 21, 2026
Merged

fix(exe): restore @pnpm/exe startup on Node.js v25.7+#11330
zkochan merged 9 commits intomainfrom
fix-pack-app-builder-version-pin

Conversation

@zkochan
Copy link
Copy Markdown
Member

@zkochan zkochan commented Apr 21, 2026

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 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.tsresolveBuilderBinary 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/.

Test plan

  • releasing/commands — 53 pack-app unit tests pass
  • pnpm run lint:ts clean
  • pnpm run spellcheck clean
  • Local end-to-end on darwin-arm64: pack-app --runtime node@25.9.0 --target darwin-arm64 builds a SEA that runs pnpm -v cleanly (previously SIGABRT).
  • Relocation test: SEA binary copied to /tmp/relocation-success/pkg/pnpm with dist symlinked → -v prints the expected version from a path unrelated to the build tree.
  • verify-binary.mjs validated in three scenarios — fresh run, stale symlink from aborted run, pre-existing real dist/ dir. All pass and leave no trailing symlink.
  • Release 11.0.0-rc.5 from this branch and verify @pnpm/exe@11.0.0-rc.5 runs pnpm -v cleanly on macOS + Linux + Windows via a live npm install.

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.
@zkochan zkochan marked this pull request as ready for review April 21, 2026 14:18
Copilot AI review requested due to automatic review settings April 21, 2026 14:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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-sea version.
  • Strengthen artifact publishing gates by adding a verify-binary.mjs prepublish check that (when runnable) executes pnpm -v and validates the output.
  • Add a test that actually runs the hardlinked pnpm binary with -v in the @pnpm/exe test 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.

Comment thread releasing/commands/src/pack-app/packApp.ts
Comment thread pnpm/artifacts/verify-binary.mjs Outdated
Comment thread pnpm/artifacts/linux-x64-musl/package.json Outdated
Comment thread pnpm/artifacts/linux-arm64-musl/package.json Outdated
…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread releasing/commands/src/pack-app/packApp.ts
Comment thread pnpm/artifacts/verify-binary.mjs Outdated
Comment thread pnpm/artifacts/verify-binary.mjs Outdated
Comment thread pnpm/artifacts/exe/test/setup.test.ts Outdated
Comment thread pnpm/artifacts/exe/test/setup.test.ts Outdated
zkochan added 3 commits April 21, 2026 16:53
… 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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread releasing/commands/src/pack-app/packApp.ts Outdated
Comment thread pnpm/artifacts/verify-binary.mjs Outdated
zkochan added 3 commits April 21, 2026 17:05
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread pnpm/artifacts/exe/test/setup.test.ts Outdated
Comment thread pnpm/artifacts/verify-binary.mjs Outdated
- 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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

@zkochan zkochan changed the title fix(pack-app): pin builder Node.js to embedded runtime version fix(exe): restore @pnpm/exe startup on Node.js v25.7+ Apr 21, 2026
@zkochan zkochan merged commit eb7e6ae into main Apr 21, 2026
16 checks passed
@zkochan zkochan deleted the fix-pack-app-builder-version-pin branch April 21, 2026 15:54
kairosci pushed a commit to kairosci/pnpm that referenced this pull request Apr 22, 2026
## 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/`.
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