Distribute a Rust CLI via npm (cross-compile + OIDC)
Battle-tested flow for turning a Rust CLI into npm install -g <pkg> with GitHub Actions cross-compiling per-platform binaries. Use the single-package + postinstall-downloader + OIDC pattern — do NOT use per-platform npm subpackages, and do NOT rely on long-lived npm publish tokens (both are broken by npm's 2026-07-08 security change).
Architecture (the winning shape)
- One npm package
@scope/<name>(e.g.dist/npm/<name>/). The native binary is not in the tarball. postinstall.jsdownloads the platform-matching binary from the GitHub Release at install time.bin/cli.jsis a tiny launcher that spawns the downloaded binary withstdio: 'inherit'(so TUIs and servers work transparently).- Release workflow: cross-compile N platforms → upload binaries as GitHub Release assets →
npm publish --provenancevia OIDC trusted publishing (noNPM_TOKEN).
Why not per-platform subpackages (the old esbuild/biome optionalDependencies pattern)? Publishing 8 packages needs 8 OTPs/OIDC configs, and npm's 2026 security change killed token-based CI publish. One package + postinstall = 1 publish, works everywhere.
Project layout
dist/npm/<name>/
package.json # bin, scripts.postinstall, files (NO binary), repository.url=https github
bin/cli.js # launcher: spawn join(__dirname,'<name>'|'<name>.exe'), stdio inherit
postinstall.js # platform/arch -> asset name -> download from release asset URL
.github/workflows/release-<name>.yml
scripts/pack-npm.mjs # (dev) build current-host binary into dist/npm/<name>/ for local testing
package.json (essentials)
{
"name": "@scope/<name>",
"version": "0.1.0",
"bin": { "<name>": "bin/cli.js" },
"scripts": { "postinstall": "node postinstall.js" },
"files": ["bin/cli.js", "postinstall.js"], // binary EXCLUDED — postinstall fetches it
"engines": { "node": ">=18" },
"repository": { "type": "git", "url": "https://github.com/<owner>/<repo>" } // NO .git, NO git+https — provenance needs this
}
postinstall.js (zero-dep, CommonJS, Node 18+)
- Map
(process.platform, process.arch[, LLM_HUB_VARIANT=gnu])→ asset name. (Linux glibc variant via env var since Node can't detect libc; default Linux = musl static, which runs on glibc too.) - Read version from sibling
package.json. - Download
https://github.com/<owner>/<repo>/releases/download/v<version>/<asset>→bin/<name>[.exe], follow 3xx redirects,chmod 0o755on unix. - Skip if binary already exists (override with
<NAME>_FORCE_DOWNLOAD=1). - Exit 1 on error (visible install failure); silent on success.
bin/cli.js (launcher, CommonJS)
#!/usr/bin/env node
'use strict';
const { spawn } = require('node:child_process');
const { join } = require('node:path');
const binName = process.platform === 'win32' ? '<name>.exe' : '<name>';
const binPath = join(__dirname, binName); // binary sits next to cli.js in bin/
const { existsSync } = require('node:fs');
if (!existsSync(binPath)) { process.stderr.write('binary missing; reinstall or download from releases\n'); process.exit(127); }
const child = spawn(binPath, process.argv.slice(2), { stdio: 'inherit' });
child.on('error', e => { process.stderr.write(`launch failed: ${e.message}\n`); process.exit(1); });
child.on('close', c => process.exit(c ?? 1));
Release workflow .github/workflows/release-<name>.yml
on: { push: { tags: ['v*.*.*'] } }
permissions:
contents: write # create Release + upload assets
id-token: write # OIDC npm publish
jobs:
build:
strategy: { matrix: { include: [
# macOS: build BOTH on macos-14 (Apple Silicon). Do NOT use macos-13 — it queues forever.
{ runner: macos-14, target: aarch64-apple-darwin, asset: <name>-darwin-arm64, cross: false }
{ runner: macos-14, target: x86_64-apple-darwin, asset: <name>-darwin-x64, cross: false } # Apple toolchain cross-compiles arm64->x64 natively
# Linux: use `cross` (Docker/GHCR). zig/cargo-zigbuild mirrors 503 too often.
{ runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, asset: <name>-linux-x64, cross: true }
{ runner: ubuntu-22.04, target: aarch64-unknown-linux-musl, asset: <name>-linux-arm64, cross: true }
{ runner: ubuntu-22.04, target: aarch64-unknown-linux-gnu, asset: <name>-linux-arm64-gnu, cross: true } # optional glibc variant
{ runner: windows-2022, target: x86_64-pc-windows-msvc, asset: <name>-win32-x64.exe, cross: false }
] } }
env: { CARGO_TARGET_DIR: target } # don't depend on a global target dir
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable, with: { targets: ${{ matrix.target }} }
- if: matrix.cross, run: cargo install --locked cross
- run: (matrix.cross ? cross : cargo) build --release --target ${{ matrix.target }}
- if: runner.os != 'Windows', run: strip target/${{ matrix.target }}/release/<name>
- upload-artifact the binary named ${{ matrix.asset }}
release:
needs: build
steps:
- download all artifacts into assets/
- chmod +x unix binaries
- softprops/action-gh-release@v2 with: { files: assets/<name>-* } # creates Release + uploads binaries
- setup-node@v4 with: { node-version: '24' } # NO registry-url for OIDC
- run: npm install -g npm@latest # npm >=11.5.1 needed for trusted publishing
- run: npm version --no-git-tag-version "${GITHUB_REF_NAME#v}" # in dist/npm/<name>
- run: npm publish --access public --provenance # NO NODE_AUTH_TOKEN — OIDC via id-token:write
continue-on-error: true # first release: trusted publisher not configured yet; Release+binaries still created above
One-time npm setup (chicken-and-egg: the package must EXIST before you can configure trusted publishing)
- First publish locally with OTP (the package can't be created by OIDC because it doesn't exist yet):
cd dist/npm/<name> && npm login && npm publish --access public # enter OTP from authenticator - Configure Trusted Publisher on npm:
https://www.npmjs.com/package/<pkg>/access→ Trusted Publisher → GitHub Actions → owner<owner>, repo<repo>, workflow filenamerelease-<name>.yml, allow publish. (Environment: blank.) - Done. Future
git tag vX.Y.Z && git push origin vX.Y.Z→ CI builds + Release + OIDC auto-publish. No token, no OTP.
Release flow (after setup)
git tag v0.1.0 && git push origin v0.1.0
# CI: cross-compile -> GitHub Release w/ binaries -> OIDC npm publish (fully automated)
# users: npm install -g <pkg> (postinstall downloads the right binary)
⚠️ Pitfalls (all hit for real — with the fix)
| Pitfall | Symptom | Fix |
|---|---|---|
| npm 2026-07-08 security change | bypass-2fa granular tokens report EOTP/E403 "2FA or bypass token required"; disabling account 2FA does NOT help | Use OIDC trusted publishing (id-token: write + npm publish --provenance, no token). Token-based CI publish is dead for new accounts. |
| Package must exist before trusted publishing | OIDC publish 404s; can't configure trusted publisher on a non-existent package | One-time local npm publish (with OTP) to create the package, THEN configure trusted publisher. |
| npm pack "<bare-name>" | E404 — npm treats the bare name as a registry package | npm pack "./<bare-name>" (the ./ forces local folder). Avoid per-subpackage packing anyway in the single-package design. |
| mlugg/setup-zig zig mirror 503 | cross-compile jobs fail downloading Zig | Use cross (Docker images from GHCR) for linux musl/gnu targets. Reliable, no external mirror. |
| macos-13 Intel runner stuck "queued" | the darwin-x64 job never starts; blocks the whole release (publish waits for all build jobs) | Build x86_64-apple-darwin on macos-14 (Apple Silicon) — the Apple toolchain cross-compiles arm64→x64 natively. Never depend on macos-13. |
| npm publish --provenance fails | E422 / provenance error | Repo must be public; id-token: write set; package.json repository.url = https://github.com/... (not git+https://, no .git). |
| CARGO_TARGET_DIR global in dev | CI can't find the binary / caching broken | Set CARGO_TARGET_DIR: target explicitly in the CI job env. cross respects it. |
| 8-package subpackage design | 8× publish/OIDC/OTP friction; multiplies the auth pain | Single package + postinstall downloader. 1 publish, 1 trusted publisher. |
| Stale LSP errors during edits | "unresolved module" / "non-exhaustive match" that don't match reality | Trust cargo build/cargo check, not the LSP cache. |
| --access public missing on scoped package | scoped package publishes as private/restricted (or fails on free accounts) | Always npm publish --access public for scoped public packages. |
Verification checklist (run before declaring done)
- [ ]
cargo build+cargo testgreen locally. - [ ]
node scripts/pack-npm.mjsstages local binary;node dist/npm/<name>/bin/cli.js --helpworks. - [ ]
npm pack --dry-runindist/npm/<name>shows ONLYbin/cli.js+postinstall.js+package.json+README(binary excluded). - [ ] Workflow YAML parses; matrix has all targets;
permissionshasid-token: write+contents: write; noNPM_TOKENanywhere. - [ ] Asset-name mapping unit-tested (postinstall
assetName()for every platform/variant + unsupported→null). - [ ] After release:
npm view <pkg> versionshows it; GitHub Release has all platform binaries as assets. - [ ] Real end-to-end:
npm install <pkg>in a temp dir →./node_modules/.bin/<name> --versionruns the downloaded binary.
Quick start (copy-paste order)
- Rust CLI compiles to binary
<name>;[[bin]] name = "<name>"in Cargo.toml. - Create
dist/npm/<name>/{package.json, bin/cli.js, postinstall.js}per templates above. - Create
.github/workflows/release-<name>.ymlper template; addscripts/pack-npm.mjs. .gitignorethe staged binary:dist/npm/<name>/bin/<name>[.exe].- One-time:
cd dist/npm/<name> && npm publish --access public(OTP) → configure trusted publisher on npm. git tag v0.1.0 && git push origin v0.1.0→ CI does the rest.
微信扫一扫