Skip to content

fix: a version collision published nothing and said nothing - #595

Merged
blooop merged 2 commits into
mainfrom
fix/version-collision-is-loud
Sep 9, 2026
Merged

fix: a version collision published nothing and said nothing#595
blooop merged 2 commits into
mainfrom
fix/version-collision-is-loud

Conversation

@blooop

@blooop blooop commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #594, which cleaned up the consequence of a version collision without doing anything about the cause.

What happened

#591 and #593 both bumped to 0.36.0. Git merged that without a conflict, because picking the same number means both sides made the identical edit: one version = "0.36.0" line in rust/Cargo.toml, and one byte-identical ## [0.36.0] heading in CHANGELOG.md with the differing bodies merged in underneath as separate additions. Had #591 picked 0.37.0, Cargo.toml would have conflicted loudly and it would have been caught at the merge.

The second merge then published nothing:

v0.36.0 is already tagged; nothing to publish

That is also what the workflow says for every ordinary push, so the dl -- <cmd> quoting fix sat on main released nowhere, with no check red.

Two guards, because only one place can see it

scripts/version_untaken.py, on the pull request. This is the one that would have caught #591. It compares the version at pull_request.base.sha with the branch's; if the branch proposes a bump, the proposed version must not already be tagged.

base.sha is the base as of the pull request rather than the moving tip of main — for #591 it was 908a24e, the commit before #593 landed, where the version was still 0.35.0. So the comparison sees 0.35.0 -> 0.36.0 and asks the only question worth asking about it. test_the_collision_that_happened_is_refused replays #591's own two manifests rather than a hand-built imitation:

this branch bumps 0.35.0 -> 0.36.0, and v0.36.0 is already tagged.

The four cases it has to get right: an ordinary branch that never touches the version passes, a release cut to an unused version passes, a bump onto a taken version fails, and an unreadable manifest fails closed.

publish.yml, and this half is narrower than it looks. It now separates a re-run over the commit it already published (skip, idempotent — gh release create --target $GITHUB_SHA is what puts the tag on HEAD) from a push that moves the version onto somebody else's tag (error, with the reason).

That second arm would not have caught #591, and the tests say so rather than leaving it as a claim in a comment. By the time the merge reached main it carried the same version as its first parent — both 0.36.0 — so there was no bump left to see. test_the_case_that_needs_the_pull_request_guard pins exactly that. The arm is defence in depth for the collision shape that is visible there, not a second answer to this one.

The tag-existence check is still asked of the ref alone and never of the commit behind it: this is a fetch-depth: 2 clone, so a tag pointing anywhere but HEAD may have no commit object to resolve, and a failed resolve must not read as "free to publish".

Testing the workflow as the shell it is

test_publish_decision.py extracts the run: body from publish.yml and executes it against throwaway repositories, one per arm. A reimplementation here would be a second hand-maintained copy of the logic and would keep passing while the real step rotted.

What is still not covered

The convention is the other half and is not enforced by anything: every release for the last eight versions was cut on its own release/X.Y.Z branch doing nothing but the bump, which is why this is the first time it has happened. These guards make the collision loud; they do not make the bump land in the right kind of PR.

Verification

776 passed in the Python guards, prek clean, pylint 10.00/10 on the new script.

🤖 Generated with Claude Code

Summary by Sourcery

Prevent version collisions from merging unnoticed and leaving releases unpublished by validating proposed versions in pull requests and reporting visible collisions during publishing.

New Features:

  • Add a pull-request guard that rejects proposed versions already present in Git tags.
  • Add workflow coverage for detecting version collisions during publishing.

Bug Fixes:

  • Prevent release bumps that would merge silently and leave changes on main without publishing them.
  • Distinguish idempotent publish reruns and non-release pushes from version collisions, reporting the latter as errors.

Enhancements:

  • Fail closed when release manifests or tag checks cannot be read reliably.
  • Test the actual publish workflow shell and version guard behavior across release, rerun, ordinary-push, collision, and invalid-input cases.

CI:

  • Run the version-availability guard during pull-request CI.
  • Extend publish workflow decision handling to identify and reject bumps onto existing tags.

Tests:

  • Add repository-backed tests for version collision prevention and publish decision behavior.

#591 and #593 both bumped to 0.36.0. Git merged that without a conflict --
picking the same number means both sides wrote the identical version line, and
the identical `## [0.36.0]` heading, so there was nothing to resolve -- and the
second merge published nothing at all. `publish.yml` found the tag and reported
`nothing to publish`, which is also what it says for every ordinary push, so the
fix landed on main released nowhere with no check red and no line to act on.

Two guards, because one place can see it and the other cannot.

`scripts/version_untaken.py` runs on the pull request, and it is the one that
would have caught #591: compare the version at `pull_request.base.sha` with the
branch's, and if the branch proposes a bump, refuse a version that is already
tagged. `base.sha` is the base as of the pull request rather than the moving tip
of main -- for #591 it was 908a24e, before the rival release, where the version
was still 0.35.0 -- so the comparison sees 0.35.0 -> 0.36.0 and asks the only
question that matters about it. `test_the_collision_that_happened_is_refused`
replays #591's own two manifests rather than a hand-built imitation.

`publish.yml` gets the narrower half, and the commit message should be honest
about how narrow. It now separates a re-run over the commit it already published
(skip, idempotent) from a push that moves the version *onto* somebody else's tag
(error, with the reason). That second arm would **not** have caught #591: by then
the merge carried the same version as its first parent, so there was no bump left
to see. `test_the_case_that_needs_the_pull_request_guard` pins that fact rather
than leaving it as a claim in a comment.

The decision step is extracted from the workflow and run as the shell it is, so
the four arms are tested against the real text instead of a reimplementation that
would pass while the original rotted.

Claude-Session: https://claude.ai/code/session_01XvY78XEnrkzNjxZE5EtnyW

@sourcery-ai sourcery-ai 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.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 21 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR prevents silent version-collision releases with two complementary guards: a fail-closed pull-request check compares the proposed version against existing tags before merge, while publish.yml provides narrower defense in depth and clearer failure behavior for collisions visible on main. Tests execute the real workflow shell and cover both the historical collision and the cases each guard can or cannot detect.

Sequence diagram for version collision prevention

sequenceDiagram
    participant PR as PullRequest
    participant CI as CIWorkflow
    participant Guard as version_untaken.py
    participant Git as GitTags
    participant Main as MainPush
    participant Publish as publish.yml

    PR->>CI: Open or update pull request
    CI->>Guard: Compare base Cargo.toml and branch Cargo.toml
    Guard->>Git: Check refs/tags/v<proposed_version>
    alt version is already tagged
        Git-->>Guard: Tag exists
        Guard-->>CI: Fail with collision reason
        CI-->>PR: Block merge
    else version is unused
        Git-->>Guard: Tag absent
        Guard-->>CI: Pass
        CI-->>PR: Allow merge
    end
    Main->>Publish: Run after push
    Publish->>Git: Check version tag
    alt tag is on HEAD
        Git-->>Publish: Existing tag on current commit
        Publish-->>Main: Skip idempotently
    else version changed onto another existing tag
        Git-->>Publish: Existing tag elsewhere
        Publish-->>Main: Fail with collision error
    else no relevant tag
        Git-->>Publish: Tag absent
        Publish-->>Main: Continue publishing
    end
Loading

File-Level Changes

Change Details Files
Add a pull-request guard that rejects version bumps onto already-published tags.
  • Compare base and head Cargo manifest versions.
  • Check the proposed tag and fail closed when manifests or Git cannot be read.
  • Allow unchanged versions and unused release versions.
  • Run the guard only for pull-request CI using the PR base SHA.
scripts/version_untaken.py
.github/workflows/ci.yml
test/test_version_untaken.py
Make release workflow decisions distinguish idempotent reruns, ordinary pushes, and version collisions.
  • Treat a tag on HEAD as an idempotent rerun and skip publishing.
  • Fail with an actionable error when a changed version is already tagged elsewhere.
  • Keep ordinary pushes that encounter an existing tag as quiet no-ops.
  • Check tag refs independently of resolving their target commit for shallow-clone safety.
.github/workflows/publish.yml
test/test_publish_decision.py
Test the actual workflow shell and historical collision scenarios.
  • Extract and execute the publish decision step from publish.yml in temporary Git repositories.
  • Cover successful bumps, reruns, unchanged pushes, visible collisions, and the merged collision shape that requires the PR guard.
  • Replay the historical manifests and verify unreadable inputs fail closed.
test/test_publish_decision.py
test/test_version_untaken.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.86%. Comparing base (07eccdd) to head (fbe0e61).

Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.13% <ø> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.13% <ø> (-0.03%) ⬇️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`test_the_collision_that_happened_is_refused` and its neighbour replayed #591's
real manifests by SHA, which fails everywhere but a full clone:
`actions/checkout` fetches depth 1, so `git show 908a24e:rust/Cargo.toml` is
`fatal: path exists on disk, but not in ...`.

Reading the real commits was wrong for a second reason the failure did not show.
The guard's oracle is `git rev-parse refs/tags/v<version>` in whatever directory
it runs in, and those tests ran it in the checkout -- which has not fetched tags
by the time pytest runs, since `pixi run ci` is the step before the one that
fetches them. The collision test would have gone green by the guard finding no
tag and permitting the bump, which is the opposite of what it asserts.

Each test builds its own repository and tags it now. The numbers and the shape
are #591's still; only the bytes are the test's own. The module docstring said
the replay was the point, so it says the opposite now and why.

Also stops `test_publish_decision.py` hardcoding `PATH` for the shell it runs:
the script needs `git` and `sed`, and naming three directories is a test that
passes here and fails on a runner that puts them elsewhere.

Claude-Session: https://claude.ai/code/session_01XvY78XEnrkzNjxZE5EtnyW
@blooop
blooop merged commit 7653de3 into main Sep 9, 2026
15 checks passed
@blooop
blooop deleted the fix/version-collision-is-loud branch September 9, 2026 16:22
@blooop blooop mentioned this pull request Sep 9, 2026
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