Skip to content

Commit 0640fa2

Browse files
committed
Land the release commit on main via an auto-merged PR
A direct push of the release commit is rejected by the branch ruleset ("N of N required status checks are expected"), so the script built every artifact and then failed at publish, leaving a local-only tag. The commit now goes through a release-X.Y.Z branch and a PR that GitHub auto-merges once the same required checks pass. The tag is cut after that merge rather than before: a squash or rebase merge rewrites the commit SHA, and a tag cut earlier would point at a commit that is not in main. The PR is merged with --merge for the same reason. Every stage is idempotent, so a re-run after a failure resumes instead of duplicating. Also gate the GitHub release step on --no-push. It was unguarded, so --no-push still created or clobbered the live release, which made the script impossible to dry-run.
1 parent 3ee0fee commit 0640fa2

1 file changed

Lines changed: 94 additions & 11 deletions

File tree

scripts/release.sh

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
#
77
# End to end this:
88
# 1. bumps "version" in package.json,
9-
# 2. commits "Release corbits X.Y.Z" and tags vX.Y.Z locally (no push yet),
9+
# 2. commits "Release corbits X.Y.Z" locally (no tag yet -- see step 5),
1010
# 3. cross-compiles standalone binaries (no runtime required) for
1111
# macOS arm64/x64 and Linux x64/arm64 with `bun build --compile`,
1212
# 4. smoke-tests the host-native binary, then packages each target as a
1313
# .tar.gz (+ .sha256) and each Linux target as a .deb (built from stock
1414
# `ar` + `tar`, so no dpkg is needed),
15-
# 5. pushes the release commit and tag only after artifacts exist,
15+
# 5. lands the release commit on main through an auto-merged PR (a direct
16+
# push is rejected by the branch ruleset), then tags vX.Y.Z at the merged
17+
# main and pushes the tag -- both only after artifacts exist,
1618
# 6. creates the GitHub release on corbitsdev/corbits-code with those assets,
1719
# 7. regenerates the Homebrew formula (per-arch url + sha256) in the
1820
# corbitsdev/homebrew-tap tap and pushes it, so `brew install corbits-code` works.
@@ -22,6 +24,16 @@
2224
# Push is deferred until after a successful build so a failed compile never
2325
# publishes a tag without binaries.
2426
#
27+
# The release commit reaches main via a PR because main requires status checks
28+
# and a direct push cannot satisfy them. The tag is cut only after that PR
29+
# merges: a squash or rebase merge rewrites the commit SHA, and a tag cut
30+
# earlier would point at a commit that is not in main. The PR is merged with
31+
# --merge for the same reason.
32+
#
33+
# NOTE: do not pipe this script (e.g. `release.sh X.Y.Z | tail -40`) without
34+
# `set -o pipefail` -- you get the pipe's exit code, not this script's, and a
35+
# failed release reads as success.
36+
#
2537
# Requirements: run on a Mac with git, gh (authenticated), bun, jq, ar, tar,
2638
# and shasum available. `bun build --compile` cross-compiles every target
2739
# from here; no Linux host is needed. For the tap step, the corbitsdev/tap
@@ -70,6 +82,9 @@ done
7082
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "version must be X.Y.Z, got: $VERSION" >&2; exit 2; }
7183

7284
TAG="v$VERSION"
85+
# main requires status checks, so the release commit lands via a PR, not a
86+
# direct push. Matches the existing convention (release-0.2.104 -> PR #548).
87+
RELEASE_BRANCH="release-$VERSION"
7388
ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
7489
cd "$ROOT"
7590
STAGE="$ROOT/dist/release"
@@ -296,21 +311,26 @@ else
296311
fi
297312
NOTES_FILE="$NOTES_TMP"
298313

299-
# ---- 1-2. version bump, commit, tag (local only; push after build) ---------
300-
step "Version, commit, and tag (local)"
314+
# ---- 1-2. version bump and release commit (local; tagged after the merge) --
315+
# The tag is deliberately NOT cut here. The release commit reaches main through
316+
# a PR (step 4), and a squash or rebase merge would rewrite its SHA and leave
317+
# the tag pointing at a commit that is not in main. Nothing before step 4b
318+
# needs the tag to exist, so it is created once the commit is actually on main.
319+
step "Version and release commit (local)"
301320
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
302321
skip "tag $TAG already exists"
303322
cur=$(jq -r .version package.json)
304323
[ "$cur" = "$VERSION" ] || info "note: package.json says $cur, not $VERSION (tag already cut)"
324+
elif [ "$(jq -r .version package.json)" = "$VERSION" ]; then
325+
skip "package.json already at $VERSION (release commit exists or is merged)"
305326
else
306327
[ -z "$(git status --porcelain)" ] || die "working tree not clean; commit or stash first"
307328
jq --arg v "$VERSION" '.version = $v' package.json > package.json.tmp
308329
mv package.json.tmp package.json
309330
[ "$(jq -r .version package.json)" = "$VERSION" ] || die "package.json bump failed"
310331
git add package.json
311332
git commit -q -m "Release $FORMULA $VERSION"
312-
git tag -a "$TAG" -m "$FORMULA $VERSION"
313-
info "committed and tagged $TAG (push deferred until after build)"
333+
info "committed release $VERSION (PR and tag deferred until after build)"
314334
fi
315335

316336
# ---- 3. build binaries, smoke, tarballs, and debs --------------------------
@@ -362,10 +382,71 @@ for entry in "${TARGETS[@]}"; do
362382
rm -f "$STAGE/$FORMULA-$label.bin"
363383
done
364384

365-
# ---- 4. push commit + tag only after artifacts exist -----------------------
366-
step "Push release commit and tag"
367-
git_push "$ROOT"
368-
git_push "$ROOT" "$TAG"
385+
# ---- 4. land the release commit on main via PR, then tag ------------------
386+
# A direct push to main is rejected by the branch ruleset ("N of N required
387+
# status checks are expected"), so the commit goes through a PR that GitHub
388+
# auto-merges once those same checks pass. --merge (never --squash/--rebase)
389+
# keeps the release commit's SHA intact. Idempotent at every stage so a
390+
# re-run after a failure resumes rather than duplicating.
391+
step "Land release commit on $MAIN_REPO main"
392+
remote_version() { git show origin/main:package.json 2>/dev/null | jq -r .version 2>/dev/null || echo ""; }
393+
if [ "$DO_PUSH" != 1 ]; then
394+
skip "would: push $RELEASE_BRANCH, open+merge its PR, then tag $TAG"
395+
else
396+
git fetch origin main --quiet --no-tags
397+
if [ "$(remote_version)" = "$VERSION" ]; then
398+
skip "release commit for $VERSION is already on main"
399+
else
400+
git_push "$ROOT" "HEAD:refs/heads/$RELEASE_BRANCH"
401+
PR_NUM=$(gh pr list --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --state open \
402+
--json number --jq '.[0].number // empty')
403+
if [ -z "$PR_NUM" ]; then
404+
gh pr create --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --base main \
405+
--title "Release $FORMULA $VERSION" \
406+
--body "Version bump to $VERSION. Release notes are the CHANGELOG.md \`## [$VERSION]\` section." >/dev/null
407+
PR_NUM=$(gh pr list --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --state open \
408+
--json number --jq '.[0].number // empty')
409+
[ -n "$PR_NUM" ] || die "could not create or find the release PR for $RELEASE_BRANCH"
410+
info "opened release PR #$PR_NUM"
411+
else
412+
info "reusing open release PR #$PR_NUM"
413+
fi
414+
# Auto-merge lets the required checks be the gate without polling them here.
415+
gh pr merge "$PR_NUM" --repo "$MAIN_REPO" --merge --auto --delete-branch >/dev/null \
416+
|| die "could not arm auto-merge on PR #$PR_NUM"
417+
info "waiting for required checks and auto-merge on #$PR_NUM"
418+
merged=0
419+
for _ in $(seq 1 160); do # 160 * 15s = 40 min ceiling
420+
state=$(gh pr view "$PR_NUM" --repo "$MAIN_REPO" --json state --jq .state 2>/dev/null || echo "")
421+
case "$state" in
422+
MERGED) merged=1; break ;;
423+
CLOSED) die "release PR #$PR_NUM was closed without merging" ;;
424+
esac
425+
sleep 15
426+
done
427+
[ "$merged" = 1 ] || die "release PR #$PR_NUM did not merge in time; check its status checks, then re-run"
428+
info "PR #$PR_NUM merged"
429+
git fetch origin main --quiet --no-tags
430+
[ "$(remote_version)" = "$VERSION" ] || die "main is not at $VERSION after merge; aborting before tag"
431+
fi
432+
fi
433+
434+
# ---- 4b. tag the merged release commit ------------------------------------
435+
step "Tag $TAG"
436+
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
437+
skip "tag $TAG already on origin"
438+
else
439+
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
440+
if [ "$DO_PUSH" = 1 ]; then
441+
git tag -a "$TAG" -m "$FORMULA $VERSION" "origin/main"
442+
info "tagged $TAG at origin/main"
443+
else
444+
git tag -a "$TAG" -m "$FORMULA $VERSION"
445+
info "tagged $TAG at HEAD (--no-push)"
446+
fi
447+
fi
448+
git_push "$ROOT" "$TAG"
449+
fi
369450

370451
# ---- 5. GitHub release on the main repo ------------------------------------
371452
step "GitHub release on $MAIN_REPO"
@@ -380,7 +461,9 @@ do
380461
[ -e "$f" ] && ASSETS+=("$f")
381462
done
382463
[ "${#ASSETS[@]}" -gt 0 ] || die "no assets for $VERSION in $STAGE"
383-
if gh release view "$TAG" --repo "$MAIN_REPO" >/dev/null 2>&1; then
464+
if [ "$DO_PUSH" != 1 ]; then
465+
skip "would: create/refresh release $TAG on $MAIN_REPO with ${#ASSETS[@]} assets"
466+
elif gh release view "$TAG" --repo "$MAIN_REPO" >/dev/null 2>&1; then
384467
skip "release $TAG exists -- refreshing assets"
385468
gh release upload "$TAG" "${ASSETS[@]}" --repo "$MAIN_REPO" --clobber
386469
else

0 commit comments

Comments
 (0)