fix(release): a lost publish race is not a failed publish - #432
Conversation
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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
pbean has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
Carries the fix for #431 onto
release/0.9.x, alongside #430 which carries it tomain.Both branches need it by hand. CodeRabbit found this on #430, but its config does not
cover
release/0.9.x— on #429 it reportedpasswith "Review skipped: reviews aredisabled for this base branch", and Greptile is out of trial credits. Any finding raised on
a
mainPR has to be walked across deliberately or this branch silently misses it.The defect
#429 gave
release.ymlarelease/*trigger, which is what makes two branches able topublish — and that turns
cmd_publish's idempotency guard into a TOCTOU:Nothing re-fetches between the probe and the create, so
tag_existsanswers for tag stateat 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_existsisgit rev-parse -q --verify refs/tags/<tag>with no fetch anywhere in
cmd_publishor 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
concurrency.groupdropsgithub.ref→release-publish, one group repo-wide, sopublish runs serialize and the second checks out after the first pushed its tag and
short-circuits cleanly on the probe.
cancel-in-progressstaysfalse; a cancelledpublish leaves the tag unmade.
gh release createno longer runs undercheck=True. A stderr naming a duplicatetag 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_existsmatches the phrase rather than the HTTP status on purpose — a 422 raisedfor some other validation failure (malformed target, bad notes) stays on the loud path.
Verification
Suite 3032 passed / 1 skipped.
release.py checkgreen,trunk check(no filter) clean.setup-uvdeliberately left atv8.3.2here —mainis onv9.0.0and that difference isnot this PR's business.
Both directions of the new guard were ablated and each paired test seen failing before
restore:
_already_exists→ alwaysFalse(guard removed)test_publish_treats_a_lost_race_as_success+ 3 parametrized rows_already_exists→ alwaysTrue(swallows everything)test_publish_still_dies_on_a_genuine_gh_failure+ 3 parametrized rowsThe second one is the one worth having: a guard that swallowed every
ghfailure wouldhave passed the first test and turned a bad-credentials publish into a silent green.
Refs #431, #405.