Skip to content

ci(release): unbreak PyPI publish — bump publish action past twine 6.1.0 - #63

Merged
abbaseya merged 1 commit into
mainfrom
fix/release-metadata-2-5
Sep 18, 2026
Merged

abbaseya merged 1 commit into
mainfrom
fix/release-metadata-2-5

Conversation

@abbaseya

@abbaseya abbaseya commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

What broke

The Release run for the 2.1.0 merge (run 35232748196) failed at the publish step:

InvalidDistribution: Invalid distribution metadata: '2.5' is not a valid metadata version

Nothing was published and nothing is half-released: the tag job is gated on publish, so it was skipped. There is no v2.1.0 tag, no GitHub Release, and PyPI is still on 2.0.0. main has the code; the version simply does not exist yet.

Cause

Measured, not inferred:

Fact Evidence
Publish action pinned to ba38be9e = v1.14.1 requirements/runtime.txt at that tag pins twine==6.1.0
requires = ["hatchling"] had no bound uv build resolves the newest backend at build time
hatchling 1.31.0 → metadata 2.4, 1.32.0 → 2.5 bisected locally by building a probe against each
twine 6.1.0 rejects 2.5, 7.0.0 accepts it run against this package's own wheel, reproducing the CI error string exactly

hatchling 1.32.0 landed 2026-08-11. The last real publish was v2.0.0 on 2026-07-24, before it existed.

Why it hid for five weeks: the Release runs on 2026-08-12 and 2026-09-15 both reported success with the publish job skipped — semantic-release found no releasable commits. The pipeline was green twice while broken. This merge is the first actual publish since July.

(hatchling 1.32.1, 2026-09-16, is yanked — "Broken by change to types" — and is a red herring. 1.32.0 already emits 2.5.)

The fix

  1. Pin the publish action to v1.14.2, sha dc37677b = v1.14.2^{commit}, which pins twine==7.0.0. Metadata 2.5 is valid and PyPI accepts it — the old twine was the laggard, so this follows the ecosystem rather than holding the backend back. The peel matters: the annotated tag object a892a5a6 is not a commit, and the action turns github.action_ref into a GHCR image tag verbatim, so that id fails with manifest unknown before twine runs. Caught in review — see below.
  2. Run twine check in CI's build job with the same twine the publish action ships. The coupling that broke is the backend's default metadata version against the uploader's accepted list, and it was observable only at publish time on main — after the merge that should have caught it. This is the actual guard.
  3. Add .github/dependabot.yml for github-actions. v1.14.2 shipped 13 days before hatchling 1.32.0; a weekly bump lands the new uploader inside that window, and Dependabot writes the peeled sha.
  4. Cap the build backend at hatchling<2 — a conventional cap, not a guard: both metadata bumps (1.30.0, 1.32.0) happened inside 1.x, so it would not have prevented this.

hatchling>=1.31 was tried first and is wrong: hatchling requires Python >=3.10 from 1.28.0, and NFR22 mandates a 3.9 floor, so it made build-system.requires unsatisfiable on the bottom matrix row. Under hatchling<2 the backend resolves per interpreter — 3.9 takes 1.27.0 (metadata 2.4), 3.13 takes 1.32.0 (2.5) — and twine 7.0.0 accepts both. The release build runs on 3.13.

Verified against the real package, not a synthetic one:

built: convert_python_sdk-*.whl   Metadata-Version: 2.5
twine 7.0.0 (v1.14.2 ships this)  PASSED   (wheel + sdist)
twine 6.1.0 (v1.14.1 shipped it)  ERROR: '2.5' is not a valid metadata version

Review round 1 — CHANGES_REQUESTED, all three findings fixed

# Finding Action
1 CRITICAL — the pin a892a5a6 is the annotated tag object, not the release commit; no GHCR image exists under it, so publish fails with manifest unknown Repinned to dc37677b (v1.14.2^{commit}). Verified independently: commits/a892a5a6 → 422; GHCR manifest probe → a892a5a6 404, dc37677b 200, v1.14.2 200, old pin ba38be9e 200
2 IMPORTANT — PR body described hatchling>=1.31,<2, the diff was hatchling<2; "1.31+" should be "1.28.0+" Body rewritten above. PyPI requires_python: >=3.8 at 1.27.0, >=3.10 from 1.28.0 (2025-11-27)
3 IMPORTANT — the recurrence claim is not delivered; <2 is inert against the observed class Took option (a): twine check in CI + Dependabot config. <2 is now described as a conventional cap, and the twine check named as the guard

After merge — no manual retry needed

Do not re-run the failed run: a re-run replays that commit's workflow file, which still carries the v1.14.1 pin, so it would fail identically.

Merging this pushes mainCI runs → Release fires on workflow_run with the corrected workflow. semantic-release computes from the last tag (v2.0.0), and the feat commits from #62 are still untagged, so it derives 2.1.0 and publishes it. This PR's own ci: commit is not releasable on its own and does not need to be.

🤖 Generated with Claude Code

Decision records (B-G7)

None of the records below are this PR's provenance. This is a two-line CI fix; its reasoning is in the commit message and the cause table above.

B-G7 resolves AgDRs by BMAD feature slug2026-04-06-convert-python-sdk, a folder spanning many workflows — rather than by the run that produced them, so it requires every record under that slug in any PR touching this repo. These belong to the per-call bucketing-attributes work already merged in #62:

  • AgDR-0011-the-ac1-full-pipeline-actually-reads-the-gate-wiring-test
  • AgDR-0174-experience-keys-is-sequence-str-with-a-runtime-str-guard
  • AgDR-0175-type-casting-off-does-not-normalise-variable-keys
  • AgDR-0176-precedence-proof-is-two-tests-not-one-parametrize
  • AgDR-0177-python-claims-the-shared-run-all-experience-keys-blockquote
  • AgDR-0178-shared-docs-edits-are-serialised-behind-the-sdk-work
  • AgDR-0179-both-cast-branches-normalise-variable-keys-to-str
  • AgDR-0180-cap-5-wiki-pages-are-not-this-workflows-deliverable
  • AgDR-0184-cap-4-ships-signature-equality-and-narrows-the-honoured-half

Tracked as F-35. Note AgDR-0184 was AgDR-0173 two days ago — the renumber the full-stem rule exists to survive, and why a bare number would not do here.

@abbaseya abbaseya self-assigned this Sep 17, 2026
@abbaseya
abbaseya force-pushed the fix/release-metadata-2-5 branch from e0480fd to 9509e80 Compare September 17, 2026 14:40

@JosephSamirL JosephSamirL 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.

Review — convertcom/python-sdk PR #63 @ 9509e80

ci(release): unbreak PyPI publish — bump publish action past twine 6.1.0 · fix/release-metadata-2-5main · author abbaseya · 2 files, +2/−2 · reviewed 2026-09-17

VERDICT: CHANGES_REQUESTED (1 CRITICAL, 2 IMPORTANT)

The diagnosis is correct end to end — every causal claim about hatchling, twine and the failing run reproduces from source and from a scratchpad build. The fix as pushed does not unbreak publish. The new uses: sha a892a5a6… is the annotated tag object of v1.14.2, not the commit it points to (dc37677b…). pypa/gh-action-pypi-publish turns github.action_ref into a GHCR image tag, no image exists under a892a5a6…, and a public repository that pinned the identical line already failed with manifest unknown. Merging this reproduces today's shape (prepare ok → publish fails → release skipped) with a different error, and 2.1.0 stays unpublished. Separately, the PR body describes a diff (hatchling>=1.31,<2) that is not the one pushed (hatchling<2).


PR-body claims — verified / refuted

# Claim Status Evidence
1 Run 35232748196 failed at the publish step with '2.5' is not a valid metadata version VERIFIED gh api …/runs/35232748196/jobs: Compute version + build → success, Publish to PyPI (OIDC) → failure (step 3 Run pypa/gh-action-pypi-publish@ba38be9e…), Tag + GitHub Release → skipped. Log: Checking dist/convert_python_sdk-2.1.0-py3-none-any.whl: ERROR InvalidDistribution: Invalid distribution metadata: '2.5' is not a valid metadata version
2 Tag job skipped, nothing half-released; no v2.1.0 tag/Release; PyPI on 2.0.0 VERIFIED gh release list: v2.0.0 (Latest, 2026-07-24), v1.0.1, v1.0.0. Tags API: v2.0.0 → 1f4bd828. PyPI JSON: info.version = 2.0.0, 2.0.0 uploaded 2026-07-24T17:04:17
3 Old pin ba38be9e = v1.14.1; requirements/runtime.txt there pins twine==6.1.0 VERIFIED git rev-parse v1.14.1^{commit} = ba38be9e461d3875417946c167d0b5f3d385a247 (tag object 2834a314…). git show v1.14.1:requirements/runtime.txt: twine==6.1.0, packaging==25.0
4 New pin a892a5a6 = v1.14.2, which pins twine==7.0.0 REFUTED as a commit pin (twine pin VERIFIED) gh api repos/pypa/gh-action-pypi-publish/git/ref/tags/v1.14.2object.type: "tag", object.sha: a892a5a6…; git/tags/a892a5a6…object.sha: dc37677b2e1c63e2034f94d8a5b11f265b73ba33 (type commit, tagger Sviatoslav Sydorenko 2026-07-29T16:57:12Z). git cat-file -t a892a5a6… = tag; git rev-parse v1.14.2^{commit} = dc37677b… = current release/v1 head. git/commits/a892a5a6… → 404; commits/a892a5a6… → 422 No commit found for SHA. git show v1.14.2:requirements/runtime.txt: twine==7.0.0, packaging==26.2
5 hatchling 1.31.0 emits Metadata 2.4, 1.32.0 emits 2.5; 1.32.0 landed 2026-08-11; 1.32.1 (2026-09-16) yanked "Broken by change to types" VERIFIED backend/src/hatchling/metadata/spec.py: DEFAULT_METADATA_VERSION = "2.4" at hatchling-v1.31.0, "2.5" at hatchling-v1.32.0. PyPI JSON: 1.32.0 uploaded 2026-08-11; 1.32.1 uploaded 2026-09-16, yanked=True, reason Broken by change to types. Corroboration: 1.30.0 (2026-06-01) yanked with reason "the default core metadata version was increased to 2.5 and some major projects like twine do not yet support it"; 1.30.1 reverted to 2.4. Reproduced (scratchpad venvs, py3.13, python -m hatchling build on this repo at 9509e80): 1.31.0 → Metadata-Version: 2.4; 1.32.0 → Metadata-Version: 2.5
6 twine 6.1.0 rejects 2.5, twine 7.0.0 accepts; old twine was the laggard VERIFIED (and sharpened) The rejecting code is packaging.metadata._VALID_METADATA_VERSIONS, but twine 6.1.0 twine/package.py monkeypatches that list to a frozen […, "2.0", …, "2.4"], so it rejects 2.5 regardless of packaging version — reproduced: twine 6.1.0 + packaging 26.2 → same ERROR; twine 6.2.0 + packaging 26.2 → same ERROR; twine 7.0.0 + packaging 26.2 → PASSED (wheel + sdist). twine 7.0.0 (2026-07-27) changelog: "Fix uploading packages with metadata version 2.5. The fix no longer allows metadata version 2.0 … (#1317)". No 6.x accepts 2.5. Twine was the laggard, and 7.0.0 is the first release that isn't
7 "Verified against the real package … Metadata-Version 2.5 / twine 7.0.0 PASSED / twine 6.1.0 ERROR" VERIFIED Reproduced exactly (table row 6). twine 6.1.0 + packaging 25.0 against the 1.31.0 (2.4) build → PASSED, matching the July v2.0.0 publish
8 Fix item 2: "Bound the build backend to hatchling>=1.31,<2" REFUTED by the diff pyproject.toml at 9509e80: requires = ["hatchling<2"]. The commit message says >=1.31 was tried and dropped because it is unsatisfiable on 3.9 — verified: hatchling ≥1.28.0 has requires_python >=3.10 (PyPI JSON); uv pip install "hatchling>=1.31,<2" on Python 3.9.6 → No solution found … hatchling>=1.31.0,<=1.32.0 depends on Python>=3.10; hatchling<2 resolves to 1.27.0 on 3.9 and 1.32.0 on 3.13. The PR body is stale relative to the commit
9 The unbounded backend "is the part that recurs"; <2 addresses it REFUTED See Finding 3. Both hatchling metadata bumps happened inside 1.x (1.30.0, 1.32.0); a <2 cap changes nothing for that class
10 Last real publish v2.0.0 on 2026-07-24; Release runs 2026-08-12 and 2026-09-15 green with publish skipped VERIFIED Release runs: 34982774276 (09-15) and 31601523118 (08-12): prepare success, publish skipped, release skipped. Also 30637880488 (07-31) had the same shape — omitted from the body, immaterial
11 Do not re-run the failed run — it replays that commit's workflow file VERIFIED GitHub docs (re-running workflows): "The workflow will also use the same GITHUB_SHA (commit SHA) and GITHUB_REF (git ref) of the original event that triggered the workflow run." The failing run's head_sha is e5b946f2 (main's head at the time, which carries the old pin)
12 Merge → CIRelease fires on workflow_run with the corrected workflow Mechanism VERIFIED; outcome REFUTED release.yml: on.workflow_run.workflows: ['CI'], types: [completed], branches: [main]; ci.yml name: CI, on.push.branches: [main]. Docs: workflow_run "will only trigger a workflow run if the workflow file exists on the default branch" and runs with GITHUB_SHA = last commit on the default branch. The corrected file will run — and fail at the docker pull (Finding 1)
13 semantic-release computes from tag v2.0.0, the feat commits from #62 derive 2.1.0; the ci: commit is not releasable; version is stamped, not hard-coded VERIFIED git merge-base --is-ancestor v2.0.0 main → yes. git log v2.0.0..main includes feat(features): type_casting on run_feature and run_features, feat(features): experience_keys on run_feature, run_features and diagnose_feature, two fix(features): …. release.config.mjs: tagFormat: 'v${version}', commit-analyzer preset: 'conventionalcommits' with no releaseRules → defaults in @semantic-release/commit-analyzer@13.0.1 lib/default-release-rules.js (feat → minor, fix → patch; ci absent → no release). pyproject.toml dynamic = ["version"] read from src/convert_sdk/version.py (0.0.0 placeholder), stamped by the prepare job before uv build; the failing run built convert_python_sdk-2.1.0-py3-none-any.whl
14 "Metadata 2.5 is valid and PyPI accepts it" VERIFIED hatchling 1.32.0's own wheel on PyPI carries Metadata-Version: 2.5 (PEP 658 .metadata file for hatchling-1.32.0-py3-none-any.whl); pypa v1.14.2 release notes: "update of Twine to v7 … will let them upload their sdists and wheels containing core packaging metadata v2.5 to (Test)PyPI"
15 (implicit) Inputs, auth mode and permissions unchanged across the bump VERIFIED git diff v1.14.1 v1.14.2 -- action.yml is empty; only requirements/runtime.{in,txt} changed. packages-dir is a canonical input. permissions: id-token: write, environment: pypi, no password input. release.yml at 9509e80 parses (PyYAML)

Findings

1. [CRITICAL] The new pin is the annotated tag object, not the release commit — publish will fail again with manifest unknown (confidence: 97)

File: .github/workflows/release.yml:128uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2
Convention: ReleaseProcess wiki, "Third-party actions are pinned to a commit SHA" — a tag-object id is not a commit SHA. hooks/gate note: the old pin ba38be9e… was the peeled commit; this bump silently changed the object type.

Evidence, in order of the failure path

  1. a892a5a6… is a git tag object (git cat-file -ttag; git/tags/a892a5a6… → target commit dc37677b2e1c63e2034f94d8a5b11f265b73ba33). GitHub's commits/{sha} API rejects it with 422 No commit found for SHA. The contents/tarball endpoints peel it, which is why the Actions service can still fetch the action source — the failure is downstream.
  2. action.yml at v1.14.2 is a composite: the Create Docker container action step runs create-docker-action.py with REF: ${{ github.action_ref || … }}. set_image in that script returns docker://ghcr.io/pypa/gh-action-pypi-publish:{REF}; the only non-registry path (Dockerfile) is for REPO_ID == '178055147', the action's own repository. There is no fallback build.
  3. github.action_ref is the uses: ref verbatim (actions/runner ActionRunner.cs: SetGitHubContext("action_ref", repoPathReferenceAction.Ref)). The failing run's own log shows this: it pulled ghcr.io/pypa/gh-action-pypi-publish:ba38be9e461d3875417946c167d0b5f3d385a247 — the literal old pin.
  4. GHCR manifest probe (anonymous pull token): :a892a5a61159132606e93a2fa6f4358831b04d26404; :dc37677b2e1c63e2034f94d8a5b11f265b73ba33 → 200; :v1.14.2 → 200; :ba38be9e… → 200 (digest sha256:cc949598…, identical to the digest in the failing run's log). The registry tag list contains commit shas and release names, never tag-object ids.
  5. Ground truth from an unrelated public repository that pinned the identical linemmzen/se_harness, .github/workflows/publish-pypi.yml line 115 uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2, run https://github.com/mmzen/se_harness/actions/runs/31514559160 (2026-08-11), step Publish exact distributions with Trusted Publishing → failure:
    /usr/bin/docker run --name ghcriopypaghactionpypipublisha892a5a6… … ghcr.io/pypa/gh-action-pypi-publish:a892a5a61159132606e93a2fa6f4358831b04d26
    Unable to find image 'ghcr.io/pypa/gh-action-pypi-publish:a892a5a61159132606e93a2fa6f4358831b04d26' locally
    docker: Error response from daemon: manifest unknown
    
    The composite's earlier steps ran (source resolved); the docker step failed. Same action version, same code path, same sha.

Consequence: after merge, prepare succeeds, publish-pypi fails at "Run Docker container", release is skipped, no tag, 2.1.0 still absent — exactly today's state with a different error. The PR body's "After merge — no manual retry needed" becomes false, and another PR cycle is required to publish.

Fix:

- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

dc37677b… is v1.14.2^{commit}, the current release/v1 head, and the sha carried by ~7,100 public files (Dependabot output) versus 31 for the tag object. Resolve future pins with git rev-parse <tag>^{commit}, or dereference git/ref/tags/<tag>.object.type == "tag"git/tags/<sha>.object.sha. Update the commit message and PR body to the new sha.

Would resolve it: the pinned sha returns 200 from https://ghcr.io/v2/pypa/gh-action-pypi-publish/manifests/<sha>; after merge, the publish log reads Status: Downloaded newer image for ghcr.io/pypa/gh-action-pypi-publish:dc37677b… followed by PASSED for wheel and sdist.

2. [IMPORTANT] The PR body describes a diff that was not pushed (confidence: 95)

File: PR body, "The fix" item 2 and the cause table; pyproject.toml:26.

The body says "Bound the build backend to hatchling>=1.31,<2". The diff is requires = ["hatchling<2"]. The commit message has the correct story — >=1.31 was tried and is unsatisfiable on the 3.9 matrix row — and that story checks out: hatchling moved to requires_python >=3.10 at 1.28.0 (2025-11-27, not 1.31 as the commit message says; the conclusion is unchanged), uv pip install "hatchling>=1.31,<2" on 3.9.6 fails to resolve, and hatchling<2 picks 1.27.0 (Metadata 2.4) on 3.9 and 1.32.0 (Metadata 2.5) on 3.13. The uv sync --group dev --python 3.9 cells build the project editable and would have failed under >=1.31.

The body is the record this review approves against and what future readers see first; it currently presents a lower bound that does not exist as part of a "measured" fix.

Fix: rewrite item 2 to hatchling<2 with the 3.9 rationale from the commit message; change "1.31+" to "1.28+" in the commit message.

3. [IMPORTANT] The recurrence claim is not delivered — hatchling<2 is inert against the observed class and nothing else guards it (confidence: 85)

Files: pyproject.toml:26; commit message ("the unbounded backend is what let the metadata version change under us silently, and that is the part that recurs"); absence of .github/dependabot.yml; ci.yml build job.

Evidence

  • Every metadata-version bump hatchling has shipped happened inside 1.x: 1.30.0 (2026-06-01, yanked for exactly this breakage) and 1.32.0 (2026-08-11). A <2 cap present in July would not have changed the 2026-09-17 outcome; it excludes only a hypothetical 2.0. It is harmless (no interpreter resolves differently than before; CI is green) and conventional (flit's docs recommend flit_core >=3.2,<4; hatch's own docs recommend an unbounded ["hatchling"]), but it is not a guard.
  • The coupling that broke is "backend's default metadata version ≤ uploader's accepted list". It is not testable by a build-system bound; it is testable by running the uploader's check, and it is kept from drifting by keeping the uploader current.
  • There is no .github/dependabot.yml (the js-yaml bump in 0100280 is a Dependabot security update, which needs no config). pypa published v1.14.2 on 2026-07-29, 13 days before hatchling 1.32.0 — a github-actions Dependabot config would have proposed the bump inside that window, and Dependabot writes the peeled commit sha, so Finding 1 would not have occurred either.
  • CI's build job runs uv build on 3.13 and never runs twine check, so a backend/uploader mismatch is only ever observed at publish time on main, after the merge that should have caught it.
  • Pinning to a known-good minor (<1.33) is the wrong tool: the package ships as a pure-Python wheel, so consumers never invoke the backend and an upper cap only constrains sdist builders — and it still tests nothing.

Fix (either resolves the finding):
(a) Add .github/dependabot.yml with package-ecosystem: github-actions (weekly), and add to the CI build job a uv run --with twine==7.0.0 twine check dist/* step with a comment tying the twine version to the publish-action pin; or
(b) remove the recurrence claim from the commit message and PR body (keep <2 as a conventional cap, described as such) and file (a) as a follow-up.


Notes (not findings)

  • release.config.mjs's comment and the ReleaseProcess wiki say perf: is a no-release type. @semantic-release/commit-analyzer@13.0.1 default rules (lib/default-release-rules.js) make perf → patch; only the release-notes types map hides it. Pre-existing and out of scope; noted for the wiki drift routine.
  • GHCR images :dc37677b… and :v1.14.2 have different digests (created 2026-07-29T17:05:51Z and 17:01:18Z — separate builds of the same release day). Consistent with pypa's release pipeline; not proof of byte-identical content (see "not checked").

What I checked

  • PR metadata, body, head commit message; diff main..pr63 (merge base e5b946f2 = current main).
  • Failing run 35232748196: run object, job/step conclusions, --log-failed; prior Release runs 30637880488 / 31601523118 / 34982774276 job shapes.
  • gh release list, tags API, PyPI JSON for convert-python-sdk.
  • pypa/gh-action-pypi-publish: blobless clone; git rev-parse of both tags and their ^{commit}; git cat-file -t; requirements/runtime.txt at v1.14.1 and v1.14.2; action.yml and create-docker-action.py at v1.14.2; git diff v1.14.1 v1.14.2 -- action.yml; v1.14.2 release notes; GitHub git/ref, git/tags, git/commits, commits, contents, tarball and codeload behaviour for the tag-object sha.
  • GHCR: manifest HEAD for seven tags, tag list, OCI config created for two images.
  • actions/runner ActionRunner.cs for github.action_ref semantics; GitHub docs for workflow_run default-branch rule and re-run GITHUB_SHA/GITHUB_REF semantics.
  • Public-repo ground truth: GitHub code search for both shas (31 vs 7,136 hits); mmzen/se_harness run 31514559160 log and the workflow file at its head sha; AboveColin/petsseries and OthmanAdi/hf-exit runs (publish step skipped in both — no signal).
  • hatchling: PyPI release dates, requires_python, yanked and yanked_reason for 1.26–1.32.1; DEFAULT_METADATA_VERSION in backend/src/hatchling/metadata/spec.py at 1.27.0, 1.30.0, 1.30.1, 1.31.0, 1.32.0.
  • twine/packaging: PyPI requires_dist for twine 6.0.0–7.0.0 and packaging 24.0–26.3; _VALID_METADATA_VERSIONS in packaging 25.0/26.0/26.2; the monkeypatch in twine 6.1.0 twine/package.py; twine 7.0.0 changelog and issue #1317.
  • Reproduction (scratchpad only, UV_CACHE_DIR and all venvs under the scratchpad, Python 3.13.12 and 3.9.6 from the system, nothing installed globally, no background processes): built this repo at 9509e80 with hatchling 1.31.0 and 1.32.0; ran twine check with twine 6.1.0+packaging 25.0, 6.1.0+26.2, 6.2.0+26.2, 7.0.0+26.2; resolved hatchling>=1.31,<2 and hatchling<2 under 3.9 and 3.13.
  • semantic-release: release.config.mjs, package.json scripts, yarn.lock commit-analyzer version and its default release rules; git log v2.0.0..main; src/convert_sdk/version.py; pyproject.toml dynamic version.
  • release.yml and ci.yml at 9509e80: triggers, guards, permissions, environment, YAML parse; sweep of every sha-pinned uses: in the repo and the git object type of each sha (astral-sh/setup-uv@d4b2f3b6… is a commit; only the new pypa pin is a tag object).
  • PR CI checks at 9509e80: all green (CodeQL, PR title, Ruff, mypy, 15-cell test matrix, bounds-check lower/upper, build).

What I did NOT check

  • The contents of the GHCR image :dc37677b… (did not pull it); its provenance rests on pypa's tag list, creation time and the source pin at that commit.
  • A live run of the corrected workflow — no workflow was executed and nothing was posted to GitHub.
  • The PyPI Trusted Publisher configuration on pypi.org (not visible from the outside); the failing run reached the metadata check before any OIDC exchange, so it is not implicated.
  • The B-G7 / AgDR section of the PR body and gate markers — outside this review's scope.
  • Whether GitHub's Actions service would resolve a tag-object sha for a non-composite (JS/Docker) action; not needed — the pypa action's own image lookup fails first, as the se_harness run shows.

The Release run for the 2.1.0 merge failed at the publish step:

    InvalidDistribution: Invalid distribution metadata:
    '2.5' is not a valid metadata version

Cause, measured end to end:

- The publish action was pinned to ba38be9e = v1.14.1, whose
  requirements/runtime.txt pins twine==6.1.0.
- `requires = ["hatchling"]` carried no bound, so `uv build` resolves the
  newest backend at build time. hatchling 1.31.0 emits Metadata-Version 2.4;
  1.32.0 (2026-08-11) emits 2.5. Bisected locally.
- twine 6.1.0 rejects 2.5; twine 7.0.0 accepts it. No 6.x accepts it: 6.1.0
  freezes the valid list in twine/package.py, so the packaging version does
  not matter. Verified against this package's own wheel.

It went unnoticed for five weeks because the last real publish was v2.0.0 on
2026-07-24, before hatchling 1.32.0 existed. The Release runs on 2026-08-12
and 2026-09-15 both reported success with the publish job SKIPPED — no
releasable commits — so the pipeline was green while broken.

Changes:

- Pin the publish action to v1.14.2, which pins twine==7.0.0. The sha is
  dc37677b, `v1.14.2^{commit}`. The peel matters: the annotated tag object
  a892a5a6 was pushed first and is NOT a commit. The action turns
  github.action_ref into a GHCR image tag verbatim, and no image exists under
  the tag-object id, so it fails with `manifest unknown` before twine runs.
  Resolve an annotated tag with `git rev-parse <tag>^{commit}`.
- Run `twine check` in CI's build job with the same twine the publish action
  ships. This coupling — the backend's default metadata version against the
  uploader's accepted list — is what actually broke, and it was observable
  only at publish time on main, after the merge that should have caught it.
- Add .github/dependabot.yml for github-actions. v1.14.2 shipped 13 days
  before hatchling 1.32.0; a weekly bump lands the new uploader inside that
  window, and Dependabot writes the peeled sha.
- Cap the build backend at `hatchling<2`. A conventional cap, not a guard:
  both metadata bumps (1.30.0, 1.32.0) happened inside 1.x, so it would not
  have prevented this. The twine check is the guard.

`hatchling>=1.31` was tried first and is wrong — hatchling requires Python
>=3.10 from 1.28.0, and NFR22 mandates a 3.9 floor, so it made
build-system.requires unsatisfiable on the bottom matrix row. Under
`hatchling<2` the backend resolves per interpreter: 3.9 takes 1.27.0
(metadata 2.4), 3.13 takes 1.32.0 (2.5). twine 7.0.0 accepts both. The
release build runs on 3.13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abbaseya
abbaseya force-pushed the fix/release-metadata-2-5 branch from 9509e80 to 174933d Compare September 17, 2026 16:21
@abbaseya

Copy link
Copy Markdown
Collaborator Author

Thanks — all three accepted and fixed at 174933d. I re-verified each one independently rather than taking the review on trust; every load-bearing claim reproduced.

1 (CRITICAL) — correct, and it was my error. I resolved the pin with git/refs/tags/v1.14.2 -q .object.sha and never peeled it, so I recorded the annotated tag object. Confirmed here: commits/a892a5a6… → 422 No commit found for SHA; git/tags/a892a5a6… peels to dc37677b2e1c63e2034f94d8a5b11f265b73ba33. Anonymous GHCR manifest probe, which is the part that decides it:

a892a5a6…  (my pin)      404
dc37677b…  (peeled)      200
v1.14.2                  200
ba38be9e…  (old pin)     200

So the pushed fix would have failed at the docker pull before twine ran — today's shape with a different error, as you said. Repinned to dc37677b. Your git rev-parse <tag>^{commit} rule is now in the commit message so the next person doesn't repeat it.

2 — correct. The body was written against the first push and I amended the commit without updating it. Rewritten. Your 1.28.0 correction also holds: PyPI requires_python is >=3.8 at 1.27.0 and >=3.10 from 1.28.0 (2025-11-27), not 1.31 — commit message fixed.

3 — correct, and the more useful finding. Both metadata bumps happened inside 1.x, so <2 could not have prevented this and calling it the recurrence guard was wrong. Took option (a):

  • twine check in CI's build job, running the same twine the publish action ships, with a comment tying the two versions together. Verified locally — PASSED on wheel and sdist. This is what would have caught the original break at PR time instead of at publish on main.
  • .github/dependabot.yml for github-actions, weekly. Your point that it would have landed v1.14.2 inside the 13-day window and written the peeled sha is what sold it — it addresses finding 1 as well.
  • <2 stays, now described as a conventional cap rather than a guard.

Noted and not actioned, as out of scope: the perf: drift between release.config.mjs's comment and commit-analyzer's defaults — that belongs to the wiki drift routine.

One thing your review did not cover that I found while fixing the first push, in case it is useful: the py3.9 matrix cells do not run 3.9. In run 35232477588 the test (py3.9 / ubuntu-latest) coverage header reads python 3.13.12-final-0uv sync --python 3.9 is followed by a bare uv run pytest, which lands on 3.13. Forcing a real 3.9 run fails at collection on tests/test_packaging.py's bare import tomllib. Pre-existing and untouched by this PR; not fixed here.

@JosephSamirL JosephSamirL 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.

Approved via /convert:approve. An independent code review ran through /convert:review, and this issues the B-G4 human marker at 174933d.

@abbaseya
abbaseya merged commit 8a977fb into main Sep 18, 2026
28 checks passed
@abbaseya
abbaseya deleted the fix/release-metadata-2-5 branch September 18, 2026 11:33
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.

2 participants