|
6 | 6 | # |
7 | 7 | # End to end this: |
8 | 8 | # 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), |
10 | 10 | # 3. cross-compiles standalone binaries (no runtime required) for |
11 | 11 | # macOS arm64/x64 and Linux x64/arm64 with `bun build --compile`, |
12 | 12 | # 4. smoke-tests the host-native binary, then packages each target as a |
13 | 13 | # .tar.gz (+ .sha256) and each Linux target as a .deb (built from stock |
14 | 14 | # `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, |
16 | 18 | # 6. creates the GitHub release on corbitsdev/corbits-code with those assets, |
17 | 19 | # 7. regenerates the Homebrew formula (per-arch url + sha256) in the |
18 | 20 | # corbitsdev/homebrew-tap tap and pushes it, so `brew install corbits-code` works. |
|
22 | 24 | # Push is deferred until after a successful build so a failed compile never |
23 | 25 | # publishes a tag without binaries. |
24 | 26 | # |
| 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 | +# |
25 | 37 | # Requirements: run on a Mac with git, gh (authenticated), bun, jq, ar, tar, |
26 | 38 | # and shasum available. `bun build --compile` cross-compiles every target |
27 | 39 | # from here; no Linux host is needed. For the tap step, the corbitsdev/tap |
|
70 | 82 | [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "version must be X.Y.Z, got: $VERSION" >&2; exit 2; } |
71 | 83 |
|
72 | 84 | 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" |
73 | 88 | ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) |
74 | 89 | cd "$ROOT" |
75 | 90 | STAGE="$ROOT/dist/release" |
@@ -296,21 +311,26 @@ else |
296 | 311 | fi |
297 | 312 | NOTES_FILE="$NOTES_TMP" |
298 | 313 |
|
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)" |
301 | 320 | if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then |
302 | 321 | skip "tag $TAG already exists" |
303 | 322 | cur=$(jq -r .version package.json) |
304 | 323 | [ "$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)" |
305 | 326 | else |
306 | 327 | [ -z "$(git status --porcelain)" ] || die "working tree not clean; commit or stash first" |
307 | 328 | jq --arg v "$VERSION" '.version = $v' package.json > package.json.tmp |
308 | 329 | mv package.json.tmp package.json |
309 | 330 | [ "$(jq -r .version package.json)" = "$VERSION" ] || die "package.json bump failed" |
310 | 331 | git add package.json |
311 | 332 | 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)" |
314 | 334 | fi |
315 | 335 |
|
316 | 336 | # ---- 3. build binaries, smoke, tarballs, and debs -------------------------- |
@@ -362,10 +382,71 @@ for entry in "${TARGETS[@]}"; do |
362 | 382 | rm -f "$STAGE/$FORMULA-$label.bin" |
363 | 383 | done |
364 | 384 |
|
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 |
369 | 450 |
|
370 | 451 | # ---- 5. GitHub release on the main repo ------------------------------------ |
371 | 452 | step "GitHub release on $MAIN_REPO" |
|
380 | 461 | [ -e "$f" ] && ASSETS+=("$f") |
381 | 462 | done |
382 | 463 | [ "${#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 |
384 | 467 | skip "release $TAG exists -- refreshing assets" |
385 | 468 | gh release upload "$TAG" "${ASSETS[@]}" --repo "$MAIN_REPO" --clobber |
386 | 469 | else |
|
0 commit comments