返回 Skill 列表
extension
分类: 开发与工程无需 API Key

Rust-CLI-NPM-Distribute

帮你把Rust写的CLI程序在Github Action中配置好交叉编译,并用npm来进行发布

person作者: ipconfigerhubModelScope

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.js downloads the platform-matching binary from the GitHub Release at install time.
  • bin/cli.js is a tiny launcher that spawns the downloaded binary with stdio: 'inherit' (so TUIs and servers work transparently).
  • Release workflow: cross-compile N platforms → upload binaries as GitHub Release assetsnpm publish --provenance via OIDC trusted publishing (no NPM_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 0o755 on 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)

  1. 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
    
  2. Configure Trusted Publisher on npm: https://www.npmjs.com/package/<pkg>/access → Trusted Publisher → GitHub Actions → owner <owner>, repo <repo>, workflow filename release-<name>.yml, allow publish. (Environment: blank.)
  3. 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 test green locally.
  • [ ] node scripts/pack-npm.mjs stages local binary; node dist/npm/<name>/bin/cli.js --help works.
  • [ ] npm pack --dry-run in dist/npm/<name> shows ONLY bin/cli.js + postinstall.js + package.json + README (binary excluded).
  • [ ] Workflow YAML parses; matrix has all targets; permissions has id-token: write + contents: write; no NPM_TOKEN anywhere.
  • [ ] Asset-name mapping unit-tested (postinstall assetName() for every platform/variant + unsupported→null).
  • [ ] After release: npm view <pkg> version shows it; GitHub Release has all platform binaries as assets.
  • [ ] Real end-to-end: npm install <pkg> in a temp dir → ./node_modules/.bin/<name> --version runs the downloaded binary.

Quick start (copy-paste order)

  1. Rust CLI compiles to binary <name>; [[bin]] name = "<name>" in Cargo.toml.
  2. Create dist/npm/<name>/{package.json, bin/cli.js, postinstall.js} per templates above.
  3. Create .github/workflows/release-<name>.yml per template; add scripts/pack-npm.mjs.
  4. .gitignore the staged binary: dist/npm/<name>/bin/<name>[.exe].
  5. One-time: cd dist/npm/<name> && npm publish --access public (OTP) → configure trusted publisher on npm.
  6. git tag v0.1.0 && git push origin v0.1.0 → CI does the rest.