Skip to content

Commit 402e27d

Browse files
authored
fix(ci): make a failed publish retryable, and unpin the stale twine (#63)
* fix(ci): make a failed publish retryable, and unpin the stale twine v0.11.0 was tagged and its GitHub Release created, but the PyPI upload failed: Checking dist/hotdata_framework-0.11.0-py3-none-any.whl: ERROR InvalidDistribution: Invalid distribution metadata: '2.5' is not a valid metadata version Nothing to do with the code. gh-action-pypi-publish was pinned at v1.13.0 (Sept 2025), whose bundled twine predates `Metadata-Version: 2.5`, which current hatchling emits. Bumped to v1.14.2. Note the build job's own `twine check --strict` PASSED, because it pip-installs a current twine. So the incompatibility was invisible until upload, by which point the tag was already public -- the check that exists to catch bad metadata cannot catch this class at all. RETRYABILITY IS THE REAL FIX. Both workflows triggered only on tag push, so a publish that failed for reasons unrelated to the code left two bad options: delete and re-push the tag, or burn a version number on a CI fix. Neither is a reasonable answer to "the upload failed, run it again". Both now accept a workflow_dispatch with a tag input, checkout that ref, and derive the version from it. release.yml gets the same treatment for a second reason: RELEASING.md already documents the recovery command gh workflow run "GitHub Release" --ref main -f tag=vX.Y.Z and the trigger it needs was never there, so that documented path has always failed. This makes the doc true. Verified both files parse with triggers ['push', 'workflow_dispatch'] and a `tag` input, and no GITHUB_REF_NAME references remain in either. * fix(ci): watch action pins, and stop dispatch from demoting a newer release Three review findings, all confirmed before acting. Dependabot had no github-actions ecosystem at all -- only a uv entry narrowed to one dependency -- which is how gh-action-pypi-publish sat at v1.13.0 until its bundled twine broke a release at upload. Added, with no allow filter, since the point is to see every stale pin rather than a chosen one. Fixes the class, not just the instance. make_latest was unconditional. On the push trigger the tag is always the newest version so that is right, but a dispatch repairs a Release for a tag that already exists, by which time a newer version may have shipped -- re-running for an older tag would silently demote the newer one and point /releases/latest at it. Now gated on the push event. And my comment cited a RELEASING.md recovery section that is not in this repo. It is in sdk-python's RELEASING.md; I read that one earlier and attributed it here. So the trigger was adding a capability documented nowhere, not making a doc true. Added the section for real, including the bit worth stating outright: --ref main selects the workflow definition while the tag input selects what gets built, which reads like a contradiction until you know they differ on purpose. * fix(ci): make_latest legacy, not a gate on the event The gate was backwards for the case the dispatch will mostly see. make_latest is not "set latest / leave alone" -- false is an explicit instruction that a release is NOT the latest. release.yml's dispatch exists to repair a Release that failed on the push run, where the tag IS the newest version, so gating on the event passed false exactly there and left /releases/latest on the previous version. legacy covers both directions without the workflow having to know which case it is in: GitHub picks by tag date and semver, so repairing the newest tag marks it latest and repairing an older one leaves the newer release alone. * docs(releasing): scope what --ref main actually picks up "any fix landed since the tag" was too broad. Both workflows check out ref: ${{ inputs.tag || github.ref_name }}, so only the workflow YAML comes from main -- everything it runs comes from the tag, including scripts/extract-changelog.py. That is the right design, but the loose wording invites the opposite conclusion for the exact failure this section covers: if that script is what broke, a dispatch re-run does not pick up its fix.
1 parent 54b33f0 commit 402e27d

4 files changed

Lines changed: 80 additions & 9 deletions

File tree

‎.github/dependabot.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,12 @@ updates:
66
interval: daily
77
allow:
88
- dependency-name: hotdata
9+
10+
# Action pins had no watcher, which is how gh-action-pypi-publish sat at
11+
# v1.13.0 until its bundled twine broke a release at upload time. Unlike the
12+
# uv entry there is no `allow` filter: the point is to see every stale pin,
13+
# not a chosen one.
14+
- package-ecosystem: github-actions
15+
directory: "/"
16+
schedule:
17+
interval: weekly

‎.github/workflows/publish.yml‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@ on:
44
push:
55
tags:
66
- 'v[0-9]*'
7+
# Retry an existing tag without moving it. A publish can fail for reasons that
8+
# have nothing to do with the code — a stale action pin, a PyPI outage — and
9+
# with only the tag trigger the choices were to delete and re-push the tag or
10+
# to burn a version number on a CI fix. Neither is a good answer to
11+
# "the upload failed, run it again".
12+
workflow_dispatch:
13+
inputs:
14+
tag:
15+
description: Existing tag to build and publish (e.g. v1.2.3)
16+
required: true
17+
type: string
718

819
concurrency:
9-
group: pypi-publish-${{ github.ref_name }}
20+
group: pypi-publish-${{ inputs.tag || github.ref_name }}
1021
cancel-in-progress: false
1122

1223
permissions:
@@ -16,8 +27,13 @@ jobs:
1627
build:
1728
name: Build distribution
1829
runs-on: ubuntu-latest
30+
env:
31+
# The tag being released, whether it arrived by push or by dispatch.
32+
TAG: ${{ inputs.tag || github.ref_name }}
1933
steps:
2034
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
35+
with:
36+
ref: ${{ inputs.tag || github.ref_name }}
2137

2238
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
2339
with:
@@ -28,11 +44,11 @@ jobs:
2844

2945
- name: Verify tag matches pyproject version
3046
run: |
31-
if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
32-
echo "Release tag '$GITHUB_REF_NAME' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2
47+
if [[ ! "$TAG" =~ ^v[0-9] ]]; then
48+
echo "Release tag '$TAG' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2
3349
exit 1
3450
fi
35-
tag="${GITHUB_REF_NAME#v}"
51+
tag="${TAG#v}"
3652
pkg_version=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
3753
if [ "$tag" != "$pkg_version" ]; then
3854
echo "Release tag ($tag) does not match pyproject.toml version ($pkg_version)" >&2
@@ -65,5 +81,10 @@ jobs:
6581
name: dist
6682
path: dist/
6783

84+
# v1.13.0's bundled twine rejects `Metadata-Version: 2.5`, which current
85+
# hatchling emits: `InvalidDistribution: '2.5' is not a valid metadata
86+
# version`. The build job's own `twine check --strict` passes, because it
87+
# pip-installs a current twine — so the failure appears only at upload,
88+
# after the tag is already public.
6889
- name: Publish via Trusted Publishing
69-
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
90+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

‎.github/workflows/release.yml‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ on:
44
push:
55
tags:
66
- 'v[0-9]*'
7+
# Repair the Release for a tag that already exists, without moving it. See
8+
# RELEASING.md, "If a release workflow fails" — added alongside this trigger,
9+
# since a capability nobody can find is not much better than not having it.
10+
workflow_dispatch:
11+
inputs:
12+
tag:
13+
description: Existing tag to create or update a Release for (e.g. v1.2.3)
14+
required: true
15+
type: string
716

817
permissions:
918
contents: write
@@ -12,8 +21,13 @@ jobs:
1221
release:
1322
name: Create GitHub Release
1423
runs-on: ubuntu-latest
24+
env:
25+
# The tag being released, whether it arrived by push or by dispatch.
26+
TAG: ${{ inputs.tag || github.ref_name }}
1527
steps:
1628
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
29+
with:
30+
ref: ${{ inputs.tag || github.ref_name }}
1731

1832
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
1933
with:
@@ -23,15 +37,15 @@ jobs:
2337
id: meta
2438
run: |
2539
pkg_name=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['name'])")
26-
pkg_version="${GITHUB_REF_NAME#v}"
40+
pkg_version="${TAG#v}"
2741
echo "name=${pkg_name}" >> "$GITHUB_OUTPUT"
2842
echo "version=${pkg_version}" >> "$GITHUB_OUTPUT"
2943
3044
- name: Extract changelog notes
3145
id: notes
3246
run: |
3347
set -euo pipefail
34-
version="${GITHUB_REF_NAME#v}"
48+
version="${TAG#v}"
3549
if [[ -f CHANGELOG.md ]]; then
3650
body="$(python scripts/extract-changelog.py "$version")"
3751
else
@@ -47,8 +61,14 @@ jobs:
4761
- name: Create GitHub Release
4862
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2
4963
with:
50-
tag_name: ${{ github.ref_name }}
64+
tag_name: ${{ inputs.tag || github.ref_name }}
5165
name: ${{ steps.meta.outputs.name }} ${{ steps.meta.outputs.version }}
5266
body: ${{ steps.notes.outputs.body }}
5367
generate_release_notes: false
54-
make_latest: true
68+
# Let GitHub decide by tag date/semver rather than by run order. On a
69+
# push the tag is the newest version and becomes latest; on a dispatch
70+
# repairing an older tag, a newer release keeps the badge. Note `false`
71+
# is not "leave alone" — it explicitly marks a release NOT latest, so
72+
# gating on the event would demote the newest tag in the very case this
73+
# trigger exists for: repairing its Release after a failed push run.
74+
make_latest: legacy

‎RELEASING.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,27 @@ Pushing a `vX.Y.Z` tag triggers two workflows:
3434
| `publish.yml` | Build wheel/sdist and publish to PyPI |
3535
| `release.yml` | Create the GitHub Release with notes from `CHANGELOG.md` |
3636

37+
## If a release workflow fails
38+
39+
Both workflows also accept a manual re-run against an existing tag, so a failure
40+
unrelated to the code — a stale action pin, a PyPI outage — does not require
41+
deleting the tag or burning a version number:
42+
43+
```bash
44+
gh workflow run "Publish to PyPI" --ref main -f tag=vX.Y.Z
45+
gh workflow run "GitHub Release" --ref main -f tag=vX.Y.Z
46+
```
47+
48+
`--ref main` selects the workflow *definition*, so a fix to the workflow file
49+
itself is picked up; everything it runs — including `scripts/extract-changelog.py`
50+
— still comes from the tag, since that is what is being built and released. The
51+
two refs serve different purposes, which is why they can differ.
52+
53+
A version is only spent once PyPI has accepted an upload. If the publish failed
54+
before that, the same version can still be published — check with
55+
`curl -s -o /dev/null -w '%{http_code}' https://pypi.org/pypi/<pkg>/<version>/json`
56+
returning 404.
57+
3758
## Enforcement
3859

3960
- **PR check** (`check-release.yml`): if `pyproject.toml` version changes, `CHANGELOG.md` must contain a matching `## [X.Y.Z]` section.

0 commit comments

Comments
 (0)