From d0b8f4e649e45f844ee63787bba39a51ed6c9ff2 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 6 Sep 2024 20:07:32 -0400 Subject: [PATCH] Create backport-base.yml --- .github/workflows/backport-base.yml | 228 ++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 .github/workflows/backport-base.yml diff --git a/.github/workflows/backport-base.yml b/.github/workflows/backport-base.yml new file mode 100644 index 00000000..971c45c5 --- /dev/null +++ b/.github/workflows/backport-base.yml @@ -0,0 +1,228 @@ +# Based on: https://github.com/dotnet/arcade/blob/main/.github/workflows/backport-base.yml +# Ref: https://github.com/dotnet/arcade/blob/7cd1cae2edfb3895f5f0ee577131e0214ed8af1a/.github/workflows/backport-base.yml + +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% + 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: + # Add cleanup job + # cleanup: + # uses: dotnet/arcade/.github/workflows/scheduled-action-cleanup-base.yml@main + # with: + # repository_owners: ${{ inputs.repository_owners }} + + 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 + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Extract backport target branch + uses: actions/github-script@v7 + 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: Unlock comments if PR is locked + uses: actions/github-script@v7 + 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@v7 + 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@v4 + with: + fetch-depth: 0 + - name: Run backport + uses: actions/github-script@v7 + 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 "github-actions@github.com"`); + + // 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 --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; + } + + 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 + }); + } + + - name: Re-lock PR comments + uses: actions/github-script@v7 + 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" + });