release: prepare 0.3.0 and publish each package from its own PyPI environment - #6
Open
SOSOVSKI wants to merge 3 commits into
Open
release: prepare 0.3.0 and publish each package from its own PyPI environment#6SOSOVSKI wants to merge 3 commits into
SOSOVSKI wants to merge 3 commits into
Conversation
…ish action CHANGELOG.md carried `unreleased (release date set at publication)` and CITATION.cff carried a TODO for the two fields that were unknowable before the repository was published. Both are knowable now. Also pin `pypa/gh-action-pypi-publish` to v1.14.2. It was the only action in the repository still on a mutable reference (`@release/v1`), and it is the single highest-privilege step we have: it runs with `id-token: write` inside the `release` environment and can upload to PyPI under our name. Everything else was pinned exactly during release prep; this was missed because the job had never run. check_versions --tag v0.3.0 and check_public_tree both pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcdE6eeSDBh8xrgKCoGrkw
PyPI enforces uniqueness on the (owner, repository, workflow, environment) tuple for pending trusted publishers. All four packages ship from one repo through one workflow, so with a single shared `release` environment only the first of them could be registered — the other three are rejected. That makes the current single-job layout unable to claim the four project names at all. A job may declare exactly one environment, so the publish step has to fan out. Split the workflow into build → publish (matrix, one environment per package) → github-release, passing the verified distributions between jobs as an artifact so every package publishes the exact bytes that were tested rather than a rebuild. Two things fall out of the split that are worth keeping on their own merits: - Each package's OIDC token is now scoped to an environment that can publish that package and nothing else, instead of one credential able to push all four. - Permissions are per-job. `contents: write` belongs only to the release job and `id-token: write` only to the publish jobs; the top level drops to `contents: read`. `github-release` needs `always()`, because `publish` is skipped whenever PUBLISH_TO_PYPI is unset and a skipped dependency would otherwise skip it too. It still refuses to run after a failed upload. The per-package globs are checked against real output rather than assumed: `uv build` was run for all four packages and each prefix claims exactly its own wheel and sdist, with all eight distributions claimed exactly once and no cross-matching between `akms-` and `akms_*`. The isolate step asserts the count of 2 at release time so a glob that silently stops matching fails loudly instead of publishing an empty directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcdE6eeSDBh8xrgKCoGrkw
…n waves PyPI caps how many *pending* trusted publishers one account may hold, and the cap is lower than the four projects released here. Combined with the uniqueness constraint on (owner, repository, workflow, environment), that means the four names cannot all be pre-registered before the first release. They can be claimed in waves instead. A pending publisher converts into a normal publisher on first use, which frees its slot — so registering what fits, publishing, then registering the rest works, provided a second run does not choke on the distributions the first one already uploaded. `skip-existing` makes that safe. It also turns recovery from a partial upload, such as a network failure part-way through the matrix, into a re-run rather than a version bump. It cannot overwrite a release: PyPI rejects a duplicate filename whatever its contents, and this flag only swallows that rejection. The signal it costs — a loud failure when republishing an existing version — is already covered upstream by check_versions tying the tag to all four package versions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcdE6eeSDBh8xrgKCoGrkw
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.
Everything the tree needs before
v0.3.0can be tagged and published.Why the workflow had to change
Two separate PyPI constraints, both hit while registering the publishers.
1. One environment cannot serve four packages. PyPI enforces uniqueness on
the
(owner, repository, workflow, environment)tuple for pendingpublishers (warehouse#16920).
All four packages ship from one repository through one workflow, so with a
single shared
releaseenvironment only the first could be registered. A jobmay declare exactly one environment, so the publish step has to fan out:
The verified distributions pass between jobs as an artifact, so each package
publishes the exact bytes that were tested rather than a rebuild.
2. An account may hold only a few pending publishers at once — fewer than
the four projects here. So the four names cannot all be pre-registered before
the first release. They can be claimed in waves: a pending publisher converts
into a normal publisher on first use, which frees its slot.
skip-existingmakes the second run safe, so it does not choke on what the first already
uploaded. That flag cannot overwrite a release — PyPI rejects a duplicate
filename whatever its contents, and the flag only swallows that rejection.
Two things fall out of the split that are worth keeping regardless:
environment that can publish that package and nothing else, instead of one
credential able to push all four.
contents: writebelongs only togithub-releaseand
id-token: writeonly topublish; the top level drops tocontents: read.github-releasecarriesalways(), becausepublishis skipped wheneverPUBLISH_TO_PYPIis unset and a skipped dependency would otherwise skip ittoo. It still refuses to run after a failed upload — so a wave that leaves one
package unpublished will not cut a release claiming all four.
Also in this PR
CHANGELOG.md—## [0.3.0] — unreleased (release date set at publication)becomes
## [0.3.0] — 2026-08-31.CITATION.cff— fills the TODO withrepository-codeanddate-released.pypa/gh-action-pypi-publishpinned from@release/v1to@v1.14.2. It wasthe last action on a mutable reference and is the highest-privilege step we
have. Missed during release prep because the release job had never run.
Verification
The per-package globs are checked against real output, not assumed.
uv buildwas run for all four packages and each prefix claims exactly its own wheel and
sdist, with no cross-matching between
akms-andakms_*:The isolate step asserts that count of 2 at release time, so a glob that
silently stops matching fails loudly instead of publishing an empty directory.
Also verified:
Version audit passed: 0.3.0,Public-tree audit passed, andthe CFF parses with the right three fields.
Repository side, already applied
Four environments created, each restricted to
v*tags so no branch canmint a token that publishes to PyPI:
pypi-akms,pypi-akms-learn,pypi-akms-nodes-gen,pypi-akms-failure-memory. The now-unusedreleaseenvironment was deleted.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TcdE6eeSDBh8xrgKCoGrkw