Skip to content

Ship the CLI on npm, and prune the lock so --locked passes - #6

Merged
las7 merged 5 commits into
mainfrom
feat/npm-cli
Aug 14, 2026
Merged

Ship the CLI on npm, and prune the lock so --locked passes#6
las7 merged 5 commits into
mainfrom
feat/npm-cli

Conversation

@las7

@las7 las7 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

@reachpad/cli plus four platform packages (-darwin-arm64, -darwin-x64, -linux-x64, -linux-arm64), published from the same cli-v* tag as the GitHub release and carrying the same binaries.

The launcher (npm/cli/bin/reachpad.js) resolves the one platform package npm installed for the machine and execs it. There is no second implementation of any verb, so parity is by construction — same binary, same flags, same exit codes.

Why npm

Our release binaries are ad-hoc signed by the linker, not Developer ID signed and notarized. I checked the shipped 0.3.0 Mach-O: one CodeDirectory slot, identifier reachpad-c5850a1ca39ad196, no CMS blob. Ad-hoc is enough to execute on Apple silicon, which is why brew and the curl installer work. It is not enough for anything that stamps com.apple.quarantine — a browser download from the releases page, or the Homebrew cask we shipped until 2026-08-13. npm never sets that attribute.

Also: npx @reachpad/cli needs no install, and Node is already on every machine that runs coding agents.

The lock

assemble-cli-repo.sh copies the private monorepo's Cargo.lock verbatim onto a generated three-member Cargo.toml. The lock therefore describes a different workspace than the manifest, and cargo has to rewrite it. Consequences, both live on main today:

  • cargo test --workspace --locked — this repo's own greentree test check — fails on pristine main.
  • The committed lock is not the lock a release builds from: release.yml runs without --locked and re-resolves on the runner.

Pruned here: 532 to 265 packages. Every surviving (name, version) was already in the lock; nothing added, no version changed (the apparent digest/sha2 changes are duplicate majors losing the copy no public crate references). The companion fix in reachpad-infra prunes it in the sync script, and produces a byte-identical lock to this one.

Release workflow

  • New target x86_64-apple-darwin, cross-compiled on the same arm64 runner. Intel Macs previously had no binary at all — install.sh refused them and the formula rejected them on an arch requirement.
  • Developer ID sign + notarize step, which warns loudly and skips while the six secrets are absent, so a release can never quietly claim to be signed. See the comment in release.yml for the exact secrets.
  • npm publish job, OIDC Trusted Publishing with no token in the repo, same shape as Reachpad/reachpad-mcp. Idempotent (skips a version already on npm), platform packages first.

Tests

npm/test/packages.test.mjs, 16 tests, no network and no build. Two kinds:

  • The four lists are one list. release.yml's build matrix (parsed, not restated), npm/prepare.mjs's staging table, the launcher's lookup, and @reachpad/cli's optionalDependencies. A target added to the matrix and nowhere else publishes tarballs npm install can never reach.
  • The three things a launcher can silently break, each against a real child process: exit codes (reachpad run returns the guest's status), signals (a SIGTERMed guest must make the launcher die of SIGTERM, not exit 1), and stdio inheritance (a pipe would hand reachpad attach a non-tty).

Wired into both ci.yml and greentree.yaml.

Verified by hand

Staged the real 0.3.0 linux tarball, packed both tarballs, npm install -g into a prefix, and ran against the live control plane:

$ reachpad --version
reachpad 0.3.0
$ reachpad list
ws-429  scratch-3   paused   saved snap-610 (disk+mem)
0 running, 1 paused, 4 archived

npx reachpad and npm install --ignore-scripts both work — the reason the binary is an optional dependency rather than a postinstall download.

Not in this PR

The first publish of each of the five packages must be manual: npm Trusted Publishing can only be configured on a package that already exists. npm/README.md has the exact commands and the per-package settings.

las7 added 5 commits August 14, 2026 22:17
@reachpad/cli plus four platform packages, published from the same
cli-v* tag as the GitHub release and carrying the same binaries. The
launcher resolves the one platform package npm installed and execs it —
no second implementation of any verb, so there is nothing to keep in
parity.

npm is here because our binaries are ad-hoc signed, not notarized: any
download that stamps com.apple.quarantine (a browser download from the
releases page; the Homebrew cask we shipped until 2026-08-13) meets
Gatekeeper, and npm never sets that attribute. release.yml now also
builds x86_64-apple-darwin — Intel Macs previously had no binary at all
— and carries a Developer ID sign + notarize step that warns loudly and
skips while the six secrets are absent.

The lock is the other half. assemble-cli-repo.sh copied the private
monorepo's Cargo.lock verbatim onto a generated three-member manifest,
so cargo had to rewrite it: 'cargo test --workspace --locked' failed on
pristine main, which is this repo's own greentree gate, and the
committed lock was never the one a release built from. Pruned to 265
packages from 532, with no version changed and nothing added.

Greentree-Change-Id: b5b7dc593c8a5054d9076f415fd90957
A browser download from the releases page is the one install path that
stamps com.apple.quarantine, so it is the one that meets Gatekeeper
while the binaries are only ad-hoc signed. Someone who hits "Apple
could not verify reachpad is free of malware" with no explanation next
to the download concludes the binary is malware rather than unsigned.

The release body now leads with the three install commands that avoid
the tag, and gives the checksum-then-xattr pair for the tarball itself.

Greentree-Change-Id: a4f6e540b26185556c82353e2250920e
npm signs provenance on every OIDC publish and refuses the upload when
package.json does not name the repository it was built from — after the
tarball is built and the attestation is already in the transparency log.

The check covered the four platform packages and skipped @reachpad/cli,
which is the one published LAST: a missing field there fails with four
packages already public and a version npm will not accept again.

Greentree-Change-Id: cdd9dfd0b14b83eafd89b9970533937d
Trusted Publishing can only be configured on a package that already
exists, so the first version of all five must be pushed by hand — and
until then the publish dies with ENEEDAUTH next to a release that
otherwise succeeded, which reads as a credentials problem rather than a
setup step nobody has done yet.

The failure path now checks whether the package exists at all and, when
it does not, emits an annotation with the bootstrap commands and the
per-package trusted-publisher settings.

Greentree-Change-Id: 4128d9db73fdace724aba6623606bc16
Pruning the lock made it correct; nothing was using it. The release
build and CI both re-resolved on the runner, which means the committed
lock described nothing that was ever built, two builds of one tag could
differ, and a transitive dependency publishing a semver-compatible
release between them would be picked up with nobody deciding to.

The provenance claim at the top of release.yml — tag, readable source,
workflow run, SHA256SUMS — needs the source to determine the binary.
--locked is the step that makes that true rather than nearly true.

CI gets it too, so the lock is checked on every PR instead of first
discovered at release time, and so CI stops being green on trees the
repo's own greentree check refuses.

Verified: cargo build --release --locked -p reach succeeds and the
binary reports reachpad 0.3.0.

Greentree-Change-Id: 921271c61caa36784a769b292483d5a9
@las7
las7 merged commit b19427e into main Aug 14, 2026
2 checks passed
@las7
las7 deleted the feat/npm-cli branch August 14, 2026 23:37
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