Rust + PyO3 wheels
This page is for projects that ship two artifacts from one Rust core:
- A plain crate to crates.io.
- PyO3 wheels to PyPI via
maturin, targeting multiple platforms.
No napi-rs, no top-level npm package. A very common shape for scientific Python libraries with a Rust hot path (polars, pydantic-core, tokenizers).
If that's your shape, putitoutthere covers the publishing layer. This page is the subset of Polyglot Rust library without the npm pieces.
What piot covers
| Responsibility | piot | Your workflow |
|---|---|---|
| Decide which of the two packages ship on a given merge (cascade) | ✅ | |
| Topologically order the publishes (crate before wheels) | ✅ | |
| Compute the next version from a commit trailer | ✅ | |
| Per-registry OIDC trusted publishing (crates.io, PyPI) | ✅ | |
| Skip-if-already-published idempotency | ✅ | |
| Emit a per-target build matrix with the right runner per triple | ✅ | |
Run maturin build --target … — the compilation itself | ✅ | |
| Install Python, Rust, maturin, and twine on runners | ✅ (runner prereqs) | |
| Register the trusted-publisher policy on each registry (one-time) | ✅ |
Configuration shape
Two [[package]] entries. The Python package declares depends_on = ["my-crate"] so a change to the Rust core cascades both publishes.
[putitoutthere]
version = 1
[[package]]
name = "my-crate"
kind = "crates"
path = "crates/my-crate"
paths = ["crates/my-crate/**", "Cargo.toml", "Cargo.lock"]
[[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"]Maturin reads the version from Cargo.toml, so piot's rewrite of [package].version in the crate's manifest is what flows into the wheel metadata. This shape almost never needs the dynamic-versions env-var handoff.
Workflow shape
putitoutthere init scaffolds release.yml with three jobs. For this shape, the build job runs maturin build once per target:
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: dtolnay/rust-toolchain@stable
if: matrix.kind == 'pypi'
with:
targets: ${{ matrix.target }}
- uses: actions/setup-python@v5
if: matrix.kind == 'pypi'
with: { python-version: '3.12' }
- name: Build wheel
if: matrix.kind == 'pypi'
run: |
pip install maturin
cd ${{ matrix.path }}
maturin build --release --target ${{ matrix.target }} --out dist
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_path }}Crates have no per-target build — cargo publish uploads source, so the matrix rows for the crates package don't need a build step. The if: matrix.kind == 'pypi' guard above keeps the build job idle on those rows.
Publish job prerequisites
- Python + twine on PATH. The PyPI handler shells out to
twine upload. Addactions/setup-python@v5andpip install twinebefore the piot step. - 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
- Register trusted publishers on crates.io and PyPI. See Authentication.
- Declare
[package.trust_policy]on each[[package]]sodoctorcatches a rename mismatch before the publish tries. - Delete long-lived
CARGO_REGISTRY_TOKEN/PYPI_API_TOKENsecrets once OIDC is working.
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.1across both, consumers reading tags need to update. - crates.io is immutable. Once a version is published there, it cannot be re-used. If the wheel build fails partway through a release, bump-and-republish; don't try to delete the crate version.
manylinux/musllinuxwheel naming. maturin buildsmanylinuxwheels by default on Linux. If you needmusllinux(Alpine-style), add--compatibility musllinux_1_2to the maturin command and declare separate targets for the two libc variants if you need both. piot's target emitter appends alibcmarker to the plan row for linux triples; your build job reads it and passes the right--compatibilityflag.- Wheel that needs a CLI binary too. If you stage a
cargo build --bin …binary into the Python source tree beforematurin buildso the wheel ships aconsole_scriptsentry pointing at it, keep that staging step in yourbuildjob. piot doesn't have a pre-build hook for it.
Further reading
- Polyglot Rust library — the superset with npm added.
- Rust + napi npm — the inverse: crate + npm, no PyPI.
- Dynamic versions — only needed if your wheel uses
hatch-vcs/setuptools-scminstead of maturin's default. - Runner prerequisites.
- Configuration reference.