Skip to content

feat(release): publish leech and leech-core to PyPI on tag - #210

Merged
jayhesselberth merged 2 commits into
mainfrom
feat/pypi-release
Aug 24, 2026
Merged

feat(release): publish leech and leech-core to PyPI on tag#210
jayhesselberth merged 2 commits into
mainfrom
feat/pypi-release

Conversation

@jayhesselberth

@jayhesselberth jayhesselberth commented Aug 24, 2026

Copy link
Copy Markdown
Member

leech is public now, but a tag only produced a GitHub Release. Publishing was
the manual uv publish at the end of .claude/commands/release.md, and
leech-core was never published at all — so pip install "leech[rust]" could
not resolve for anyone outside the workspace.

This publishes both distributions on a v* tag via Trusted Publishing
(OIDC — no API token, no repo secret), following the shape of
escapepod-rs/.github/workflows/release.yml.

Workflow

Three new jobs alongside the existing four:

job why
check-version Fails before anything builds if the tag, pyproject.toml, and rust/Cargo.toml disagree. A PyPI upload cannot be replaced, so a wrong version reaching the index is permanent.
test Runs the suite at the tagged revision. Nothing did before — ci.yml triggers on pushes to main and on PRs, never on a tag.
publish-pypi-core / publish-pypi-leech Two jobs, not one, so a leech-core failure never blocks leech and each downloads an unambiguous artifact set. Both stay independent of the release job, so a PyPI outage does not cost the GitHub Release.

Permissions narrow from a blanket contents: write to contents: read, with
write on release only and id-token: write on the publish jobs only.

abi3

pyo3 gains abi3-py312 and --find-interpreter is dropped. maturin now emits
one cp312-abi3 wheel per target that loads on CPython 3.12 and every later
3.x, so a new CPython release no longer needs a new leech release to get a
wheel.

📦 Built wheel for abi3 Python ≥ 3.12 to leech_core-0.6.6-cp312-abi3-linux_x86_64.whl

The rust extra is now pinned

rust = ["leech-core==0.6.6"] rather than a bare leech-core. check_rust()
only warns on a mismatch, so for a PyPI install the extra is the only thing
that actually prevents pairing a current leech with a stale extension — the
hazard tests/test_rust_version_pairing.py exists to catch, and the one that
let leech_core sit at 0.3.0 across ten releases.

The version now lives in three files. A new test enforces all three agree, and
check-version enforces they agree with the tag. uv.lock is unaffected: uv
drops the specifier for workspace sources, so the pin appears only in the
published wheel.

Packaging metadata

rust/pyproject.toml carried a name and nothing else, which is a blank PyPI
page — it gains description, README, license, classifiers, and URLs, plus a new
rust/README.md and a rust/LICENSE copy (maturin resolves license-files
against the crate directory, so ../LICENSE is out of reach; escapepod-rs keeps
the same copy for the same reason). Root pyproject.toml moves to the PEP 639
license form and gains [project.urls].

Verification

  • Full suite 1019 passed, 27 skipped under the abi3 build, including all of
    test_backend_parity.py, test_parallel_prep.py, and test_prepare_dispatch.py.
  • twine check passes on all three artifacts (leech wheel + sdist, leech-core sdist).
  • Built leech wheel carries Requires-Dist: leech-core==0.6.6; extra == 'rust',
    License-Expression: MIT, License-File: LICENSE, and no [tool.uv.sources] leakage.
  • check-version shell logic exercised against both a matching and a mismatched tag.
  • ruff format/lint and ty check src/leech/ clean; uv lock --check current.

Before the first tag

Two pending publishers must be registered at
https://pypi.org/manage/account/publishing/ — both names are currently free.
Without them the publish jobs fail on OIDC.

field leech leech-core
Owner rnabioco rnabioco
Repository leech leech
Workflow release.yml release.yml
Environment pypi-leech pypi-leech-core

The environments must differ. PyPI identifies a publisher by
(owner, repo, workflow, environment) and enforces a unique constraint on
exactly that tuple — project_name is not part of it (warehouse
_pending_github_oidc_publisher_uc
).
Two packages released from one workflow under one environment name collide, and
registering the second fails with "A pending trusted publisher matching this
configuration has already been registered for a different project name."

That message is misleading: it is not a name conflict (a pending publisher never
reserves a name) and needs no admin. The environment is the only field left to
distinguish the two packages, so it carries the package name. The constraint
applies only to pending publishers, which are 1:1 with a project name — once a
project exists its publisher becomes a normal one that several projects may
share — so this is purely a bootstrapping constraint.

Worth tagging a -rc first as an end-to-end rehearsal: check_prerelease
already marks it a GitHub pre-release, and pip install leech will not pick up
a pre-release by default.

Known caveat

The leech-core sdist needs a Rust toolchain and network access to
github.com — escapepod-signal is fetched from git, not crates.io. That affects
anyone off manylinux x86_64/aarch64 (macOS, Windows, musl). It is a property of
the dependency rather than this change, so it is documented in the installation
page rather than worked around.

🤖 Generated with Claude Code

leech is public now, but a tag only produced a GitHub Release; publishing
was the manual `uv publish` at the end of .claude/commands/release.md, and
leech-core was never published at all -- so `pip install leech[rust]` could
not resolve for anyone outside the workspace.

Publish both distributions via Trusted Publishing (OIDC), following
escapepod-rs/.github/workflows/release.yml. Two publish jobs, not one, so a
leech-core failure never blocks leech and each downloads an unambiguous
artifact set; both stay independent of the `release` job so a PyPI outage
does not cost the GitHub Release.

Two new gates run before anything is built or uploaded. `check-version`
compares the tag against both declared versions -- a PyPI upload cannot be
replaced, so a wrong version reaching the index is permanent. `test` runs
the suite at the tagged revision, which nothing did before: ci.yml triggers
on pushes to main and on PRs, never on a tag.

Build leech_core against the stable ABI (pyo3 abi3-py312) and drop
--find-interpreter. maturin now emits one cp312-abi3 wheel per target that
loads on CPython 3.12 and every later 3.x, so a new CPython release no
longer needs a new leech release to get a wheel.

Pin the `rust` extra to `leech-core==<version>`. check_rust() only *warns*
on a mismatch, so for a PyPI install the extra is the only thing that
actually prevents pairing a current leech with a stale extension -- the
hazard test_rust_version_pairing.py exists to catch, and the one that let
leech_core sit at 0.3.0 across ten releases. The version now lives in three
files; the test enforces all three agree, and check-version enforces they
agree with the tag. uv.lock is unaffected: uv drops the specifier for
workspace sources, so the pin appears only in the published wheel.

rust/pyproject.toml carried a name and nothing else, which is a blank PyPI
page. Add the metadata, plus rust/README.md and a rust/LICENSE copy --
maturin resolves license-files against the crate directory, so ../LICENSE
is out of reach. Narrow the workflow's blanket `contents: write` to
read, with write on `release` and id-token on the publish jobs only.

Note in the install docs that the leech-core sdist needs a Rust toolchain
and network access to github.com: escapepod-signal is fetched from git, not
crates.io. That affects anyone off manylinux x86_64/aarch64.
PyPI identifies a trusted publisher by (owner, repo, workflow, environment)
and enforces a unique constraint on exactly that tuple; project_name is not
part of it. Both publish jobs used `environment: pypi`, so the two packages
shared one external identity and only the first pending publisher could be
registered -- the second fails with "A pending trusted publisher matching
this configuration has already been registered for a different project
name".

That message reads like a name conflict and points at PyPI's admins, but it
is neither: a pending publisher never reserves a name, and the collision is
with our own other package. The environment is the only field left to tell
the two apart, so it now carries the package name.

The constraint applies only to pending publishers, which are 1:1 with a
project name -- once a project exists its publisher becomes a normal one
that several projects may share. Distinct environments sidestep the
bootstrap entirely and give each package its own approval gate.
@jayhesselberth
jayhesselberth merged commit 21edf7d into main Aug 24, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the feat/pypi-release branch August 24, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant