Skip to content

Commit a1a050e

Browse files
authored
Refactor backport workflow, make it reusable (#11469)
This allows using the workflow from other repos and also replaces deprecated node12 action code with inline github-script instead.
1 parent 5be5561 commit a1a050e

4 files changed

Lines changed: 234 additions & 238 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
on:
2+
workflow_call:
3+
inputs:
4+
pr_title_template:
5+
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%.'
6+
required: false
7+
type: string
8+
default: '[%target_branch%] %source_pr_title%'
9+
pr_description_template:
10+
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%.'
11+
required: false
12+
type: string
13+
default: |
14+
Backport of #%source_pr_number% to %target_branch%
15+
16+
/cc %cc_users%
17+
18+
jobs:
19+
cleanup:
20+
if: ${{ github.repository_owner == 'dotnet' && github.event_name == 'schedule' }}
21+
runs-on: ubuntu-latest
22+
permissions:
23+
actions: write
24+
steps:
25+
- name: Cleanup workflow runs
26+
uses: actions/github-script@v6
27+
with:
28+
script: |
29+
const repo_owner = context.payload.repository.owner.login;
30+
const repo_name = context.payload.repository.name;
31+
32+
// look up workflow from current run
33+
const currentWorkflowRun = await github.rest.actions.getWorkflowRun({
34+
owner: repo_owner,
35+
repo: repo_name,
36+
run_id: context.runId
37+
});
38+
39+
// get runs which are 'completed' (other candidate values of status field are e.g. 'queued' and 'in_progress')
40+
for await (const response of github.paginate.iterator(
41+
github.rest.actions.listWorkflowRuns, {
42+
owner: repo_owner,
43+
repo: repo_name,
44+
workflow_id: currentWorkflowRun.data.workflow_id,
45+
status: 'completed'
46+
}
47+
)) {
48+
// delete each run
49+
for (const run of response.data) {
50+
console.log(`Deleting workflow run ${run.id}`);
51+
await github.rest.actions.deleteWorkflowRun({
52+
owner: repo_owner,
53+
repo: repo_name,
54+
run_id: run.id
55+
});
56+
}
57+
}
58+
59+
run_backport:
60+
if: ${{ github.repository_owner == 'dotnet' && github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to') }}
61+
runs-on: ubuntu-latest
62+
permissions:
63+
contents: write
64+
issues: write
65+
pull-requests: write
66+
steps:
67+
- name: Extract backport target branch
68+
uses: actions/github-script@v6
69+
id: target-branch-extractor
70+
with:
71+
result-encoding: string
72+
script: |
73+
if (context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
74+
75+
// extract the target branch name from the trigger phrase containing these characters: a-z, A-Z, digits, forward slash, dot, hyphen, underscore
76+
const regex = /^\/backport to ([a-zA-Z\d\/\.\-\_]+)/;
77+
target_branch = regex.exec(context.payload.comment.body);
78+
if (target_branch == null) throw "Error: No backport branch found in the trigger phrase.";
79+
80+
return target_branch[1];
81+
- name: Post backport started comment to pull request
82+
uses: actions/github-script@v6
83+
with:
84+
script: |
85+
const target_branch = '${{ steps.target-branch-extractor.outputs.result }}';
86+
const backport_start_body = `Started backporting to ${target_branch}: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
87+
await github.rest.issues.createComment({
88+
issue_number: context.issue.number,
89+
owner: context.repo.owner,
90+
repo: context.repo.repo,
91+
body: backport_start_body
92+
});
93+
- name: Checkout repo
94+
uses: actions/checkout@v3
95+
with:
96+
fetch-depth: 0
97+
- name: Run backport
98+
uses: actions/github-script@v6
99+
env:
100+
BACKPORT_PR_TITLE_TEMPLATE: ${{ inputs.pr_title_template }}
101+
BACKPORT_PR_DESCRIPTION_TEMPLATE: ${{ inputs.pr_description_template }}
102+
with:
103+
script: |
104+
const target_branch = '${{ steps.target-branch-extractor.outputs.result }}';
105+
const repo_owner = context.payload.repository.owner.login;
106+
const repo_name = context.payload.repository.name;
107+
const pr_number = context.payload.issue.number;
108+
const comment_user = context.payload.comment.user.login;
109+
110+
try {
111+
// verify the comment user is a repo collaborator
112+
try {
113+
await github.rest.repos.checkCollaborator({
114+
owner: repo_owner,
115+
repo: repo_name,
116+
username: comment_user
117+
});
118+
console.log(`Verified ${comment_user} is a repo collaborator.`);
119+
} catch (error) {
120+
console.log(error);
121+
throw new Error(`Error: @${comment_user} is not a repo collaborator, backporting is not allowed. If you're a collaborator please make sure your ${repo_owner} team membership visibility is set to Public on https://github.com/orgs/${repo_owner}/people?query=${comment_user}`);
122+
}
123+
124+
try { await exec.exec(`git ls-remote --exit-code --heads origin ${target_branch}`) } catch { throw new Error(`Error: The specified backport target branch ${target_branch} wasn't found in the repo.`); }
125+
console.log(`Backport target branch: ${target_branch}`);
126+
127+
console.log("Applying backport patch");
128+
129+
await exec.exec(`git checkout ${target_branch}`);
130+
await exec.exec(`git clean -xdff`);
131+
132+
// configure git
133+
await exec.exec(`git config user.name "github-actions"`);
134+
await exec.exec(`git config user.email "github-actions@github.com"`);
135+
136+
// create temporary backport branch
137+
const temp_branch = `backport/pr-${pr_number}-to-${target_branch}`;
138+
await exec.exec(`git checkout -b ${temp_branch}`);
139+
140+
// skip opening PR if the branch already exists on the origin remote since that means it was opened
141+
// by an earlier backport and force pushing to the branch updates the existing PR
142+
let should_open_pull_request = true;
143+
try {
144+
await exec.exec(`git ls-remote --exit-code --heads origin ${temp_branch}`);
145+
should_open_pull_request = false;
146+
} catch { }
147+
148+
// download and apply patch
149+
await exec.exec(`curl -sSL "${context.payload.issue.pull_request.patch_url}" --output changes.patch`);
150+
151+
const git_am_command = "git am --3way --ignore-whitespace --keep-non-patch changes.patch";
152+
let git_am_output = `$ ${git_am_command}\n\n`;
153+
let git_am_failed = false;
154+
try {
155+
await exec.exec(git_am_command, [], {
156+
listeners: {
157+
stdout: function stdout(data) { git_am_output += data; },
158+
stderr: function stderr(data) { git_am_output += data; }
159+
}
160+
});
161+
} catch (error) {
162+
git_am_output += error;
163+
git_am_failed = true;
164+
}
165+
166+
if (git_am_failed) {
167+
const git_am_failed_body = `@${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!`;
168+
await github.rest.issues.createComment({
169+
owner: repo_owner,
170+
repo: repo_name,
171+
issue_number: pr_number,
172+
body: git_am_failed_body
173+
});
174+
throw new Error("Error: git am failed, most likely due to a merge conflict.", false);
175+
}
176+
else {
177+
// push the temp branch to the repository
178+
await exec.exec(`git push --force --set-upstream origin HEAD:${temp_branch}`);
179+
}
180+
181+
if (!should_open_pull_request) {
182+
console.log("Backport temp branch already exists, skipping opening a PR.");
183+
return;
184+
}
185+
186+
// prepare the GitHub PR details
187+
188+
// get users to cc (append PR author if different from user who issued the backport command)
189+
let cc_users = `@${comment_user}`;
190+
if (comment_user != context.payload.issue.user.login) cc_users += ` @${context.payload.issue.user.login}`;
191+
192+
// replace the special placeholder tokens with values
193+
const { BACKPORT_PR_TITLE_TEMPLATE, BACKPORT_PR_DESCRIPTION_TEMPLATE } = process.env
194+
195+
const backport_pr_title = BACKPORT_PR_TITLE_TEMPLATE
196+
.replace(/%target_branch%/g, target_branch)
197+
.replace(/%source_pr_title%/g, context.payload.issue.title)
198+
.replace(/%source_pr_number%/g, context.payload.issue.number)
199+
.replace(/%cc_users%/g, cc_users);
200+
201+
const backport_pr_description = BACKPORT_PR_DESCRIPTION_TEMPLATE
202+
.replace(/%target_branch%/g, target_branch)
203+
.replace(/%source_pr_title%/g, context.payload.issue.title)
204+
.replace(/%source_pr_number%/g, context.payload.issue.number)
205+
.replace(/%cc_users%/g, cc_users);
206+
207+
// open the GitHub PR
208+
await github.rest.pulls.create({
209+
owner: repo_owner,
210+
repo: repo_name,
211+
title: backport_pr_title,
212+
body: backport_pr_description,
213+
head: temp_branch,
214+
base: target_branch
215+
});
216+
217+
console.log("Successfully opened the GitHub PR.");
218+
} catch (error) {
219+
220+
core.setFailed(error);
221+
222+
// post failure to GitHub comment
223+
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}`;
224+
await github.rest.issues.createComment({
225+
owner: repo_owner,
226+
repo: repo_name,
227+
issue_number: pr_number,
228+
body: unknown_error_body
229+
});
230+
}
231+

.github/workflows/backport.yml

Lines changed: 3 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,17 @@
11
name: Backport PR to branch
22
on:
3-
workflow_call:
43
issue_comment:
54
types: [created]
65
schedule:
7-
# once a day at 13:00 UTC
6+
# once a day at 13:00 UTC to cleanup old runs
87
- cron: '0 13 * * *'
98

109
permissions:
1110
contents: write
1211
issues: write
1312
pull-requests: write
13+
actions: write
1414

1515
jobs:
16-
cleanup_old_runs:
17-
if: github.event.schedule == '0 13 * * *'
18-
runs-on: ubuntu-latest
19-
permissions:
20-
actions: write
21-
env:
22-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23-
steps:
24-
- name: Delete old workflow runs
25-
run: |
26-
_UrlPath="/repos/$GITHUB_REPOSITORY/actions/workflows"
27-
_CurrentWorkflowID="$(gh api -X GET "$_UrlPath" | jq '.workflows[] | select(.name == '\""$GITHUB_WORKFLOW"\"') | .id')"
28-
29-
# delete workitems which are 'completed'. (other candidate values of status field are: 'queued' and 'in_progress')
30-
31-
gh api -X GET "$_UrlPath/$_CurrentWorkflowID/runs" --paginate \
32-
| jq '.workflow_runs[] | select(.status == "completed") | .id' \
33-
| xargs -I{} gh api -X DELETE "/repos/$GITHUB_REPOSITORY/actions/runs"/{}
34-
3516
backport:
36-
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to')
37-
runs-on: ubuntu-20.04
38-
steps:
39-
- name: Extract backport target branch
40-
uses: actions/github-script@v3
41-
id: target-branch-extractor
42-
with:
43-
result-encoding: string
44-
script: |
45-
if (context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
46-
47-
// extract the target branch name from the trigger phrase containing these characters: a-z, A-Z, digits, forward slash, dot, hyphen, underscore
48-
const regex = /^\/backport to ([a-zA-Z\d\/\.\-\_]+)/;
49-
target_branch = regex.exec(context.payload.comment.body);
50-
if (target_branch == null) throw "Error: No backport branch found in the trigger phrase.";
51-
52-
return target_branch[1];
53-
- name: Post backport started comment to pull request
54-
uses: actions/github-script@v3
55-
with:
56-
script: |
57-
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}`;
58-
await github.issues.createComment({
59-
issue_number: context.issue.number,
60-
owner: context.repo.owner,
61-
repo: context.repo.repo,
62-
body: backport_start_body
63-
});
64-
- name: Checkout repo
65-
uses: actions/checkout@v2
66-
with:
67-
fetch-depth: 0
68-
- name: Run backport
69-
uses: ./eng/actions/backport
70-
with:
71-
target_branch: ${{ steps.target-branch-extractor.outputs.result }}
72-
auth_token: ${{ secrets.GITHUB_TOKEN }}
73-
pr_description_template: |
74-
Backport of #%source_pr_number% to %target_branch%
75-
76-
/cc %cc_users%
17+
uses: dotnet/arcade/.github/workflows/backport-base.yml@main

eng/actions/backport/action.yml

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)