fix(ci): make a failed publish retryable, and unpin the stale twine - #63
Conversation
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.
There was a problem hiding this comment.
Approving. Three non-blocking nits inline: no github-actions ecosystem in dependabot.yml (the reason this pin went stale in the first place), make_latest: true now reachable for older tags via the dispatch path, and the RELEASING.md recovery section that release.yml's new comment cites but which does not exist in the repo.
Two things I did not verify: the v1.14.2 pin SHA (dc37677) could not be checked against upstream from this environment, and CI was still in progress when this review started, so I am not asserting anything about its result.
…elease 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.
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.
There was a problem hiding this comment.
Prior threads resolved. make_latest: legacy is right: the action passes true/false/legacy through as-is, so legacy lets GitHub pick by tag date/semver — the newest tag becomes latest when repaired, an older one leaves the newer release alone. inputs is a valid context in workflow-level concurrency, job env, and step with, and it resolves empty on the push trigger so the || github.ref_name fallback holds. Workflow names match the gh workflow run invocations in RELEASING.md. One doc-scoping super nit inline, non-blocking.
"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.
My description claimed parity between the two workflows and it was not true. In this repo publish.yml never gained a pre-checkout step -- #63 only switched its existing "Verify tag matches pyproject version" check to $TAG, which runs AFTER checkout and is looser (^v[0-9]). So `-f tag=v1.2` or `v1.2.3rc1` was accepted there and rejected in release.yml. Tightening publish.yml rather than loosening release.yml, since release.sh only ever produces X.Y.Z -- it enforces ^[0-9]+\.[0-9]+\.[0-9]+$ on explicit versions, so the strict form is the correct contract. Both workflows now validate the same input the same way, before fetching an arbitrary ref. The post-checkout version match stays: it catches a tag that is well-formed but does not match pyproject.
* fix(ci): validate the dispatch tag in release.yml Gap I introduced in #63, caught in review of the same port to hotdata-ibis (hotdata-dev/hotdata-ibis#44). publish.yml got a pre-checkout guard on the dispatch input; release.yml did not, despite needing it more. release.yml holds contents: write, and action-gh-release CREATES a tag when tag_name does not resolve to one. So an unvalidated `tag: main` checks out cleanly and then leaves refs/tags/main plus a release named for it, both needing manual cleanup. publish.yml at worst wastes a run. The push path is constrained by the v[0-9]* tag filter; the dispatch path had no constraint at all. Same guard and same strict form as publish.yml, so the input contract matches in both. sdk-python already had this -- its dispatch predates this work. * fix(ci): give publish.yml the same strict dispatch guard My description claimed parity between the two workflows and it was not true. In this repo publish.yml never gained a pre-checkout step -- #63 only switched its existing "Verify tag matches pyproject version" check to $TAG, which runs AFTER checkout and is looser (^v[0-9]). So `-f tag=v1.2` or `v1.2.3rc1` was accepted there and rejected in release.yml. Tightening publish.yml rather than loosening release.yml, since release.sh only ever produces X.Y.Z -- it enforces ^[0-9]+\.[0-9]+\.[0-9]+$ on explicit versions, so the strict form is the correct contract. Both workflows now validate the same input the same way, before fetching an arbitrary ref. The post-checkout version match stays: it catches a tag that is well-formed but does not match pyproject.
v0.11.0 was tagged and its GitHub Release created, but the PyPI upload failed:
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 currenthatchling emits. Bumped to v1.14.2.
Note the build job's own
twine check --strictPASSED, because itpip-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
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
taginput, and no GITHUB_REF_NAME references remain in either.State right now
v0.11.0is tagged, its GitHub Release exists, and PyPI has nothing —/pypi/hotdata-framework/0.11.0/jsonreturns 404, so the version is not burned and can still be published as intended.Plan after this merges
Re-point
v0.11.0at the merge commit and let the tag push republish, rather than burning 0.11.1 on a CI fix. The tag has produced no consumable artifact, so moving it is invisible; a phantom version on PyPI would not be.release.ymlcreates-or-updates, so the existing Release is handled.Once this is in, that choice never arises again — a failed upload is just
gh workflow run "Publish to PyPI" -f tag=vX.Y.Z.