Skip to main content

PyPI

Publish prebuilt binaries as native Python wheels on PyPI

Anodizer publishes your compiled binaries as native Python wheels, letting users install your CLI via pip install <name> (or pipx install <name>). Each built target becomes one py3-none-<platform> wheel carrying the prebuilt executable under the wheel's .data/scripts/ directory, so pip drops it straight onto the console-script PATH — the same layout maturin's bindings = "bin" mode emits, with no Python code and no compilation on the user's machine.

The platform tag is derived by inspecting each binary, never guessed:

Built targetInspectionWheel platform tag
x86_64-unknown-linux-gnu (dynamic)max GLIBC_* requirement in the ELF (e.g. 2.28)manylinux_2_28_x86_64
aarch64-unknown-linux-gnu (dynamic)max GLIBC_* requirement (e.g. 2.17)manylinux_2_17_aarch64
x86_64-unknown-linux-gnu (fully static)no PT_INTERP, no glibc dependencymanylinux_2_5_x86_64
aarch64-unknown-linux-gnu (fully static)no PT_INTERP, no glibc dependencymanylinux_2_17_aarch64
x86_64-unknown-linux-muslnone needed (static musl)musllinux_1_2_x86_64
aarch64-unknown-linux-muslnone neededmusllinux_1_2_aarch64
x86_64-apple-darwinMach-O deployment target (LC_BUILD_VERSION, e.g. 10.13)macosx_10_13_x86_64
aarch64-apple-darwinMach-O deployment target (e.g. 11.0)macosx_11_0_arm64
universal (fat) darwin binarymax deployment target across slicesmacosx_11_0_universal2
x86_64-pc-windows-msvcwin_amd64
i686-pc-windows-msvcwin32
aarch64-pc-windows-msvcwin_arm64

Because the manylinux tag comes from the binary's real glibc floor, a wheel never claims broader compatibility than the executable actually has.

Fully-static gnu binaries (anodizer's default linux build — no PT_INTERP, no dynamic loader, no glibc dependency) legitimately declare no GLIBC_* requirement, so they cannot derive a glibc floor. They run on any glibc host, so anodizer tags them at the arch's lowest recognized manylinux profile: manylinux_2_5 (the manylinux1 baseline) for x86_64/i686, and manylinux_2_17 (manylinux2014, the first profile to include aarch64) for aarch64. A dynamically linked gnu binary that declares no glibc requirement is still a hard error — that means the wrong binary (or one with a stripped version-needed table) landed under that target. Likewise a darwin-target artifact that is not a Mach-O object is a hard error (the Mach-O analogue of the missing-glibc case). When a Mach-O carries no version load command, the tag falls back to 10_12 (x86_64) / 11_0 (arm64 and universal). macOS 11+ deployment targets always tag macosx_<major>_0 (e.g. an 11.2 minos wheel tags macosx_11_0), matching what pip/packaging enumerate. A binary whose only glibc requirement is the ancient x86_64 baseline (GLIBC_2.2.5) floors to manylinux_2_5 rather than the unrecognized manylinux_2_2.

One binary per platform per entry

A wheel filename carries the project name, so two binaries that resolve to the same platform tag would collide on one identical .whl — the second silently overwriting the first (or, on the index, being rejected as a duplicate). This happens in a multi-binary workspace where more than one crate builds the same target triple. Give each pypis[] entry its own ids: so it selects exactly one binary per platform:

pypis:
  - name: tool-a
    ids: [crate-a]        # crate-a's binaries only
  - name: tool-b
    ids: [crate-b]        # crate-b's binaries only

anodizer preflight warns when the selected crates would build the same triple more than once, and the publish itself hard-errors on an actual duplicate platform tag.

Publishing a subset of targets

targets: restricts this entry to a subset of the built target triples — only binaries whose triple is listed become wheels; the rest are silently skipped. It is orthogonal to ids: (both filters apply). Besides trimming what ships, it resolves a same-platform-tag collision without splitting into separate entries: x86_64-pc-windows-gnu and x86_64-pc-windows-msvc both tag win_amd64, so building both would collide on one .whl — list only the one you publish:

pypis:
  - name: git-cliff
    targets:
      - x86_64-unknown-linux-gnu
      - aarch64-unknown-linux-gnu
      - x86_64-pc-windows-msvc      # gnu-windows omitted — no win_amd64 collision
      - x86_64-apple-darwin
      - aarch64-apple-darwin

The collision preflight honours the allowlist (a triple filtered out cannot collide), and a listed triple that no selected build produces is a config error naming the offending triple.

Classification

GroupRequired (default)RollbackToken
Managertruenone — one-way doorPYPI_TOKEN / MATURIN_PYPI_TOKEN

PyPI uploads are a one-way door. A published filename can never be re-uploaded — even after deleting the file or the release — so there is no programmatic rollback (rollback is warn-only). Like cargo, a failed release is fixed forward to the next version. Re-runs of an already-published tag are safe: skip_existing (default true) folds the index's "file already exists" rejection into an idempotent skip.

Quick start

pypis:
  - requires_python: ">=3.7"

Run with PYPI_TOKEN=pypi-... exported. Everything else is derived: the project name falls back to the crate name, summary/homepage/license fall back to the project metadata (and the crate's Cargo.toml), the wheel version is the release version mapped to PEP 440, and each built binary contributes one wheel with an inspected platform tag.

$ anodizer release
  • processing pypi project 'pypis[0]'
  • built wheel my_tool-1.2.3-py3-none-manylinux_2_28_x86_64.whl (manylinux_2_28_x86_64)
  • built wheel my_tool-1.2.3-py3-none-macosx_11_0_arm64.whl (macosx_11_0_arm64)
  • uploaded my_tool-1.2.3-py3-none-manylinux_2_28_x86_64.whl → https://upload.pypi.org/legacy/
  • uploaded my_tool-1.2.3-py3-none-macosx_11_0_arm64.whl → https://upload.pypi.org/legacy/
  • pypi publish complete for 'my-tool' (2 file(s))

Configuration

pypis:
  - id: main                              # CLI selector (--id=main)
    ids: [my-tool]                        # only this crate's binaries
    name: my-tool                         # PyPI project name (default: crate name)
    sdist: true                           # also `maturin sdist` (default: false)
    sdist_manifest: "pypi/"               # dir containing pyproject.toml (required with sdist)
    repository: "https://upload.pypi.org/legacy/"  # default; templated
    skip_existing: true                   # default; duplicate file ⇒ idempotent skip
    requires_python: ">=3.7"              # METADATA Requires-Python
    summary: "A demo CLI"                 # default: metadata.description
    description: |                        # long METADATA body (default: summary)
      Renders on the PyPI project page.
    homepage: "https://example.com"       # default: metadata.homepage
    license: MIT                          # default: metadata.license
    keywords: [cli, rust]
    classifiers:
      - "Programming Language :: Rust"
      - "Environment :: Console"
    token: "{{ .Env.MY_PYPI_TOKEN }}"     # default: $PYPI_TOKEN, then $MATURIN_PYPI_TOKEN
    skip: false                           # bool or template
    if: "{{ not .IsNightly }}"            # template-conditional gate
    required: true                        # default; failure aborts the release
    retain_on_rollback: false             # default
FieldDefaultPurpose
idCLI selector for --id=...
idsall cratesOnly include binaries built from these crates
targetsall builtTarget-triple allowlist: build wheels only for these triples. See Publishing a subset of targets
namecrate namePyPI project name; any PEP 508 form (My.Tool, my_tool) — PyPI normalizes per PEP 503, wheel filenames escape per PEP 427
sdistfalseAlso build + upload a source distribution via maturin sdist
sdist_manifestTemplated. Directory containing pyproject.toml; required when sdist: true
repositoryhttps://upload.pypi.org/legacy/Templated upload endpoint
skip_existingtrueTreat the index's already-exists rejection as an idempotent skip
requires_pythonRequires-Python specifier (pip honors it during resolution)
summarymetadata.descriptionOne-line METADATA Summary
descriptionfalls back to summaryLong description (the PyPI project page body)
homepagemetadata.homepageEmitted as Project-URL: Homepage
licensemetadata.licenseMETADATA License
keywordsComma-joined METADATA Keywords
classifiersOne Classifier: header each
token$PYPI_TOKEN$MATURIN_PYPI_TOKENTemplated API token
skip / ifEntry gating (bool/template; falsy if skips)
requiredtrueWhether failure fails the release
retain_on_rollbackfalseLeave work in place on rollback

Versions: semver → PEP 440

PyPI only accepts PEP 440 versions, so the release's semver version is mapped — the same mapping maturin applies, so a project migrating from maturin-built wheels keeps identical versions on the index:

semverPEP 440
1.2.31.2.3
1.2.3-alpha.4 / -alpha4 / -a.4 / -a41.2.3a4
1.2.3-beta.4 / -beta4 / -b.4 / -b41.2.3b4
1.2.3-rc.1 / -rc1 / -c.1 / -pre.1 / -preview.11.2.3rc1
1.2.3-rc (bare label, no number)1.2.3rc0
1.2.3-dev.9 / -dev91.2.3.dev9
1.2.3+build.71.2.3+build.7 (local segment)

The label is matched case-sensitively against the supported set, with both dotted (-rc.1) and suffix (-rc1) number forms accepted:

semver label(s)PEP 440 segment
alpha, aa
beta, bb
rc, c, pre, previewrc
dev.dev

A bare label with no number defaults the number to 0 (1.2.3-rc1.2.3rc0). A prerelease whose label is outside this set (e.g. -nightly.20260712) has no faithful PEP 440 equivalent and is an error, not a silent rename — uploading a version pip would order differently than cargo does is worse than failing.

Source distributions (sdist)

Anodizer never synthesizes a pyproject.toml — sdist consumers build from source, so the project must own a real maturin manifest:

# pypi/pyproject.toml
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"

[project]
name = "my-tool"

[tool.maturin]
bindings = "bin"
manifest-path = "../Cargo.toml"
pypis:
  - sdist: true
    sdist_manifest: "pypi/"

With sdist: true, maturin must be on PATH (surfaced by anodizer preflight / anodizer tools only when enabled) and the produced tarball uploads alongside the wheels with filetype: sdist. SOURCE_DATE_EPOCH is pinned from the run context so the tarball is reproducible.

Authentication

The auth field selects between a stored API token and PyPI Trusted Publishing (GitHub Actions OIDC):

authBehaviour
auto (default)A token when one is available, otherwise a Trusted-Publishing exchange when an OIDC context is present. Errors only when neither exists.
tokenAlways the token; never OIDC.
oidcAlways Trusted Publishing; never fall back to a token. Errors loudly if the OIDC request env is absent.

Token

HTTP Basic auth with the literal username __token__ — a PyPI API token:

  1. pypis[].token (templated) when set;
  2. $PYPI_TOKEN;
  3. $MATURIN_PYPI_TOKEN (so a project migrating from maturin publish keeps its existing secret name).

Trusted Publishing (OIDC)

No stored secret. anodizer requests a GitHub Actions id-token (audience pypi) and exchanges it at the index's /_/oidc/mint-token endpoint for a short-lived upload token. Requires id-token: write on the release job and a Trusted Publisher (or a pending publisher, for a brand-new project) configured on PyPI for this repository and workflow. Supported for pypi.org and test.pypi.org only — a custom index has no mint endpoint.

pypis:
  - name: my-tool
    auth: oidc          # no PYPI_TOKEN secret needed

To rehearse against TestPyPI (token):

pypis:
  - repository: "https://test.pypi.org/legacy/"
    token: "{{ .Env.TEST_PYPI_TOKEN }}"

Re-runs and the one-way door

The upload API rejects any filename the index has ever seen. anodizer's semantics around that:

  • skip_existing: true (default) — a duplicate rejection (400 File already exists / 409 / 403 re-upload text) logs skipped '<file>' — already on <repository> (idempotent) and continues. Re-publishing a tag after a partial failure uploads only the missing files.
  • skip_existing: false — a duplicate is a hard error naming the fix (bump the version).
  • Changed bytes, same version — impossible to ship. A changed file with a published name can never replace the original; fix forward to the next version.
  • Preflightanodizer preflight probes the index (https://pypi.org/pypi/<name>/<version>/json, or the custom repository's PEP 503 /simple/<name>/ page) and warns when the version already appears, mirroring the run path's idempotent handling.

Wheel contents

For a project my-tool at 1.2.3 targeting x86_64-unknown-linux-gnu (glibc floor 2.28):

my_tool-1.2.3-py3-none-manylinux_2_28_x86_64.whl
├── my_tool-1.2.3.data/scripts/my-tool        (the prebuilt binary, mode 0755)
└── my_tool-1.2.3.dist-info/
    ├── METADATA                              (Metadata-Version 2.1)
    ├── WHEEL                                 (Root-Is-Purelib: false, Tag: py3-none-…)
    └── RECORD                                (per-file sha256 + size)

Wheel bytes are deterministic: entries are written in sorted order, deflate-compressed, with every mtime pinned to the commit timestamp (or SOURCE_DATE_EPOCH), so two builds of the same commit are byte-identical.

Nightlies

The PyPI publisher skips nightly runs: every nightly upload would permanently consume a version/filename on an index users resolve against. See Nightlies.