feat(release): publish leech and leech-core to PyPI on tag - #210
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
leechis public now, but a tag only produced a GitHub Release. Publishing wasthe manual
uv publishat the end of.claude/commands/release.md, andleech-corewas never published at all — sopip install "leech[rust]"couldnot 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:
check-versionpyproject.toml, andrust/Cargo.tomldisagree. A PyPI upload cannot be replaced, so a wrong version reaching the index is permanent.testci.ymltriggers on pushes tomainand on PRs, never on a tag.publish-pypi-core/publish-pypi-leechleech-corefailure never blocksleechand each downloads an unambiguous artifact set. Both stay independent of thereleasejob, so a PyPI outage does not cost the GitHub Release.Permissions narrow from a blanket
contents: writetocontents: read, withwrite on
releaseonly andid-token: writeon the publish jobs only.abi3
pyo3gainsabi3-py312and--find-interpreteris dropped. maturin now emitsone
cp312-abi3wheel per target that loads on CPython 3.12 and every later3.x, so a new CPython release no longer needs a new
leechrelease to get awheel.
The
rustextra is now pinnedrust = ["leech-core==0.6.6"]rather than a bareleech-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
leechwith a stale extension — thehazard
tests/test_rust_version_pairing.pyexists to catch, and the one thatlet
leech_coresit at0.3.0across ten releases.The version now lives in three files. A new test enforces all three agree, and
check-versionenforces they agree with the tag.uv.lockis unaffected: uvdrops the specifier for workspace sources, so the pin appears only in the
published wheel.
Packaging metadata
rust/pyproject.tomlcarried a name and nothing else, which is a blank PyPIpage — it gains description, README, license, classifiers, and URLs, plus a new
rust/README.mdand arust/LICENSEcopy (maturin resolveslicense-filesagainst the crate directory, so
../LICENSEis out of reach; escapepod-rs keepsthe same copy for the same reason). Root
pyproject.tomlmoves to the PEP 639license form and gains
[project.urls].Verification
test_backend_parity.py,test_parallel_prep.py, andtest_prepare_dispatch.py.twine checkpasses on all three artifacts (leechwheel + sdist,leech-coresdist).leechwheel carriesRequires-Dist: leech-core==0.6.6; extra == 'rust',License-Expression: MIT,License-File: LICENSE, and no[tool.uv.sources]leakage.check-versionshell logic exercised against both a matching and a mismatched tag.ty check src/leech/clean;uv lock --checkcurrent.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.
leechleech-corernabiocornabiocoleechleechrelease.ymlrelease.ymlpypi-leechpypi-leech-coreThe environments must differ. PyPI identifies a publisher by
(owner, repo, workflow, environment)and enforces a unique constraint onexactly that tuple —
project_nameis 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
-rcfirst as an end-to-end rehearsal:check_prereleasealready marks it a GitHub pre-release, and
pip install leechwill not pick upa pre-release by default.
Known caveat
The
leech-coresdist needs a Rust toolchain and network access togithub.com —
escapepod-signalis fetched from git, not crates.io. That affectsanyone 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