From 579884d0fd5db2f325e272afb3ccd5af3892adff Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sun, 19 Jul 2026 23:20:33 +0000 Subject: [PATCH] fix(release): run tests before mutating, restore files on abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the fix that landed in the core library's release.sh (livetemplate/livetemplate#499) to the client, where it hadn't been. Two related problems, both hit while releasing v0.19.1: update_versions and generate_changelog ran before build_and_test, so a failing test left VERSION, package.json, package-lock.json and CHANGELOG.md already rewritten. main() refuses to start on a dirty tree, so the next attempt would fail for a reason unrelated to why the first one did. Tests and the build now run first, on the pre-bump tree. The dist bundle embeds no version string, so building ahead of the bump is equivalent; verify_build still runs after, since it asserts package.json carries the new version. Reordering can't help with failures that only happen after the write — verify_build, npm pack, gh release create — so an EXIT trap restores the four release-managed files when the run aborts before committing, and stands down once commit_and_tag has run. Restores from HEAD rather than the index (commit_and_tag stages the files first, so a plain checkout would copy the modified versions back over themselves), and one path per invocation (a combined pathspec bails before touching anything if any entry is missing from HEAD, restoring none of them). Verified both directions: a simulated pre-commit abort restores VERSION and CHANGELOG.md, and a simulated post-commit abort leaves them alone. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG --- scripts/release.sh | 57 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 964a5d9..f3c85df 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -128,11 +128,56 @@ bump_version() { echo "${major}.${minor}.${patch}" } +# Restore the release-managed files if the run aborts between writing them and +# committing. Without this, a failure in verify_build, verify_package_contents or +# `gh release create` leaves VERSION, package.json, package-lock.json and +# CHANGELOG.md rewritten in the worktree — and main() refuses to start on a dirty +# tree, so the next attempt fails for a reason unrelated to why this one did. +release_files_written=false +release_committed=false + +restore_release_files() { + [ "$release_files_written" = true ] || return 0 + [ "$release_committed" = false ] || return 0 + + log_warn "Release aborted before committing — restoring version and changelog files" + + # Restore from HEAD, not from the index: commit_and_tag stages these 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 *none* of them when one is untracked. + # 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 package.json package-lock.json 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" + 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 +} +trap restore_release_files EXIT + # Update all version files update_versions() { local new_version=$1 log_step "Updating VERSION file to $new_version" + release_files_written=true echo "$new_version" > VERSION log_step "Updating package.json to $new_version" @@ -203,6 +248,11 @@ This release follows the core library version: $(get_major_minor "$new_version") 🤖 Generated with automated release script" + # Set immediately after the commit: from here the changes live in history, + # so restoring the worktree from HEAD would be a no-op at best and would + # discard the release commit's content at worst. + release_committed=true + log_step "Creating git tag v$new_version" git tag -a "v$new_version" -m "Release v$new_version" @@ -599,9 +649,14 @@ main() { # Execute release steps. Note: npm publish runs in CI (publish.yml), # triggered by the GitHub release created in publish_github below. + # Tests and the build run first, on the pre-bump tree, so a failing test + # aborts before VERSION/package.json/CHANGELOG.md are touched at all. The + # dist bundle embeds no version string, so building ahead of the bump is + # equivalent. verify_build still runs after, since it asserts package.json + # carries the new version. + build_and_test update_versions "$new_version" generate_changelog "$new_version" - build_and_test verify_build "$new_version" verify_package_contents commit_and_tag "$new_version"