From 77ebbdc1b8ac1d6cbb5151eebfab22ebdc59bb76 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 14:08:47 +0000 Subject: [PATCH 1/3] fix(release): survive an aborted release instead of leaving a half-bumped tree Same ordering bug as livetemplate#499, found there and confirmed here. The order was update_versions -> generate_changelog -> build_and_test, so a test failure aborted with VERSION and CHANGELOG.md already rewritten. main() refuses to run on a dirty tree, so the retry was blocked until someone worked out what to revert. - build_and_test now runs FIRST. The only Go code reading the VERSION file is getCurrentVersion(), which no test exercises and which resolves to "unknown" under 'go test' anyway (it reads a relative path from the package directory), so the bump cannot change the result. - A trap restores VERSION/CHANGELOG.md if the run still aborts between writing them and committing. It restores from HEAD rather than the index, because commit_and_tag stages both files before committing: on a failed commit they are already staged, and a plain 'git checkout --' would restore them from the index, copying the modified versions back over themselves. Verified in a throwaway clone: a forced failure after the bump leaves VERSION at 0.2.0 with zero dirty files, and the reorder is visible in the log (tests run before the version bump). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/release.sh | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 4aaa655..4c4cb6d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -83,10 +83,31 @@ bump_version() { } # Update version files +# Restore VERSION / CHANGELOG.md if the release aborts after they are written +# but before the release commit lands. Without this, an abort (a flaky test, a +# failed tag) leaves a dirty tree — and main() refuses to run on a dirty tree, +# so the retry is blocked until someone works out what to revert. +release_files_written=false +release_committed=false + +restore_release_files() { + if [ "$release_files_written" = true ] && [ "$release_committed" = false ]; then + log_warn "Release aborted before committing — restoring VERSION and CHANGELOG.md" + # Restore from HEAD, not from the index: commit_and_tag stages both files + # before committing, so if the commit itself fails they are already + # staged and a plain `git checkout --` would restore them from the index + # — copying the modified versions back over themselves. + git checkout HEAD -- VERSION CHANGELOG.md 2>/dev/null || \ + log_warn "Could not restore VERSION / CHANGELOG.md; revert them by hand before retrying" + fi +} +trap restore_release_files EXIT + update_versions() { local new_version=$1 log_step "Updating VERSION file to $new_version" + release_files_written=true echo "$new_version" > VERSION log_info "Version files updated to $new_version" @@ -153,6 +174,10 @@ Release LVT CLI v$new_version 🤖 Generated with automated release script" + # VERSION / CHANGELOG.md now live in a commit; restoring them on a later + # failure would discard the release commit itself, so stand the guard down. + release_committed=true + log_step "Creating git tag v$new_version" git tag -a "v$new_version" -m "Release v$new_version" @@ -420,10 +445,15 @@ main() { log_info "Starting release process..." echo "" - # Execute release steps + # Execute release steps. Tests run FIRST, before anything is written: the + # only Go code reading the VERSION file is getCurrentVersion(), which no + # test exercises (and which resolves to "unknown" under `go test` anyway, + # since it reads a relative path from the package directory). So the bump + # cannot change the result, and a failure — a flaky test especially — leaves + # the tree untouched instead of half-bumped. + build_and_test update_versions "$new_version" generate_changelog "$new_version" - build_and_test commit_and_tag "$new_version" publish_github "$new_version" "$branch" From b5ece6f89939bd1562a2aaa48b533e54ba70f250 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 14:29:25 +0000 Subject: [PATCH 2/3] fix(release): restore VERSION and CHANGELOG.md independently git checkout HEAD -- a b resolves the pathspec before touching the worktree and bails if any entry is missing from HEAD, so one call would restore *neither* file when CHANGELOG.md is untracked. That is reachable here: commit_and_tag guards CHANGELOG.md with [ -f ], so this script already allows for a repo without one. Verified in a throwaway clone with CHANGELOG.md untracked: the single-call form left VERSION bumped; per-path restore returns it to 0.2.0 and reports the untracked file with an actionable message. Also stopped swallowing git's stderr, and moved the trap block above the '# Update version files' header it was awkwardly sitting under. Addresses claude-review feedback on #339. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/release.sh | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 4c4cb6d..21fa628 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -82,7 +82,6 @@ bump_version() { echo "${major}.${minor}.${patch}" } -# Update version files # Restore VERSION / CHANGELOG.md if the release aborts after they are written # but before the release commit lands. Without this, an abort (a flaky test, a # failed tag) leaves a dirty tree — and main() refuses to run on a dirty tree, @@ -91,18 +90,36 @@ release_files_written=false release_committed=false restore_release_files() { - if [ "$release_files_written" = true ] && [ "$release_committed" = false ]; then - log_warn "Release aborted before committing — restoring VERSION and CHANGELOG.md" - # Restore from HEAD, not from the index: commit_and_tag stages both files - # before committing, so if the commit itself fails they are already - # staged and a plain `git checkout --` would restore them from the index - # — copying the modified versions back over themselves. - git checkout HEAD -- VERSION CHANGELOG.md 2>/dev/null || \ - log_warn "Could not restore VERSION / CHANGELOG.md; revert them by hand before retrying" - fi + [ "$release_files_written" = true ] || return 0 + [ "$release_committed" = false ] || return 0 + + log_warn "Release aborted before committing — restoring VERSION and CHANGELOG.md" + + # Restore from HEAD, not from the index: commit_and_tag stages both files + # before committing, so if the commit itself fails they are already staged + # and a plain `git checkout --` would restore them from the index — copying + # the modified versions back over themselves. + # + # One path per invocation. `git checkout HEAD -- a b` resolves the pathspec + # first and bails before touching the worktree if any entry is missing from + # HEAD, so a single call would restore *neither* file when one is untracked. + # That is reachable here: commit_and_tag guards CHANGELOG.md with `[ -f ]`, + # so this script already allows for a repo without one. git's stderr is left + # visible; a generic "couldn't restore" would send the next person down the + # wrong trail. + local f + for f in VERSION CHANGELOG.md; do + if git cat-file -e "HEAD:$f" 2>/dev/null; then + git checkout HEAD -- "$f" || \ + log_warn "Could not restore $f; revert it by hand before retrying" + else + log_warn "$f is not in HEAD — this run created it; delete it by hand before retrying" + fi + done } trap restore_release_files EXIT +# Update version files update_versions() { local new_version=$1 From 56e3a59deaf6bf90a5e91866e8beb04ddfa5a9ef Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 14:39:09 +0000 Subject: [PATCH 3/3] fix(release): remove a CHANGELOG.md the aborted run created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trap restored tracked files but only told the operator to delete an untracked CHANGELOG.md by hand, so the 'retry unblocked' guarantee held for the common case and not the edge one: git status --porcelain lists untracked and staged-new files alike, and main() refuses to start on any of them. Removing it is provably safe rather than a guess. main() already refuses to start on a dirty tree, and --porcelain reports untracked files, so a CHANGELOG.md absent from HEAD at trap time cannot have pre-existed — this run created it. Both git rm and rm are needed: commit_and_tag may already have staged it, and a bare rm would leave an 'A ' entry that still counts as dirty. Verified in throwaway clones, both branches of the condition: with CHANGELOG.md untracked the abort now leaves zero dirty files; with it tracked it is restored rather than deleted. Raised by claude-review on lvt#339. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/release.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 21fa628..8777da1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -112,8 +112,16 @@ restore_release_files() { if git cat-file -e "HEAD:$f" 2>/dev/null; then git checkout HEAD -- "$f" || \ log_warn "Could not restore $f; revert it by hand before retrying" - else - log_warn "$f is not in HEAD — this run created it; delete it by hand before retrying" + elif [ -f "$f" ]; then + # Absent from HEAD but on disk means this run created it: main() + # refuses to start on a dirty tree and `git status --porcelain` + # lists untracked files, so it cannot have pre-existed. Removing it + # restores the exact pre-run state. Both steps are needed — + # commit_and_tag may already have staged it, and a bare `rm` would + # leave an "A " entry that still counts as dirty. + git rm -f --quiet --ignore-unmatch -- "$f" 2>/dev/null || true + rm -f "$f" + log_warn "Removed $f, which this run created" fi done }