From a7db3cff04d3688be85ec8d46999b2756e9fed37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:46:40 +0000 Subject: [PATCH] ci: Reconcile PEP 440 prerelease versions with semantic-release semantic-release computes semver versions, but uv normalizes prerelease versions to PEP 440 when cutting them, so the tag it produces (v3.0.0b1) is not valid semver. semantic-release silently skips tags it cannot parse, so every run on a prerelease branch reported the channel's first prerelease as the next version and tried to cut a version that had already been published. Mirror each PEP 440 prerelease tag onto its semver equivalent before running semantic-release. The mirrored tags are local to the workflow run, so the PEP 440 tags stay the only real ones and continue to match the published package version. Recording the channel a prerelease went out on, which semantic-release requires before it will treat a prerelease tag as released, stays in the Version workflow where the release is cut. It now keys the note to the PEP 440 tag that actually gets created, rather than to a semver tag name that never exists, and derives the channel from the normalized version so a release cut by hand is recorded the same way. Notes attach to commits rather than tags, so the mirrored tag picks the channel up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKRv7ht6XA9Sm8brKctEva --- .github/workflows/semantic-release.yml | 25 +++++++++++++++++++++++++ .github/workflows/version.yml | 26 +++++++++++++++++--------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml index b2e3aab9..b6c2c920 100644 --- a/.github/workflows/semantic-release.yml +++ b/.github/workflows/semantic-release.yml @@ -25,6 +25,31 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 + - name: Mirror prerelease tags as semver + run: | + # Prerelease tags use PEP 440 (v3.0.0b1) to match the published + # package version, but semantic-release only understands semver and + # silently ignores tags it cannot parse. Without this, every run + # reports the first prerelease of the channel as the next version. + # Mirror each PEP 440 prerelease tag onto its semver equivalent + # (v3.0.0-beta.1). These are local to the run and are never pushed; + # the PEP 440 tags remain the only real ones. The channel notes the + # Version workflow records against the tagged commit apply to the + # mirrored tag too, since notes attach to commits, not tags. + for tag in $(git tag --list 'v*'); do + semver="$( + printf '%s' "${tag#v}" | sed -E \ + -e 's/^([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)$/\1-alpha.\2/' \ + -e 's/^([0-9]+\.[0-9]+\.[0-9]+)b([0-9]+)$/\1-beta.\2/' \ + -e 's/^([0-9]+\.[0-9]+\.[0-9]+)rc([0-9]+)$/\1-rc.\2/' + )" + if [ "$semver" = "${tag#v}" ]; then + continue + fi + + git tag --force "v$semver" "$tag^{commit}" + echo "Mirrored $tag as v$semver." + done - name: Semantic release id: release uses: cycjimmy/semantic-release-action@v6 diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index afb09b8c..723815d3 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -31,20 +31,28 @@ jobs: passphrase: ${{ secrets.GPG_PASSPHRASE }} - name: Setup uses: ./.github/actions/setup + # uv normalizes the semver version semantic-release computes + # (3.0.0-beta.1) to PEP 440 (3.0.0b1), which is what gets committed, + # tagged and published. The Semantic Release workflow mirrors that tag + # back to semver so it can pick up where the last prerelease left off. - name: Cut ${{ github.event.inputs.version }} version run: | uv version "${{ github.event.inputs.version }}" just version + # semantic-release only treats a prerelease tag as released when a note + # records the channel it went out on, so record it here, once the release + # exists. The note is keyed to the commit rather than the tag, which is + # what lets the mirrored semver tag pick it up. - name: Record prerelease channel - env: - VERSION: ${{ github.event.inputs.version }} run: | - case "$VERSION" in - *-*) channel="${VERSION#*-}"; channel="${channel%%.*}" ;; + tag="v$(uv version --short)" + case "$(uv version --short)" in + *.*.*a[0-9]*) channel=alpha ;; + *.*.*b[0-9]*) channel=beta ;; + *.*.*rc[0-9]*) channel=rc ;; *) echo "Stable release, no channel note required."; exit 0 ;; esac - git fetch origin "+refs/notes/semantic-release:refs/notes/semantic-release" || true - git notes --ref semantic-release add --force \ - --message "{\"channels\":[\"$channel\"]}" "v$VERSION^{commit}" - git push origin refs/notes/semantic-release - echo "Recorded v$VERSION on the '$channel' channel." + git notes --ref "semantic-release-$tag" add --force \ + --message "{\"channels\":[\"$channel\"]}" "$tag^{commit}" + git push origin "refs/notes/semantic-release-$tag" + echo "Recorded $tag on the '$channel' channel."