OMEP-0009: Publishing & Distribution (crates.io + PyPI)¶
Amended 2026-08-02
The original decision chose T2 (tag-triggered releases). That has been reversed in favour of T3: a Release-PR model orchestrated by gh-ship. The authentication (A2), wheel matrix (W2) and library-only (C1) decisions are unchanged. Sections marked (amended) below carry the current behaviour.
Bootstrapped 2026-08-03
This OMEP is written from the pre-release state: statements that OxydeMark
is unpublished and that the repository carries no tags described the
situation at decision time. The pipeline has since run: 0.2.0, 0.2.1
and 0.2.2 are tagged and published on both crates.io and PyPI, and the
PyPI pending publisher has converted to a normal one.
Context and Problem Statement¶
OxydeMark is unpublished. Both of its consumable surfaces (the Rust rlib
crate and the Python package built by maturin, per OMEP-0008) exist only in the
repository. CI already builds wheels for a smoke-test (OMEP-0005), but there is
no defined path from a merged commit to artifacts that downstream projects --
notably OxydePress -- can cargo add or pip install.
We need a release pipeline that publishes the same version, in lockstep, to both crates.io and PyPI, covering:
- The crates.io publish flow for the
oxydemarkcrate. - The PyPI wheel/sdist matrix -- which platforms and Python versions we build binary wheels for, plus a source distribution.
- Authentication -- how the release job authenticates with each index.
- Versioning and release triggering -- how a version is chosen, tagged, and turned into a GitHub Release, consistent with the git-cliff changelog (OMEP-0003) and the 0.x semver policy (OMEP-0008).
- Whether v0.2.0 ships a CLI binary or remains library-only.
This OMEP defines that pipeline. It builds directly on the versioning contract
frozen in OMEP-0008 (lockstep 0.MINOR.PATCH, both manifests share one
version) and the CI foundations in OMEP-0005.
Decision Drivers¶
- Two indexes, one version -- crates.io and PyPI must always receive the same version at the same time; a release is atomic across ecosystems (OMEP-0008 lockstep versioning).
- Broad wheel coverage -- Python consumers should not need a Rust toolchain; binary wheels must cover the common platforms and supported Python versions (3.12+, per AGENTS.md).
- Secretless authentication -- Prefer OIDC-based Trusted Publishing over long-lived API tokens stored as repository secrets, to reduce credential leakage risk.
- Reproducible with existing tooling -- Reuse maturin, GitHub Actions, and git-cliff already adopted in prior OMEPs rather than adding new tools.
- Tag-driven and auditable -- Releases are triggered by a version tag and produce a GitHub Release whose notes match the changelog, so every published artifact is traceable to a commit.
- Minimal scope for v0.2.0 -- The first release should ship the surfaces we already commit to (OMEP-0008) without expanding the maintenance burden.
Considered Options¶
Publishing trigger & versioning¶
- Option T1: Manual publish -- Maintainer runs
cargo publishandmaturin publishlocally, ad hoc. - Option T2: Tag-triggered release workflow -- Pushing an
X.Y.Ztag runs a GitHub Actions workflow that builds, publishes to both indexes, and creates the GitHub Release. - Option T3: Release-PR model orchestrated by gh-ship (amended) -- Every
push to
mainrunsgh ship prepare, which dispatches a workflow that computes the next version, regenerates the changelog and opens a Release PR. Merging it runsgh ship release, which tags the merge commit, drafts the GitHub Release, dispatches the publish workflow, then makes the release visible.
Authentication¶
- Option A1: API tokens as GitHub secrets -- Store a crates.io token and a PyPI token as repository secrets.
- Option A2: Trusted Publishing (OIDC) -- Use PyPI Trusted Publishers and crates.io Trusted Publishing so the workflow mints short-lived credentials via GitHub's OIDC identity; no secrets stored.
Wheel matrix¶
- Option W1: Linux-only wheel + sdist -- Ship one manylinux wheel plus an sdist; other platforms build from source.
- Option W2: Full platform/Python matrix -- Build wheels for Linux (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x86_64) across every supported Python (3.12 and later), plus an sdist.
CLI in v0.2.0¶
- Option C1: Library-only -- Ship the crate and the Python package; no executable.
- Option C2: Ship an
oxydemarkCLI binary -- Add a[[bin]]/console script exposing a command-line converter in v0.2.0.
Decision Outcome¶
Chosen options: T3 (Release-PR model orchestrated by gh-ship), A2 (Trusted Publishing), W2 (full platform/Python matrix + sdist), and C1 (library-only for v0.2.0).
Together these define a release lifecycle in which gh ship owns the
orchestration (release branch, workflow dispatch, Release PR, tag, GitHub
Release) while this repository owns the content (version bump, changelog,
build, publish). Authentication is OIDC throughout; the full wheel matrix plus
an sdist and the crate are published on merge of the Release PR. v0.2.0 remains
library-only; a CLI is deferred.
Release trigger and versioning (amended)¶
The release is driven by three workflows plus .github/ship.yml:
| File | Role |
|---|---|
.github/workflows/ship.yml |
Orchestrator. Runs gh ship prepare on every push to main, and gh ship release when the Release PR merges. |
.github/workflows/prepare-release.yml |
Ours. Computes the version, regenerates CHANGELOG.md, bumps the manifests, emits the release artifact. |
.github/workflows/release.yml |
Ours. Publishes to crates.io and PyPI and attaches assets to the draft release. |
- No manual tagging.
gh ship releasecreates the tag on the merge commit of the Release PR -- never a remembered SHA, since a squash merge produces a new one. Tags remain unprefixedX.Y.Z, matchingcliff.toml'stag_pattern. - The version is derived, not chosen.
prepare-release.ymlrunsgit cliff --bumped-versionand compares the result against the last tag (git describe --tags --abbrev=0), not against the manifests -- the manifests already carry the version being prepared, so comparing to them would report "no change" for the very first release and make bootstrapping impossible. - Bootstrapping. The repository carries no tags, so git-cliff returns
[bump] initial_tagverbatim. That is set to0.2.0, not0.1.0, becauseoxydemark0.1.0 is already published on crates.io (a name reservation predating this pipeline) and crates.io refuses to overwrite a released version. - Lockstep bump.
prepare-release.ymlwrites the version intoCargo.toml,pyproject.toml,Cargo.lock(cargo update --workspace) anduv.lock(uv lock) in one commit,chore(release): X.Y.Z. The version gate inrelease.ymlstill verifies tag/manifest agreement -- it now catches a faulty prepare run rather than a bad manual tag. - Version numbers follow the 0.x policy from OMEP-0008: a breaking change to a
public surface bumps MINOR (
0.1.z -> 0.2.0); additive/fix changes bump PATCH.cliff.tomlsetsfeatures_always_bump_minorandbreaking_always_bump_major. - Notes are generated pre-merge.
git cliff --unreleased --bump --strip allproduces the notes, which travel in theship.release.jsonartifact through the Release PR body to the GitHub Release. What ships is therefore exactly what was reviewed.CHANGELOG.mdis never edited by hand (OMEP-0003). release.ymlmust keep its filename. The crates.io and PyPI Trusted Publishers are bound to the workflow filename; renaming it topublish-release.yml(gh-ship's template name) would make both registries reject the OIDC token..github/ship.ymltherefore declaresworkflows.publish: release.- Contract with gh-ship. Both dispatched workflows must declare
workflow_dispatch(aworkflow_call-only workflow cannot be started through the API) and must carry theship:${{ inputs.ship_id }}nonce in theirrun-name, which is how gh-ship correlates a dispatch to a run.
crates.io publish flow¶
- The crate is published with
cargo publishfrom a dedicated job. - Because the crate declares
crate-type = ["cdylib", "rlib"]for the PyO3 build, publishing to crates.io targets therlibconsumers; thecdyliboutput is irrelevant tocargo addusers and is simply ignored by them. cargo publish --dry-run(packaging + verification build) also runs in CI on pull requests so packaging problems surface before release.- Authentication uses crates.io Trusted Publishing (GitHub OIDC): the
workflow exchanges its OIDC token for a short-lived crates.io token; no
CARGO_REGISTRY_TOKENsecret is stored.
Publish ordering and idempotency (amended)¶
Publishing is irreversible on both indexes: neither permits replacing a released version, so a release that reaches one index and fails on the other cannot be repaired -- the version has to be abandoned in favour of the next patch. The pipeline is therefore ordered to make a partial release as unlikely as possible, and recoverable when it happens anyway.
- Nothing publishes until everything builds.
crates-iois gated onwheelsandsdistas well asversion, so a cross-compilation failure cannot strand a published crate against wheels that never existed. - The publishes are serialised, riskiest first.
crates-iois gated onpypi. PyPI is the riskier leg: its Trusted Publisher begins as a pending publisher against a project that does not exist yet, whereas crates.io publishes a new version of an existing crate through an already-bound publisher, with packaging proven bycargo publish --dry-runin the pull request CI. Running PyPI first means the likely failure happens while crates.io is still untouched and the version number is still free. - Both publishes are idempotent, because serialising them means recovering from a crates.io failure requires re-running the whole workflow:
- the PyPI upload sets
skip-existing: true, so re-uploading files the index already has is a no-op rather than an error; - the crates.io job first queries
GET /api/v1/crates/oxydemark/{version}and skips publishing on200. crates.io rejects requests without aUser-Agent, and an unexpected status fails the job loudly rather than guessing -- silently treating an error as "already published" would skip a real release. - The
assetsjob stays parallel to the publishes: gh-ship only undrafts the GitHub Release when the whole workflow succeeds, so a failed publish leaves a draft release carrying its artifacts, which is what you want for diagnosis.
PyPI wheel/sdist matrix¶
Built with maturin (via PyO3/maturin-action) and published to PyPI.
abi3 (the stable Python ABI) was adopted: the crate's extension-module
feature enables pyo3/abi3-py312, so maturin emits a single cp312-abi3 wheel
per target that covers every supported Python (3.12, 3.13, 3.14 and later).
The matrix therefore varies by target only:
| Runner | Target |
|---|---|
ubuntu-latest |
x86_64-unknown-linux-gnu (manylinux) |
ubuntu-latest |
aarch64-unknown-linux-gnu (manylinux, cross) |
macos-15 |
x86_64-apple-darwin (cross-compiled) |
macos-15 |
aarch64-apple-darwin |
windows-latest |
x86_64-pc-windows-msvc |
abi3-py312is gated onextension-module, not onpython, socargo test --features pythonkeeps the version-specific ABI itsauto-initializedev-dependency requires.- A source distribution (sdist) is also built (
maturin sdist) so that platforms outside the matrix can build from source given a Rust toolchain. - Each wheel is verified before upload (
.github/scripts/check_wheel.py): it must beabi3-tagged and must containpy.typedand_core.pyi(OMEP-0008). The sdist is installed and imported in a clean virtualenv, which guards theCargo.tomlincludeallow-list. - All wheels and the sdist are collected as workflow artifacts, then published
in a single final
pypijob withpypa/gh-action-pypi-publish. - Authentication uses PyPI Trusted Publishing (OIDC via
pypa/gh-action-pypi-publishor maturin's OIDC support); no PyPI API token secret is stored.
Trusted Publishing (amended)¶
- Both indexes are configured with the GitHub repository, the workflow
filename
release.yml, and thereleaseGitHub Environment as the Trusted Publisher. The filename is part of the OIDC claim, sorelease.ymlmay not be renamed and its publishing jobs must keepenvironment: release. - The trigger type is not part of the claim, so moving
release.ymlfromon: push: tagstoon: workflow_dispatch(dispatched by gh-ship) does not affect either publisher. oxydemarkdoes not yet exist on PyPI, so it is bootstrapped with a PyPI pending publisher: a Trusted Publisher registered against a project that does not exist, which reserves the name and converts to a normal publisher on the first upload. No manual placeholder release is needed.- The
releaseworkflow requestspermissions: id-token: writeso GitHub mints the OIDC token used to authenticate to both crates.io and PyPI. - Rationale: short-lived, scope-limited credentials eliminate the standing risk of leaked long-lived tokens and remove secret-rotation toil.
- gh-ship itself is not secretless:
ship.ymlandprepare-release.ymlneed aSHIP_TOKEN(GitHub App token or fine-grained PAT) in thereleaseenvironment, because a pull request authored by the defaultGITHUB_TOKENdoes not trigger workflows and the Release PR would show no CI results. It needs Contents, Actions, Pull requests and Issues read/write, plus Metadata read.
CLI in v0.2.0: library-only¶
v0.2.0 ships no CLI binary. OMEP-0008 froze the public surfaces as the
parse/transform/render functions, AstNode, OxydeEngine, and Plugin -- all
library entry points. A CLI would add a new, separately versioned surface
(argument grammar, output flags, exit codes) that we are not ready to commit to
this early. A future OMEP may introduce an oxydemark CLI (as a Cargo [[bin]]
and/or a Python [project.scripts] console entry point) once the library
surface has stabilised.
Consequences¶
- Good, because merging one reviewed pull request produces coherent, traceable releases across both ecosystems with release notes that match the changelog.
- Good, because the version bump and the release notes are reviewable before they exist as a tag: a mistake is a closed pull request, not a published version that neither registry lets you overwrite.
- Good, because Trusted Publishing removes all long-lived registry secrets from the repository.
- Good, because the full wheel matrix means Python consumers never need a Rust toolchain on the common platforms.
- Good, because reusing maturin, GitHub Actions, and git-cliff avoids new tooling in the content half; gh-ship is confined to orchestration and never parses a version or renders a changelog.
- Bad, because the wheel matrix (cross-compilation for aarch64, macOS, Windows) materially lengthens the release workflow and adds runner cost.
- Bad, because lockstep versioning can force a no-op bump on one ecosystem when only the other changed (already accepted in OMEP-0008).
- Bad, because gh-ship reintroduces one stored secret (
SHIP_TOKEN) after A2 had removed all of them, and adds a third-party dependency to the release path. - Bad, because serialising the two publishes to avoid a half-released version makes the release workflow strictly slower: crates.io now waits for the whole wheel matrix and the PyPI upload rather than publishing immediately.
- Neutral, because deferring the CLI keeps scope small now but leaves a frequently-requested convenience for later.
Confirmation¶
ls docs/specs/OMEP-0009-publishing.mdconfirms the OMEP exists..github/ship.yml,.github/workflows/ship.yml,.github/workflows/prepare-release.ymland.github/workflows/release.ymlall exist;gh ship validatereports the setup and both dispatched workflows as conformant.git cliff --bumped-versionprints0.2.0on a repository with no tags.gh ship previewdry-runs the preparation without mutating anything and reportschanged: true,version: 0.2.0,tag: 0.2.0.- On a pull request, CI runs
cargo publish --dry-runandmaturin buildso packaging regressions are caught before a release is prepared, and renders the release notes so acliff.tomlregression fails there rather than mid-release. - A version-consistency check fails the release if the tag,
Cargo.toml, andpyproject.tomlversions disagree. - After the first release,
cargo add oxydemarkandpip install oxydemarkresolve the sameX.Y.Z, and a matching non-draft GitHub Release exists with git-cliff-generated notes and the wheels plus sdist attached.
Pros and Cons of the Options¶
Trigger: T1 (manual) vs T2 (tag-triggered) vs T3 (Release PR)¶
- T1 -- Good, because zero workflow to maintain. Bad, because error-prone, non-reproducible, and easy to publish mismatched versions to the two indexes.
- T2 -- Good, because reproducible, auditable, and atomic across ecosystems. Bad, because the tag is the point of no return: the version bump, the changelog and the notes are only observable after the tag exists, and neither crates.io nor PyPI permits overwriting a published version. It also relies on the maintainer bumping four files by hand, in lockstep, correctly.
- T3 (Chosen) -- Good, because the Release PR makes the bump, the regenerated
changelog and the exact release notes reviewable before anything is
published, and the notes travel in a single artifact from the PR body to the
GitHub Release so what ships equals what was reviewed. Good, because tagging
becomes a consequence of merging rather than a manual act. Bad, because it
adds a third-party orchestrator and a stored
SHIP_TOKEN. Neutral, because the publish workflow is largely the T2 one with its trigger swapped.
Auth: A1 (tokens) vs A2 (Trusted Publishing)¶
- A1 -- Good, because simple and universally supported. Bad, because long-lived secrets must be stored and rotated and can leak.
- A2 (Chosen) -- Good, because secretless, short-lived, scope-limited credentials. Neutral, because it requires one-time Trusted Publisher setup on each index.
Matrix: W1 (Linux + sdist) vs W2 (full matrix + sdist)¶
- W1 -- Good, because fast and cheap builds. Bad, because macOS/Windows users must compile from source (Rust toolchain required), a poor first-run experience.
- W2 (Chosen) -- Good, because turnkey installs on all common platforms. Bad, because slower, costlier release builds and more moving parts.
CLI: C1 (library-only) vs C2 (ship CLI)¶
- C1 (Chosen) -- Good, because keeps the committed surface small and matches OMEP-0008. Neutral, because a CLI can be added later without breaking anything.
- C2 -- Good, because immediately useful from a shell. Bad, because it adds a new versioned interface to design, document, and support before the library has settled.
More Information¶
- crates.io Trusted Publishing.
- PyPI Trusted Publishers.
- maturin -- distribution & GitHub Actions.
PyO3/maturin-action.pypa/gh-action-pypi-publish.- Cargo -- publishing on crates.io.
- gh-ship and its workflow contract / release artifact spec.
- Related: OMEP-0003 (git-cliff release notes), OMEP-0005 (CI that already builds smoke-test wheels), OMEP-0008 (lockstep versioning and the frozen surfaces being distributed).
- Follow-up actions:
- ~~Add
.github/workflows/release.ymlimplementing the publish pipeline.~~ Done: version gate,crates-io,wheels,sdist,pypiandassetsjobs. - ~~Adopt gh-ship and add the Release-PR orchestration.~~ Done:
.github/ship.yml,ship.ymlandprepare-release.yml. - ~~Add
cargo publish --dry-runandmaturin build/maturin sdistchecks to the PR CI.~~ Done: thepackage,pythonandsdistjobs inci.yml. - Outstanding (manual, one-time):
- Register the PyPI pending publisher: project
oxydemark, repositorynoirbizarre/oxydemark, workflowrelease.yml, environmentrelease. - Confirm the crates.io Trusted Publisher is bound to
release.ymland environmentrelease. - Create
SHIP_TOKEN(GitHub App token preferred) and store it in thereleaseenvironment.
- Register the PyPI pending publisher: project
- Note: with maturin's default
sdist-generator = "cargo", the sdist file list is derived fromcargo package --list. Anyinclude/excludein[package]therefore constrains the PyPI sdist as well as the crates.io tarball;python/**andpyproject.tomlmust stay included. - Author a future OMEP for an
oxydemarkCLI if/when demand justifies it.