|
| 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 | + |
0 commit comments