Skip to content

fix: fail the release when the computed version already exists on the registry - #897

Merged
n24q02m merged 1 commit into
mainfrom
fix/cd-guard-version-already-published
Jul 10, 2026
Merged

fix: fail the release when the computed version already exists on the registry#897
n24q02m merged 1 commit into
mainfrom
fix/cd-guard-version-already-published

Conversation

@n24q02m

@n24q02m n24q02m commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Problem

PyPI never allows reusing a version number. If this repo's git history is ever rewritten away from what was published, python-semantic-release (PSR) can later compute an already-published version. Two bad outcomes follow:

  • Silent freeze: if a matching git tag exists, PSR reports the version "already released", sets released=false, and the downstream publish-pypi job is silently skipped. Nobody notices.
  • Opaque failure: if no tag exists, uv publish dies with an opaque 409 deep inside the pipeline, after PSR has already created a git tag and GitHub Release.

Neither is acceptable. This adds a loud, early, actionable failure instead.

Change (.github/workflows/cd.yml only)

Placed in the release job, before the real semantic-release step, so it fires before any tag or Release is created:

  1. Dry-run step (id: dryrun) -- the exact same pinned PSR action (350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0) with no_operation_mode: true (--noop) and the same prerelease/prerelease_token inputs as the real id: release step. This repo's real step passes no config_file, so the dry-run doesn't either -- both compute the version identically against the same untouched checkout. No new action or dependency.

  2. Guard step -- gated on steps.dryrun.outputs.released == 'true' (mirrors the existing needs.release.outputs.released == 'true' gate on publish-pypi, so a "no releasable commits" dispatch doesn't false-fail on the current already-published version). Queries PyPI for qwen3-embed==<computed version> (name verbatim from pyproject.toml [project].name) and classifies:

    curl -o /dev/null -w '%{http_code}' meaning action
    200 COLLISION -- version exists ::error:: + exit 1
    404 FREE -- version/package absent proceed
    anything else UNKNOWN -- network/registry error ::error:: + exit 1, never treated as free

    I confirmed myself that PyPI normalizes the version string in the URL path: https://pypi.org/pypi/qwen3-embed/1.12.1-beta.1/json and https://pypi.org/pypi/qwen3-embed/1.12.1b1/json both return 200 for the same release, so PSR's raw semver version output can go straight into the URL without reformatting.

A transient error is deliberately treated as fatal rather than "free" -- reading it as free would let a real collision slip through, which is worse than the bug this guard prevents.

The release job's outputs (released/tag/version/is_prerelease) still come from the real id: release step and are unchanged, so publish-pypi, notify-downstream, create-downstream-issues consume them exactly as before.

Verification

YAML parses:

$ python -c "import yaml; yaml.safe_load(open('.github/workflows/cd.yml')); print('YAML OK: parsed successfully')"
YAML OK: parsed successfully

Guard's shell classification logic exercised live against the real PyPI registry, using the exact curl invocation from the guard, three ways:

CASE 1  version 1.12.1 (published)   -> http=200 -> CLASSIFICATION: COLLISION (fail, exit 1)
CASE 2  version 99.99.99 (unused)    -> http=404 -> CLASSIFICATION: FREE (proceed, exit 0)
CASE 3  forced-unreachable endpoint  -> http=000 -> CLASSIFICATION: UNKNOWN (fail, exit 1)

Commit created with pre-commit hooks running normally (the Python-only hooks -- ruff/ty/pytest -- correctly skip a YAML-only change).

Do not merge.

@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

… registry

PyPI never allows reusing a version number. If this repo's git history is
ever rewritten away from what was published, python-semantic-release can
later compute an already-published version, and that failed in one of two
bad ways: PSR sees a matching git tag, reports the version "already
released", sets released=false, and every downstream publish job is
silently skipped (nobody notices); or no tag exists, and `uv publish` dies
with an opaque 409 deep inside the pipeline, after PSR has already created
a git tag and GitHub Release.

Adds a dry-run PSR step (no_operation_mode: true, same pinned action/SHA
and same prerelease inputs as the real release step) purely to learn the
version PSR would publish, followed by a guard step gated on
steps.dryrun.outputs.released == 'true' that queries PyPI for that exact
version and classifies the result: HTTP 200 is a collision (fail loudly,
explain the recovery), 404 is free (proceed), and anything else (network
error, rate limit, outage) is unknown and is deliberately treated as fatal
rather than as "free" -- a transient error read as "free" would let a real
collision slip through, which is worse than the bug this guard prevents.

Both fire before any tag or Release exists, converting the two silent/opaque
failure modes above into one explicit, early, actionable error.
@n24q02m
n24q02m force-pushed the fix/cd-guard-version-already-published branch from 46b7f94 to 30897e0 Compare July 10, 2026 02:29
@n24q02m
n24q02m merged commit 59b22ed into main Jul 10, 2026
13 of 25 checks passed
@n24q02m
n24q02m deleted the fix/cd-guard-version-already-published branch July 10, 2026 02:36
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