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 withoptionalDependencies).
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
| Responsibility | piot | Your 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:
[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:
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 piotpiot'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:
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. Addactions/setup-python@v5andpip install twinebefore the piot step. See runner prerequisites. - A git committer identity. piot cuts an annotated tag per package. On hosted runners,
user.name/user.emailare unset; configuregithub-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 fromCargo.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
- 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]sodoctorcatches a mismatch before the publish tries. - Delete any long-lived
NPM_TOKEN/PYPI_API_TOKEN/CARGO_REGISTRY_TOKENrepo 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:
[[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:
# 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:
# 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 sharedv0.3.1tag 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.gzinstead of<pkg>-X.Y.Z.tar.gz. See dynamic versions.
Further reading
- Concepts — plan/build/publish, cascade, idempotency.
- npm platform packages — the family pattern in detail.
- Authentication — trusted publisher setup.
- Runner prerequisites — twine, git identity, and other non-obvious runner needs.
- Dynamic versions — the env-var handoff for
hatch-vcs/setuptools-scm. - Configuration reference.
- Single-package Python library — the simpler shape if you don't need Rust or napi.