Skip to content

Commit

Permalink
Refactor backport workflow, make it reusable (#11469)
Browse files Browse the repository at this point in the history
This allows using the workflow from other repos and also replaces deprecated node12 action code with inline github-script instead.
  • Loading branch information
akoeplinger authored Nov 3, 2022
1 parent 5be5561 commit a1a050e
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 238 deletions.
231 changes: 231 additions & 0 deletions .github/workflows/backport-base.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
on:
workflow_call:
inputs:
pr_title_template:
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%.'
required: false
type: string
default: '[%target_branch%] %source_pr_title%'
pr_description_template:
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%.'
required: false
type: string
default: |
Backport of #%source_pr_number% to %target_branch%
/cc %cc_users%
jobs:
cleanup:
if: ${{ github.repository_owner == 'dotnet' && github.event_name == 'schedule' }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Cleanup workflow runs
uses: actions/github-script@v6
with:
script: |
const repo_owner = context.payload.repository.owner.login;
const repo_name = context.payload.repository.name;
// look up workflow from current run
const currentWorkflowRun = await github.rest.actions.getWorkflowRun({
owner: repo_owner,
repo: repo_name,
run_id: context.runId
});
// get runs which are 'completed' (other candidate values of status field are e.g. 'queued' and 'in_progress')
for await (const response of github.paginate.iterator(
github.rest.actions.listWorkflowRuns, {
owner: repo_owner,
repo: repo_name,
workflow_id: currentWorkflowRun.data.workflow_id,
status: 'completed'
}
)) {
// delete each run
for (const run of response.data) {
console.log(`Deleting workflow run ${run.id}`);
await github.rest.actions.deleteWorkflowRun({
owner: repo_owner,
repo: repo_name,
run_id: run.id
});
}
}
run_backport:
if: ${{ github.repository_owner == 'dotnet' && github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to') }}
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Extract backport target branch
uses: actions/github-script@v6
id: target-branch-extractor
with:
result-encoding: string
script: |
if (context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
// extract the target branch name from the trigger phrase containing these characters: a-z, A-Z, digits, forward slash, dot, hyphen, underscore
const regex = /^\/backport to ([a-zA-Z\d\/\.\-\_]+)/;
target_branch = regex.exec(context.payload.comment.body);
if (target_branch == null) throw "Error: No backport branch found in the trigger phrase.";
return target_branch[1];
- name: Post backport started comment to pull request
uses: actions/github-script@v6
with:
script: |
const target_branch = '${{ steps.target-branch-extractor.outputs.result }}';
const backport_start_body = `Started backporting to ${target_branch}: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: backport_start_body
});
- name: Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Run backport
uses: actions/github-script@v6
env:
BACKPORT_PR_TITLE_TEMPLATE: ${{ inputs.pr_title_template }}
BACKPORT_PR_DESCRIPTION_TEMPLATE: ${{ inputs.pr_description_template }}
with:
script: |
const target_branch = '${{ steps.target-branch-extractor.outputs.result }}';
const repo_owner = context.payload.repository.owner.login;
const repo_name = context.payload.repository.name;
const pr_number = context.payload.issue.number;
const comment_user = context.payload.comment.user.login;
try {
// verify the comment user is a repo collaborator
try {
await github.rest.repos.checkCollaborator({
owner: repo_owner,
repo: repo_name,
username: comment_user
});
console.log(`Verified ${comment_user} is a repo collaborator.`);
} catch (error) {
console.log(error);
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}`);
}
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.`); }
console.log(`Backport target branch: ${target_branch}`);
console.log("Applying backport patch");
await exec.exec(`git checkout ${target_branch}`);
await exec.exec(`git clean -xdff`);
// configure git
await exec.exec(`git config user.name "github-actions"`);
await exec.exec(`git config user.email "[email protected]"`);
// create temporary backport branch
const temp_branch = `backport/pr-${pr_number}-to-${target_branch}`;
await exec.exec(`git checkout -b ${temp_branch}`);
// skip opening PR if the branch already exists on the origin remote since that means it was opened
// by an earlier backport and force pushing to the branch updates the existing PR
let should_open_pull_request = true;
try {
await exec.exec(`git ls-remote --exit-code --heads origin ${temp_branch}`);
should_open_pull_request = false;
} catch { }
// download and apply patch
await exec.exec(`curl -sSL "${context.payload.issue.pull_request.patch_url}" --output changes.patch`);
const git_am_command = "git am --3way --ignore-whitespace --keep-non-patch changes.patch";
let git_am_output = `$ ${git_am_command}\n\n`;
let git_am_failed = false;
try {
await exec.exec(git_am_command, [], {
listeners: {
stdout: function stdout(data) { git_am_output += data; },
stderr: function stderr(data) { git_am_output += data; }
}
});
} catch (error) {
git_am_output += error;
git_am_failed = true;
}
if (git_am_failed) {
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!`;
await github.rest.issues.createComment({
owner: repo_owner,
repo: repo_name,
issue_number: pr_number,
body: git_am_failed_body
});
throw new Error("Error: git am failed, most likely due to a merge conflict.", false);
}
else {
// push the temp branch to the repository
await exec.exec(`git push --force --set-upstream origin HEAD:${temp_branch}`);
}
if (!should_open_pull_request) {
console.log("Backport temp branch already exists, skipping opening a PR.");
return;
}
// prepare the GitHub PR details
// get users to cc (append PR author if different from user who issued the backport command)
let cc_users = `@${comment_user}`;
if (comment_user != context.payload.issue.user.login) cc_users += ` @${context.payload.issue.user.login}`;
// replace the special placeholder tokens with values
const { BACKPORT_PR_TITLE_TEMPLATE, BACKPORT_PR_DESCRIPTION_TEMPLATE } = process.env
const backport_pr_title = BACKPORT_PR_TITLE_TEMPLATE
.replace(/%target_branch%/g, target_branch)
.replace(/%source_pr_title%/g, context.payload.issue.title)
.replace(/%source_pr_number%/g, context.payload.issue.number)
.replace(/%cc_users%/g, cc_users);
const backport_pr_description = BACKPORT_PR_DESCRIPTION_TEMPLATE
.replace(/%target_branch%/g, target_branch)
.replace(/%source_pr_title%/g, context.payload.issue.title)
.replace(/%source_pr_number%/g, context.payload.issue.number)
.replace(/%cc_users%/g, cc_users);
// open the GitHub PR
await github.rest.pulls.create({
owner: repo_owner,
repo: repo_name,
title: backport_pr_title,
body: backport_pr_description,
head: temp_branch,
base: target_branch
});
console.log("Successfully opened the GitHub PR.");
} catch (error) {
core.setFailed(error);
// post failure to GitHub comment
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}`;
await github.rest.issues.createComment({
owner: repo_owner,
repo: repo_name,
issue_number: pr_number,
body: unknown_error_body
});
}
65 changes: 3 additions & 62 deletions .github/workflows/backport.yml
Original file line number Diff line number Diff line change
@@ -1,76 +1,17 @@
name: Backport PR to branch
on:
workflow_call:
issue_comment:
types: [created]
schedule:
# once a day at 13:00 UTC
# once a day at 13:00 UTC to cleanup old runs
- cron: '0 13 * * *'

permissions:
contents: write
issues: write
pull-requests: write
actions: write

jobs:
cleanup_old_runs:
if: github.event.schedule == '0 13 * * *'
runs-on: ubuntu-latest
permissions:
actions: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Delete old workflow runs
run: |
_UrlPath="/repos/$GITHUB_REPOSITORY/actions/workflows"
_CurrentWorkflowID="$(gh api -X GET "$_UrlPath" | jq '.workflows[] | select(.name == '\""$GITHUB_WORKFLOW"\"') | .id')"
# delete workitems which are 'completed'. (other candidate values of status field are: 'queued' and 'in_progress')
gh api -X GET "$_UrlPath/$_CurrentWorkflowID/runs" --paginate \
| jq '.workflow_runs[] | select(.status == "completed") | .id' \
| xargs -I{} gh api -X DELETE "/repos/$GITHUB_REPOSITORY/actions/runs"/{}
backport:
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to')
runs-on: ubuntu-20.04
steps:
- name: Extract backport target branch
uses: actions/github-script@v3
id: target-branch-extractor
with:
result-encoding: string
script: |
if (context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
// extract the target branch name from the trigger phrase containing these characters: a-z, A-Z, digits, forward slash, dot, hyphen, underscore
const regex = /^\/backport to ([a-zA-Z\d\/\.\-\_]+)/;
target_branch = regex.exec(context.payload.comment.body);
if (target_branch == null) throw "Error: No backport branch found in the trigger phrase.";
return target_branch[1];
- name: Post backport started comment to pull request
uses: actions/github-script@v3
with:
script: |
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}`;
await github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: backport_start_body
});
- name: Checkout repo
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Run backport
uses: ./eng/actions/backport
with:
target_branch: ${{ steps.target-branch-extractor.outputs.result }}
auth_token: ${{ secrets.GITHUB_TOKEN }}
pr_description_template: |
Backport of #%source_pr_number% to %target_branch%
/cc %cc_users%
uses: dotnet/arcade/.github/workflows/backport-base.yml@main
20 changes: 0 additions & 20 deletions eng/actions/backport/action.yml

This file was deleted.

Loading

0 comments on commit a1a050e

Please sign in to comment.