From 8c489b1d4f9d4f83ea8f5c2b44dc4716020c63ab Mon Sep 17 00:00:00 2001 From: charliewwdev Date: Tue, 1 Sep 2026 17:33:02 +0800 Subject: [PATCH 1/2] fix(release): keep the tag on main and push it atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two releases were published from commits that are not on main. The tag is what triggers the release workflow, so the artifacts on npm and pub.dev were built from a tree nobody can see. v0.9.36 is the clearest case. PR #38 merged into origin/main on 04-14 02:47Z. On 04-15 06:48Z the release was cut from a stale local main still at v0.9.35, and `git push origin main --tags` pushed each ref independently: the branch was rejected as non-fast-forward, the tag went through anyway. CI started from that orphaned commit 5 seconds later. The `pull --rebase` that followed 31 seconds after replayed the same two commits onto origin/main — same author dates, later committer dates, identical patch-ids — leaving the tag on the pre-rebase copy. v0.9.24 failed the same way on 03-17. Reproduced the ref-splitting behaviour against a scratch remote. Before touching any file the script now requires that HEAD is main, that the tag does not already exist, and that main matches origin/main exactly, naming whether it is ahead or behind. Aborting this early matters because the version bumps run before the confirmation prompt. The push is now `--atomic` and names only this release's tag, so a rejected branch update can no longer let the tag through, and unrelated local tags are not swept along by `--tags`. On failure the local tag is deleted so a retry starts clean. --- scripts/release.sh | 67 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 89ab5c3b..9d780fdf 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -59,7 +59,56 @@ fi echo -e "${BLUE}🚀 Releasing v$VERSION${NC}" echo "" +# Step 0: Make sure the release will actually be cut from origin/main. +# +# The tag is what triggers the release workflow, so a tag that does not sit on +# main means the published artifacts were built from a commit nobody can see. +# This has already happened twice (v0.9.24, v0.9.36): the release was cut on a +# stale local main, `git push origin main --tags` had the branch rejected but +# still delivered the tag, CI published from the orphaned commit, and the +# follow-up `pull --rebase` left the tag pointing at the pre-rebase copy. +echo "📋 Verifying branch is in sync with origin..." + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$CURRENT_BRANCH" != "main" ]; then + echo -e "${RED}❌ Releases must be cut from main, but you are on '$CURRENT_BRANCH'.${NC}" + echo " git switch main" + exit 1 +fi + +git fetch origin main --tags --quiet + +if git rev-parse --verify --quiet "refs/tags/v$VERSION" >/dev/null; then + echo -e "${RED}❌ Tag v$VERSION already exists.${NC}" + echo " Pick a new version, or delete the tag if it was never published." + exit 1 +fi + +LOCAL=$(git rev-parse main) +REMOTE=$(git rev-parse origin/main) +if [ "$LOCAL" != "$REMOTE" ]; then + BEHIND=$(git rev-list --count main..origin/main) + AHEAD=$(git rev-list --count origin/main..main) + echo -e "${RED}❌ Local main is out of sync with origin/main${NC}" + echo " behind: $BEHIND commit(s) ahead: $AHEAD commit(s)" + echo "" + if [ "$BEHIND" -gt 0 ]; then + echo " Releasing now would tag a commit that is not on origin/main, and the" + echo " published build would silently omit those $BEHIND commit(s)." + echo "" + echo " git pull --rebase origin main" + else + echo " Push your local commits first so the tag lands on origin/main:" + echo "" + echo " git push origin main" + fi + exit 1 +fi + +echo -e " ${GREEN}✓ main matches origin/main ($(git rev-parse --short main))${NC}" + # Step 1: Check for uncommitted changes +echo "" echo "📋 Checking git status..." if [ -n "$(git status --porcelain)" ]; then echo -e "${YELLOW}⚠️ You have uncommitted changes:${NC}" @@ -210,8 +259,24 @@ echo "🏷️ Creating tag v$VERSION..." git tag "v$VERSION" # Step 7: Push +# +# --atomic makes the branch and the tag land together or not at all. Without it +# git updates each ref independently, so a rejected branch update still lets the +# tag through — which is what published v0.9.24 and v0.9.36 from commits that +# were never on main. Only this release's tag is pushed; `--tags` would also +# re-push unrelated local tags. echo "📤 Pushing to origin..." -git push origin main --tags +if ! git push --atomic origin main "refs/tags/v$VERSION"; then + echo "" + echo -e "${RED}❌ Push failed — nothing was published.${NC}" + echo " The tag was NOT pushed, so the release workflow did not start." + echo "" + echo " Removing the local tag so you can retry cleanly:" + git tag -d "v$VERSION" + echo "" + echo " Then: git pull --rebase origin main && ./scripts/release.sh $VERSION \"$DESCRIPTION\"" + exit 1 +fi echo "" echo -e "${GREEN}✅ Released v$VERSION successfully!${NC}" From a487efc30a5824618c12a77d6ce7c7cd3c46ee48 Mon Sep 17 00:00:00 2001 From: charliewwdev Date: Tue, 1 Sep 2026 18:51:09 +0800 Subject: [PATCH 2/2] fix(release): refuse to publish the CHANGELOG placeholder The generated entry carries a "TODO: Add your changes here" line and the warning to replace it only prints, so it is easy to walk past. Ten released versions have that literal string as their entire changelog, including the three most recent. Block on it instead. The check reads only the section for the version being released, so an older unfilled entry does not stop a new release. --- scripts/release.sh | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 9d780fdf..8de067e1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -234,7 +234,25 @@ else echo -e " ${YELLOW}⚠️ Edit CHANGELOG.md to add release details before confirming${NC}" fi -# Step 4: Show changes and confirm +# Step 4: Refuse to publish the placeholder. +# +# The entry above is generated with a TODO line and the warning to replace it is +# easy to miss, so ten releases shipped "TODO: Add your changes here" as their +# entire changelog. Block instead of warning. +if awk -v v="## $VERSION" ' + $0 == v {inside = 1; next} + inside && /^## / {exit} + inside && /TODO: Add your changes here/ {found = 1; exit} + END {exit !found} +' CHANGELOG.md; then + echo "" + echo -e "${RED}❌ CHANGELOG.md still has the placeholder for $VERSION.${NC}" + echo " Replace 'TODO: Add your changes here' with the actual changes," + echo " then run this script again." + exit 1 +fi + +# Step 5: Show changes and confirm echo "" echo "📋 Changes to be committed:" git add -u # Only stage modified tracked files (not untracked) @@ -247,18 +265,18 @@ if ! confirm "Commit, tag, and push v$VERSION?"; then exit 0 fi -# Step 5: Commit +# Step 6: Commit echo "" echo "💾 Committing..." git commit -m "chore: Release v$VERSION $DESCRIPTION" -# Step 6: Tag +# Step 7: Tag echo "🏷️ Creating tag v$VERSION..." git tag "v$VERSION" -# Step 7: Push +# Step 8: Push # # --atomic makes the branch and the tag land together or not at all. Without it # git updates each ref independently, so a rejected branch update still lets the