diff --git a/.github/workflows/backport-command.yaml b/.github/workflows/backport-command.yaml new file mode 100644 index 00000000000..0af5ec3a112 --- /dev/null +++ b/.github/workflows/backport-command.yaml @@ -0,0 +1,117 @@ +# Slash-command handler for /backport. +# +# Posting a comment on a merged PR with: +# +# /backport v5.0.x v4.1.x +# +# is equivalent to manually triggering the "Backport" workflow from the +# GitHub Actions UI with those branch names. Multiple branches may be +# supplied as space- or comma-separated values on the same line. +# +# The bot acknowledges receipt with a 👀 reaction on the comment. If the PR +# is not yet merged, or the command is otherwise invalid, it replies with an +# explanatory comment instead. + +name: Backport slash command + +on: + issue_comment: + types: [created] + +permissions: {} + +jobs: + dispatch: + name: Handle /backport comment + runs-on: ubuntu-latest + # Only act on PR comments (issue_comment fires for both issues and PRs). + if: github.event.issue.pull_request != null + permissions: + actions: write # trigger workflow_dispatch + issues: write # post reactions and comments + pull-requests: read # read PR merge status + steps: + - name: Parse command and validate PR + id: parse + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.comment.body; + const commentId = context.payload.comment.id; + const issueNumber = context.payload.issue.number; + + // Look for /backport at the start of any line. + const match = body.match(/^\/backport\s+([^\r\n]+)/m); + if (!match) { + core.setOutput('triggered', 'false'); + return; + } + + // Parse branch list (space- or comma-separated). + const branches = match[1].trim().split(/[\s,]+/).filter(Boolean); + if (branches.length === 0) { + core.setOutput('triggered', 'false'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: '⚠️ `/backport` requires at least one target branch, e.g. `/backport v5.0.x`.', + }); + return; + } + + // Confirm the PR is actually merged. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issueNumber, + }); + + if (!pr.merged) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: '⚠️ Cannot backport: this PR has not been merged yet.', + }); + core.setOutput('triggered', 'false'); + return; + } + + // Acknowledge the command with a 👀 reaction. + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + content: 'eyes', + }); + + core.setOutput('triggered', 'true'); + core.setOutput('pr_number', String(issueNumber)); + core.setOutput('branches', branches.join(',')); + core.notice(`Dispatching backport of PR #${issueNumber} to: ${branches.join(', ')}`); + + - name: Trigger backport workflow + if: steps.parse.outputs.triggered == 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.parse.outputs.pr_number }} + BRANCHES: ${{ steps.parse.outputs.branches }} + with: + script: | + // workflow_dispatch requires a ref; use the default branch. + const { data: repo } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'backport.yaml', + ref: repo.default_branch, + inputs: { + pr_number: process.env.PR_NUMBER, + branches: process.env.BRANCHES, + }, + }); diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml new file mode 100644 index 00000000000..deb8f596f05 --- /dev/null +++ b/.github/workflows/backport.yaml @@ -0,0 +1,272 @@ +# Backport merged PRs to release branches. +# +# This workflow supports two modes: +# +# 1. Automatic (label-based): Apply one or more "backport:vX.Y.z" labels to a +# PR before merging. Once the PR is merged, this workflow fires and creates +# a cherry-pick PR for each labelled target branch. +# +# 2. Manual (workflow_dispatch): After a PR has already been merged, trigger +# this workflow manually via the GitHub Actions UI, providing the PR number +# and a comma-separated list of target branches. +# +# For every successful cherry-pick, a new PR is opened against the target +# branch and tagged with a "target:vX.Y.z" label. If the cherry-pick +# produces conflicts, a comment is posted on the original PR instead so a +# developer can handle it manually. + +name: Backport + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: 'Number of the merged PR to backport' + required: true + type: number + branches: + description: 'Target release branches (comma-separated, e.g. v5.0.x,v4.1.x)' + required: true + type: string + +permissions: {} + +jobs: + # ------------------------------------------------------------------------- + # Determine which branches need a backport and expose them as a matrix. + # ------------------------------------------------------------------------- + prepare: + name: Prepare backport targets + runs-on: ubuntu-latest + # For pull_request_target: only act when the PR was actually merged. + # For workflow_dispatch: always proceed. + if: > + github.event_name == 'workflow_dispatch' || + github.event.pull_request.merged == true + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + has_targets: ${{ steps.targets.outputs.has_targets }} + pr_number: ${{ steps.targets.outputs.pr_number }} + steps: + - name: Determine backport targets + id: targets + uses: actions/github-script@v7 + with: + script: | + let branches = []; + let prNumber; + + if (context.eventName === 'workflow_dispatch') { + prNumber = Number(context.payload.inputs.pr_number); + branches = context.payload.inputs.branches + .split(',') + .map(b => b.trim()) + .filter(Boolean); + } else { + prNumber = context.payload.pull_request.number; + const labels = context.payload.pull_request.labels.map(l => l.name); + for (const label of labels) { + const match = label.match(/^backport:(.+)$/); + if (match) { + branches.push(match[1].trim()); + } + } + } + + core.setOutput('pr_number', String(prNumber)); + core.setOutput('has_targets', branches.length > 0 ? 'true' : 'false'); + core.setOutput('matrix', JSON.stringify({ branch: branches })); + + if (branches.length === 0) { + core.notice('No backport targets found — nothing to do.'); + } else { + core.notice(`Will backport PR #${prNumber} to: ${branches.join(', ')}`); + } + + # ------------------------------------------------------------------------- + # One job per target branch. All branches run in parallel; a failure on + # one branch does not cancel the others. + # ------------------------------------------------------------------------- + backport: + name: Backport to ${{ matrix.branch }} + needs: prepare + if: needs.prepare.outputs.has_targets == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + strategy: + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + fail-fast: false + env: + PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} + TARGET_BRANCH: ${{ matrix.branch }} + steps: + - name: Checkout repository (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Retrieve PR metadata (title, body, commit list) via the API. + - name: Fetch PR metadata + id: pr_meta + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(process.env.PR_NUMBER), + }); + core.setOutput('title', pr.data.title); + // Body may be empty/null — default to empty string. + core.setOutput('body', pr.data.body ?? ''); + + // Collect all commit SHAs in merge order. + const { data: commits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(process.env.PR_NUMBER), + per_page: 100, + }); + const shas = commits.map(c => c.sha); + core.setOutput('commits', shas.join(' ')); + + # Cherry-pick every commit from the PR onto a new branch based on + # the target release branch. Push the branch on success; set a + # flag on conflict so the next step can report the failure. + - name: Cherry-pick commits onto backport branch + id: cherry_pick + env: + COMMITS: ${{ steps.pr_meta.outputs.commits }} + run: | + set -euo pipefail + BACKPORT_BRANCH="backport/pr-${PR_NUMBER}-to-${TARGET_BRANCH}" + echo "backport_branch=${BACKPORT_BRANCH}" >> "$GITHUB_OUTPUT" + + git fetch origin "${TARGET_BRANCH}" + git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}" + + cherry_pick_failed=false + failed_sha="" + for sha in $COMMITS; do + echo "Cherry-picking ${sha} ..." + if ! git cherry-pick -x "${sha}"; then + cherry_pick_failed=true + failed_sha="${sha}" + git cherry-pick --abort 2>/dev/null || true + break + fi + done + + echo "cherry_pick_failed=${cherry_pick_failed}" >> "$GITHUB_OUTPUT" + echo "failed_sha=${failed_sha}" >> "$GITHUB_OUTPUT" + + if [ "${cherry_pick_failed}" = "false" ]; then + git push origin "${BACKPORT_BRANCH}" + fi + + # Open a PR against the target branch and attach the target:* label. + - name: Create backport PR + if: steps.cherry_pick.outputs.cherry_pick_failed == 'false' + uses: actions/github-script@v7 + env: + ORIGINAL_TITLE: ${{ steps.pr_meta.outputs.title }} + ORIGINAL_BODY: ${{ steps.pr_meta.outputs.body }} + BACKPORT_BRANCH: ${{ steps.cherry_pick.outputs.backport_branch }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const targetBranch = process.env.TARGET_BRANCH; + const labelName = `target:${targetBranch}`; + + // Ensure the target:* label exists in this repo. + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + }); + } catch (err) { + if (err.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + color: '0075ca', + description: `Backport targeting the ${targetBranch} branch`, + }); + } else { + throw err; + } + } + + const title = `[${targetBranch}] ${process.env.ORIGINAL_TITLE}`; + const body = [ + `Backport of #${prNumber} to \`${targetBranch}\`.`, + '', + '---', + '', + process.env.ORIGINAL_BODY, + ].join('\n'); + + const { data: newPR } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + head: process.env.BACKPORT_BRANCH, + base: targetBranch, + }); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: newPR.number, + labels: [labelName], + }); + + core.notice(`Opened backport PR #${newPR.number}: ${newPR.html_url}`); + + # If cherry-pick failed, leave a comment on the original PR so a + # developer knows to create the backport manually. + - name: Comment on cherry-pick failure + if: steps.cherry_pick.outputs.cherry_pick_failed == 'true' + uses: actions/github-script@v7 + env: + FAILED_SHA: ${{ steps.cherry_pick.outputs.failed_sha }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const targetBranch = process.env.TARGET_BRANCH; + const failedSha = process.env.FAILED_SHA; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: [ + `⚠️ **Automatic backport to \`${targetBranch}\` failed.**`, + '', + `Cherry-pick of commit ${failedSha} produced conflicts.`, + 'Please create the backport manually:', + '', + '```bash', + `git fetch origin ${targetBranch}`, + `git checkout -b backport/pr-${prNumber}-to-${targetBranch} origin/${targetBranch}`, + `git cherry-pick -x `, + `git push origin backport/pr-${prNumber}-to-${targetBranch}`, + '```', + ].join('\n'), + }); + + core.warning(`Cherry-pick to ${targetBranch} failed at ${failedSha} — manual backport required.`);