Skip to content

Rust + napi npm

This page is for projects that ship two artifacts from one Rust core:

  • A plain crate to crates.io.
  • A napi-rs package to npm, distributed as a per-platform family ({name}-<triple> × N + a top-level with optionalDependencies).

No PyO3 wheels. A common shape for Node-native libraries with a Rust hot path (@napi-rs/canvas, lightningcss, @swc/core and friends).

If that's your shape, putitoutthere covers the publishing layer. This page is the subset of Polyglot Rust library without the PyPI pieces.

What piot covers

ResponsibilitypiotYour workflow
Decide which of the two packages ship on a given merge (cascade)
Topologically order the publishes (crate before napi family)
Compute the next version from a commit trailer
Per-registry OIDC trusted publishing (crates.io, npm)
Skip-if-already-published idempotency
Publish a napi family with synthesised per-platform packages + optionalDependencies top-level
Emit a per-target build matrix with the right runner per triple
Run napi build --target … — the compilation itself
Install Node, Rust, and napi-cli on runners✅ (runner prereqs)
Register the trusted-publisher policy on each registry (one-time)

See npm platform packages for the full detail on the family layout piot synthesises.

Configuration shape

Two [[package]] entries. The npm package declares depends_on = ["my-crate"] so a change to the Rust core cascades both publishes.

toml
[putitoutthere]
version = 1

[[package]]
name = "my-crate"
kind = "crates"
path = "crates/my-crate"
paths = ["crates/my-crate/**", "Cargo.toml", "Cargo.lock"]

[[package]]
name = "my-napi"
kind = "npm"
npm  = "@scope/my-lib"                     # top-level published name
build = "napi"
path = "packages/ts"
paths = ["packages/ts/**"]
targets = [
  "x86_64-unknown-linux-gnu",
  "aarch64-unknown-linux-gnu",
  "x86_64-apple-darwin",
  "aarch64-apple-darwin",
  "x86_64-pc-windows-msvc",
]
depends_on = ["my-crate"]

piot publishes @scope/my-lib-<triple> for every target, then rewrites @scope/my-lib's optionalDependencies to pin each one at the freshly-published version, and publishes the top-level last. At npm install @scope/my-lib, npm's resolver picks the single sub-package matching the user's os + cpu + libc.

Workflow shape

The build job runs napi build once per target:

yaml
build:
  needs: plan
  if: fromJSON(needs.plan.outputs.matrix || '[]')[0] != null
  strategy:
    fail-fast: false
    matrix:
      include: ${{ fromJSON(needs.plan.outputs.matrix) }}
  runs-on: ${{ matrix.runs_on }}
  steps:
    - uses: actions/checkout@v4
      with: { fetch-depth: 0 }
    - uses: actions/setup-node@v4
      if: matrix.kind == 'npm'
      with: { node-version: '20' }
    - uses: dtolnay/rust-toolchain@stable
      if: matrix.kind == 'npm'
      with:
        targets: ${{ matrix.target }}
    - name: Build napi addon
      if: matrix.kind == 'npm'
      run: |
        cd ${{ matrix.path }}
        npm ci
        npx napi build --release --target ${{ matrix.target }} --platform
    - uses: actions/upload-artifact@v4
      with:
        name: ${{ matrix.artifact_name }}
        path: ${{ matrix.artifact_path }}

Crates have no per-target build. The if: matrix.kind == 'npm' guard keeps the build job idle on crate rows.

Per-target runners

piot's planner maps each triple to a sensible default runner (ubuntu-24.04-arm for aarch64 Linux, macos-latest for Darwin, windows-latest for msvc, ubuntu-latest otherwise). To override per target, use object-form targets entries:

toml
targets = [
  "x86_64-unknown-linux-gnu",
  { triple = "aarch64-unknown-linux-gnu", runner = "ubuntu-24.04-arm" },
  { triple = "aarch64-apple-darwin",      runner = "macos-14" },
]

The emitted matrix rows carry a runner field the workflow reads as runs-on.

Publish job prerequisites

  • Node on PATH, with registry-url: https://registry.npmjs.org on actions/setup-node so provenance resolution works.
  • Rust toolchain on PATH. The crates handler shells out to cargo publish.
  • A git committer identity. piot cuts an annotated tag per package.

See runner prerequisites.

One-time prerequisites before your first release

  1. Register trusted publishers on crates.io and npm. See Authentication.
  2. Declare [package.trust_policy] on each [[package]].
  3. Delete long-lived CARGO_REGISTRY_TOKEN / NPM_TOKEN secrets once OIDC is working.

Gotchas specific to this shape

  • glibc vs. musl. Linux triples carry a libc marker on the plan row (glibc or musl). The per-platform package.json piot synthesises sets libc accordingly, so glibc consumers don't accidentally resolve the musl build. If you need both, declare both targets — x86_64-unknown-linux-gnu and x86_64-unknown-linux-musl — and build both in the matrix.
  • Unknown triples rejected at plan time. Targets that don't match a known OS + CPU pattern (e.g. riscv64-*, powerpc64le-*) are rejected by plan with an error. The old behaviour of silently synthesising a no-constraints per-platform package is gone; add support triple-by-triple as napi-rs gains them.
  • napi build without --platform ships a non-platform tarball. The --platform flag tells napi-cli to emit the {name}.{triple}.node filename piot expects in the artifact directory. Without it, the per-platform package's main won't find the addon.
  • Shipping a CLI binary alongside the napi addon. piot does not support mixing bundled-cli and napi under one top-level package. If that's your shape, see Dual-family npm for the split-package workaround.
  • Two tag schemes. piot tags each package independently as {name}-v{version}. If your existing setup used a single shared tag, consumers reading tags need to update.

Further reading

Released under the MIT License.