From 68b2dfb81b1b4695a2c8b691362c4756fa86b668 Mon Sep 17 00:00:00 2001 From: Giacomo Marciani Date: Thu, 27 Aug 2026 15:32:37 -0400 Subject: [PATCH] [CICD] Add GH action to cherry-pick multiple PRs at once. --- .github/workflows/cherry-pick-to-branch.yml | 201 ++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/workflows/cherry-pick-to-branch.yml diff --git a/.github/workflows/cherry-pick-to-branch.yml b/.github/workflows/cherry-pick-to-branch.yml new file mode 100644 index 00000000..8bbb8a4c --- /dev/null +++ b/.github/workflows/cherry-pick-to-branch.yml @@ -0,0 +1,201 @@ +name: Cherry-pick to branch + +on: + workflow_dispatch: + inputs: + pr_numbers: + description: "Space-separated list of PR numbers to cherry-pick" + required: true + type: string + target_branches: + description: "Space-separated list of target branches to cherry-pick into" + required: true + type: string + +jobs: + cherry-pick: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Get PR details + id: pr-info + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBERS: ${{ inputs.pr_numbers }} + TARGET_BRANCHES: ${{ inputs.target_branches }} + REPO: ${{ github.repository }} + run: | + # workflow_dispatch has no PR event payload, so fetch everything we need + # about each PR from the API. + + # Space-separated list of PR numbers. PRs are processed in the order + # they are provided, so commits are cherry-picked in that same order. + PR_LIST=$(echo "${PR_NUMBERS}" | tr -s ' ' | sed 's/^ *//;s/ *$//') + for PR in ${PR_LIST}; do + if ! echo "${PR}" | grep -qE '^[0-9]+$'; then + echo "::error::Invalid PR number '${PR}' — must be numeric" + exit 1 + fi + done + + if [ -z "${PR_LIST// }" ]; then + echo "::error::No PR numbers provided" + exit 1 + fi + + # Validate target branches contain only safe characters + for BRANCH in ${TARGET_BRANCHES}; do + if ! echo "${BRANCH}" | grep -qE '^[a-zA-Z0-9._/-]+$'; then + echo "::error::Invalid branch name '${BRANCH}' — must match [a-zA-Z0-9._/-]+" + exit 1 + fi + done + + ALL_COMMITS="" + PR_DETAILS="" + PR_DISPLAY="" + FIRST_TITLE="" + for PR in ${PR_LIST}; do + PR_JSON=$(gh pr view "${PR}" --repo "${REPO}" \ + --json baseRefName,title,author) + BASE_BRANCH=$(echo "${PR_JSON}" | jq -r '.baseRefName') + PR_TITLE=$(echo "${PR_JSON}" | jq -r '.title') + PR_AUTHOR=$(echo "${PR_JSON}" | jq -r '.author.login') + + # A PR can contain merges from its base branch. Those commits should not be cherry-picked. + # The types of commit are distinguished by the number of parents: + # 1 parent = non-merge commits, 2 parents = merge commit + COMMITS=$(gh api --paginate \ + "repos/${REPO}/pulls/${PR}/commits?per_page=100" \ + --jq '.[] | select((.parents | length) < 2) | .sha' | tr '\n' ' ') + + if [ -z "${COMMITS// }" ]; then + echo "::error::PR #${PR} contains no non-merge commits to cherry-pick" + exit 1 + fi + + ALL_COMMITS="${ALL_COMMITS} ${COMMITS}" + PR_DETAILS="${PR_DETAILS}- #${PR} \"${PR_TITLE}\" by @${PR_AUTHOR} (from \`${BASE_BRANCH}\`)"$'\n' + + if [ -z "${PR_DISPLAY}" ]; then + PR_DISPLAY="#${PR}" + FIRST_TITLE="${PR_TITLE}" + else + PR_DISPLAY="${PR_DISPLAY}, #${PR}" + fi + done + + # Trim leading whitespace from the accumulated commit list + ALL_COMMITS=$(echo "${ALL_COMMITS}" | sed 's/^ *//') + + # Drop the trailing newline so the rendered body has no blank last line + PR_DETAILS="${PR_DETAILS%$'\n'}" + + # A single PR (no space in the list) keeps its title in the PR name; + # multiple PRs reference the first one followed by "and more". + FIRST_PR="${PR_LIST%% *}" + case "${PR_LIST}" in + *" "*) NEW_PR_TITLE="Cherry-pick #${FIRST_PR} and more" ;; + *) NEW_PR_TITLE="Cherry-pick ${PR_DISPLAY}: ${FIRST_TITLE}" ;; + esac + + echo "commits=${ALL_COMMITS}" >> "$GITHUB_OUTPUT" + echo "pr_numbers=${PR_LIST}" >> "$GITHUB_OUTPUT" + echo "pr_display=${PR_DISPLAY}" >> "$GITHUB_OUTPUT" + echo "pr_title=${NEW_PR_TITLE}" >> "$GITHUB_OUTPUT" + { + echo "pr_details<> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Cherry-pick and create PR(s) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMITS: ${{ steps.pr-info.outputs.commits }} + PR_NUMBERS: ${{ steps.pr-info.outputs.pr_numbers }} + PR_DISPLAY: ${{ steps.pr-info.outputs.pr_display }} + PR_TITLE: ${{ steps.pr-info.outputs.pr_title }} + PR_DETAILS: ${{ steps.pr-info.outputs.pr_details }} + TARGET_BRANCHES: ${{ inputs.target_branches }} + REPO: ${{ github.repository }} + run: | + # Fetch each PR's commits. They may live in a fork (different account), + # so they are not present in the base repo checkout. The refs/pull/*/head + # ref makes them available regardless of which fork the PR came from. + for PR in ${PR_NUMBERS}; do + git fetch origin "refs/pull/${PR}/head" + done + + # Name the branch after the first and last PR in the set (a single PR + # keeps the plain pr- form) so it stays within git's ref length limit + # regardless of how many PRs are cherry-picked. + FIRST_PR="${PR_NUMBERS%% *}" + LAST_PR="${PR_NUMBERS##* }" + case "${PR_NUMBERS}" in + *" "*) BRANCH_PR="pr-${FIRST_PR}-and-${LAST_PR}" ;; + *) BRANCH_PR="pr-${FIRST_PR}" ;; + esac + + HAS_FAILURE=false + for TARGET_BRANCH in ${TARGET_BRANCHES}; do + CHERRY_PICK_BRANCH="cherry-pick/${BRANCH_PR}-to-${TARGET_BRANCH}" + + echo "Cherry-picking commits from PR(s) ${PR_DISPLAY} to ${TARGET_BRANCH}" + echo "Commits: ${COMMITS}" + + git fetch origin "${TARGET_BRANCH}" + git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" + + # Cherry-pick all commits in order (works for both squash and rebase) + CHERRY_PICK_FAILED=false + for COMMIT in ${COMMITS}; do + if ! git cherry-pick "${COMMIT}" --no-edit; then + CHERRY_PICK_FAILED=true + git cherry-pick --abort + break + fi + done + + if [ "${CHERRY_PICK_FAILED}" = "false" ]; then + git push origin "${CHERRY_PICK_BRANCH}" + PR_BODY=$(printf '%s\n\nIncluded PRs:\n%s' \ + "Automated cherry-pick of ${PR_DISPLAY} to \`${TARGET_BRANCH}\`." \ + "${PR_DETAILS}") + NEW_PR_URL=$(gh pr create \ + --base "${TARGET_BRANCH}" \ + --head "${CHERRY_PICK_BRANCH}" \ + --title "${PR_TITLE}" \ + --body "${PR_BODY}" \ + --label "cherry-pick") + for PR in ${PR_NUMBERS}; do + gh pr comment "${PR}" --repo "${REPO}" \ + --body "✅ Cherry-pick to \`${TARGET_BRANCH}\` opened: ${NEW_PR_URL}" + done + echo "✅ Cherry-pick PR to ${TARGET_BRANCH} created successfully: ${NEW_PR_URL}" + else + HAS_FAILURE=true + echo "::error::Cherry-pick to ${TARGET_BRANCH} failed due to conflicts." + fi + + # Clean up for next iteration + git checkout --detach 2>/dev/null || true + git branch -D "${CHERRY_PICK_BRANCH}" 2>/dev/null || true + done + + if [ "${HAS_FAILURE}" = "true" ]; then + exit 1 + fi