Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .github/workflows/backport-command.yaml
Original file line number Diff line number Diff line change
@@ -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,
},
});
272 changes: 272 additions & 0 deletions .github/workflows/backport.yaml
Original file line number Diff line number Diff line change
@@ -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 <commits>`,
`git push origin backport/pr-${prNumber}-to-${targetBranch}`,
'```',
].join('\n'),
});

core.warning(`Cherry-pick to ${targetBranch} failed at ${failedSha} — manual backport required.`);
Loading