Repository navigation
Conversation
…bles A standalone executable (Node.js SEA or bun build --compile) has no node_modules and cannot read package.json, so: - src/version.ts holds the version constant; check-version.mjs and a CLI test keep it in step with package.json. - src/standalone.ts detects Node SEA and Bun standalone builds and reads assets embedded in the executable. - src/node-api-headers.ts resolves the headers from node_modules under npm and extracts the embedded archive into the Marmotta home when running standalone, since Zig compiles in a separate process and needs real files. The build scripts produce the embedded headers archive (build:headers) and the executables for both runtimes (build:sea, build:bun); the smoke test runs a built executable the way a user without Node.js would.
- The fixture assigns napi_status before assert(): `zig cc -O2` defines NDEBUG, so an assert() wrapping the call compiled it away and the built addon exposed no properties. - BUN_OPTIONS is out of the hostile environment: Bun parses it before the entrypoint, no compiled executable can opt out, and the issue's determinism criteria cover NODE_OPTIONS, .env and bunfig.toml - all of which both binaries ignore. - build-bun keeps --bytecode but drops the bare --sourcemap (Bun 1.4 requires a value) and skips codesign with a warning when a darwin target is cross-built off macOS. Spike results: both runtimes pass the full smoke test on linux-x64; Bun additionally builds all eight targets from this machine, starts 5.6x faster (100 x --version: 0.46s vs 2.57s), at the cost of a larger binary (93 MB vs 66 MB).
The tag-triggered Release binaries workflow compiles all eight targets with bun build --compile - Bun won the spike over Node SEA: both pass the identical smoke test, and Bun additionally cross-compiles every Zig-supported platform and starts 5.6x faster - then runs scripts/smoke-test.mjs against each executable (natively on a matching runner, in an Alpine container for the musl pair), and attaches SHA256SUMS plus provenance attestations to the release. Executables are named marmotta-vX.Y.Z-<platform> so downloads and the release tag share the same version spelling; both build scripts follow that pattern. The tag must match package.json or the workflow fails before building.
Cover where to download them, which file matches which platform, checksum and attestation verification, and the behavioral notes that differ from an npm install: no Node.js required, NODE_OPTIONS and stray project files are ignored, macOS quarantine handling.
NickNaso
left a comment
There was a problem hiding this comment.
Review: standalone executables (PR #25)
Thanks for this. Embedding the Node-API headers, keeping the version in src/version.ts with a drift check, and smoke-testing every binary on a matching runner are all good choices, and the comments explain the reasoning well.
My main concerns are about the headers cache in the Marmotta home and about the release workflow on Windows and Alpine. Items 1–4 should be fixed before the first tagged release; items 2 and 3 are worth confirming with a manual workflow_dispatch run first. Items 5–8 are smaller fixes, and 9–11 are cleanups.
Must fix
1. A header upgrade never re-extracts
src/node-api-headers.ts:44-64 (and the comment at lines 12-14)
findInstalledHeaders returns the first complete directory under ~/.marmotta/node-api-headers and never compares it with the version embedded in the running executable. The comment says "a header upgrade re-extracts automatically", but it doesn't:
- A user runs a binary that embeds
node-api-headers-1.3.0, which gets extracted. - They upgrade to a binary that embeds
node-api-headers-1.5.0. readdirfinds the1.3.0directory, bothaccess()checks pass, and that directory is returned.
From then on every build compiles against the stale include/ and generates the Windows host imports from the stale node_api.def. If several versions are present, readdir order decides which one is used, so the result also depends on the file system.
Suggestion: look up the exact directory the running binary expects instead of scanning. One option is to name the destination after a hash of the embedded archive (the asset is already in memory, so hashing it is cheap). Another is to generate the expected directory name at build time, the same way src/version.ts carries the CLI version. Old directories can then be removed, or simply ignored.
2. tar with C:\... paths will probably fail on the Windows release jobs
.github/workflows/release-binaries.yml:90-106, scripts/build-headers-asset.mjs:29, src/node-api-headers.ts:92, src/toolchain.ts:255
Every step in the release workflow uses shell: bash. On windows-latest and windows-11-arm, Git Bash puts Git's /usr/bin first on PATH, so a spawned tar resolves to GNU tar rather than C:\Windows\System32\tar.exe (bsdtar). GNU tar reads an archive name with a colon before any slash as host:path, so tar -cf C:\a\marmotta\build\node-api-headers.tar ... fails with Cannot connect to C: resolve failed.
This would break npm run build:headers first. Under the same shell, the native smoke test would hit the same problem in marmotta install (Zig extraction) and in the first build (headers extraction). The regular ci.yml uses the default shell (pwsh) on Windows, which is probably why this hasn't shown up yet.
Suggestions:
- Pass
tara relative archive name and setcwdto its directory (e.g.spawnSync('tar', ['-cf', 'node-api-headers.tar', '-C', staging, directoryName], { cwd: buildDir })). Both GNU tar and bsdtar handle that, while--force-localonly exists in GNU tar. - Or drop
shell: bashfrom the Windows steps that don't need it.
The runtime code has the same exposure for users whose PATH puts GNU tar (Git, MSYS2, Cygwin) ahead of System32, so I'd fix it in run('tar', ...) too and not only in the workflow.
3. The musl smoke test runs in an Alpine image without libstdc++
.github/workflows/release-binaries.yml:113, :118
Bun's musl executables are dynamically linked against libstdc++ and libgcc, which alpine:latest doesn't ship. docker run alpine:latest /out/marmotta-...-linux-x64-musl --version is then expected to fail with Error loading shared library libstdc++.so.6, which blocks the release. Even if the smoke test were skipped, users on a stock Alpine image would hit the same error.
Suggestions:
- Install the libraries in the smoke container (
apk add --no-cache libstdc++ libgcc) and document the requirement in the README. - Pin the image (e.g.
alpine:3.22) so the smoke test is reproducible like the pinned Bun version.
4. Concurrent first-run extractions can delete headers that are in use
src/node-api-headers.ts:99-102
await rm(destination, { recursive: true, force: true });
await rename(join(stagingDir, directories[0].name), destination);The comment says no partially written directory is ever visible, but rm followed by rename isn't atomic as a pair. Picture two marmotta build processes starting on a fresh machine (a monorepo script or a parallel CI matrix):
- Process A renames its copy into place and starts
zig cc -I <destination>/include. - Process B runs
rm(destination)before its ownrename, and A's compile fails withnode_api.hnot found. - If A's rename lands between B's
rmand B'srename, B'srenamefails withENOTEMPTY/EPERMand B reports a directory error.
Suggestion: once the destination is version-specific (item 1), every copy has the same content, so the first writer can win. Try the rename first. If it fails because the destination exists, check that the destination is complete and use it. Only remove a destination that is incomplete. downloadAndInstallZig (src/toolchain.ts:261-262) has the same pattern, so the fix could cover both (see item 10).
Should fix
5. A failed bun build or codesign leaves the tarball in dist/
scripts/build-bun.mjs:63, :80
process.exit() ends the process immediately, so the finally { rm(stagedHeaders) } block at line 86 never runs. After a failed build, dist/node-api-headers.tar stays behind. package.json publishes dist, so a later npm publish from the same checkout would ship the tarball in the npm package.
Suggestion: set process.exitCode and break/return out of the loop, or throw and let the finally run before exiting.
6. The smoke test doesn't exercise the .env / bunfig.toml protection
scripts/smoke.mjs:50-51, :77-92
The hostile .env and bunfig.toml are written to the temporary project directory, but run() spawns the binary without cwd, so it runs in the caller's working directory. Bun auto-loads these files from the process cwd, not from -C <directory>. If a later change dropped --no-compile-autoload-dotenv, this test would still pass.
Suggestions:
- Pass
cwd: directoryto the project commands (configure,build,clean,rebuild). - Also set
MARMOTTA_HOMEto a temporary directory. The smoke test then always extracts the headers fresh (which would have caught item 1), and it stops writing into the developer's real~/.marmottawhen run locally.
7. Under Node SEA a missing asset throws a raw Node error
src/standalone.ts:40 and src/node-api-headers.ts:77-80
sea.getAsset(key) throws when the asset doesn't exist; it doesn't return undefined. So under SEA the if (!archive) branch with the InternalError is unreachable. A SEA built without the asset fails marmotta build with a raw Node error instead of the intended Marmotta error, and since line 77 is outside the try, it also skips directoryError handling.
Suggestion: wrap the getAsset call and return undefined (or throw the InternalError directly) when the asset is missing.
8. The release workflow grants write permissions to every job
.github/workflows/release-binaries.yml:14-17
contents: write, id-token: write and attestations: write are set at workflow level, so the eight build jobs get them too. Those jobs run npm ci, the test suite and the build scripts, none of which need write access.
Suggestion: set permissions: contents: read at the top, and grant the three write permissions only to the release job.
Nice to have
9. Resolve the Marmotta home once and don't cache failures
src/node-api-headers.ts:17-25, :67
extractEmbeddedHeaders calls marmottaHome() again instead of reusing the home ensureZig already resolved. src/toolchain.ts states that "the Marmotta home is resolved once by ensureZig and travels with the command". Passing zig.home from build.ts into resolveNodeApiHeaders(home) would keep that rule.
Also, resolved keeps a rejected promise forever. For the CLI that doesn't matter, but in programmatic use one transient failure (for example EACCES) makes every later call fail. Clear resolved when the promise rejects.
10. The extraction logic duplicates downloadAndInstallZig
src/node-api-headers.ts:81-109 vs src/toolchain.ts:248-267
Both functions run the same sequence: create a mkdtemp under the root, write the archive, tar -xf into a staging directory, check for one top-level directory, rm the destination, rename it into place, and clean up. Items 2 and 4 would need to be fixed in both places. A shared helper in toolchain.ts (something like installArchive(home, root, archivePath)) would keep them in one place.
11. SEA artifact names and existence checks
scripts/build-sea.mjs:20-35, scripts/build-bun.mjs:32-37
- The comment at line 21 says the output is "named after the target platform, not the build host", but the name comes from
process.platform/process.arch, which is the build host. (--build-seacan only build for the host anyway, so the comment is what's wrong.) - The names also don't match the Bun artifacts:
marmotta-v0.6.0-win32-x64.exevsmarmotta-v0.6.0-windows-x64.exe. Mappingwin32towindowswould keep checksums and download scripts consistent. - Both scripts check that the header archive exists by reading the whole file with
readFile, andbuild-sea.mjswraps a single file in a loop.await access(headers)does the same job.
Runtime - Name the extracted headers directory after a hash of the embedded archive instead of scanning the Marmotta home, so a binary that embeds a newer node-api-headers version never builds against a stale extraction. - Share the archive installation between Zig and the headers: unpack into a private staging directory and move the result into place with one rename. A complete destination is reused as is; only an incomplete one is replaced, so concurrent first runs cannot delete files another build is compiling with. - Pass the home Zig resolved into the header resolution instead of reading MARMOTTA_HOME again, and stop caching a rejected header promise. - Wrap sea.getAsset, which throws for a missing asset, so a SEA built without the headers archive reports INTERNAL_ERROR instead of a raw Node.js error. - Read the embedded archive from Bun.embeddedFiles. With `--bytecode`, import.meta.url keeps the build machine's path, so the previous lookup only worked when the archive happened to exist next to the original sources. - Pass tar relative paths and a working directory: GNU tar (Git for Windows, MSYS2, Cygwin) reads a colon before the first slash in an archive name as the remote syntax host:path, so an absolute Windows path failed. Build scripts and workflow - Let the staged dist/node-api-headers.tar be removed when a bun build or codesign fails, so a later publish cannot ship it. - Smoke-test the binary with a stray .env and bunfig.toml in its working directory and with a private MARMOTTA_HOME, which always extracts the headers fresh instead of reusing the developer's cache. - Install libstdc++ in the Alpine container (Bun's musl binaries link it) and pin the image; document the requirement. - Grant write permissions and the attestation scopes only to the release job. - Check for the headers archive with access(), drop the single-iteration loop, and name the SEA output windows-x64 like the Bun artifacts. Tests - Cover the shared archive installer: reuse of a complete destination, replacement of an incomplete one, and concurrent installers of one archive.
|
Thanks for the detailed review — all eleven items are addressed in 1764857, and item 6 turned up a bug that had been hiding behind the developer's cache. Must fix
Should fix
Nice to have
Bug found through item 6 Making the smoke test extract the headers fresh exposed that the Bun binaries could not read their embedded asset at all. With |
|
@ErRusso please resolve the conflicts. |
…cutable # Conflicts: # package-lock.json # src/cli.ts # src/toolchain.ts
|
@NickNaso conflicts are now solved. |
There was a problem hiding this comment.
Code Review: pr/25, standalone executables (Bun / Node SEA)
Summary
This branch adds standalone executables for 8 platforms. They embed the Node-API headers, extract them once into the Marmotta home, and are built, smoke-tested and attested by a new release workflow. Along the way, the Zig installer now uses a shared, atomic installArchive routine. The work is careful and well commented, and failure modes like tar's C: handling, NDEBUG removing asserts and NODE_OPTIONS leaking in were clearly hit and fixed. I found nothing that blocks merging. The suggestions below are hardening.
Verified locally: check:version passes, and install-archive, standalone and cli tests show 30 pass, 0 fail, 2 skipped for platform reasons. I had to run npm ci first because the local node_modules was missing pino from the upstream merge; that was an environment problem, not a problem in the branch. I couldn't run the Bun build because Bun isn't installed here. The release workflow's smoke step gates that path.
Critical Issues
None.
Suggestions
| # | File | Line | Suggestion |
|---|---|---|---|
| 1 | entitlements.plist / build-bun.mjs | — | codesign runs without --options runtime, so the hardened runtime is off and none of these entitlements does anything. The comments say otherwise: "without allow-jit the executable is terminated" is only true under the hardened runtime. disable-library-validation is justified as "Loads Zig", but Zig is started as a separate process, not loaded as a library, so library validation never applies to it. If someone later adds the hardened runtime for notarization, these entitlements quietly allow libraries to be injected into a binary that runs compilers. Either drop disable-library-validation and fix the comments, or turn on the hardened runtime on purpose and keep only allow-jit, plus whatever else Bun really needs. |
| 2 | build-bun.mjs | 338 | dist/node-api-headers.tar is cleaned up in a finally, but that block doesn't run on SIGINT, SIGKILL or a crashed runner, and dist is in files. Add "!dist/node-api-headers.tar" to files in package.json as a guard, so an interrupted build can never publish the archive to npm. |
| 3 | workflows/release-binaries.yml | 159 | The release job has contents: write and id-token: write, but its third-party actions (softprops/action-gh-release@v2, attest-build-provenance@v3) are pinned by tag. A moved tag could then produce validly attested malicious binaries. Pin them to commit SHAs, at least in this job. ci.yml uses tags too, but it has no write or OIDC permissions. |
| 4 | release-binaries.yml | 90 | Unlike ci.yml, this workflow doesn't set up Zig, so npm test here skips every Zig integration test. That's acceptable, since smoke.mjs does the real end-to-end check, but say so in a comment so no one assumes the whole suite ran on all 8 runners. |
| 5 | node-api-headers.ts | 782 | The cached promise ignores home after the first call. A later call with a different MARMOTTA_HOME silently gets the first home's paths. This doesn't matter today, because the npm path never uses home and the standalone binary handles one CLI run per process. If programmatic use is a goal, as the "callers may retry" comment suggests, key the cache by home.path. |
| 6 | smoke.mjs | 593 | The workflow no longer uses --quick, because the Alpine step calls docker run directly. On top of that, node scripts/smoke.mjs --quick bin would treat --quick as the binary, and running it with no argument resolves to the current directory and fails with a confusing spawn error. Either remove --quick, or parse arguments properly and print usage when no path is given. |
| 7 | version.ts | 4 | npm version bumps package.json but not src/version.ts. The test and the tag check do catch the drift, but only after the bump. A "version" lifecycle script that rewrites the constant and runs git add src/version.ts would make the two impossible to get out of step. |
| 8 | build-sea.mjs | 473 | The SEA build writes to build/ while the Bun build writes to out/. Use out/ for both, so .gitignore, the smoke commands and the docs all point to one place. |
What Looks Good
- The
installArchiverefactor. Files are unpacked into a staging directory and moved into place with one rename, the destination is named after its contents, and the fallback "if another process already finished it, keep its copy" handles Windows, where renaming onto an existing directory fails. A test covers concurrent installers. - Least privilege in CI. Only the
releasejob can write, the build jobs that run the tests and scripts are read-only, andneeds: buildwithfail-fast: falsemeans one failed target blocks the whole release instead of publishing part of it. - Hostile-environment smoke test. It plants
NODE_OPTIONS,.envandbunfig.toml, uses a privateMARMOTTA_HOME, and loads the compiled addon in a real Node.js process. It's a strong end-to-end check, and the comment explaining whyBUN_OPTIONScan't be blocked is a nice touch. - Version discipline.
check:version, the tag-vs-package.jsoncheck and the--versiontest give three separate safeguards. - Clear error paths. A missing embedded asset is reported as an
InternalError, filesystem failures go throughdirectoryError, and a failed header resolution is no longer cached.
Verdict
Approve with suggestions. #1 to #3 are worth fixing before the first tagged release: the misleading entitlements, the npm leak guard and SHA pinning. The rest can come later.s
Address the second review of the standalone executables, and repair the SEA
fallback that the structured-logging merge broke.
macOS signing
- Sign the Bun binaries with `codesign --options runtime` so the JIT
entitlements actually apply, and drop disable-library-validation: Zig is a
separate process, so library validation never covers it.
- Document the hardened runtime and the missing notarization in the README.
npm package
- Exclude dist/node-api-headers.tar in "files", covering interrupted builds
whose cleanup never ran.
- Add a `version` lifecycle script that rewrites src/version.ts and stages it,
so `npm version` cannot leave the executables reporting the old version.
Release workflow
- Pin the release job's actions to commit SHAs; that job holds write and OIDC
permissions.
- Say that Zig/Deno/Bun are intentionally absent from the build jobs and that
the smoke test is the end-to-end check there.
Runtime and scripts
- Key the resolved headers cache by Marmotta home instead of caching the first
home forever; add a test for two homes.
- Drop the unused smoke-test --quick mode and print usage without a binary.
- Write the SEA executable to out/ like the Bun artifacts, and give the ESM
bundle a real require() so pino's require('node:os') no longer throws
"Dynamic require is not supported" under Node SEA.
|
Thanks — all eight suggestions are addressed in 44f92d6. Critical / worth fixing before the tag
Suggestions
One regression found while smoke-testing the fallback The SEA build no longer started after the structured-logging merge: esbuild's ESM output has no scope-level |
Closes #21
Distributes Marmotta as a self-contained executable per platform alongside the npm package, which remains the primary distribution channel.
Spike: Node SEA vs Bun (both kept buildable)
Both runtimes were implemented and run through the identical
scripts/smoke-test.mjs(version, help, install, list, configure, build, addon loads in host Node, clean, rebuild, remove, exit codes, under hostileNODE_OPTIONS+ stray.env/bunfig.toml):--version)Chosen: Bun per the issue's decision rule — it covers the full 8-platform Bun matrix (matching Zig's target support), cross-compiles from one machine, and starts 5.6× faster.
scripts/build-sea.mjsremains in the repo as the fallback path.What's in here
src/standalone.tsdetects SEA/Bun standalone builds and reads embedded assets;src/node-api-headers.tsresolves headers fromnode_modulesunder npm and extracts the embeddednode-api-headers.tarinto~/.marmotta/node-api-headers/<version>/in standalone mode (Zig compiles in a separate process and needs real files);src/version.ts+scripts/check-version.mjskeep the reported version in step withpackage.json.build:headers(archives thenode-api-headersdependency'sinclude/+def/node_api.def, not vendored),build:bun(8 targets,--bytecode,--asset,.env/bunfig autoload disabled, ad-hoc codesign + entitlements on macOS),build:sea(esbuild +node --build-sea,execArgvExtension: none)..github/workflows/release-binaries.yml— tag-triggered; each target builds on a runner matching its platform (ubuntu, ubuntu-arm, macos-15-intel, macos-latest, windows, windows-11-arm) and is smoke-tested natively, with the musl pair quick-tested in Alpine containers; publishes binaries +SHA256SUMS+actions/attest-build-provenanceattestation to the release; tag must matchpackage.json.Testing
npm test: 31 pass, 0 fail, 5 skipped (Zig/Deno/Bun not on PATH locally; covered by the existing CI matrix).Out of scope (per issue)
marmotta self-update, Homebrew/Scoop/winget manifests, signing certificates (macOS binaries are ad-hoc signed only).