pkg cross-compile test harness
Validates pkg cross-OS and cross-arch support across Node 20/22/24 and
Standard vs Enhanced SEA modes. Produces a consistent result table so
claims in docs and issues can be checked against reality.
When to use
- User is about to edit docs about cross-compile support and needs ground truth.
- User is looking at a cross-compile issue (e.g. #87, #181) and wants to reproduce it.
- User bumped
pkg-fetchor touched bootstrap/prelude and wants a smoke test across targets. - User wants to know whether a regression is Node-version specific.
Host requirements
This harness is designed for a Linux x86_64 host with:
nvmwith node 20, 22, and 24 installed (nvm install 20 22 24).dockerdaemon running.dockerimagescottyhardy/docker-wine(pulled on first win-x64 run).tonistiigi/binfmtinstalled for cross-arch containers — if you seeexec format erroron arm64 runs, install with:docker run --privileged --rm tonistiigi/binfmt --install arm64pkgbuilt from this repo (yarn build) solib-es5/bin.jsexists. The script resolves the pkg entry from its own location — it lives at.claude/skills/pkg-xcompile-test/run-matrix.shand defaultsPKG_BINto<repo-root>/lib-es5/bin.js.
Not supported: macOS runtime verification. docker-osx needs /dev/kvm
and darling is dead. macOS regressions have to be caught on a real Mac
or on the GitHub Actions macos-* runners — note that in the final report.
What it runs
For each Node major (20, 22, 24) the harness:
- Uses
nvm use $majorso the pkg host node matches the target node major. This matters — SEA's blob generator uses hostexecPathwhen host-major == target-major, otherwise tries to run the downloaded target-arch node binary (which fails without cross-arch emulation). - Loops over modes × targets:
- Modes:
std,std-public(--public-packages "*" --public),sea - Targets:
linux-x64,linux-arm64,win-x64,macos-x64,macos-arm64
- Modes:
- Records build result (OK / FAIL), then tries to run the binary:
- linux-x64 → native
- linux-arm64 →
docker run --platform linux/arm64 ubuntu:latest - win-x64 →
docker run scottyhardy/docker-wine(see gotcha below) - macos-* → skipped (
SKIP-no-mac)
- Prints a summary table.
Wine gotcha
wine inside a non-tty docker container produces invalid stdio file
descriptors, causing Node to crash with Error: open EBADF before any
user code runs. The workaround is to redirect wine's stdout/stderr to
files inside the container, then cat them back:
docker run --rm -v "$BINDIR:/mnt" scottyhardy/docker-wine \
bash -c "wine '/mnt/app.exe' </dev/null >/tmp/out 2>/tmp/err; cat /tmp/out"
Without this, every wine run will look like a pkg failure when it isn't.
Usage
Run from the repo root (paths in the examples are relative to it).
Quick run (single node version)
# Runs the full matrix for node 22 targets with node 22 as host
./.claude/skills/pkg-xcompile-test/run-matrix.sh 22
Full sweep (20 + 22 + 24)
for V in 20 22 24; do
./.claude/skills/pkg-xcompile-test/run-matrix.sh $V
done
Custom pkg build
./.claude/skills/pkg-xcompile-test/run-matrix.sh 22 /path/to/other/pkg/lib-es5/bin.js
Custom work directory
By default build outputs go to /tmp/pkg-xcompile/bin-node<major>/. Override with:
PKG_XCOMPILE_WORKDIR=/somewhere/else ./.claude/skills/pkg-xcompile-test/run-matrix.sh 22
Reading the output
Each cell is BUILD / RUN:
OK / OK— worksOK / FAIL— built, but runtime error on targetFAIL / n/a— build failedOK / SKIP-no-mac— can't test macOS runtime on this host
Known results (captured 2026-04-15)
Host: Ubuntu 24.04 x86_64, pkg HEAD of docs/github-pages-site. Tests run
with matching host-node / target-node major.
Node 20
| target | std | std-public | sea |
|---|---|---|---|
| linux-x64 | OK / OK | OK / OK | FAIL — SEA needs host ≥ 22 |
| linux-arm64 | OK / OK | OK / OK | FAIL (same) |
| win-x64 | OK / OK | OK / OK | FAIL (same) |
| macos-x64 | OK / n/a | OK / n/a | FAIL (same) |
| macos-arm64 | OK / n/a | OK / n/a | FAIL (same) |
Standard cross-compile on Node 20 works without workarounds.
SEA mode is unavailable because pkg enforces host node ≥ 22.
Node 22
| target | std | std-public | sea |
|---|---|---|---|
| linux-x64 | OK / OK | OK / OK | OK / OK |
| linux-arm64 | OK / FAIL | OK / OK | OK / OK |
| win-x64 | OK / FAIL | OK / OK | OK / OK |
| macos-x64 | OK / n/a | OK / n/a | OK / n/a |
| macos-arm64 | OK / n/a | OK / n/a | OK / n/a |
Standard cross-compile on Node 22 is broken:
linux-arm64crashes at runtime withError: UNEXPECTED-20inreadFileFromSnapshot. Matches the #181 failure mode.win-x64exits silently with EXIT=4 and no stdout. Matches the #87 Windows silent-exit bug.
Both are fixed by adding --public-packages "*" --public (which skips the
V8 bytecode step). Enhanced SEA avoids both out of the box.
Node 24
| target | std | std-public | sea |
|---|---|---|---|
| linux-x64 | OK / OK | OK / OK | OK / OK |
| linux-arm64 | OK / OK | OK / OK | OK / OK |
| win-x64 | OK / OK | OK / OK | OK / OK |
| macos-x64 | OK / n/a | OK / n/a | OK / n/a |
| macos-arm64 | OK / n/a | OK / n/a | OK / n/a |
Node 24 works out of the box for Standard and SEA, same as Node 20.
The Node 22 bug is a Node-22-specific pkg-fetch patch regression, not
a permanent Standard-mode limitation.
macOS runtime
Not verified — see "Host requirements" above. The regressions tracked
in #181 (macOS host, Node 22+) must be confirmed on a real Mac.
GitHub Actions' macos-13 / macos-14 runners are the right place for
that; a follow-up CI workflow that runs the hello.js matrix there would
close the last hole.
Debugging a failing run
Each cell writes its own log; paths are printed on stderr next to any
FAIL and re-summarised at the end of the run.
- Log location —
$PKG_XCOMPILE_WORKDIR/logs/node<major>/build-<mode>-<target>.log(andrun-<mode>-<target>.log). Default workdir is/tmp/pkg-xcompile. Logs are per-cell so a failing build is not overwritten by the next one. - Re-run one cell — the fastest way is still to invoke
pkgdirectly against the same fixture:cd /tmp/pkg-xcompile node /path/to/pkg/lib-es5/bin.js hello.js -t node22-linux-arm64 \ -o /tmp/pkg-xcompile/bin-node22/std-linux-arm64 exec format erroron arm64 runs — binfmt is not registered. After a reboottonistiigi/binfmtneeds to be re-installed:docker run --privileged --rm tonistiigi/binfmt --install arm64- Wine cell always FAIL — pull the image manually once
(
docker pull scottyhardy/docker-wine) and inspectlogs/node<major>/run-sea-win-x64.logfor the actual crash. The EBADF-stdout gotcha is documented above. - SEA FAIL on Node 20 — expected; pkg enforces
host node ≥ 22for SEA. Error: UNEXPECTED-20/ silent exit (EXIT=4) — these are the tracked Node-22 Standard-mode regressions (issues #181 and #87). Not environment bugs — see Node 22 results below.- Stale binary hiding a regression — pkg does not hash its own source
into the output. After rebuilding pkg, delete
bin-node<major>/before re-running the matrix.
The script
The harness lives next to this SKILL.md as run-matrix.sh. Before
first run, set PKG_BIN to your pkg build and ensure nvm is sourced.
Quick reference of pkg flags touched
| Flag | Effect |
|---|---|
-t nodeNN-<os>-<arch> |
Target triple |
--sea |
Enhanced SEA mode |
--public-packages "*" --public |
Disable V8 bytecode, include sources in plaintext — the cross-compile escape hatch for Standard mode |
--no-bytecode |
Same effect for bytecode, but does not imply --public — use with --public-packages if you want consistent behavior |
--debug |
Inject diagnostic bootstrap; enables DEBUG_PKG at runtime |
Notes for future maintainers
- Do not reuse cached binaries between matrix runs — delete
bin-node${major}/first if you rebuilt pkg. pkg does not hash its own source into the output, so stale binaries silently hide regressions. - If you add a new target to the matrix, also add a runner branch in
run_one(). Unknown targets fall through thecaseand count as pass. - If
scottyhardy/docker-winebecomes unavailable,tobix/wineis a smaller drop-in replacement — it has the same EBADF stdout gotcha. - Don't confuse "builds with warning" with "builds clean". The matrix
only records
OKwhen the binary file exists on disk; a warning likeFailed to make bytecode node22-arm64still produces a binary but it will crash at runtime. Runtime is the source of truth.