Skip to content

fix(ci): make a failed publish retryable, and unpin the stale twine - #63

Merged
anoop-narang merged 4 commits into
mainfrom
fix/publish-retryable
Aug 11, 2026
Merged

fix(ci): make a failed publish retryable, and unpin the stale twine#63
anoop-narang merged 4 commits into
mainfrom
fix/publish-retryable

Conversation

@anoop-narang

Copy link
Copy Markdown
Contributor

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.

State right now

v0.11.0 is tagged, its GitHub Release exists, and PyPI has nothing/pypi/hotdata-framework/0.11.0/json returns 404, so the version is not burned and can still be published as intended.

Plan after this merges

Re-point v0.11.0 at 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.yml creates-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.

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.
@anoop-narang
anoop-narang requested a review from a team as a code owner August 11, 2026 13:15
@anoop-narang
anoop-narang requested review from eddietejeda and removed request for a team August 11, 2026 13:15
Comment thread .github/workflows/publish.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml Outdated
claude[bot]
claude Bot previously approved these changes Aug 11, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread .github/workflows/release.yml Outdated
claude[bot]
claude Bot previously approved these changes Aug 11, 2026
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.
Comment thread RELEASING.md Outdated
claude[bot]
claude Bot previously approved these changes Aug 11, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior threads all resolved in code: dependabot github-actions ecosystem, make_latest: legacy, and the RELEASING.md recovery section with the scoped --ref main wording. No blocking issues.

@anoop-narang
anoop-narang merged commit 402e27d into main Aug 11, 2026
3 checks passed
anoop-narang added a commit that referenced this pull request Aug 11, 2026
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.
anoop-narang added a commit that referenced this pull request Aug 11, 2026
* 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.
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