Skip to content

Polyglot Rust library

This page is for libraries that publish three artifacts from one Rust core:

  • A plain crate to crates.io.
  • PyO3 wheels to PyPI via maturin, targeting multiple platforms.
  • A napi-rs package to npm, distributed as a per-platform family (@scope/pkg-<slug> × N + a top-level with optionalDependencies).

If that's your shape, putitoutthere covers the publishing layer. Read this page once before adopting — the fit is good but the responsibilities split is opinionated.

What piot covers

ResponsibilitypiotYour workflow
Decide which of the three packages need to ship on a given merge (cascade)
Topologically order the publishes (Rust crate before PyO3 wheel before npm)
Compute the next version from a commit trailer (release: patch|minor|major)
Per-registry OIDC trusted publishing (crates.io, PyPI, npm)
Skip-if-already-published idempotency (each handler GETs the registry first)
Publish a napi-rs family with synthesised per-platform packages + optionalDependencies top-level
Publish a bundled-cli family (per-platform binary packages + launcher)
Emit a per-target build matrix with the right runner per triple (object-form targets entries with {triple, runner})you declare the runner in config
Run maturin build --target …, napi build --target …, cargo build — the compilation itself
Attach .tar.xz / .tar.gz binary archives to the GitHub Release✅ (compose with cargo-dist)
Register the trusted-publisher policy on each registry (one-time, out-of-CI)
Diff declared trust-policy config against the workflow file + GITHUB_WORKFLOW_REF (catches the caller-filename-pin trap via doctor)✅ (when [package.trust_policy] is declared)

The publish-side plus the matrix + runner emission are piot's. The build-side — compiling the artifacts the matrix demands — is your workflow's.

Configuration shape

Three [[package]] entries, one per artifact. The Python and npm packages declare depends_on = ["my-crate"] so a change to the Rust core cascades all three:

toml
[putitoutthere]
version = 1

[[package]]
name = "my-crate"
kind = "crates"
path = "packages/rust"
paths = ["packages/rust/**", "Cargo.toml", "Cargo.lock"]
features = ["cli"]                         # cargo publish --features cli

[[package]]
name = "my-py"
kind = "pypi"
build = "maturin"
path = "packages/python"
paths = ["packages/python/**"]
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"]

[[package]]
name = "my-napi"
kind = "npm"
npm  = "my-lib"                            # published as @scope/my-lib
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"]

Workflow shape

putitoutthere init scaffolds release.yml with three jobs: plan → build → publish. For a polyglot shape, the build job is yours to fill in. A minimum sketch:

yaml
jobs:
  plan:
    # scaffolded by piot
    outputs:
      matrix: ${{ steps.plan.outputs.matrix }}

  build:
    needs: plan
    if: fromJSON(needs.plan.outputs.matrix).include != null
    strategy:
      matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
    runs-on: ${{ matrix.runner }}        # you set this per target
    steps:
      # ...install toolchain...
      - if: matrix.kind == 'pypi'
        run: maturin build --release --target ${{ matrix.target }}
      - if: matrix.kind == 'npm'
        run: napi build --release --target ${{ matrix.target }}
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.name }}-${{ matrix.target }}
          path: target/**/release/*

  publish:
    needs: build
    # scaffolded by piot

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 in putitoutthere.toml:

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

The emitted matrix rows carry a runner field the workflow reads as runs-on (see the build job above, or let the scaffolded release.yml wire it for you).

Publish job prerequisites

The scaffolded publish job checks out the repo, installs Node, and invokes the piot action. For this shape, it also needs:

  • Python + twine on PATH. The PyPI handler shells out to twine upload. Add actions/setup-python@v5 and pip install twine before the piot step. See runner prerequisites.
  • A git committer identity. piot cuts an annotated tag per package. On hosted runners, user.name / user.email are unset; configure github-actions[bot] before the piot step.
  • SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<PKG> when any PyPI package uses dynamic versioning (hatch-vcs / setuptools-scm). Maturin reads the version from Cargo.toml, so a maturin-only shape typically doesn't need this — but a mixed shape often does. See dynamic versions.

One-time prerequisites before your first release

  1. Register the trusted publisher on each of crates.io, PyPI, npm. See Authentication. All three pin the caller workflow filename in the JWT claim — if you rename release.yml, each registry's policy needs to be re-registered first or the publish fails with HTTP 400. Declare the expected workflow in [package.trust_policy] so doctor catches a mismatch before the publish tries.
  2. Delete any long-lived NPM_TOKEN / PYPI_API_TOKEN / CARGO_REGISTRY_TOKEN repo secrets once OIDC is working, so nothing can accidentally fall back.

Shipping a Rust CLI inside the PyPI wheel

A common pattern for this shape: stage a cargo build --bin … binary into the Python source tree before maturin build runs, so each wheel ships the binary as package data and a console_scripts entry points at it. Net result: pip install my-py on any supported platform gets my-cli on PATH — no Rust toolchain needed on the user's machine. ruff, uv, and pydantic-core all ship this way.

Declare it with [package.bundle_cli] on the pypi package:

toml
[[package]]
name = "my-py"
kind = "pypi"
build = "maturin"
path = "packages/python"
paths = ["packages/python/**", "crates/my-rust/**"]
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"]

[package.bundle_cli]
bin        = "my-cli"
stage_to   = "src/my_py/_binary"
crate_path = "crates/my-rust"

The scaffolded build job does the cross-compile + stage step before maturin runs, per target. Your pyproject.toml ties the staged binary into a console_scripts entry:

toml
# packages/python/pyproject.toml

[project.scripts]
my-cli = "my_py._binary:entrypoint"

[tool.maturin]
include = ["src/my_py/_binary/**"]  # ship the binary as package data

…with a small launcher in packages/python/src/my_py/_binary/__init__.py that os.execvs into the staged binary:

python
# packages/python/src/my_py/_binary/__init__.py
import os, sys
from pathlib import Path

def entrypoint():
    here = Path(__file__).parent
    binary = here / ("my-cli.exe" if os.name == "nt" else "my-cli")
    if not binary.exists():
        sys.stderr.write(f"my-cli binary not found at {binary}\n")
        sys.exit(1)
    os.execv(binary, [str(binary), *sys.argv[1:]])

Full field reference: Configuration → Bundled CLI.

Gotchas specific to this shape

  • Two tag schemes. piot tags each package independently as {name}-v{version} (e.g. my-crate-v0.3.1, my-py-v0.3.1). If your existing setup used a single shared v0.3.1 tag across all three, consumers reading tags (install scripts, docs, release pages) need to update.
  • crates.io is immutable. Once a version is published there, it cannot be yanked and re-used. piot deliberately does not delete tags after a publish failure; the completeness check runs before anything ships so partial-publish is rare, and when it happens the right move is to bump-and-republish rather than try to unpublish.
  • Dynamic versions in pyproject.toml. If any PyPI package uses [project].dynamic = ["version"] with hatch-vcs / setuptools-scm, piot skips the pyproject rewrite and the build backend derives the version from git. Without the env-var handoff, the sdist ends up named <pkg>-X.Y.Z.dev<N>.tar.gz instead of <pkg>-X.Y.Z.tar.gz. See dynamic versions.

Further reading

Released under the MIT License.