Skip to content

release: prepare 0.3.0 and publish each package from its own PyPI environment - #6

Open
SOSOVSKI wants to merge 3 commits into
mainfrom
release/v0.3.0-prep
Open

release: prepare 0.3.0 and publish each package from its own PyPI environment#6
SOSOVSKI wants to merge 3 commits into
mainfrom
release/v0.3.0-prep

Conversation

@SOSOVSKI

@SOSOVSKI SOSOVSKI commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Everything the tree needs before v0.3.0 can 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 pending
publishers (warehouse#16920).
All four packages ship from one repository through one workflow, so with a
single shared release environment only the first could be registered. A job
may declare exactly one environment, so the publish step has to fan out:

build ──► publish (matrix: one environment per package) ──► github-release

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-existing
makes 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:

  • Least privilege per package. Each package's OIDC token is scoped to an
    environment that can publish that package and nothing else, instead of one
    credential able to push all four.
  • Per-job permissions. contents: write belongs only to github-release
    and id-token: write only to publish; the top level drops to
    contents: read.

github-release carries 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 — 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 with repository-code and date-released.
  • pypa/gh-action-pypi-publish pinned from @release/v1 to @v1.14.2. It was
    the 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 build
was run for all four packages and each prefix claims exactly its own wheel and
sdist, with no cross-matching between akms- and akms_*:

ok    akms (prefix 'akms-'): akms-0.3.0-py3-none-any.whl akms-0.3.0.tar.gz
ok    akms-learn (prefix 'akms_learn-'): akms_learn-0.3.0-py3-none-any.whl akms_learn-0.3.0.tar.gz
ok    akms-nodes-gen (prefix 'akms_nodes_gen-'): akms_nodes_gen-0.3.0-py3-none-any.whl akms_nodes_gen-0.3.0.tar.gz
ok    akms-failure-memory (prefix 'akms_failure_memory-'): akms_failure_memory-0.3.0-py3-none-any.whl akms_failure_memory-0.3.0.tar.gz

distributions built: 8 (expect 8 — every one claimed exactly once)

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, and
the CFF parses with the right three fields.

Repository side, already applied

Four environments created, each restricted to v* tags so no branch can
mint a token that publishes to PyPI: pypi-akms, pypi-akms-learn,
pypi-akms-nodes-gen, pypi-akms-failure-memory. The now-unused release
environment was deleted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TcdE6eeSDBh8xrgKCoGrkw

SOSOVSKI and others added 2 commits August 31, 2026 21:00
…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
@SOSOVSKI SOSOVSKI changed the title release: set the 0.3.0 release date, repository URL, and pin the publish action release: prepare 0.3.0 and publish each package from its own PyPI environment Aug 31, 2026
…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
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