Skip to content

npm: distribute binary via optional platform packages#261

Open
l-hellmann wants to merge 5 commits into
mainfrom
npm-optional-dependencies
Open

npm: distribute binary via optional platform packages#261
l-hellmann wants to merge 5 commits into
mainfrom
npm-optional-dependencies

Conversation

@l-hellmann
Copy link
Copy Markdown
Collaborator

@l-hellmann l-hellmann commented May 25, 2026

What Type of Change is this?

  • New Feature
  • Fix
  • Improvement
  • Release
  • Other

Description (required)

Reworks npm distribution from a postinstall-time download to per-platform packages selected via optionalDependencies + os/cpu fields (the pattern used by esbuild/swc/biome, described in Sentry's "Publishing binaries on npm").

Why: Bun (and other managers / hardened setups) block postinstall scripts by default, so the binary was never downloaded and zcli failed with You have not installed zcli-.... Simply moving the download to first-run (see #260) fixes Bun but regresses the existing install base: EACCES on global installs run by a non-root user, no longer self-contained Docker/CI images, network required at first run, and stale binaries on upgrade. Delivering the binary through a normal optional dependency sidesteps all of that — Bun honors os/cpu, and the binary arrives at install time exactly like before, with the package manager guaranteeing the version matches.

What changed:

  • Main package (tools/npm) is now a thin launcher: bin/zcli.js resolves the matching platform binary via require.resolve (lib/resolve.js + lib/platforms.js) and execs it, forwarding args and exit code. Removed the axios/tar/rimraf download machinery and the postinstall/preuninstall scripts.
  • scripts/build-platform-packages.mjs generates one publishable package per platform (os/cpu, version, binary in bin/) from the release binaries.
  • Release pipeline builds the platform packages and publishes them before the main package so its optionalDependencies resolve.
  • The npm packages ship the channel=npm binaries (the ones that make zcli upgrade defer to npm instead of self-updating), which goreleaser only emits inside the *-npm.tar.gz archives. The publish job downloads and extracts those rather than the raw channel=manual release assets. goreleaser itself is unchanged by this PR.
  • Added a post-publish smoke-test CI job: installs the just-published @zerops/zcli across linux / macOS (arm64 + amd64) / Windows via npm, plus Bun on linux/macOS, and runs zcli version. A broken release (missing platform package, os/cpu mismatch, Bun incompatibility, lost exec bit) fails CI immediately instead of surfacing in user reports.
  • Pre-release support: the publish + smoke-test jobs now also run on GitHub prereleased events, publishing under the next dist-tag instead of latest. npm i @zerops/zcli keeps getting the latest stable; a pre-release is installable via @zerops/zcli@next (and bun add @zerops/zcli@next) for testing without affecting existing users. Promote a tested pre-release to stable with a full GitHub release (or npm dist-tag add @zerops/zcli@<version> latest).

Notes / open questions:

  • No postinstall fallback (the Sentry post keeps one for managers run with --no-optional/--ignore-optional). Left out to stay clean and Bun-safe; we surface a clear error instead. Easy to add if wanted.
  • Relies on npm publish preserving the executable bit on the binary (same assumption esbuild makes; build script sets chmod 755). Verified: the packed tarball keeps -rwxr-xr-x.
  • The smoke-test job is a post-publish detector, not a pre-publish gate — the package is already live when it runs, so a failure alerts rather than prevents. A Verdaccio-based pre-publish gate is possible if we want one.
  • The Discord report job still fires only on full releases, not pre-releases.

Tested locally end-to-end with a fake binary set: build script output (os/cpu/version, Windows .exe), resolver, launcher arg/exit-code forwarding, and the missing-package error path. goreleaser check passes, and npm publish --dry-run / npm pack confirm the file list and exec bit.

Related issues & labels (optional)

Replace the postinstall download with per-platform npm packages selected
via optionalDependencies + os/cpu fields. Bun and other managers block
postinstall scripts, which left the binary undownloaded ("You have not
installed zcli-..."). Delivering the binary through a normal optional
dependency works under Bun (it honors os/cpu) and avoids the
runtime-download regressions: EACCES on global installs, broken offline
and Docker installs, and stale binaries on upgrade.

The main package becomes a thin launcher that resolves and execs the
matching platform binary. A build script generates the platform packages
from the raw release binaries, and the release pipeline publishes them
before the main package so the optional deps resolve on install.
After publishing, install @zerops/zcli at the released version on linux,
macOS (arm64 and amd64) and Windows via npm, plus Bun on linux/macOS, and
run `zcli version`. Catches a broken release - missing platform package,
os/cpu mismatch, Bun incompatibility, lost exec bit - immediately rather
than via user reports.
Run the npm publish + smoke-test jobs on prereleased events too, but tag
pre-releases as `next` instead of `latest`. `npm i @zerops/zcli` keeps
installing the latest stable; pre-releases are reachable via
`@zerops/zcli@next` for testing without affecting existing users.
main now stamps an install channel into the binary (raw=manual vs npm) so
`zcli upgrade` knows whether to self-update or defer to the package
manager. The npm packages must therefore ship the channel=npm binaries,
which goreleaser only emits inside the *-npm.tar.gz archives. Download and
extract those instead of the raw (channel=manual) release assets.
@l-hellmann l-hellmann force-pushed the npm-optional-dependencies branch from 3169148 to 12cd63f Compare May 25, 2026 16:19
npm publishes aren't transactional, so guarantee the consumer-facing
invariant instead: publish @zerops/zcli only after verifying all five
platform packages exist at the target version, so its optionalDependencies
can never resolve to a missing package. Platform publishes now skip
versions already on the registry, making a re-run after a partial failure
complete without bumping the version.
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