fix(release): refuse to bump on top of an unpublished release - #340
Conversation
Same gap as livetemplate#500, fixed there in livetemplate#501. If a release got as far as commit_and_tag and then failed to publish — a push error, a goreleaser failure — the tree held a release commit and tags the remote had never seen, and nothing said so. The next run read the already-bumped VERSION as current and offered to bump on top of it, skipping a version in the published sequence. main() now detects that state before pulling and refuses, printing the commands that finish the interrupted release. Detection is before the pull because git pull --rebase would move the release commit out from under its tags, leaving them on an orphaned commit. Detect-and-refuse rather than resume-automatically: publish_github is not idempotent, so an automatic resume would break at one of the likeliest ways to reach this state. The check runs on every release while the state it catches is rare, so every branch fails toward 'proceed with the normal release'. A failed fetch returns 'not detected' rather than testing ancestry against a stale origin ref — immediately after a SUCCESSFUL release the HEAD subject and VERSION are identical to the unpublished case, and that ancestry test is the only thing telling them apart. One difference from core: this repo tags nested Go modules (components/vX.Y.Z) alongside the main tag, and publishes via goreleaser rather than gh release create. The guidance enumerates whatever tags the version actually created instead of assuming, and points at goreleaser for the release itself. Verified against a bare origin so nothing escaped: healthy tree proceeds; unpublished release refuses without bumping and lists both v0.2.1 and components/v0.2.1; following the printed commands verbatim clears the refusal; a broken remote does not false-positive; --dry-run refuses too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review Solid, well-scoped port of the core-repo fix (#501) to this repo's release script. The design rationale in the PR description (detect-before-pull, detect-and-refuse rather than auto-resume, fail-toward-proceeding) is sound and the implementation matches it faithfully. I read through Correctness
Minor nit
Edge case worth a thought (not blocking)
Test coverage
Security
Overall: a careful, well-reasoned change that mirrors an already-vetted fix. Nothing here blocks merging; the comment-placement nit is cosmetic. |
…o tags Two points from claude-review: - The pre-existing '# Get current version' comment ended up heading check_unpublished_release instead of get_current_version. Moved back. - The tag enumeration printed nothing when no tags matched, which is a reachable state rather than a hypothetical: commit_and_tag sets release_committed right after the commit and tags afterwards, so a failure at the tag step leaves a release commit with no tags at all. Guidance that looks complete while omitting the step that actually failed is worse than none. It now says the tag step never completed and prints the recreate lines, including the nested-module tag. Verified both branches: with tags present, v0.2.1 and components/v0.2.1 are both listed; with them deleted, the recreate guidance is printed instead.
|
Both applied (4c2e7b9) — and the second was more than the edge case it looked like. Applied: comment placement. You're right, Applied: empty tag list. You flagged this as theoretical — "no gap for partial failure under The flag is set immediately after the commit and before tagging, deliberately (livetemplate#499): so that a tag failure doesn't cause the abort trap to discard the release commit. That same ordering makes "commit exists, tags don't" reachable — a duplicate tag, a bad In that state the old guidance printed only Now: Verified both branches — tags present lists Acknowledged: no automated tests. Agreed, and your point that |
|
Review: refuse to bump on top of an unpublished release Overview Code quality
Potential bug: incomplete guidance when tags are missing/partial
Suggestion: compute the expected tag set the same way Performance Security Test coverage Summary |
Two bugs in the recovery guidance, both raised by claude-review on #340. 1. The no-tags branch printed only the git tag -a lines, never the matching git push. Following it literally recreated the tags locally and stopped, leaving the release exactly as unpublished as before. 2. The branch was chosen on 'did we find any tags?', which is zero-vs-some. commit_and_tag creates the main tag and then loops over nested Go modules, so a failure partway through that loop leaves a PARTIAL set: main tag present, a nested one missing. That took the push-what's-there path and never mentioned the missing tag — silently incomplete guidance, the same failure this check exists to prevent, one level down. Both fixed by deriving the tag set the release should have (main tag plus one per nested go.mod, mirroring commit_and_tag) and reporting each one independently: push it if it exists, otherwise create-then-push. Verified all three states, and end-to-end: with every tag present only pushes are printed; with a nested tag deleted the main tag is pushed and the nested one recreated; with all tags deleted both are recreated and pushed. Following the all-deleted output verbatim publishes the release and clears the refusal.
|
Both real, both fixed (0203fc4). The second one is the better catch and I'd underweighted the same risk in my own comment. 1. The no-tags branch never printed the pushes. Correct — following it literally recreated tags locally and stopped, leaving the release exactly as unpublished as before. Straightforwardly wrong. 2. Partial tag sets took the wrong branch. This is the one worth dwelling on. My condition was That's the exact failure this check exists to prevent, one level down: silently incomplete output that reads as authoritative. I'd written that sentence into the PR description and then shipped an instance of it. Fixed by inverting the question — instead of listing what exists, derive what the release should have (main tag plus one per nested Verified all three states plus end-to-end:
And following the all-missing output verbatim publishes the release and clears the refusal — which is the property I actually care about, since guidance that's merely plausible is what produced both of these bugs. Core (#501) has neither issue: it has no nested modules, and its missing-tag branch already printed the push. Left unchanged. |
ReviewSolid fix, and the commit history shows the design already went through a couple of real rounds of self-correction (the partial-tag-set bug and the missing-push-command bug are exactly the kind of thing that's easy to miss on a first pass). The comments throughout do a good job explaining why rather than what, which fits the surrounding file's style. Code quality
Bugs
Performance
SecurityNo concerns. All the new logic operates on internally-derived values (git plumbing output, the Test coverageThere's no automated test harness for Overall: nice defensive addition, ports the core fix well, and the two follow-up commits show good responsiveness to review feedback already. The only actionable suggestion is deduplicating the nested-module |
|
Converged — this round found no correctness bug, and independently re-verified the ancestry logic and both fallbacks. Stopping pushes here per the project's review-loop guidance; answering the remaining points by reply. Deferred, tracked as #341: the triplicated Deferring on blast radius, not disagreement. The recovery block is read-only — it prints guidance. Deferred to the same issue: the dead Acknowledged, no change: Acknowledged, no change: the extra fetch. Correct that it's one additional round-trip per run. For an interactive script invoked a few times a month, that's a fair price for the check being right — and it can't be avoided without reusing a possibly-stale ref, which is the failure mode the fetch exists to prevent. |
Same gap as livetemplate#500, fixed there in livetemplate#501. Raised while reviewing that one — this repo shares the structure.
If a release got as far as
commit_and_tagand then failed to publish — a push error, a goreleaser failure — the tree held a release commit and tags the remote had never seen, and nothing said so. The next run read the already-bumpedVERSIONas current and offered to bump on top of it, skipping a version in the published sequence.main()now detects that state and refuses:Two lvt-specific differences from the core fix
Nested module tags. This repo tags
components/vX.Y.Zalongside the main tag, andpublish_githubpushes both. Finishing an interrupted release therefore means pushing both, so the guidance enumerates whatever tags the version actually created (git tag -l "v$pending" "*/v$pending") rather than assuming a fixed set — it adapts if more nested modules appear.goreleaser, not
gh release create. The GitHub release and binaries come from goreleaser, which isn't a one-liner to hand-run, so the guidance points atgh release viewto check and then atpublish_githubrather than pretending there's a simple command.Design notes (same reasoning as livetemplate#501)
Detect before the pull —
git pull --rebasewould move the release commit out from under its tags, leaving them on an orphaned commit.Detect-and-refuse, not resume —
publish_githubisn't idempotent, so an automatic resume would break at one of the likeliest ways to reach this state.Fails toward proceeding — this runs on every release while the state is rare, so a false positive (blocking a healthy release) is worse than a false negative. A failed fetch returns "not detected" rather than testing ancestry against a stale ref: right after a successful release, the HEAD subject and
VERSIONare identical to the unpublished case, and that ancestry test is the only discriminator.Verification
Against a bare origin so nothing escaped:
VERSIONunchangedv0.2.1+components/v0.2.1--dry-runon unpublishedThe fourth row is the one I most wanted to confirm — the guidance isn't just plausible, following it verbatim actually resolves the state.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG