Skip to content

fix(release): a lost publish race is not a failed publish - #432

Merged
pbean merged 1 commit into
release/0.9.xfrom
chore/publish-race-0.9.x
Aug 2, 2026
Merged

fix(release): a lost publish race is not a failed publish#432
pbean merged 1 commit into
release/0.9.xfrom
chore/publish-race-0.9.x

Conversation

@pbean

@pbean pbean commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Carries the fix for #431 onto release/0.9.x, alongside #430 which carries it to main.

Both branches need it by hand. CodeRabbit found this on #430, but its config does not
cover release/0.9.x — on #429 it reported pass with "Review skipped: reviews are
disabled for this base branch", and Greptile is out of trial credits. Any finding raised on
a main PR has to be walked across deliberately or this branch silently misses it.

The defect

#429 gave release.yml a release/* trigger, which is what makes two branches able to
publish — and that turns cmd_publish's idempotency guard into a TOCTOU:

if tag_exists(tag):        # git rev-parse against THIS CHECKOUT's refs
    return 0
...
subprocess.run([... "gh", "release", "create", tag ...], check=True)

Nothing re-fetches between the probe and the create, so tag_exists answers for tag state
at checkout time, not for the remote now. And concurrency: group: release-${{ github.ref }}
does not serialize the two, because two branches are two refs. So two runs carrying the same
version could both pass the probe, both create, and the loser died on check=True.

Verified rather than assumed: tag_exists is git rev-parse -q --verify refs/tags/<tag>
with no fetch anywhere in cmd_publish or the workflow beyond the initial checkout.

Severity is low on its own — the winner's release is correct, so the symptom is a red job
for an outcome that already holds — but it fails in exactly the situation the 0.9.x line
exists to support.

The fix, from both ends

  1. concurrency.group drops github.refrelease-publish, one group repo-wide, so
    publish runs serialize and the second checks out after the first pushed its tag and
    short-circuits cleanly on the probe. cancel-in-progress stays false; a cancelled
    publish leaves the tag unmade.
  2. gh release create no longer runs under check=True. A stderr naming a duplicate
    tag returns 0 with a distinct message; anything else prints the stderr and dies as
    before. This is the half that actually closes the window, since it does not depend on
    scheduling.

_already_exists matches the phrase rather than the HTTP status on purpose — a 422 raised
for some other validation failure (malformed target, bad notes) stays on the loud path.

Verification

Suite 3032 passed / 1 skipped. release.py check green, trunk check (no filter) clean.
setup-uv deliberately left at v8.3.2 here — main is on v9.0.0 and that difference is
not this PR's business.

Both directions of the new guard were ablated and each paired test seen failing before
restore:

Ablation Reddens
_already_exists → always False (guard removed) test_publish_treats_a_lost_race_as_success + 3 parametrized rows
_already_exists → always True (swallows everything) test_publish_still_dies_on_a_genuine_gh_failure + 3 parametrized rows

The second one is the one worth having: a guard that swallowed every gh failure would
have passed the first test and turned a bad-credentials publish into a silent green.

Refs #431, #405.

Adding the `release/*` trigger lets two branches publish, which turns
`cmd_publish`'s idempotency guard into a TOCTOU. `tag_exists` is `git rev-parse`
against the *checkout's* refs and nothing re-fetches before `gh release create`,
so it answers for tag state at checkout time. Two runs carrying the same version
could both pass it, both create, and the loser died on `check=True` — a red job
for an outcome that was already correct, since the winner made this exact tag
from this exact CHANGELOG section.

Close it from both ends. The concurrency group drops `github.ref` so all publish
runs serialize repo-wide and the second one checks out after the first pushed its
tag, short-circuiting cleanly on the probe. And `gh release create` no longer runs
under `check=True`: a stderr naming a duplicate tag returns 0, anything else
prints the stderr and dies as before.

Matching the phrase rather than the HTTP status keeps a 422 raised for some other
validation failure — a malformed target, bad notes — on the loud path.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2803178c-f3d6-4fda-a2a5-b254cbe4aaec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps greptile-apps 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.

pbean has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@pbean
pbean merged commit 90d67bd into release/0.9.x Aug 2, 2026
9 checks passed
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