From c95317fc715eee211b7665d258c4db9f55ee235c Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 23 Aug 2026 02:18:29 +0200 Subject: [PATCH 1/3] fix(release): support Metadata 2.5 publishing --- .github/workflows/publish-pypi.yml | 48 ++++++++++++++++++------- .github/workflows/publish-testpypi.yml | 2 +- docs/team-workflow/release-checklist.md | 15 ++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9040fdd..f78df68 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,20 +1,28 @@ name: Publish to PyPI -# Production PyPI release via Trusted Publishing (OIDC). Triggered ONLY by a -# published GitHub Release. NO API tokens, NO username/password: the publish -# job mints a short-lived OIDC identity token (id-token: write) that PyPI -# verifies against a pre-registered trusted publisher. +# Production PyPI release via Trusted Publishing (OIDC). Normally triggered by +# a published GitHub Release; workflow_dispatch is a guarded recovery path for +# retrying an already-published, non-prerelease tag after a publisher-side +# failure. NO API tokens, NO username/password: the publish job mints a +# short-lived OIDC identity token (id-token: write) that PyPI verifies against +# a pre-registered trusted publisher. # -# Why a plain push to main cannot publish: the trigger is `release: published`, -# not push. A fork/PR cannot publish either — release events run only in the -# base repo, and the `pypi` environment (repo-side protection: required -# reviewers + tag rules) gates the publish job independently of who triggered. +# Why a plain push to main cannot publish: there is no push trigger. A fork/PR +# cannot publish either. Manual recovery must be dispatched from main, names an +# existing published production release, repeats all artifact guards, and still +# crosses the `pypi` environment reviewer/tag protections. # # Third-party actions are pinned to full commit SHAs (supply-chain hardening); # the trailing comment records the human-readable version. on: release: types: [published] + workflow_dispatch: + inputs: + tag: + description: "Existing published release tag to rebuild and publish (vMAJOR.MINOR.PATCH)" + required: true + type: string # Workflow-level default: read-only. Each job narrows further below. permissions: @@ -25,14 +33,15 @@ permissions: # cancel-in-progress: false — a publish already in flight is NEVER killed # mid-upload; the second run queues and fails cleanly on the duplicate instead. concurrency: - group: publish-pypi-${{ github.event.release.tag_name }} + group: publish-pypi-${{ github.event.release.tag_name || inputs.tag }} cancel-in-progress: false jobs: build: name: Build & verify release # A published prerelease must never enter the production artifact path. - if: ${{ github.event.release.prerelease == false }} + # Manual recovery is checked against the Releases API in the tag guard. + if: ${{ github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -43,7 +52,7 @@ jobs: # job never pushes, so persisting GITHUB_TOKEN is needless surface. - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.event.release.tag_name || inputs.tag }} fetch-depth: 0 persist-credentials: false @@ -54,7 +63,8 @@ jobs: - name: Guard release tag shell: bash env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} run: | tag="${RELEASE_TAG}" echo "release tag: ${tag}" @@ -72,6 +82,18 @@ jobs: echo "::error::tagged commit is not reachable from origin/main" exit 1 fi + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "::error::manual recovery must be dispatched from main" + exit 1 + fi + draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq '.draft')" + prerelease="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq '.prerelease')" + if [ "${draft}" != "false" ] || [ "${prerelease}" != "false" ]; then + echo "::error::manual recovery requires an existing published, non-prerelease GitHub Release" + exit 1 + fi + fi echo "guards passed: ${tag} == v${version}, ancestor of origin/main" - name: Install uv @@ -129,4 +151,4 @@ jobs: # attestations generated + uploaded with the distributions); left at # default intentionally. - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 06a4f7a..6f2902e 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -108,7 +108,7 @@ jobs: # attestations generated + uploaded with the distributions); left at # default intentionally. - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ # Every TestPyPI publish attempt uses a unique package version. A retry diff --git a/docs/team-workflow/release-checklist.md b/docs/team-workflow/release-checklist.md index a2717f9..4cc65c2 100644 --- a/docs/team-workflow/release-checklist.md +++ b/docs/team-workflow/release-checklist.md @@ -45,3 +45,18 @@ reviewable history. - [ ] If anything above is uncertain, it was treated as private and excluded. - [ ] Version number and release notes are correct and final. - [ ] A fresh clone of the release builds and runs from scratch. + +## PyPI publication and recovery + +- [ ] Publish production artifacts by publishing the GitHub Release. The + release event rebuilds the tag, runs `make release-verify`, and pauses at + the protected `pypi` environment before Trusted Publishing. +- [ ] If artifact verification passed but the publisher failed before upload, + fix the workflow on `main` and use the manual **Publish to PyPI** recovery + input with the existing release tag. The recovery path accepts only a + strict semver tag that matches `pyproject.toml`, is reachable from + `origin/main`, and already has a published, non-prerelease GitHub Release. +- [ ] Never move, delete, or reuse a public release tag to retry publication. +- [ ] After workflow success, verify the version and file digests through the + PyPI JSON API, then install the exact version from PyPI in a clean + environment and run the CLI smoke checks. From 850cd26a5d881c4fb6d494f0b6c9a7cd2a68c440 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 23 Aug 2026 02:34:23 +0200 Subject: [PATCH 2/3] fix(release): harden manual recovery ref --- .github/workflows/publish-pypi.yml | 38 +++++++++++++++++++------ docs/team-workflow/release-checklist.md | 15 +++++++--- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f78df68..26eaeaa 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -8,9 +8,11 @@ name: Publish to PyPI # a pre-registered trusted publisher. # # Why a plain push to main cannot publish: there is no push trigger. A fork/PR -# cannot publish either. Manual recovery must be dispatched from main, names an -# existing published production release, repeats all artifact guards, and still -# crosses the `pypi` environment reviewer/tag protections. +# cannot publish either. Manual recovery names an existing published production +# release and must be dispatched from an immutable `-pypi-recovery-N` +# tag cut from a reviewed main commit. That keeps the `pypi` environment's +# reviewer + `v*` tag protections effective while the workflow checks out and +# rebuilds the original release tag. # # Third-party actions are pinned to full commit SHAs (supply-chain hardening); # the trailing comment records the human-readable version. @@ -33,7 +35,7 @@ permissions: # cancel-in-progress: false — a publish already in flight is NEVER killed # mid-upload; the second run queues and fails cleanly on the duplicate instead. concurrency: - group: publish-pypi-${{ github.event.release.tag_name || inputs.tag }} + group: publish-pypi-${{ github.event.release.tag_name || github.event.inputs.tag }} cancel-in-progress: false jobs: @@ -52,7 +54,7 @@ jobs: # job never pushes, so persisting GITHUB_TOKEN is needless surface. - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ format('refs/tags/{0}', github.event.release.tag_name || github.event.inputs.tag) }} fetch-depth: 0 persist-credentials: false @@ -64,7 +66,8 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | tag="${RELEASE_TAG}" echo "release tag: ${tag}" @@ -77,14 +80,31 @@ jobs: echo "::error::tag '${tag}' != 'v${version}' (pyproject version)" exit 1 fi + tag_commit="$(git rev-parse "refs/tags/${tag}^{commit}")" + head_commit="$(git rev-parse HEAD)" + if [ "${head_commit}" != "${tag_commit}" ]; then + echo "::error::checkout HEAD ${head_commit} != peeled tag commit ${tag_commit}" + exit 1 + fi git fetch --no-tags origin main if ! git merge-base --is-ancestor HEAD origin/main; then echo "::error::tagged commit is not reachable from origin/main" exit 1 fi if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - if [ "${GITHUB_REF}" != "refs/heads/main" ]; then - echo "::error::manual recovery must be dispatched from main" + recovery_prefix="refs/tags/${tag}-pypi-recovery-" + recovery_number="${GITHUB_REF#"${recovery_prefix}"}" + if [ "${recovery_number}" = "${GITHUB_REF}" ] || ! printf '%s' "${recovery_number}" | grep -Eq '^[1-9][0-9]*$'; then + echo "::error::manual recovery ref must match ${recovery_prefix}N (N >= 1)" + exit 1 + fi + recovery_commit="$(git rev-parse "${GITHUB_REF}^{commit}")" + if [ "${recovery_commit}" != "${WORKFLOW_SHA}" ]; then + echo "::error::recovery tag commit ${recovery_commit} != workflow source commit ${WORKFLOW_SHA}" + exit 1 + fi + if ! git merge-base --is-ancestor "${recovery_commit}" origin/main; then + echo "::error::recovery workflow commit is not reachable from origin/main" exit 1 fi draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq '.draft')" @@ -94,7 +114,7 @@ jobs: exit 1 fi fi - echo "guards passed: ${tag} == v${version}, ancestor of origin/main" + echo "guards passed: ${tag} == v${version}, exact tag checkout, commits reachable from origin/main" - name: Install uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 diff --git a/docs/team-workflow/release-checklist.md b/docs/team-workflow/release-checklist.md index 4cc65c2..6f8123e 100644 --- a/docs/team-workflow/release-checklist.md +++ b/docs/team-workflow/release-checklist.md @@ -52,10 +52,17 @@ reviewable history. release event rebuilds the tag, runs `make release-verify`, and pauses at the protected `pypi` environment before Trusted Publishing. - [ ] If artifact verification passed but the publisher failed before upload, - fix the workflow on `main` and use the manual **Publish to PyPI** recovery - input with the existing release tag. The recovery path accepts only a - strict semver tag that matches `pyproject.toml`, is reachable from - `origin/main`, and already has a published, non-prerelease GitHub Release. + fix and review the workflow on `main`, then create a new immutable + `-pypi-recovery-N` tag from that exact green main commit. Run + the manual **Publish to PyPI** workflow from the recovery tag and pass the + existing release tag as its `tag` input. The recovery tag keeps the + protected environment's `v*` tag rule effective; it is not a package + version and must not receive a GitHub Release. +- [ ] The recovery path accepts only a strict semver release tag that matches + `pyproject.toml`, checks out that tag by its qualified `refs/tags/...` ref, + equals the peeled tag commit, is reachable from `origin/main`, and already + has a published, non-prerelease GitHub Release. It also proves that the + numbered recovery tag and loaded workflow come from the same main commit. - [ ] Never move, delete, or reuse a public release tag to retry publication. - [ ] After workflow success, verify the version and file digests through the PyPI JSON API, then install the exact version from PyPI in a clean From ca216b62210d957e8347761b5b0e891174bf2304 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 23 Aug 2026 02:46:57 +0200 Subject: [PATCH 3/3] fix(release): bind recovery to trigger commit --- .github/workflows/publish-pypi.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 26eaeaa..d432071 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -98,7 +98,10 @@ jobs: echo "::error::manual recovery ref must match ${recovery_prefix}N (N >= 1)" exit 1 fi - recovery_commit="$(git rev-parse "${GITHUB_REF}^{commit}")" + # GITHUB_SHA is the commit selected by the triggering recovery tag; + # do not depend on that tag ref remaining present after checkout + # switches the worktree to the original release tag. + recovery_commit="$(git rev-parse "${GITHUB_SHA}^{commit}")" if [ "${recovery_commit}" != "${WORKFLOW_SHA}" ]; then echo "::error::recovery tag commit ${recovery_commit} != workflow source commit ${WORKFLOW_SHA}" exit 1