From 247c6b35ceb0313538d128fb7663fc0910c37f01 Mon Sep 17 00:00:00 2001 From: ludamad <163993+ludamad@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:21:12 +0000 Subject: [PATCH] wip: unify backport flow with merge-train infrastructure - backport trains are now exactly merge trains - we commit to backport trains with conflict markers --- .claude/skills/backport/SKILL.md | 141 ++++++------- .github/workflows/backport.yml | 203 +++++++++---------- .github/workflows/merge-train-auto-merge.yml | 10 +- .github/workflows/merge-train-create-pr.yml | 68 +++++-- scripts/backport_to_staging.sh | 152 +++++++------- scripts/merge-train/auto-merge.sh | 12 +- scripts/merge-train/merge-next.sh | 8 +- 7 files changed, 302 insertions(+), 292 deletions(-) diff --git a/.claude/skills/backport/SKILL.md b/.claude/skills/backport/SKILL.md index 80d0666d12db..bb09e2486ba4 100644 --- a/.claude/skills/backport/SKILL.md +++ b/.claude/skills/backport/SKILL.md @@ -6,9 +6,10 @@ argument-hint: # Backport PR -Backport a merged PR to a release branch staging area. Uses the existing -`scripts/backport_to_staging.sh` for the happy path, then resolves conflicts -manually if the diff does not apply cleanly. +Backport a merged PR to its release branch train. The automated workflow +cherry-picks onto `merge-train/` and commits conflict markers +with a `CONFLICTS:` prefix. This skill is typically invoked by ClaudeBox to +resolve those conflicts, or manually to trigger/redo a backport. ## Usage @@ -41,19 +42,18 @@ gh pr view --repo AztecProtocol/aztec-packages --json state,title ### Step 3: Check if Already Backported -Check whether this PR has already been backported to the staging branch by -looking for its PR number in the commit log: +Check whether this PR has already been backported to the train branch: ```bash -STAGING_BRANCH="backport-to-${TARGET_BRANCH}-staging" -git fetch origin "$STAGING_BRANCH" 2>/dev/null -if git log "origin/$STAGING_BRANCH" --oneline --grep="(#)" | grep -q .; then - echo "PR # has already been backported to $STAGING_BRANCH." +TRAIN_BRANCH="merge-train/${TARGET_BRANCH}" +git fetch origin "$TRAIN_BRANCH" 2>/dev/null +if git log "origin/$TRAIN_BRANCH" --oneline --grep="(#)" | grep -q .; then + echo "PR # has already been backported to $TRAIN_BRANCH." fi ``` -**Abort if** the PR number appears in the staging branch commit log. Show the -matching commit(s) and tell the user the backport already exists. +If it exists as a `CONFLICTS:` commit, proceed to conflict resolution (Step 6). +If it exists as a clean commit, abort — already done. ### Step 4: Create Isolated Worktree @@ -79,60 +79,55 @@ Run the backport script from the worktree: ./scripts/backport_to_staging.sh ``` -**If the script succeeds:** Skip to Step 10 (cleanup and report). +**If the script succeeds (no conflicts):** Skip to Step 10 (cleanup and report). -**If the script fails:** Continue to Step 6 (conflict resolution). +**If the script produces a `CONFLICTS:` commit:** Continue to Step 6. ### Step 6: Assess Conflicts -The script will have left the worktree on the `backport-to--staging` -branch with partially applied changes and `.rej` files for hunks that failed. +The script has already committed the conflict markers onto the train branch as a +`CONFLICTS: (#<PR>)` commit. Check out the train branch and inspect: -1. **Verify current branch** is `backport-to-<TARGET_BRANCH>-staging` - -2. **Identify the state of the working tree:** - ```bash - git status - ``` +```bash +TRAIN_BRANCH="merge-train/${TARGET_BRANCH}" +git fetch origin "$TRAIN_BRANCH" +git checkout -B "$TRAIN_BRANCH" "origin/$TRAIN_BRANCH" +``` -3. **Find all reject files:** - ```bash - find . -name '*.rej' -not -path './node_modules/*' -not -path './.git/*' - ``` +Identify conflicted files: +```bash +git diff HEAD~1 HEAD --name-only +grep -rn "<<<<<<" . --include="*.ts" --include="*.nr" --include="*.cpp" \ + --include="*.hpp" --include="*.rs" --include="*.sol" -l 2>/dev/null \ + | grep -v node_modules | grep -v .git +``` -4. **Get the full PR diff for reference:** - ```bash - gh pr diff <PR_NUMBER> - ``` +Get the full PR diff for reference: +```bash +gh pr diff <PR_NUMBER> +``` ### Step 7: Resolve Conflicts -For each `.rej` file: - -1. **Read the reject file** to understand what hunk failed to apply -2. **Read the current version** of the corresponding source file on the staging branch -3. **Understand the intent** of the change from the PR diff context -4. **Apply the change manually** by editing the source file, adapting the change to - the current state of the code on the release branch -5. **Delete the `.rej` file** after resolving +For each file with conflict markers: -Also check for files that may need to be created or deleted based on the PR diff -but were not handled by the partial apply. +1. **Read the file** to understand what conflicted +2. **Read the PR diff** to understand the intent of the change +3. **Resolve the markers** by editing the file, adapting the change to the + current state of the code on the release branch +4. The release branch may have diverged significantly from `next` — preserve + semantic intent, not exact line-by-line diff **Important considerations:** -- The release branch may have diverged significantly from `next`. Do not assume - the surrounding code is the same as in the original PR. -- When adapting changes, preserve the semantic intent of the PR, not the exact - line-by-line diff. -- If a file referenced in the diff does not exist at all on the release branch, - evaluate whether it should be created or if the change is irrelevant. If - irrelevant, skip it and note this in the final report. +- If a file referenced in the diff does not exist on the release branch, evaluate + whether it should be created or if the change is irrelevant. If irrelevant, + skip it and note in the final report. ### Step 8: Verify Build Check if changes exist in `yarn-project`: ```bash -git diff --name-only | grep '^yarn-project/' || true +git diff HEAD~1 --name-only | grep '^yarn-project/' || true ``` If yarn-project changes exist, run from `yarn-project`: @@ -140,11 +135,6 @@ If yarn-project changes exist, run from `yarn-project`: yarn build ``` -Check if changes exist outside `yarn-project`: -```bash -git diff --name-only | grep -v '^yarn-project/' || true -``` - If changes exist outside yarn-project, run bootstrap from the repo root: ```bash BOOTSTRAP_TO=yarn-project ./bootstrap.sh @@ -152,14 +142,16 @@ BOOTSTRAP_TO=yarn-project ./bootstrap.sh Fix any build errors that arise from the backport adaptation. -### Step 9: Finish with Script +### Step 9: Amend the Conflicts Commit -Clean up and let the script handle commit, push, and PR management: +Replace the `CONFLICTS:` commit with a clean one (drop the `CONFLICTS:` prefix): ```bash -find . -name '*.rej' -delete git add -A -./scripts/backport_to_staging.sh --continue <PR_NUMBER> <TARGET_BRANCH> +git commit --amend --no-edit -m "<original title> (#<PR_NUMBER>) + +<original PR body>" +git push origin "$TRAIN_BRANCH" --force-with-lease ``` ### Step 10: Cleanup and Report @@ -171,35 +163,28 @@ cd "$ORIGINAL_DIR" git worktree remove "$WORKTREE_DIR" ``` -**Always clean up the worktree**, even if earlier steps failed. If `git worktree -remove` fails (e.g., uncommitted changes), use `git worktree remove --force`. +**Always clean up the worktree**, even if earlier steps failed. Print a summary: - PR number and title that was backported -- Target branch and staging branch name +- Target branch and train branch name - Whether conflicts were encountered and resolved -- Link to the staging PR (if one was created or already exists) +- Link to the train PR (if one exists) ## Key Points +- **Train branch convention**: `merge-train/<target>` + (e.g. `merge-train/v4`). Multiple backports accumulate here. +- **Conflicts are pre-committed**: The script commits conflict markers with a + `CONFLICTS:` prefix rather than failing. Resolve and amend that commit. - **Always use a worktree**: All backport work happens in a temporary git worktree - so the user's current branch and working tree are never disturbed. Always clean - up the worktree when done, even on failure. -- **Script first, manual second**: Always try the automated script first. It handles - branch setup, authorship, push, and PR management. Only do manual conflict - resolution if it fails. -- **Use `--continue` after resolving**: The script's `--continue` mode picks up where - the initial run left off (commit, push, PR creation, body update). -- **Preserve author attribution**: The script uses `--author` to set the original PR - author on the commit. The committer stays as whoever runs the command (GPG signing - works). + so the user's current branch and working tree are never disturbed. +- **Script first, manual second**: Always try the automated script first. Only + resolve conflicts manually if it produced a `CONFLICTS:` commit. +- **Preserve author attribution**: The script sets `--author` to the original PR + author. Preserve this when amending. - **Verify builds but skip tests**: Run `yarn build` or bootstrap to confirm the - backport compiles. Do not run the full test suite -- that is CI's job. + backport compiles. Do not run the full test suite — that is CI's job. - **Semantic, not mechanical**: When resolving conflicts, adapt the change to the - release branch's code state. The goal is the same behavioral change, not an exact - diff match. -- **Clean up `.rej` files**: Always delete `.rej` files before committing. -- **Staging branch convention**: The staging branch is always - `backport-to-{TARGET_BRANCH}-staging` (e.g., `backport-to-v4-staging`, - `backport-to-v4-devnet-2-staging`). Multiple backports accumulate on the same - staging branch and get merged together. + release branch's code state. The goal is the same behavioral change, not an + exact diff match. diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index c900b91d802a..68e23f2cfe16 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -5,136 +5,115 @@ on: types: ["labeled", "closed"] jobs: - label_checker: - name: Check labels - runs-on: ubuntu-latest - outputs: - state: ${{ steps.check.outputs.label_check }} - steps: - - id: check - uses: agilepathway/label-checker@825944377ab3bce1269b38c99b718767e2ca6bbc - with: - prefix_mode: true - any_of: backport-to- - repo_token: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} - allow_failure: true - - name: Print status - shell: bash - run: 'echo "Label detection status: ${{ steps.check.outputs.label_check }}"' - backport: - needs: [label_checker] name: Backport PR - if: github.event.pull_request.merged == true && needs.label_checker.outputs.state == 'success' + if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: + - name: Check for backport labels + id: check-labels + run: | + LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' + echo "Labels: $LABELS" + TARGETS=$(echo "$LABELS" | jq -r '.[] | select(startswith("backport-to-")) | sub("backport-to-"; "")') + if [ -z "$TARGETS" ]; then + echo "No backport-to-* labels — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "Targets: $TARGETS" + echo "skip=false" >> "$GITHUB_OUTPUT" + { + echo "targets<<EOF" + echo "$TARGETS" + echo "EOF" + } >> "$GITHUB_OUTPUT" + fi + - name: Checkout repository + if: steps.check-labels.outputs.skip == 'false' uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} - - name: Extract target branch from labels - id: extract-branch - run: | - LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' - echo "All labels: $LABELS" - - # Extract the branch name from backport-to-* label - TARGET_BRANCH=$(echo "$LABELS" | jq -r '.[] | select(startswith("backport-to-")) | sub("backport-to-"; "")') - - if [ -z "$TARGET_BRANCH" ]; then - echo "No backport-to-* label found" - exit 1 - fi - - echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT - echo "Target branch: $TARGET_BRANCH" - - - name: Run backport script + - name: Run backport script for each target id: backport - continue-on-error: true + if: steps.check-labels.outputs.skip == 'false' env: GH_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | - # Kludge. We should write this in Python or not checkout history. - # Bash has an ugly footgun with changing history while running the script. - buffer_script=$(cat ./scripts/backport_to_staging.sh) - bash <(echo "$buffer_script") \ - ${{ github.event.pull_request.number }} \ - ${{ steps.extract-branch.outputs.target_branch }} - - - name: Comment on original PR (success) - if: steps.backport.outcome == 'success' + CONFLICTED_TARGETS="" + + while IFS= read -r target; do + [[ -z "$target" ]] && continue + echo "::group::Backporting to $target (merge-train/${target})" + tmp_out=$(mktemp) + GITHUB_OUTPUT="$tmp_out" bash ./scripts/backport_to_staging.sh \ + "$PR_NUMBER" "$target" + if grep -q "has_conflicts=true" "$tmp_out"; then + CONFLICTED_TARGETS="${CONFLICTED_TARGETS} ${target}" + fi + rm -f "$tmp_out" + echo "::endgroup::" + done <<< "${{ steps.check-labels.outputs.targets }}" + + echo "conflicted_targets=${CONFLICTED_TARGETS# }" >> "$GITHUB_OUTPUT" + + - name: Comment on original PR + if: steps.check-labels.outputs.skip == 'false' env: GH_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} run: | - TARGET_BRANCH="${{ steps.extract-branch.outputs.target_branch }}" - STAGING_BRANCH="backport-to-${TARGET_BRANCH}-staging" - STAGING_PR=$(gh pr list --base "$TARGET_BRANCH" --head "$STAGING_BRANCH" --json number,url --jq '.[0]') - STAGING_PR_NUMBER=$(echo "$STAGING_PR" | jq -r '.number') - STAGING_PR_URL=$(echo "$STAGING_PR" | jq -r '.url') - - gh pr comment "${{ github.event.pull_request.number }}" --body \ - "✅ Successfully backported to [$STAGING_BRANCH #$STAGING_PR_NUMBER]($STAGING_PR_URL)." - - - name: Comment on original PR (failure) - if: steps.backport.outcome == 'failure' + CONFLICTED=" ${{ steps.backport.outputs.conflicted_targets }} " + BODY="" + + while IFS= read -r target; do + [[ -z "$target" ]] && continue + TRAIN_BRANCH="merge-train/${target}" + if echo "$CONFLICTED" | grep -q " ${target} "; then + BODY="${BODY}⚠️ Backported to \`${TRAIN_BRANCH}\` — conflicts present, ClaudeBox dispatched to resolve.\n" + else + BODY="${BODY}✅ Backported to \`${TRAIN_BRANCH}\`.\n" + fi + done <<< "${{ steps.check-labels.outputs.targets }}" + + gh pr comment "${{ github.event.pull_request.number }}" \ + --repo "$GITHUB_REPOSITORY" \ + --body "$(printf '%b' "$BODY")" + + - name: Dispatch ClaudeBox for each conflicted target + if: steps.check-labels.outputs.skip == 'false' && steps.backport.outputs.conflicted_targets != '' env: GH_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} - run: | - TARGET_BRANCH="${{ steps.extract-branch.outputs.target_branch }}" - WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - gh pr comment "${{ github.event.pull_request.number }}" --body \ - "❌ Failed to cherry-pick to \`$TARGET_BRANCH\` due to conflicts. Dispatching ClaudeBox to resolve. [View backport run]($WORKFLOW_URL)." - - - name: Notify Slack on backport failure - if: steps.backport.outcome == 'failure' - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - run: | - PR_NUMBER="${{ github.event.pull_request.number }}" - PR_TITLE="${{ github.event.pull_request.title }}" - PR_URL="${{ github.event.pull_request.html_url }}" - TARGET_BRANCH="${{ steps.extract-branch.outputs.target_branch }}" - WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - TEXT=$(printf '⚠️ Automatic backport failed\n• <%s|#%s %s>\n• Target: `%s`\n• <%s|View Run>' \ - "$PR_URL" "$PR_NUMBER" "$PR_TITLE" "$TARGET_BRANCH" "$WORKFLOW_URL") - RESP=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ - -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-type: application/json" \ - -d "$(jq -n --arg c "#backports" --arg t "$TEXT" '{channel:$c, text:$t}')") - echo "Slack response: $RESP" - - - name: Dispatch ClaudeBox to resolve conflicts - if: steps.backport.outcome == 'failure' - env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - GH_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} + CONFLICTED_TARGETS: ${{ steps.backport.outputs.conflicted_targets }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - PR="${{ github.event.pull_request.number }}" - TITLE="${{ github.event.pull_request.title }}" - URL="${{ github.event.pull_request.html_url }}" - BRANCH="${{ steps.extract-branch.outputs.target_branch }}" - - # Post to #backports, derive permalink from response - TEXT=$(printf '⚠️ Backport failed: <%s|#%s %s> → `%s`. Dispatching ClaudeBox…' \ - "$URL" "$PR" "$TITLE" "$BRANCH") - RESP=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ - -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-type: application/json" \ - -d "$(jq -n --arg c "#backports" --arg t "$TEXT" '{channel:$c, text:$t}')") - echo "Slack response: $RESP" - TS=$(echo "$RESP" | jq -r '.ts // empty') - CHANNEL_ID=$(echo "$RESP" | jq -r '.channel // empty') - - LINK="" - if [[ -n "$TS" && -n "$CHANNEL_ID" ]]; then - LINK="https://aztecprotocol.slack.com/archives/$CHANNEL_ID/p${TS//./}" - fi - - gh workflow run claudebox.yml \ - -f prompt="Backport PR #$PR ($TITLE) to $BRANCH. The automatic cherry-pick failed due to conflicts. Follow the backport skill (.claude/skills/backport/SKILL.md) to resolve conflicts and create a PR targeting $BRANCH." \ - -f link="${LINK:-$URL}" + while IFS= read -r target; do + [[ -z "$target" ]] && continue + TRAIN_BRANCH="merge-train/${target}" + + # Post conflict notification to #backports + TEXT=$(printf '⚠️ Backport conflicts on `%s`: <%s|#%s %s>.' \ + "$TRAIN_BRANCH" "$PR_URL" "$PR_NUMBER" "$PR_TITLE") + RESP=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-type: application/json" \ + -d "$(jq -n --arg c "#backports" --arg t "$TEXT" '{channel:$c,text:$t}')") + TS=$(echo "$RESP" | jq -r '.ts // empty') + CHANNEL_ID=$(echo "$RESP" | jq -r '.channel // empty') + LINK="$PR_URL" + if [[ -n "$TS" && -n "$CHANNEL_ID" ]]; then + LINK="https://aztecprotocol.slack.com/archives/$CHANNEL_ID/p${TS//./}" + fi + + gh workflow run claudebox.yml \ + -f prompt="Run: /backport $PR_NUMBER $target + +Backport PR #$PR_NUMBER was cherry-picked onto \`$TRAIN_BRANCH\` but has conflicts. The conflict markers are already committed on that branch (look for a commit with the CONFLICTS: prefix). Resolve them and open a chore: fix PR targeting the train branch. See workflow run for details: $WORKFLOW_URL" \ + -f link="$LINK" + done <<< "$(echo "$CONFLICTED_TARGETS" | tr ' ' '\n')" diff --git a/.github/workflows/merge-train-auto-merge.yml b/.github/workflows/merge-train-auto-merge.yml index 265312572016..e4a1a809ddbf 100644 --- a/.github/workflows/merge-train-auto-merge.yml +++ b/.github/workflows/merge-train-auto-merge.yml @@ -22,11 +22,11 @@ jobs: MERGE_TRAIN_GITHUB_TOKEN=${{ secrets.MERGE_TRAIN_GITHUB_TOKEN }} \ ./scripts/merge-train/auto-merge.sh - - name: Run auto-merge script (backport-train) + - name: Run auto-merge script (backport trains) run: | GH_TOKEN=${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} \ - MERGE_TRAIN_GITHUB_TOKEN=${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} \ - BRANCH_PATTERN=backport-to- \ - MERGE_STRATEGY=merge \ - INACTIVITY_HOURS=8 \ + MERGE_TRAIN_GITHUB_TOKEN=${{ secrets.MERGE_TRAIN_GITHUB_TOKEN }} \ + BRANCH_PATTERN=merge-train/v \ + BASE_BRANCH_PATTERN='v[0-9]' \ ./scripts/merge-train/auto-merge.sh + diff --git a/.github/workflows/merge-train-create-pr.yml b/.github/workflows/merge-train-create-pr.yml index a596af51c55e..b18ab3bf8fa7 100644 --- a/.github/workflows/merge-train-create-pr.yml +++ b/.github/workflows/merge-train-create-pr.yml @@ -20,42 +20,68 @@ jobs: - name: Check if PR exists and create if needed env: GITHUB_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} + MERGE_TRAIN_GITHUB_TOKEN: ${{ secrets.MERGE_TRAIN_GITHUB_TOKEN }} run: | branch="${{ github.ref_name }}" + branch_suffix="${branch#merge-train/}" - # Skip if this is a merge commit (check for multiple parents) - parent_count=$(git rev-list --parents -n 1 "${{ github.sha }}" | wc -w) - if [[ $parent_count -gt 2 ]]; then - echo "Skipping: This is a merge commit." - exit 0 + # Detect backport trains: merge-train/v<N>[...] targets a release branch + if [[ "$branch_suffix" =~ ^v[0-9] ]]; then + base_branch="$branch_suffix" + is_backport=true + else + base_branch="next" + is_backport=false fi - # Skip if this commit is already in the next branch - if git merge-base --is-ancestor "${{ github.sha }}" origin/next; then - echo "Skipping: This commit is already in the next branch" - exit 0 + if [[ "$is_backport" == "false" ]]; then + # Skip if this is a merge commit (check for multiple parents) + parent_count=$(git rev-list --parents -n 1 "${{ github.sha }}" | wc -w) + if [[ $parent_count -gt 2 ]]; then + echo "Skipping: This is a merge commit." + exit 0 + fi + + # Skip if this commit is already in the next branch + if git merge-base --is-ancestor "${{ github.sha }}" origin/next; then + echo "Skipping: This commit is already in the next branch" + exit 0 + fi fi # Check if PR already exists for this branch existing_pr=$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty') - if [[ -z "$existing_pr" ]]; then - echo "No PR exists for $branch, creating one" + if [[ -n "$existing_pr" ]]; then + echo "PR #$existing_pr already exists for $branch" + exit 0 + fi - # Determine base branch (default to next) - base_branch="next" + echo "Creating PR: $branch → $base_branch" - # Create PR with ci-no-squash label + if [[ "$is_backport" == "true" ]]; then + labels="ci-no-squash,ci-skip" + title="chore: Accumulated backports to $base_branch" + body="Backport train for \`$base_branch\`. Commits prefixed \`CONFLICTS:\` need manual resolution before merging." + else labels="ci-no-squash" if [[ "$branch" == "merge-train/spartan" ]]; then labels="$labels,ci-full-no-test-cache" fi - gh pr create --base "$base_branch" --head "$branch" \ - --title "feat: $branch" \ - --body "$(echo -e "See [merge-train-readme.md](https://github.com/${{ github.repository }}/blob/next/.github/workflows/merge-train-readme.md).\nThis is a merge-train.")" \ - --label "$labels" + title="feat: $branch" + body="$(echo -e "See [merge-train-readme.md](https://github.com/${{ github.repository }}/blob/next/.github/workflows/merge-train-readme.md).\nThis is a merge-train.")" + fi - echo "Created PR for $branch" - else - echo "PR #$existing_pr already exists for $branch" + gh pr create --base "$base_branch" --head "$branch" \ + --title "$title" \ + --body "$body" \ + --label "$labels" + + echo "Created PR for $branch → $base_branch" + + # Auto-approve backport train PRs using the merge-train bot account + if [[ "$is_backport" == "true" ]]; then + pr_number=$(gh pr list --state open --head "$branch" --json number --jq '.[0].number') + echo "Auto-approving backport train PR #$pr_number" + GH_TOKEN="${MERGE_TRAIN_GITHUB_TOKEN}" gh pr review "$pr_number" --approve --body "🤖 Auto-approved backport train" fi diff --git a/scripts/backport_to_staging.sh b/scripts/backport_to_staging.sh index 0c4243d009b1..75ab5d0c3fed 100755 --- a/scripts/backport_to_staging.sh +++ b/scripts/backport_to_staging.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash NO_CD=1 source $(git rev-parse --show-toplevel)/ci3/source -# Apply a PR's diff to a backport staging branch +# Cherry-pick a merged PR onto its backport merge-train branch. +# The branch is named merge-train/<target_branch> (e.g. merge-train/v2). +# On first push, merge-train-create-pr.yml auto-creates the PR targeting <target_branch>. # Usage: backport_to_staging.sh [--dry-run] [--continue] <pr_number> <target_branch> usage() { cat >&2 <<EOF Usage: $0 [--dry-run] [--continue] <pr_number> <target_branch> -Apply a PR's diff to a backport staging branch. +Cherry-pick a merged PR onto its backport merge-train branch. Arguments: pr_number The GitHub PR number to backport @@ -19,13 +21,13 @@ Options: --continue Continue after manually fixing conflicts Examples: - # Backport PR #123 to v2 + # Backport PR #123 to v2 (pushes to merge-train/v2) $0 123 v2 # Dry-run to preview $0 --dry-run 123 v2 - # Continue after fixing conflicts + # Continue after fixing conflicts manually $0 --continue 123 v2 EOF exit 1 @@ -66,18 +68,18 @@ if [[ -z "$PR_NUMBER" || -z "$TARGET_BRANCH" ]]; then usage fi -STAGING_BRANCH="backport-to-${TARGET_BRANCH}-staging" +TRAIN_BRANCH="merge-train/${TARGET_BRANCH}" # Check for required tools command -v gh >/dev/null 2>&1 || { echo "Error: 'gh' CLI not found. Install from https://cli.github.com/" >&2; exit 1; } command -v jq >/dev/null 2>&1 || { echo "Error: 'jq' not found. Install jq." >&2; exit 1; } echo "=== Backport Configuration ===" -echo "PR Number: $PR_NUMBER" -echo "Target Branch: $TARGET_BRANCH" -echo "Staging Branch: $STAGING_BRANCH" -echo "Dry Run: ${DRY_RUN:-0}" -echo "Continue Mode: $CONTINUE_MODE" +echo "PR Number: $PR_NUMBER" +echo "Target: $TARGET_BRANCH" +echo "Train Branch: $TRAIN_BRANCH" +echo "Dry Run: ${DRY_RUN:-0}" +echo "Continue: $CONTINUE_MODE" echo "" # Set a default git committer identity @@ -86,31 +88,29 @@ if ! git config user.name &>/dev/null; then git config user.email "tech@aztecprotocol.com" fi -# Get PR information +# Get PR information via gh using AZTEC_BOT_GITHUB_TOKEN (set GH_TOKEN in the workflow). echo "Fetching PR information..." -if ! PR_INFO=$(gh pr view "$PR_NUMBER" --json number,title,state,mergedAt,body,author); then +if ! PR_INFO=$(gh pr view "$PR_NUMBER" --json number,title,state,mergedAt,body,author,mergeCommit); then echo "Error: Failed to fetch PR #$PR_NUMBER" >&2 exit 1 fi - PR_TITLE=$(echo "$PR_INFO" | jq -r '.title') PR_STATE=$(echo "$PR_INFO" | jq -r '.state') PR_BODY=$(echo "$PR_INFO" | jq -r '.body') -PR_MERGED_AT=$(echo "$PR_INFO" | jq -r '.mergedAt') +MERGE_COMMIT=$(echo "$PR_INFO" | jq -r '.mergeCommit.oid // empty') PR_AUTHOR=$(echo "$PR_INFO" | jq -r '.author.login // empty') -if [[ -n "$PR_AUTHOR" && "$PR_AUTHOR" != "null" ]]; then + +if [[ -n "$PR_AUTHOR" && "$PR_AUTHOR" != "null" && "$PR_AUTHOR" != "AztecBot" ]]; then PR_AUTHOR_EMAIL="${PR_AUTHOR}@users.noreply.github.com" else - echo "Warning: Could not determine PR author, using AztecBot as fallback" >&2 PR_AUTHOR="AztecBot" PR_AUTHOR_EMAIL="tech@aztec-labs.com" fi -echo "PR Title: $PR_TITLE" -echo "PR State: $PR_STATE" -echo "Merged At: $PR_MERGED_AT" -echo "Author: $PR_AUTHOR" -echo "Author Email: $PR_AUTHOR_EMAIL" +echo "PR Title: $PR_TITLE" +echo "PR State: $PR_STATE" +echo "Merge Commit: $MERGE_COMMIT" +echo "Author: $PR_AUTHOR" if [[ "$PR_STATE" != "MERGED" ]]; then echo "Error: PR #$PR_NUMBER is not merged yet (state: $PR_STATE)" >&2 @@ -118,79 +118,91 @@ if [[ "$PR_STATE" != "MERGED" ]]; then fi if [[ $CONTINUE_MODE -eq 0 ]]; then - # Fetch the target branch + # Fetch the target branch and the merge commit echo "Fetching origin/$TARGET_BRANCH..." git fetch origin "$TARGET_BRANCH" - # Check if staging branch exists remotely - echo "Checking for staging branch. $STAGING_BRANCH.." - if git ls-remote --heads origin "$STAGING_BRANCH" | grep -q "$STAGING_BRANCH"; then - echo "Staging branch exists, fetching and checking out..." - git fetch origin "$STAGING_BRANCH" - git checkout -B "$STAGING_BRANCH" FETCH_HEAD - else - echo "Creating new staging branch from origin/$TARGET_BRANCH..." - git checkout -B "$STAGING_BRANCH" "origin/$TARGET_BRANCH" + if [[ -n "$MERGE_COMMIT" ]]; then + echo "Fetching merge commit $MERGE_COMMIT..." + git fetch origin "$MERGE_COMMIT" fi - echo "Fetching PR diff..." + # Check if train branch exists remotely; create from target if not + echo "Checking for train branch $TRAIN_BRANCH..." + if git ls-remote --heads origin "refs/heads/$TRAIN_BRANCH" | grep -q .; then + echo "Train branch exists, fetching and checking out..." + git fetch origin "$TRAIN_BRANCH" + git checkout -B "$TRAIN_BRANCH" FETCH_HEAD + else + echo "Creating new train branch from origin/$TARGET_BRANCH..." + git checkout -B "$TRAIN_BRANCH" "origin/$TARGET_BRANCH" + fi - if ! gh pr diff "$PR_NUMBER" 2>/dev/null | git apply --verbose --reject; then - git status -s - echo "Error: Failed to apply diff. Fix conflicts manually, then run: ./scripts/backport_to_staging.sh --continue $PR_NUMBER $TARGET_BRANCH" >&2 - exit 1 + HAS_CONFLICTS=0 + if [[ -n "$MERGE_COMMIT" ]]; then + echo "Cherry-picking $MERGE_COMMIT..." + # -m 1 selects the first parent (the branch the PR merged into) for merge commits. + # --no-commit applies changes without committing so we can set author/message below. + if ! git cherry-pick -m 1 --no-commit "$MERGE_COMMIT"; then + echo "Warning: Cherry-pick had conflicts. Committing with conflict markers." >&2 + HAS_CONFLICTS=1 + # Quit cherry-pick state but keep working tree (conflict markers intact) + git cherry-pick --quit + fi + else + echo "No merge commit SHA available, falling back to PR diff..." >&2 + PR_DIFF=$(gh pr diff "$PR_NUMBER" 2>/dev/null) + if [[ -z "$PR_DIFF" ]]; then + echo "Error: Could not fetch diff for PR #$PR_NUMBER" >&2 + exit 1 + fi + if ! echo "$PR_DIFF" | git apply --reject --verbose; then + echo "Warning: Diff did not apply cleanly. Committing what we have." >&2 + HAS_CONFLICTS=1 + fi fi else - echo "Continuing from previous failure..." - # Verify we're on the correct branch + echo "Continuing from previous conflict resolution..." CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - if [[ "$CURRENT_BRANCH" != "$STAGING_BRANCH" ]]; then - echo "Error: Not on expected branch $STAGING_BRANCH (currently on $CURRENT_BRANCH)" >&2 + if [[ "$CURRENT_BRANCH" != "$TRAIN_BRANCH" ]]; then + echo "Error: Not on expected branch $TRAIN_BRANCH (currently on $CURRENT_BRANCH)" >&2 exit 1 fi fi -# Commit changes - base the commit details off of the PR title and body -echo "Diff applied successfully! Committing changes..." - # Ensure commit subject contains PR reference for get_meaningful_commits COMMIT_SUBJECT="$PR_TITLE" if ! echo "$COMMIT_SUBJECT" | grep -qE '\(#[0-9]+\)'; then COMMIT_SUBJECT="$COMMIT_SUBJECT (#$PR_NUMBER)" fi -# Use --author to preserve original PR author while keeping the committer -# as whoever runs the script (so GPG signing works for local devs). -git add -A -git commit --author="$PR_AUTHOR <$PR_AUTHOR_EMAIL>" -m "$COMMIT_SUBJECT +# Prefix with CONFLICTS so it's obvious in the train PR commit list +if [[ "${HAS_CONFLICTS:-0}" -eq 1 ]]; then + COMMIT_SUBJECT="CONFLICTS: $COMMIT_SUBJECT" + echo "Committing with conflict markers..." +else + echo "Committing..." +fi -$PR_BODY" +# Preserve original PR author; committer is whoever runs the script. +COMMIT_MSG_FILE=$(mktemp) +printf '%s\n\n%s\n' "$COMMIT_SUBJECT" "$PR_BODY" > "$COMMIT_MSG_FILE" +do_or_dryrun git add -A +do_or_dryrun git commit --no-gpg-sign --author="$PR_AUTHOR <$PR_AUTHOR_EMAIL>" -F "$COMMIT_MSG_FILE" +rm -f "$COMMIT_MSG_FILE" git log -1 --pretty=format:'Committed as %H by %an <%ae>%n%n%s%n%n%b' -# Push staging branch -echo "Pushing to origin/$STAGING_BRANCH..." -do_or_dryrun git push origin "$STAGING_BRANCH" -# Create or update PR -echo "" -echo "Managing PR from $STAGING_BRANCH -> $TARGET_BRANCH..." - -EXISTING_PR=$(gh pr list --base "$TARGET_BRANCH" --head "$STAGING_BRANCH" --json number --jq '.[0].number' || echo "") - -if [[ -z "$EXISTING_PR" ]]; then - echo "Creating new PR..." - do_or_dryrun gh pr create \ - --base "$TARGET_BRANCH" \ - --head "$STAGING_BRANCH" \ - --title "chore: Accumulated backports to $TARGET_BRANCH" \ - --body "Backport staging PR. Body will be updated with commit list." - do_or_dryrun echo "Created new backport PR" +# Signal to the caller whether conflicts were present +if [[ "${HAS_CONFLICTS:-0}" -eq 1 ]]; then + echo "has_conflicts=true" >> "${GITHUB_OUTPUT:-/dev/null}" else - echo "PR already exists (#$EXISTING_PR)" + echo "has_conflicts=false" >> "${GITHUB_OUTPUT:-/dev/null}" fi -# Update PR body with commit override markers (same mechanism as merge-trains) -echo "Updating PR body with commit list..." -do_or_dryrun "$root/scripts/merge-train/update-pr-body.sh" "$STAGING_BRANCH" +# Push — merge-train-create-pr.yml creates the PR on first push; +# merge-train-update-pr-body.yml updates the body on every push. +echo "Pushing to origin/$TRAIN_BRANCH..." +do_or_dryrun git push origin "$TRAIN_BRANCH" -do_or_dryrun echo "Successfully backported PR #$PR_NUMBER to $STAGING_BRANCH" +do_or_dryrun echo "Done: PR #$PR_NUMBER → $TRAIN_BRANCH" diff --git a/scripts/merge-train/auto-merge.sh b/scripts/merge-train/auto-merge.sh index 00549eca9f7b..62ed8be8f0cf 100755 --- a/scripts/merge-train/auto-merge.sh +++ b/scripts/merge-train/auto-merge.sh @@ -33,13 +33,21 @@ function enable_auto_merge { # Configuration (can be overridden via environment variables) BRANCH_PATTERN="${BRANCH_PATTERN:-merge-train/}" +BASE_BRANCH_PATTERN="${BASE_BRANCH_PATTERN:-}" # if set, filter PRs whose base branch matches this regex MERGE_STRATEGY="${MERGE_STRATEGY:-merge}" INACTIVITY_HOURS="${INACTIVITY_HOURS:-4}" INACTIVITY_SECONDS=$((INACTIVITY_HOURS * 3600)) function get_prs_by_branch_pattern { - gh pr list --state open --limit 100 --search "head:$BRANCH_PATTERN" \ - --json number,headRefName,updatedAt --jq '.[]' + # Filter to --base next for regular merge trains. + # For backport trains (BASE_BRANCH_PATTERN set), list all open PRs and filter by base branch pattern. + if [[ -n "$BASE_BRANCH_PATTERN" ]]; then + gh pr list --state open --json number,headRefName,baseRefName,updatedAt \ + --jq '.[] | select(.headRefName | startswith("'"$BRANCH_PATTERN"'")) | select(.baseRefName | test("'"$BASE_BRANCH_PATTERN"'"))' + else + gh pr list --state open --base next --json number,headRefName,updatedAt \ + --jq '.[] | select(.headRefName | startswith("'"$BRANCH_PATTERN"'"))' + fi } function get_meaningful_commits_for_pr { diff --git a/scripts/merge-train/merge-next.sh b/scripts/merge-train/merge-next.sh index 18bb64467828..df043e426ffa 100755 --- a/scripts/merge-train/merge-next.sh +++ b/scripts/merge-train/merge-next.sh @@ -145,10 +145,10 @@ ${conflicts} Please resolve the conflicts manually." - # Post comment on the most recent commit on next - latest_commit=$(gh api repos/{owner}/{repo}/commits/next --jq '.sha') - gh api "repos/{owner}/{repo}/commits/${latest_commit}/comments" \ - -f body="$conflict_comment" + # Post conflict comment on the merge-train PR (visible to the PR author). + if [[ -n "${pr_number:-}" ]]; then + gh pr comment "$pr_number" --body "$conflict_comment" || true + fi log_error "Merge failed due to conflicts" exit 1