Skip to content

one binary per platform, one package per binary, and nothing running at install time - #2

Merged
tamnd merged 3 commits into
mainfrom
packaging
Aug 18, 2026
Merged

tamnd merged 3 commits into
mainfrom
packaging

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

npm i zudb should be a download of one file for the machine doing the installing, and nothing else. This is the packaging that makes that true.

The root package carries the loader and no binary. Each of the eight platforms has its own package holding exactly one addon, and npm picks the one that fits out of optionalDependencies by os, cpu and libc. There is no postinstall, no node-gyp, no compiler and no fetch from anywhere but the registry, which is what makes the package installable behind a proxy, inside a locked-down CI image, and on a machine with no toolchain on it.

The eight

The tier 1 rows of platforms.toml in tamnd/zu, plus Windows on arm64, which is tier 2 there and cheap here because the runner exists. The list lives once, in tools/platforms.mjs. The release workflow builds it, the test suite holds package.json and the npm/ directories to it, and tools/packages.mjs checks that every row ended up with a binary in it before anything is published.

The glibc rows build inside manylinux_2_28 rather than on the runner, because a binary linked against the runner's own glibc loads where it was built and dies on the user's. The musl rows build inside Alpine through docker rather than as a job container, because GitHub runs the JavaScript half of an action inside the job's container and has no Alpine build of it for arm64, so a job container of Alpine is a checkout that fails on the arm runner and passes on the other. macOS x64 is cross compiled from the arm runner, which on macOS is one flag.

The optional dependencies are not checked in

Naming the eight packages in package.json is the obvious way to say what an install resolves. It is also a lock file npm cannot complete: a package that has not been published yet resolves to nothing, npm install records the range and no entry, and every npm ci after it fails with "missing from lock file" on all eight. That is not a guess, it is what happened here first. napi pre-publish writes the block from napi.targets at the version being released, and there is a test that keeps it out of the manifest for the reason it has to stay out.

What is checked

Five tests over the manifests: no install-time script and no gyp-shaped dependency, files is four files and no binary, the targets and the directories and the loader's requires all agree, every platform package says which machine it is for and holds one file, and one built addon sits beside the loader.

Two tools for the release: tools/binary.mjs fails the build row that produced a file named after a different target, and tools/packages.mjs checks the whole set at once, because a release that ships seven of eight installs on the eighth platform and fails at the require.

One tool that reads nothing at all: tools/install.mjs packs the two tarballs npm would fetch, installs them into an empty project outside the checkout, and runs a statement through what came out. It is also the only thing that covers the case where seven of the eight optional dependencies cannot be fetched, which is every install today and any install of a platform whose package is missing.

Verified here

The whole publish path was run locally against a staged copy: eight packages with binaries in them, napi pre-publish writing the eight optional dependencies at 0.0.1, tools/packages.mjs green, the root tarball 14.3 kB with no binary in it and the platform tarball carrying exactly one, then both installed into an empty project where connect, exec, query and close did what they say. 40 tests pass.

This pull request touches the release workflow and package.json, so the workflow runs on it, which is the point: the eight-row build matrix gets exercised here rather than on the tag, which is the one moment a release cannot be rebuilt quietly.

Part of DX3, tamnd/zu#169.

tamnd added 3 commits August 18, 2026 19:00
…at install time

An install of this package is a download of one file for the machine doing the installing. The root package carries the loader and no binary, each of the eight platforms has its own package holding exactly one addon, and npm picks the one that fits out of optionalDependencies by os, cpu and libc. No postinstall, no node-gyp, no compiler and no fetch from anywhere but the registry.

The platform list is the tier 1 rows of platforms.toml in tamnd/zu plus Windows on arm64, which is tier 2 there and cheap here because the runner exists. It lives once, in tools/platforms.mjs, and the release workflow builds it, the test suite holds package.json and the npm directories to it, and tools/packages.mjs checks that every row ended up with a binary in it before anything is published.

The glibc rows build inside manylinux_2_28 rather than on the runner, because a binary linked against the runner's own glibc loads where it was built and dies on the user's. The musl rows build inside Alpine through docker rather than as a job container, because GitHub runs the JavaScript half of an action inside the job's container and has no Alpine build of it for arm64.

The optional dependencies are not checked in. A package that has not been published yet resolves to nothing, so npm install records the range and no lock entry, and every npm ci after it fails on all eight. napi pre-publish writes the block from napi.targets at the version being released, and there is a test that keeps it out of the manifest for the reason it has to stay out.

tools/install.mjs is the check that reads nothing: it packs the two tarballs npm would fetch, installs them into an empty project outside the checkout, and runs a statement through what came out.
Rust's aarch64-unknown-linux-musl target asks for aarch64-linux-musl-gcc, which is the name a cross toolchain gives its compiler. Inside Alpine on an arm64 machine there is no cross toolchain and the compiler is called gcc, so the build got as far as linking the first proc macro and stopped. The x86_64 musl target asks for cc and found it, which is why seven of the eight rows were green.
The flag went into RUSTFLAGS, which cargo stops applying to host artifacts as soon as a target is named, and napi names one. The host artifacts were the proc macro build scripts, so the build died in the same place with the same message. A per target linker applies to every unit built for that triple, host ones included.
@tamnd
tamnd merged commit 172b223 into main Aug 18, 2026
18 checks passed
@tamnd
tamnd deleted the packaging branch August 18, 2026 12:22
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.

1 participant