Skip to content

Commit 42ad8b9

Browse files
authored
Add backport action (PowerShell#17212)
1 parent 8fb34e8 commit 42ad8b9

File tree

3 files changed

+238
-0
lines changed

3 files changed

+238
-0
lines changed

.github/workflows/backport.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Backport PR to branch
2+
on:
3+
issue_comment:
4+
types: [created]
5+
6+
permissions:
7+
contents: write
8+
issues: write
9+
pull-requests: write
10+
11+
jobs:
12+
backport:
13+
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to')
14+
runs-on: ubuntu-20.04
15+
steps:
16+
- name: Extract backport target branch
17+
uses: actions/github-script@v3
18+
id: target-branch-extractor
19+
with:
20+
result-encoding: string
21+
script: |
22+
if (context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
23+
24+
// extract the target branch name from the trigger phrase containing these characters: a-z, A-Z, digits, forward slash, dot, hyphen, underscore
25+
const regex = /^\/backport to ([a-zA-Z\d\/\.\-\_]+)/;
26+
target_branch = regex.exec(context.payload.comment.body);
27+
if (target_branch == null) throw "Error: No backport branch found in the trigger phrase.";
28+
29+
return target_branch[1];
30+
- name: Post backport started comment to pull request
31+
uses: actions/github-script@v3
32+
with:
33+
script: |
34+
const backport_start_body = `Started backporting to ${{ steps.target-branch-extractor.outputs.result }}: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
35+
await github.issues.createComment({
36+
issue_number: context.issue.number,
37+
owner: context.repo.owner,
38+
repo: context.repo.repo,
39+
body: backport_start_body
40+
});
41+
- name: Checkout repo
42+
uses: actions/checkout@v2
43+
with:
44+
fetch-depth: 0
45+
- name: Run backport
46+
uses: ./tools/actions/backport
47+
with:
48+
target_branch: ${{ steps.target-branch-extractor.outputs.result }}
49+
auth_token: ${{ secrets.GITHUB_TOKEN }}
50+
pr_description_template: |
51+
Backport of #%source_pr_number% to %target_branch%
52+
53+
/cc %cc_users%
54+
55+
## Customer Impact
56+
57+
## Testing
58+
59+
- [ ] For any change that affects the release process, please work with a maintainer to come up with a plan to test this.
60+
61+
## Risk

tools/actions/backport/action.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: 'PR Backporter'
2+
description: 'Backports a pull request to a branch using the "/backport to <branch>" comment'
3+
inputs:
4+
target_branch:
5+
description: 'Backport target branch.'
6+
auth_token:
7+
description: 'The token used to authenticate to GitHub.'
8+
pr_title_template:
9+
description: 'The template used for the PR title. Special placeholder tokens that will be replaced with a value: %target_branch%, %source_pr_title%, %source_pr_number%, %cc_users%.'
10+
default: '[%target_branch%] %source_pr_title%'
11+
pr_description_template:
12+
description: 'The template used for the PR description. Special placeholder tokens that will be replaced with a value: %target_branch%, %source_pr_title%, %source_pr_number%, %cc_users%.'
13+
default: |
14+
Backport of #%source_pr_number% to %target_branch%
15+
16+
/cc %cc_users%
17+
18+
runs:
19+
using: 'node12'
20+
main: 'index.js'

tools/actions/backport/index.js

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// from https://github.com/dotnet/runtime/blob/main/eng/actions/backport/index.js
4+
5+
function BackportException(message, postToGitHub = true) {
6+
this.message = message;
7+
this.postToGitHub = postToGitHub;
8+
}
9+
10+
async function run() {
11+
const util = require("util");
12+
const jsExec = util.promisify(require("child_process").exec);
13+
14+
console.log("Installing npm dependencies");
15+
const { stdout, stderr } = await jsExec("npm install @actions/core @actions/github @actions/exec");
16+
console.log("npm-install stderr:\n\n" + stderr);
17+
console.log("npm-install stdout:\n\n" + stdout);
18+
console.log("Finished installing npm dependencies");
19+
20+
const core = require("@actions/core");
21+
const github = require("@actions/github");
22+
const exec = require("@actions/exec");
23+
24+
const repo_owner = github.context.payload.repository.owner.login;
25+
const repo_name = github.context.payload.repository.name;
26+
const pr_number = github.context.payload.issue.number;
27+
const comment_user = github.context.payload.comment.user.login;
28+
29+
let octokit = github.getOctokit(core.getInput("auth_token", { required: true }));
30+
let target_branch = core.getInput("target_branch", { required: true });
31+
32+
try {
33+
// verify the comment user is a repo collaborator
34+
try {
35+
await octokit.rest.repos.checkCollaborator({
36+
owner: repo_owner,
37+
repo: repo_name,
38+
username: comment_user
39+
});
40+
console.log(`Verified ${comment_user} is a repo collaborator.`);
41+
} catch (error) {
42+
console.log(error);
43+
throw new BackportException(`Error: @${comment_user} is not a repo collaborator, backporting is not allowed. If you're a collaborator please make sure your Microsoft team membership visibility is set to Public on https://github.com/orgs/microsoft/people?query=${comment_user}`);
44+
}
45+
46+
try { await exec.exec(`git ls-remote --exit-code --heads origin ${target_branch}`) } catch { throw new BackportException(`Error: The specified backport target branch ${target_branch} wasn't found in the repo.`); }
47+
console.log(`Backport target branch: ${target_branch}`);
48+
49+
console.log("Applying backport patch");
50+
51+
await exec.exec(`git checkout ${target_branch}`);
52+
await exec.exec(`git clean -xdff`);
53+
54+
// configure git
55+
await exec.exec(`git config user.name "github-actions"`);
56+
await exec.exec(`git config user.email "[email protected]"`);
57+
58+
// create temporary backport branch
59+
const temp_branch = `backport/pr-${pr_number}-to-${target_branch}`;
60+
await exec.exec(`git checkout -b ${temp_branch}`);
61+
62+
// skip opening PR if the branch already exists on the origin remote since that means it was opened
63+
// by an earlier backport and force pushing to the branch updates the existing PR
64+
let should_open_pull_request = true;
65+
try {
66+
await exec.exec(`git ls-remote --exit-code --heads origin ${temp_branch}`);
67+
should_open_pull_request = false;
68+
} catch { }
69+
70+
// download and apply patch
71+
await exec.exec(`curl -sSL "${github.context.payload.issue.pull_request.patch_url}" --output changes.patch`);
72+
73+
const git_am_command = "git am --3way --ignore-whitespace --keep-non-patch changes.patch";
74+
let git_am_output = `$ ${git_am_command}\n\n`;
75+
let git_am_failed = false;
76+
try {
77+
await exec.exec(git_am_command, [], {
78+
listeners: {
79+
stdout: function stdout(data) { git_am_output += data; },
80+
stderr: function stderr(data) { git_am_output += data; }
81+
}
82+
});
83+
} catch (error) {
84+
git_am_output += error;
85+
git_am_failed = true;
86+
}
87+
88+
if (git_am_failed) {
89+
const git_am_failed_body = `@${github.context.payload.comment.user.login} backporting to ${target_branch} failed, the patch most likely resulted in conflicts:\n\n\`\`\`shell\n${git_am_output}\n\`\`\`\n\nPlease backport manually!`;
90+
await octokit.rest.issues.createComment({
91+
owner: repo_owner,
92+
repo: repo_name,
93+
issue_number: pr_number,
94+
body: git_am_failed_body
95+
});
96+
throw new BackportException("Error: git am failed, most likely due to a merge conflict.", false);
97+
}
98+
else {
99+
// push the temp branch to the repository
100+
await exec.exec(`git push --force --set-upstream origin HEAD:${temp_branch}`);
101+
}
102+
103+
if (!should_open_pull_request) {
104+
console.log("Backport temp branch already exists, skipping opening a PR.");
105+
return;
106+
}
107+
108+
// prepate the GitHub PR details
109+
let backport_pr_title = core.getInput("pr_title_template");
110+
let backport_pr_description = core.getInput("pr_description_template");
111+
112+
// get users to cc (append PR author if different from user who issued the backport command)
113+
let cc_users = `@${comment_user}`;
114+
if (comment_user != github.context.payload.issue.user.login) cc_users += ` @${github.context.payload.issue.user.login}`;
115+
116+
// replace the special placeholder tokens with values
117+
backport_pr_title = backport_pr_title
118+
.replace(/%target_branch%/g, target_branch)
119+
.replace(/%source_pr_title%/g, github.context.payload.issue.title)
120+
.replace(/%source_pr_number%/g, github.context.payload.issue.number)
121+
.replace(/%cc_users%/g, cc_users);
122+
123+
backport_pr_description = backport_pr_description
124+
.replace(/%target_branch%/g, target_branch)
125+
.replace(/%source_pr_title%/g, github.context.payload.issue.title)
126+
.replace(/%source_pr_number%/g, github.context.payload.issue.number)
127+
.replace(/%cc_users%/g, cc_users);
128+
129+
// open the GitHub PR
130+
await octokit.rest.pulls.create({
131+
owner: repo_owner,
132+
repo: repo_name,
133+
title: backport_pr_title,
134+
body: backport_pr_description,
135+
head: temp_branch,
136+
base: target_branch
137+
});
138+
139+
console.log("Successfully opened the GitHub PR.");
140+
} catch (error) {
141+
142+
core.setFailed(error);
143+
144+
if (error.postToGitHub === undefined || error.postToGitHub == true) {
145+
// post failure to GitHub comment
146+
const unknown_error_body = `@${comment_user} an error occurred while backporting to ${target_branch}, please check the run log for details!\n\n${error.message}`;
147+
await octokit.rest.issues.createComment({
148+
owner: repo_owner,
149+
repo: repo_name,
150+
issue_number: pr_number,
151+
body: unknown_error_body
152+
});
153+
}
154+
}
155+
}
156+
157+
run();

0 commit comments

Comments
 (0)