From 8e7b8ec0060f4aa92a80d5f501e41ae40c637ba0 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 15:39:41 +0000 Subject: [PATCH 1/3] fix(release): refuse to bump on top of an unpublished release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/release.sh | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/scripts/release.sh b/scripts/release.sh index 8777da1..b5a2a6e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -45,6 +45,44 @@ check_prerequisites() { } # Get current version +# Detect a release that was committed locally but never reached origin — a push +# or goreleaser failure after commit_and_tag. Left undetected, the next run reads +# the already-bumped VERSION and offers to bump *on top of it*, skipping a +# version in the published sequence and leaving local tags on a commit the +# remote has never seen. See livetemplate#500 for the same bug in core. +# +# This runs on every release while the state it catches is rare, so it fails +# toward "proceed with the normal release": any check it cannot complete returns +# 1 (not detected) rather than guessing. A false positive would block a healthy +# release; a false negative only restores the previous behaviour. +check_unpublished_release() { + local branch=$1 + local version + version=$(get_current_version) + + # Only meaningful when HEAD is this version's release commit. + [ "$(git log -1 --pretty=%s 2>/dev/null)" = "chore(release): v$version" ] || return 1 + + # Deciding this needs an up-to-date origin/$branch. Immediately after a + # SUCCESSFUL release the HEAD subject and VERSION look identical to the + # unpublished case, so the ancestry test below is the only thing telling + # them apart — on a stale ref it would report a published release as + # unpublished. A fetch that fails therefore means "cannot tell", not + # "unpublished". + git fetch --quiet origin "$branch" 2>/dev/null || return 1 + + # An ancestor of the remote branch means it is published. + if git merge-base --is-ancestor HEAD "refs/remotes/origin/$branch" 2>/dev/null; then + return 1 + fi + + # Not an ancestor — but merge-base also fails when the ref is missing + # entirely (a branch never pushed), which is not this bug. + git rev-parse -q --verify "refs/remotes/origin/$branch" >/dev/null 2>&1 || return 1 + + return 0 +} + get_current_version() { if [ ! -f VERSION ]; then log_error "VERSION file not found" @@ -398,6 +436,38 @@ main() { log_error "Repository is in a detached HEAD state. Please check out a branch before running this script." exit 1 fi + # Before pulling: a rebase would move the release commit out from under its + # tags, leaving them on an orphaned commit. + if check_unpublished_release "$branch"; then + local pending t + pending=$(get_current_version) + echo "" + log_error "v$pending is committed locally but has not reached origin/$branch" + echo "" + echo "A previous release committed and tagged v$pending, then failed before" + echo "publishing it. Releasing again from here would bump on top of v$pending" + echo "and skip it in the published sequence." + echo "" + echo "Finish that release first:" + echo "" + echo " git push origin $branch" + # Nested module tags (components/vX.Y.Z) are part of the release too — + # enumerate whatever this version actually tagged rather than guessing. + for t in $(git tag -l "v$pending" "*/v$pending"); do + echo " git push origin $t" + done + echo "" + echo "Then check whether the GitHub release and binaries exist:" + echo "" + echo " gh release view v$pending" + echo "" + echo "If it reports no release, goreleaser never ran — re-run it from a" + echo "clean tree at that tag (see publish_github in this script)." + echo "" + echo "Once v$pending is published, re-run this script for the next version." + exit 1 + fi + if [ "$dry_run_mode" = true ]; then log_info "[dry-run] Would pull latest from origin/$branch" else From 4c2e7b9bddb525e04cc07c7bb54151e576324595 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 15:57:48 +0000 Subject: [PATCH 2/3] fix(release): fix comment placement and say so when the release has no 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. --- scripts/release.sh | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index b5a2a6e..92e70bf 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -44,7 +44,6 @@ check_prerequisites() { fi } -# Get current version # Detect a release that was committed locally but never reached origin — a push # or goreleaser failure after commit_and_tag. Left undetected, the next run reads # the already-bumped VERSION and offers to bump *on top of it*, skipping a @@ -83,6 +82,7 @@ check_unpublished_release() { return 0 } +# Get current version get_current_version() { if [ ! -f VERSION ]; then log_error "VERSION file not found" @@ -453,9 +453,25 @@ main() { echo " git push origin $branch" # Nested module tags (components/vX.Y.Z) are part of the release too — # enumerate whatever this version actually tagged rather than guessing. - for t in $(git tag -l "v$pending" "*/v$pending"); do - echo " git push origin $t" - done + # + # An empty list is a real state, not just a defensive branch: + # commit_and_tag sets release_committed immediately after the commit and + # tags afterwards, so a failure at the tag step leaves a release commit + # with no tags at all. Saying nothing here would print guidance that + # looks complete while omitting the step that actually failed. + local tags + tags=$(git tag -l "v$pending" "*/v$pending") + if [ -n "$tags" ]; then + for t in $tags; do + echo " git push origin $t" + done + else + echo "" + echo " # No v$pending tag exists locally — the tag step never completed." + echo " # Recreate the tags before pushing:" + echo " git tag -a v$pending -m \"Release v$pending\"" + echo " # ...plus one per nested Go module, e.g. components/v$pending" + fi echo "" echo "Then check whether the GitHub release and binaries exist:" echo "" From 0203fc468a16d8f8bbe8ea1a683bfa8a10d95b78 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 16:07:05 +0000 Subject: [PATCH 3/3] fix(release): derive the expected tag set instead of listing what exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/release.sh | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 92e70bf..cdd012e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -451,27 +451,32 @@ main() { echo "Finish that release first:" echo "" echo " git push origin $branch" - # Nested module tags (components/vX.Y.Z) are part of the release too — - # enumerate whatever this version actually tagged rather than guessing. + # Build the tag set this release SHOULD have — the main tag plus one per + # nested Go module — mirroring commit_and_tag, then report each one. # - # An empty list is a real state, not just a defensive branch: - # commit_and_tag sets release_committed immediately after the commit and - # tags afterwards, so a failure at the tag step leaves a release commit - # with no tags at all. Saying nothing here would print guidance that - # looks complete while omitting the step that actually failed. - local tags - tags=$(git tag -l "v$pending" "*/v$pending") - if [ -n "$tags" ]; then - for t in $tags; do + # Deriving the expected set matters more than listing what exists. + # commit_and_tag creates the main tag and then loops over nested modules, + # so a failure partway through that loop leaves a PARTIAL set: main tag + # present, a nested one missing. Asking only "did we find any tags?" + # would take the push-what's-there path and never mention the missing + # one — silently incomplete guidance, which is the same failure this + # check exists to prevent, one level down. + local expected=("v$pending") subdir gomod + while IFS= read -r gomod; do + subdir=$(dirname "$gomod") + subdir=${subdir#./} + [ "$subdir" != "." ] && expected+=("${subdir}/v$pending") + done < <(find . -name "go.mod" -not -path "./vendor/*" -not -path "./.git/*" -not -path "./.worktrees/*" | grep -v "^\./go.mod$" | sort) + + local t + for t in "${expected[@]}"; do + if git rev-parse -q --verify "refs/tags/$t" >/dev/null 2>&1; then echo " git push origin $t" - done - else - echo "" - echo " # No v$pending tag exists locally — the tag step never completed." - echo " # Recreate the tags before pushing:" - echo " git tag -a v$pending -m \"Release v$pending\"" - echo " # ...plus one per nested Go module, e.g. components/v$pending" - fi + else + echo " git tag -a $t -m \"Release $t\" # missing — tag step did not complete" + echo " git push origin $t" + fi + done echo "" echo "Then check whether the GitHub release and binaries exist:" echo ""