207 lines
8.1 KiB
YAML
207 lines
8.1 KiB
YAML
name: Backport Command
|
|
|
|
# Triggered by a maintainer commenting `/backport [branch]` on a merged pull
|
|
# request. Cherry-picks the merged commit onto the target branch and opens a
|
|
# pull request against it, so a fix can ship as a bugfix release without
|
|
# pulling in everything that landed on master since.
|
|
#
|
|
# Without an argument the branch of the next bugfix release is used, e.g.
|
|
# `release/0.313.1`. It is created at the newest tag of that line on first use.
|
|
#
|
|
# Pushing and opening the pull request use RELEASE_DEPLOY_TOKEN so its checks
|
|
# start without approval. GITHUB_TOKEN would create them in a pending state.
|
|
|
|
on:
|
|
issue_comment:
|
|
types: [created]
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
backport:
|
|
name: Backport
|
|
# on any PR comment starting with /backport, only from maintainers
|
|
if: |
|
|
github.event.issue.pull_request &&
|
|
startsWith(github.event.comment.body, '/backport') &&
|
|
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
|
|
runs-on: depot-ubuntu-24.04-arm
|
|
permissions:
|
|
contents: write
|
|
issues: write
|
|
pull-requests: write
|
|
|
|
steps:
|
|
- name: React to comment
|
|
uses: actions/github-script@v8
|
|
with:
|
|
script: |
|
|
await github.rest.reactions.createForIssueComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: context.payload.comment.id,
|
|
content: 'eyes',
|
|
});
|
|
|
|
- name: Resolve target branch
|
|
id: pr
|
|
uses: actions/github-script@v8
|
|
with:
|
|
script: |
|
|
const { owner, repo } = context.repo;
|
|
|
|
// reported back as a comment, so the reason is visible on the pull request
|
|
const fail = (message) => {
|
|
core.setOutput('error', message);
|
|
core.setFailed(message);
|
|
};
|
|
|
|
const { data: pr } = await github.rest.pulls.get({
|
|
owner,
|
|
repo,
|
|
pull_number: context.payload.issue.number,
|
|
});
|
|
if (!pr.merged) {
|
|
fail(`pull request #${pr.number} is not merged`);
|
|
return;
|
|
}
|
|
if (!pr.labels.some((l) => l.name === 'bug')) {
|
|
fail(`pull request #${pr.number} is not labelled \`bug\`, only bugfixes are backported`);
|
|
return;
|
|
}
|
|
// same marker the changelog uses to group breaking changes
|
|
if (/\(BC\)/i.test(pr.title)) {
|
|
fail(`pull request #${pr.number} is a breaking change, it must not go into a bugfix release`);
|
|
return;
|
|
}
|
|
|
|
const compare = (a, b) => {
|
|
const [x, y] = [a, b].map((v) => v.split('.').map(Number));
|
|
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
};
|
|
const tags = await github.paginate(github.rest.repos.listTags, { owner, repo, per_page: 100 });
|
|
const releases = tags.map((t) => t.name).filter((n) => /^\d+\.\d+\.\d+$/.test(n)).sort(compare);
|
|
|
|
let target = context.payload.comment.body.trim().split(/\s+/)[1];
|
|
if (target) {
|
|
// the target ends up in a checkout ref, keep it to plain branch names
|
|
if (!/^[\w.\-\/]+$/.test(target) || target.includes('..')) {
|
|
fail('usage: /backport [branch]');
|
|
return;
|
|
}
|
|
} else {
|
|
// named after the bugfix release the branch will produce
|
|
const [major, minor, patch] = releases[releases.length - 1].split('.').map(Number);
|
|
target = `release/${major}.${minor}.${patch + 1}`;
|
|
}
|
|
|
|
// branch off the newest tag of the release line on first backport
|
|
try {
|
|
await github.rest.repos.getBranch({ owner, repo, branch: target });
|
|
} catch (err) {
|
|
if (err.status !== 404) throw err;
|
|
|
|
const line = target.replace(/^release\//, '').split('.').slice(0, 2).join('.');
|
|
const base = releases.filter((n) => n.startsWith(`${line}.`)).pop();
|
|
if (!base) {
|
|
fail(`branch \`${target}\` does not exist and no release matches it`);
|
|
return;
|
|
}
|
|
|
|
const { data: commit } = await github.rest.repos.getCommit({ owner, repo, ref: base });
|
|
await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${target}`, sha: commit.sha });
|
|
core.notice(`created ${target} at ${base}`);
|
|
}
|
|
|
|
core.setOutput('target', target);
|
|
core.setOutput('branch', `backport/${pr.number}-${target}`);
|
|
core.setOutput('sha', pr.merge_commit_sha);
|
|
core.setOutput('title', pr.title);
|
|
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
fetch-depth: 0
|
|
ref: ${{ steps.pr.outputs.target }}
|
|
# persisted for the push below
|
|
token: ${{ secrets.RELEASE_DEPLOY_TOKEN }}
|
|
|
|
- name: Cherry-pick
|
|
env:
|
|
BRANCH: ${{ steps.pr.outputs.branch }}
|
|
SHA: ${{ steps.pr.outputs.sha }}
|
|
ACTOR: ${{ github.event.comment.user.login }}
|
|
ACTOR_ID: ${{ github.event.comment.user.id }}
|
|
run: |
|
|
# the cherry-pick keeps the original author, commit it as the requester
|
|
git config user.name "$ACTOR"
|
|
git config user.email "$ACTOR_ID+$ACTOR@users.noreply.github.com"
|
|
git switch -c "$BRANCH"
|
|
|
|
# -m 1 picks the first-parent diff of a merge commit; squashed pull
|
|
# requests are ordinary commits and must not get the flag
|
|
mainline=""
|
|
if git rev-parse --quiet --verify "$SHA^2" >/dev/null; then
|
|
mainline="-m 1"
|
|
fi
|
|
|
|
git cherry-pick $mainline "$SHA"
|
|
git push origin "$BRANCH"
|
|
|
|
- name: Create pull request
|
|
id: create
|
|
env:
|
|
GH_TOKEN: ${{ secrets.RELEASE_DEPLOY_TOKEN }}
|
|
BRANCH: ${{ steps.pr.outputs.branch }}
|
|
TARGET: ${{ steps.pr.outputs.target }}
|
|
TITLE: ${{ steps.pr.outputs.title }}
|
|
ACTOR: ${{ github.event.comment.user.login }}
|
|
run: |
|
|
# the pull request is authored by the deploy token, so assign the
|
|
# requester. the body stays empty, squashing it would carry the text
|
|
# into the release branch as the commit description.
|
|
# the title stays untouched so the changelog filters and groups still
|
|
# see the original prefix, the backport label identifies the pull request
|
|
url=$(gh pr create \
|
|
--base "$TARGET" \
|
|
--head "$BRANCH" \
|
|
--title "$TITLE" \
|
|
--label backport \
|
|
--assignee "$ACTOR" \
|
|
--body "")
|
|
echo "url=$url" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Comment result
|
|
if: always()
|
|
uses: actions/github-script@v8
|
|
env:
|
|
TARGET: ${{ steps.pr.outputs.target }}
|
|
URL: ${{ steps.create.outputs.url }}
|
|
ERROR: ${{ steps.pr.outputs.error }}
|
|
with:
|
|
script: |
|
|
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
const { TARGET, URL, ERROR } = process.env;
|
|
const body = URL
|
|
? `✅ Opened backport pull request on \`${TARGET}\`: ${URL}`
|
|
: ERROR
|
|
? `❌ ${ERROR}`
|
|
: `❌ Backport failed, most likely a cherry-pick conflict. See the [run logs](${runUrl}).`;
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.payload.issue.number,
|
|
body,
|
|
});
|
|
|
|
- name: Resolve command comment
|
|
if: always()
|
|
uses: actions/github-script@v8
|
|
with:
|
|
script: |
|
|
// collapse the /backport comment as resolved, the result was reported
|
|
// back as a comment either way
|
|
await github.graphql(
|
|
`mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:RESOLVED}){minimizedComment{isMinimized}}}`,
|
|
{ id: context.payload.comment.node_id }
|
|
);
|