From aa9b883d4cd3d314de2ce837fb1bbf84626afbc5 Mon Sep 17 00:00:00 2001 From: Camryn Carter Date: Wed, 18 Mar 2026 20:26:13 -0700 Subject: [PATCH] add cherry-pick workflow for release branches (#533) this workflow automates cherry-picking changes from merged pull requests to specified release branches based on comments... it handles permission checks, version parsing, and conflict resolution during the cherry-pick process. Signed-off-by: Camryn Carter --- .github/workflows/cherrypick.yml | 298 +++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 .github/workflows/cherrypick.yml diff --git a/.github/workflows/cherrypick.yml b/.github/workflows/cherrypick.yml new file mode 100644 index 0000000..3752241 --- /dev/null +++ b/.github/workflows/cherrypick.yml @@ -0,0 +1,298 @@ +name: Cherry-pick to release branch + +on: + issue_comment: + types: [created] + pull_request: + types: [closed] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + # ────────────────────────────────────────────────────────────── + # Trigger 1: /cherrypick-X.Y comment on a PR + # - If already merged → run cherry-pick immediately + # - If not yet merged → add label, cherry-pick will run on merge + # ────────────────────────────────────────────────────────────── + handle-comment: + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/cherrypick-') + runs-on: ubuntu-latest + steps: + - name: Check commenter permissions + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENTER: ${{ github.event.comment.user.login }} + run: | + PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${COMMENTER}/permission \ + --jq '.permission') + echo "Permission level for $COMMENTER: $PERMISSION" + if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "maintain" && "$PERMISSION" != "write" ]]; then + echo "::warning::User $COMMENTER does not have write access, ignoring cherry-pick request" + exit 1 + fi + + - name: Parse version from comment + id: parse + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + VERSION=$(echo "$COMMENT_BODY" | head -1 | grep -oP '(?<=/cherrypick-)\d+\.\d+') + if [ -z "$VERSION" ]; then + echo "::error::Could not parse version from comment" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "target_branch=release/$VERSION" >> "$GITHUB_OUTPUT" + echo "label=cherrypick/$VERSION" >> "$GITHUB_OUTPUT" + + - name: React to comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -f content='+1' + + - name: Check if PR is merged + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_JSON=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}) + MERGED=$(echo "$PR_JSON" | jq -r '.merged') + echo "merged=$MERGED" >> "$GITHUB_OUTPUT" + echo "pr_title=$(echo "$PR_JSON" | jq -r '.title')" >> "$GITHUB_OUTPUT" + echo "base_sha=$(echo "$PR_JSON" | jq -r '.base.sha')" >> "$GITHUB_OUTPUT" + echo "head_sha=$(echo "$PR_JSON" | jq -r '.head.sha')" >> "$GITHUB_OUTPUT" + + - name: Add cherry-pick label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LABEL: ${{ steps.parse.outputs.label }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + gh api repos/${{ github.repository }}/labels \ + -f name="$LABEL" -f color="fbca04" -f description="Queued for cherry-pick" 2>/dev/null || true + gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/labels \ + -f "labels[]=$LABEL" + + - name: Notify if queued (not yet merged) + if: steps.check.outputs.merged != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LABEL: ${{ steps.parse.outputs.label }} + TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \ + -f body="🏷️ Labeled \`$LABEL\` — backport to \`$TARGET_BRANCH\` will be created automatically when this PR is merged." + + - name: Checkout repository + if: steps.check.outputs.merged == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify target branch exists + if: steps.check.outputs.merged == 'true' + env: + TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then + gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \ + -f body="❌ Cannot cherry-pick: branch \`$TARGET_BRANCH\` does not exist." + exit 1 + fi + + - name: Apply PR diff and push + if: steps.check.outputs.merged == 'true' + id: apply + env: + TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }} + PR_NUMBER: ${{ github.event.issue.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${TARGET_BRANCH//\//-}" + echo "backport_branch=$BACKPORT_BRANCH" >> "$GITHUB_OUTPUT" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout "$TARGET_BRANCH" + git checkout -b "$BACKPORT_BRANCH" + + # Download the PR's patch from GitHub (pure diff of the PR's changes) + gh api repos/${{ github.repository }}/pulls/${PR_NUMBER} \ + -H "Accept: application/vnd.github.v3.patch" > /tmp/pr.patch + + # Apply the patch + HAS_CONFLICTS="false" + CONFLICTED_FILES="" + + if git apply --check /tmp/pr.patch 2>/dev/null; then + # Clean apply + git apply /tmp/pr.patch + git add -A + git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}" + elif git apply --3way /tmp/pr.patch; then + # Applied with 3-way merge (auto-resolved) + git add -A + git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}" || true + else + # Has real conflicts — apply what we can + HAS_CONFLICTS="true" + CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ',' | sed 's/,$//') + # Take the incoming version for conflicted files + git diff --name-only --diff-filter=U | while read -r file; do + git checkout --theirs -- "$file" + done + git add -A + git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH} (conflicts)" || true + fi + + echo "has_conflicts=$HAS_CONFLICTS" >> "$GITHUB_OUTPUT" + echo "conflicted_files=$CONFLICTED_FILES" >> "$GITHUB_OUTPUT" + + git push origin "$BACKPORT_BRANCH" + + - name: Create backport PR + if: steps.check.outputs.merged == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }} + VERSION: ${{ steps.parse.outputs.version }} + PR_TITLE: ${{ steps.check.outputs.pr_title }} + PR_NUMBER: ${{ github.event.issue.number }} + BACKPORT_BRANCH: ${{ steps.apply.outputs.backport_branch }} + run: | + TITLE="[${VERSION}] ${PR_TITLE}" + BODY="Backport of #${PR_NUMBER} to \`${TARGET_BRANCH}\`." + + PR_URL=$(gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$BACKPORT_BRANCH" \ + --title "$TITLE" \ + --body "$BODY") + + gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \ + -f body="✅ Backport PR created: ${PR_URL}" + + # ────────────────────────────────────────────────────────────── + # Trigger 2: PR merged → process any queued cherrypick/* labels + # ────────────────────────────────────────────────────────────── + handle-merge: + if: > + github.event_name == 'pull_request' && + github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Collect cherry-pick labels + id: labels + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + LABELS=$(gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/labels \ + --jq '[.[] | select(.name | startswith("cherrypick/")) | .name] | join(",")') + + if [ -z "$LABELS" ]; then + echo "No cherrypick labels found, nothing to do." + echo "has_labels=false" >> "$GITHUB_OUTPUT" + else + echo "Found labels: $LABELS" + echo "has_labels=true" >> "$GITHUB_OUTPUT" + echo "labels=$LABELS" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout repository + if: steps.labels.outputs.has_labels == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download PR patch + if: steps.labels.outputs.has_labels == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh api repos/${{ github.repository }}/pulls/${PR_NUMBER} \ + -H "Accept: application/vnd.github.v3.patch" > /tmp/pr.patch + + - name: Process each cherry-pick label + if: steps.labels.outputs.has_labels == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LABELS: ${{ steps.labels.outputs.labels }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + IFS=',' read -ra LABEL_ARRAY <<< "$LABELS" + for LABEL in "${LABEL_ARRAY[@]}"; do + VERSION="${LABEL#cherrypick/}" + TARGET_BRANCH="release/$VERSION" + + echo "=== Processing backport to $TARGET_BRANCH ===" + + # Verify target branch exists + if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then + gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \ + -f body="❌ Cannot cherry-pick to \`$TARGET_BRANCH\`: branch does not exist." + continue + fi + + BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${TARGET_BRANCH//\//-}" + + git checkout "$TARGET_BRANCH" + git checkout -b "$BACKPORT_BRANCH" + + # Apply the patch + HAS_CONFLICTS="false" + CONFLICTED_FILES="" + + if git apply --check /tmp/pr.patch 2>/dev/null; then + git apply /tmp/pr.patch + git add -A + git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}" + elif git apply --3way /tmp/pr.patch; then + git add -A + git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}" || true + else + HAS_CONFLICTS="true" + CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ',' | sed 's/,$//') + git diff --name-only --diff-filter=U | while read -r file; do + git checkout --theirs -- "$file" + done + git add -A + git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH} (conflicts)" || true + fi + + git push origin "$BACKPORT_BRANCH" + + # Build PR title and body + TITLE="[${VERSION}] ${PR_TITLE}" + BODY="Backport of #${PR_NUMBER} to \`${TARGET_BRANCH}\`." + + PR_URL=$(gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$BACKPORT_BRANCH" \ + --title "$TITLE" \ + --body "$BODY") + + gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \ + -f body="✅ Backport PR to \`$TARGET_BRANCH\` created: ${PR_URL}" + + # Clean up for next iteration + git checkout "$TARGET_BRANCH" + git branch -D "$BACKPORT_BRANCH" + done