Skip to content

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 ​

ResponsibilitypiotYour 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.

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-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:

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: 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. Add actions/setup-python@v5 and pip install twine before 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 ​

  1. Register trusted publishers on crates.io and PyPI. See Authentication.
  2. Declare [package.trust_policy] on each [[package]] so doctor catches a rename mismatch before the publish tries.
  3. Delete long-lived CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN secrets 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 shared v0.3.1 across 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 / musllinux wheel naming. maturin builds manylinux wheels by default on Linux. If you need musllinux (Alpine-style), add --compatibility musllinux_1_2 to the maturin command and declare separate targets for the two libc variants if you need both. piot's target emitter appends a libc marker to the plan row for linux triples; your build job reads it and passes the right --compatibility flag.
  • Wheel that needs a CLI binary too. If you stage a cargo build --bin … binary into the Python source tree before maturin build so the wheel ships a console_scripts entry pointing at it, keep that staging step in your build job. piot doesn't have a pre-build hook for it.

Further reading ​

Released under the MIT License.