From 18d1611cc6824b4a479cf913407b4db07403cd33 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 16 Jun 2026 09:32:34 -0400 Subject: [PATCH] =?UTF-8?q?ci(#1339):=20harden=20auto-backmerge=20main?= =?UTF-8?q?=E2=86=92next=20=E2=80=94=20never-conflict=20reconcile=20+=20re?= =?UTF-8?q?view=20gate=20(#1340)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain 'git merge origin/main' (which exit-1'd on conflict and silently broke the back-merge for 11 days across v1.4.0–1.4.5) with a -s ours reconciliation: keep next's tree (next already contains every code change on main), overlay main's CHANGELOG.md, and replay main's .changeset add/modify/delete. This never conflicts and records main as an ancestor of next. Safety net: detect code files main changed since the merge-base that next did NOT change (a rare straight-to-main emergency fix the -s ours would drop) — label the PR needs-manual-review and skip the auto-admin-merge so a human verifies. Also guard the version-sync step so it never downgrades an ahead next (e.g. next 1.5.0-rc.5 vs main 1.4.5). Validated: YAML parses, all run blocks pass bash -n, version comparator unit-checked, and the reconcile logic dry-run reproduces the #1337 result (CHANGELOG + 52 fragment deletions, zero code change, dropped-code empty). Co-authored-by: Claude Opus 4.8 --- .github/workflows/auto-backmerge.yml | 141 +++++++++++++++++---------- 1 file changed, 92 insertions(+), 49 deletions(-) diff --git a/.github/workflows/auto-backmerge.yml b/.github/workflows/auto-backmerge.yml index 110629122..a13361562 100644 --- a/.github/workflows/auto-backmerge.yml +++ b/.github/workflows/auto-backmerge.yml @@ -1,9 +1,17 @@ name: Auto Back-Merge main → next # Keep `next` at-or-ahead of `main` automatically. Every push to `main` -# (release finalize, hotfix finalize, occasional emergency fix) opens — or -# updates — a PR titled "chore: back-merge main → next". CI gates the merge; -# auto-merge is enabled so it lands as soon as it's green. +# (release finalize, hotfix finalize, occasional emergency fix) opens a PR +# titled "chore: back-merge main → next" and admin-merges it when it is safe. +# +# Resolution model (see #1336 / #1339): `next` is the integration branch and +# already contains every CODE change on `main` (fixes flow next -> release -> +# main). So the back-merge keeps `next`'s tree wholesale (`-s ours`, which never +# conflicts) and overlays only `main`'s release-engineering: the rendered +# CHANGELOG.md and the `.changeset` fragments `main` consumed at release. A +# safety net detects the rare case of a change that exists ONLY on `main` (an +# emergency fix pushed straight to `main`) and routes that PR to human review +# instead of auto-merging. # # Phase 1 of the next-branch migration: this workflow runs but is a no-op # (`if: false` on the job) until `next` exists in the repo. Once Phase 2 @@ -59,51 +67,57 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Create or update back-merge branch + - name: Create back-merge branch (reconcile main → next) if: steps.check.outputs.next_exists == 'true' id: branch - env: - GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} run: | set -euo pipefail - SHORT_SHA=$(git rev-parse --short HEAD) + git fetch --no-tags origin main next + SHORT_SHA=$(git rev-parse --short origin/main) BR="chore/backmerge-main-to-next-${SHORT_SHA}" echo "branch=$BR" >> "$GITHUB_OUTPUT" - # If there's already an open back-merge PR, reuse its branch by - # updating it with the new main HEAD via a merge. Otherwise create - # a fresh branch. - EXISTING_PR=$(gh pr list --base next --label automation --label backmerge --state open \ - --json number,headRefName --jq '.[0]' 2>/dev/null || echo "") - EXISTING_BR=$(echo "$EXISTING_PR" | jq -r '.headRefName // empty') + git checkout -B "$BR" origin/next + BASE=$(git merge-base HEAD origin/main) - if [ -n "$EXISTING_BR" ]; then - case "$EXISTING_BR" in - chore/backmerge-main-to-next-*) ;; - *) echo "::error::refusing to operate on non-backmerge branch: $EXISTING_BR"; exit 1 ;; + # Keep next's tree wholesale and record main as a second parent so main + # becomes an ancestor of next (stops the back-merge backlog recurring). + # -s ours never conflicts; next already has main's code. + git merge -s ours --no-commit --no-ff origin/main + + # Overlay main's rendered CHANGELOG.md (the release promotions). + if git cat-file -e "origin/main:CHANGELOG.md" 2>/dev/null; then + git checkout origin/main -- CHANGELOG.md + git add CHANGELOG.md + fi + + # Replay main's .changeset changes vs the merge-base: prune fragments + # main consumed at release (D); bring any main-authored fragment (A/M). + while IFS="$(printf '\t')" read -r status file; do + [ -n "${file:-}" ] || continue + case "$status" in + D) git rm --quiet --ignore-unmatch -- "$file" ;; + A|M) git checkout origin/main -- "$file" && git add -- "$file" ;; esac - echo "Updating existing back-merge branch: $EXISTING_BR" - git fetch origin "$EXISTING_BR":"$EXISTING_BR" || true - git checkout "$EXISTING_BR" - # Fast-forward to current main if possible; otherwise merge. - git merge --no-edit origin/main || { - echo "::warning::Merge conflict back-merging main into existing back-merge branch. Human resolution required." - exit 1 - } - git push --force origin "$EXISTING_BR" - echo "branch=$EXISTING_BR" >> "$GITHUB_OUTPUT" - echo "reused=true" >> "$GITHUB_OUTPUT" + done < <(git diff --name-status "$BASE" origin/main -- .changeset) + + # Safety net: -s ours silently drops anything existing ONLY on main. + # List code files (excluding CHANGELOG/.changeset) that main changed + # since the merge-base but next did not — a rare straight-to-main fix. + MAIN_CODE=$(git diff --name-only "$BASE" origin/main -- . ':(exclude)CHANGELOG.md' ':(exclude).changeset' | sort) + NEXT_CODE=$(git diff --name-only "$BASE" origin/next -- . ':(exclude)CHANGELOG.md' ':(exclude).changeset' | sort) + DROPPED=$(comm -23 <(printf '%s\n' "$MAIN_CODE") <(printf '%s\n' "$NEXT_CODE") | grep -v '^$' || true) + + git commit -m "chore: back-merge main into next (${SHORT_SHA})" + git push --force origin "$BR" + + if [ -n "$DROPPED" ]; then + echo "needs_review=true" >> "$GITHUB_OUTPUT" + echo "dropped_oneline=$(printf '%s' "$DROPPED" | tr '\n' ' ')" >> "$GITHUB_OUTPUT" + echo "::warning::Back-merge auto-resolved in favor of next and dropped main-only change(s) in: $(printf '%s' "$DROPPED" | tr '\n' ' '). PR left open for manual review." else - git fetch origin next:next - git checkout -b "$BR" next - # Bring main's commits onto next via a merge commit (preserves tag history). - if ! git merge --no-edit -m "chore: back-merge main into next" origin/main; then - echo "::error::Cannot auto-back-merge main into next: merge conflict. A maintainer must back-merge manually (git checkout next; git merge origin/main; resolve; push)." - git merge --abort || true - exit 1 - fi - git push --force origin "$BR" - echo "reused=false" >> "$GITHUB_OUTPUT" + echo "needs_review=false" >> "$GITHUB_OUTPUT" + echo "dropped_oneline=" >> "$GITHUB_OUTPUT" fi - name: Sync next's version to main's released version @@ -115,10 +129,26 @@ jobs: [0-9]*.[0-9]*.[0-9]*) : ;; *) echo "refusing to sync next: unexpected version '$VERSION' from origin/main" >&2; exit 1 ;; esac - node scripts/sync-next-version.cjs "$VERSION" --in-place - git add -u - git diff --cached --quiet || git commit -m "chore: sync next package version to ${VERSION}" - git push origin HEAD + NEXT_VERSION=$(node -pe "require('./package.json').version") + # Never downgrade an ahead next (e.g. next on 1.5.0-rc.N while main is + # 1.4.5). Only sync when main's version is strictly higher; a release + # (no prerelease) outranks a prerelease at the same X.Y.Z base. + if node -e ' + const split = v => { const [b, pre] = String(v).split("-"); return [b.split(".").map(Number), pre]; }; + const [a, ap] = split(process.argv[1]); + const [b, bp] = split(process.argv[2]); + let c = 0; + for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) { c = a[i] - b[i]; break; } } + if (c === 0 && !!ap !== !!bp) c = ap ? -1 : 1; // release > prerelease + process.exit(c > 0 ? 0 : 1); + ' "$VERSION" "$NEXT_VERSION"; then + node scripts/sync-next-version.cjs "$VERSION" --in-place + git add -u + git diff --cached --quiet || git commit -m "chore: sync next package version to ${VERSION}" + git push origin HEAD + else + echo "next ($NEXT_VERSION) is at or ahead of main ($VERSION); leaving next version unchanged." + fi - name: Open or update PR if: steps.check.outputs.next_exists == 'true' @@ -126,16 +156,24 @@ jobs: env: GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} BR: ${{ steps.branch.outputs.branch }} + NEEDS_REVIEW: ${{ steps.branch.outputs.needs_review }} + DROPPED_ONELINE: ${{ steps.branch.outputs.dropped_oneline }} run: | set -euo pipefail - SHORT_SHA=$(git rev-parse --short HEAD) + SHORT_SHA=$(git rev-parse --short origin/main) + REVIEW_NOTE="" + if [ "${NEEDS_REVIEW:-false}" = "true" ]; then + REVIEW_NOTE=" + REVIEW REQUIRED: this back-merge auto-resolved in favor of next and dropped main-only change(s) in: ${DROPPED_ONELINE}. A maintainer must verify these are not emergency fixes that need re-applying to next. Do not merge until verified." + fi BODY=$(cat <> "$GITHUB_OUTPUT" - name: Admin-merge the back-merge PR - if: steps.check.outputs.next_exists == 'true' + # Skip auto-merge when the back-merge dropped main-only changes — a human + # must review those before this lands. + if: steps.check.outputs.next_exists == 'true' && steps.branch.outputs.needs_review != 'true' env: GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} PR: ${{ steps.openpr.outputs.pr }} run: | - # Squash would lose the merge-commit context; use merge commit. + # Merge commit (not squash) preserves main as an ancestor of next. gh pr merge --admin --merge "$PR"