From b79c0459275f2cb25d26c151cb09c2e3af4e8fa8 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 4 Apr 2026 18:42:07 +0100 Subject: [PATCH] fix(ci): replace rebase sync with batched merge PR, add auto-retarget (#8957) --- .cspell/tech-terms.txt | 2 + .github/workflows/auto-retarget.yml | 74 +++++++++++++++++++++++++ .github/workflows/promote.yml | 20 +++++-- .github/workflows/release.yml | 56 +++++++++---------- .github/workflows/verify-changesets.yml | 4 +- .github/zizmor.yml | 3 + 6 files changed, 121 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/auto-retarget.yml diff --git a/.cspell/tech-terms.txt b/.cspell/tech-terms.txt index 15d381a2c1..93cb8ee25b 100644 --- a/.cspell/tech-terms.txt +++ b/.cspell/tech-terms.txt @@ -52,3 +52,5 @@ moonshotai kimi ilike nvmrc +retarget +retargeted diff --git a/.github/workflows/auto-retarget.yml b/.github/workflows/auto-retarget.yml new file mode 100644 index 0000000000..4f730a1baa --- /dev/null +++ b/.github/workflows/auto-retarget.yml @@ -0,0 +1,74 @@ +name: Auto-retarget + +on: + pull_request_target: + types: [opened, synchronize, reopened] + branches: [main] + +permissions: {} + +jobs: + retarget: + if: > + github.repository_owner == 'better-auth' && + !startsWith(github.event.pull_request.head.ref, 'changeset-release/') && + github.actor != 'github-actions[bot]' && + github.actor != 'better-auth-releases[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Detect max bump type from changesets + id: bump + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MAX="patch" + FILES=$(gh pr diff "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --name-only | grep '^\.changeset/.*\.md$' | grep -v 'README.md' || true) + if [ -z "$FILES" ]; then + echo "No changeset files in PR" + echo "max=none" >> "$GITHUB_OUTPUT" + exit 0 + fi + + while IFS= read -r f; do + [ -z "$f" ] && continue + CONTENT=$(gh api "repos/$GITHUB_REPOSITORY/contents/$f?ref=${{ github.event.pull_request.head.sha }}" --jq '.content' | base64 -d 2>/dev/null || true) + [ -z "$CONTENT" ] && continue + BUMPS=$(echo "$CONTENT" | sed -n '/^---$/,/^---$/p' | grep -oE '"[^"]+"\s*:\s*(major|minor|patch)' | grep -oE '(major|minor|patch)$' | sort -u) + if echo "$BUMPS" | grep -q "major"; then + MAX="major" + break + fi + if echo "$BUMPS" | grep -q "minor"; then + MAX="minor" + fi + done <<< "$FILES" + echo "max=$MAX" >> "$GITHUB_OUTPUT" + + - name: Check next branch exists + if: steps.bump.outputs.max == 'minor' || steps.bump.outputs.max == 'major' + id: next-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh api "repos/$GITHUB_REPOSITORY/branches/next" --silent 2>/dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::warning::next branch does not exist. Cannot retarget." + fi + + - name: Retarget PR to next + if: (steps.bump.outputs.max == 'minor' || steps.bump.outputs.max == 'major') && steps.next-check.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MAX_BUMP: ${{ steps.bump.outputs.max }} + run: | + gh pr edit "$PR_NUMBER" --base next --repo "$GITHUB_REPOSITORY" + gh pr edit "$PR_NUMBER" --add-label "retargeted-to-next" --repo "$GITHUB_REPOSITORY" || true + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body \ + "This PR was automatically retargeted from \`main\` to \`next\` because it contains a **${MAX_BUMP}** changeset. The \`main\` branch only accepts \`patch\` (bug fix) changes. Features and breaking changes go through \`next\` for beta testing before promotion to stable." diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 8e2c856413..ef8efe2d6d 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -67,7 +67,7 @@ jobs: echo "No changes to commit (previous run already pushed)" else git commit -m "chore: exit pre-release mode for v${VERSION}" - git push origin next + git push origin next || { echo "::error::Push to next failed. Check branch protection rules."; exit 1; } fi - name: Create promote PR @@ -83,14 +83,22 @@ jobs: --base main \ --head next \ --title "chore: promote v${VERSION} to stable" \ - --body "Promotes beta to stable. **Use 'Rebase and merge'** to preserve individual commits. Merging publishes v${VERSION} to npm with the \`latest\` tag." + --body "Promotes v${VERSION} from beta to stable. Merging this PR publishes all packages to npm with the \`latest\` tag.\n\n**This PR must be merged by an admin using 'Create a merge commit'** (not squash, not rebase). The admin bypass is required because main enforces linear history for regular PRs, but the promotion needs a merge commit to preserve individual contributor commits and their verified signatures." fi - name: Summary env: VERSION: ${{ steps.version.outputs.version }} run: | - echo "### Beta promoted" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- **Version:** \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" - echo "- **Action:** Review and merge the PR to publish as stable." >> "$GITHUB_STEP_SUMMARY" + { + echo "### Beta promoted" + echo "" + echo "- **Version:** \`$VERSION\`" + echo "- **Action:** Review and merge the PR with **Create a merge commit** (admin bypass)." + echo "" + echo "### After promotion" + echo "Once the promote PR is merged and the sync PR (main to next) is merged:" + echo '```' + echo "pnpm changeset pre enter beta && git add .changeset/pre.json && git commit -m 'chore: re-enter beta pre-release mode' && git push origin next" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0d872028c..c0ad78a50b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,15 +103,11 @@ jobs: GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} NPM_CONFIG_TAG: ${{ steps.npm-tag.outputs.tag }} - - name: Rebase next onto main + - name: Sync main to next via PR if: github.ref_name == 'main' env: GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} run: | - git config user.name "better-auth-releases[bot]" - git config user.email "273320539+better-auth-releases[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git fetch origin next || { echo "next branch does not exist, skipping"; exit 0; } AHEAD=$(git rev-list --count origin/next..origin/main) @@ -121,40 +117,40 @@ jobs: fi echo "Found $AHEAD commit(s) to sync from main to next" - # Capture next's SHA for force-with-lease (detached HEAD has no tracking ref) - NEXT_SHA=$(git rev-parse origin/next) - - git checkout origin/next - if git rebase origin/main; then - echo "Clean rebase" + EXISTING=$(gh pr list --base next --head "${GITHUB_REPOSITORY_OWNER}:main" --state open --repo "$GITHUB_REPOSITORY" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Sync PR #$EXISTING already exists ($AHEAD commits pending)" else - echo "::warning::Rebase conflicts — creating draft PR for manual resolution" - git rebase --abort + SYNC_BODY="Brings stable fixes from main into the next branch." + SYNC_BODY="$SYNC_BODY\n\n**This PR must be merged by a maintainer using 'Create a merge commit'** (not squash, not rebase). This preserves individual fix commits and their verified signatures." + SYNC_BODY="$SYNC_BODY\n\nIf there are conflicts, resolve them by keeping next's versions for \`package.json\` files (next is always ahead of main in version numbers)." + gh pr create \ --base next \ --head main \ - --title "chore: sync main to next (conflicts)" \ - --body "Automated rebase failed due to code conflicts. Resolve manually by rebasing next onto main." \ - --draft \ - || true - exit 0 + --repo "$GITHUB_REPOSITORY" \ + --title "chore: sync main to next" \ + --body "$(echo -e "$SYNC_BODY")" || true + + EXISTING=$(gh pr list --base next --head "${GITHUB_REPOSITORY_OWNER}:main" --state open --repo "$GITHUB_REPOSITORY" --json number --jq '.[0].number // empty') fi - # Re-enter pre-release mode if needed (after a promote cycle), - # but only if there's no open promote PR (to avoid invalidating it) - if [ ! -f .changeset/pre.json ]; then - OPEN_PROMOTE=$(gh pr list --base main --head next --state open --json number --jq 'length') - if [ "$OPEN_PROMOTE" -eq 0 ]; then - pnpm changeset pre enter beta - git add .changeset/pre.json - git commit -m "chore: re-enter beta pre-release mode" - else - echo "Promote PR is open, skipping pre-mode re-entry" + # Check for merge conflicts (GitHub computes merge state asynchronously) + if [ -n "$EXISTING" ]; then + sleep 10 + for _ in 1 2 3 4 5; do + MERGEABLE=$(gh pr view "$EXISTING" --repo "$GITHUB_REPOSITORY" --json mergeable --jq '.mergeable') + [ "$MERGEABLE" != "UNKNOWN" ] && break + sleep 10 + done + if [ "$MERGEABLE" = "CONFLICTING" ]; then + gh pr edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --add-label "has-conflicts" || true + echo "::warning::Sync PR #$EXISTING has merge conflicts. Resolve by keeping next's versions for package.json files." + elif [ "$MERGEABLE" = "MERGEABLE" ]; then + gh pr edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --remove-label "has-conflicts" 2>/dev/null || true fi fi - git push origin HEAD:refs/heads/next --force-with-lease=refs/heads/next:"$NEXT_SHA" - snapshot: if: github.event_name == 'workflow_dispatch' && inputs.snapshot != '' && github.repository_owner == 'better-auth' runs-on: ubuntu-latest diff --git a/.github/workflows/verify-changesets.yml b/.github/workflows/verify-changesets.yml index 3629e16ac2..7d6e1be706 100644 --- a/.github/workflows/verify-changesets.yml +++ b/.github/workflows/verify-changesets.yml @@ -127,11 +127,11 @@ jobs: for f in $CHANGESET_FILES; do BUMPS=$(sed -n '/^---$/,/^---$/p' "$f" | grep -oE '(major|minor|patch)' | sort -u) if echo "$BUMPS" | grep -q "major"; then - echo "::error::Changeset $f contains a 'major' bump. Only 'patch' bumps are allowed on '$BASE_REF'." + echo "::error::Changeset $f contains a 'major' bump. Only 'patch' bumps are allowed on '$BASE_REF'. PRs with minor/major bumps should target 'next' (auto-retarget may not have run yet)." exit 1 fi if echo "$BUMPS" | grep -q "minor"; then - echo "::error::Changeset $f contains a 'minor' bump. Only 'patch' bumps are allowed on '$BASE_REF'." + echo "::error::Changeset $f contains a 'minor' bump. Only 'patch' bumps are allowed on '$BASE_REF'. PRs with minor/major bumps should target 'next' (auto-retarget may not have run yet)." exit 1 fi done diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 338be0db19..98e0483f81 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -5,6 +5,9 @@ rules: # never checks out fork code. The pull_request_target trigger is required # for the action to post/delete PR comments. - semantic-pull-request.yml + # Intentional: reads .changeset/*.md content via GitHub API only. + # No checkout of fork code. No code execution. + - auto-retarget.yml cache-poisoning: ignore: # False positive: neither setup-node call has caching enabled (no `cache:` param).