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%, %source_pr_author%, %cc_users%.' required: false type: string default: '%source_pr_title% by @%source_pr_author% in #%source_pr_number% (backport to %target_branch%)' 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% repository_owners: description: 'A comma-separated list of repository owners where the workflow will run. Defaults to "dotnet,microsoft".' required: false type: string default: 'dotnet,microsoft' jobs: cleanup: if: ${{ contains(format('{0},', inputs.repository_owners), format('{0},', github.repository_owner)) && github.event_name == 'schedule' }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: actions: write steps: - name: Cleanup workflow runs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 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: ${{ contains(format('{0},', inputs.repository_owners), format('{0},', github.repository_owner)) && github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to') }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: write issues: write pull-requests: write steps: - name: Extract backport target branch uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 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: Extract PR id uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 id: pr-id-extractor with: result-encoding: string script: | return context.issue.number; - name: Calculate backport branch name uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 id: backport-branch-name-extractor with: result-encoding: string script: | return `backport/${{ steps.pr-id-extractor.outputs.result }}/to/${{ steps.target-branch-extractor.outputs.result }}`; - name: Calculate backport PR title uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 id: backport-pr-title-extractor env: BACKPORT_PR_TITLE_TEMPLATE: ${{ inputs.pr_title_template }} with: result-encoding: string script: | // replace the special placeholder tokens with values const { BACKPORT_PR_TITLE_TEMPLATE } = process.env const target_branch = '${{ steps.target-branch-extractor.outputs.result }}'; 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(/%source_pr_author%/g, context.payload.issue.user.login) return backport_pr_title; - name: Calculate backport PR description uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 id: backport-pr-description-extractor env: BACKPORT_PR_DESCRIPTION_TEMPLATE: ${{ inputs.pr_description_template }} with: result-encoding: string script: | // get users to cc (append PR author if different from user who issued the backport command) const comment_user = context.payload.comment.user.login; 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_DESCRIPTION_TEMPLATE } = process.env const target_branch = '${{ steps.target-branch-extractor.outputs.result }}'; 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); return backport_pr_description; - name: Unlock comments if PR is locked uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 if: ${{ github.event.issue.locked == true }} with: script: | console.log(`Unlocking locked PR #${context.issue.number}.`); await github.rest.issues.unlock({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, }); - name: Post backport started comment to pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Run backport uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 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`); // download and apply patch await exec.exec(`curl -sSL "${context.payload.issue.pull_request.patch_url}" --output changes.patch`); // configure git await exec.exec(`git config user.name "github-actions"`); await exec.exec(`git config user.email "github-actions@github.com"`); let git_am_command = "git am --3way --empty=keep --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; } let failed_count = 0; while (git_am_failed) { failed_count++; if (failed_count >= 20) { await github.rest.issues.createComment({ owner: repo_owner, repo: repo_name, issue_number: pr_number, body: "Potential infinite loop guard hit. Stopping" }); return; } 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\n**NOTE: A PR will be created, but needs to be revised manually!**`; await github.rest.issues.createComment({ owner: repo_owner, repo: repo_name, issue_number: pr_number, body: git_am_failed_body }); await exec.exec(`git add .`); await exec.exec(`git restore --staged changes.patch`); git_am_command = "git am --continue"; git_am_output = `$ ${git_am_command}\n\n`; 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; } } await exec.exec(`rm changes.patch`); } 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 }); } - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.BACKPORT_MACHINE_USER_PAT }} push-to-fork: nohwnd-bot/testfx branch: ${{ steps.backport-branch-name-extractor.outputs.result }} title: ${{ steps.backport-pr-title-extractor.outputs.result }} body: ${{ steps.backport-pr-description-extractor.outputs.result }} - name: Re-lock PR comments uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 if: ${{ github.event.issue.locked == true && (success() || failure()) }} with: script: | console.log(`Locking previously locked PR #${context.issue.number} again.`); await github.rest.issues.lock({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, lock_reason: "resolved" });