From 37099c498b6768caa163ae14f524ed1468611d60 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 6 Aug 2025 01:19:14 -0700 Subject: [PATCH] [feat] Add automatic backport workflow (#4778) --- .github/workflows/backport.yaml | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/backport.yaml diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml new file mode 100644 index 000000000..f9106caee --- /dev/null +++ b/.github/workflows/backport.yaml @@ -0,0 +1,165 @@ +name: Auto Backport + +on: + pull_request_target: + types: [closed] + branches: [main] + +jobs: + backport: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Extract version labels + id: versions + run: | + # Extract version labels (e.g., "1.24", "1.22") + VERSIONS="" + LABELS='${{ toJSON(github.event.pull_request.labels) }}' + for label in $(echo "$LABELS" | jq -r '.[].name'); do + # Match version labels like "1.24" (major.minor only) + if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then + # Validate the branch exists before adding to list + if git ls-remote --exit-code origin "core/${label}" >/dev/null 2>&1; then + VERSIONS="${VERSIONS}${label} " + else + echo "::warning::Label '${label}' found but branch 'core/${label}' does not exist" + fi + fi + done + + if [ -z "$VERSIONS" ]; then + echo "::error::No version labels found (e.g., 1.24, 1.22)" + exit 1 + fi + + echo "versions=${VERSIONS}" >> $GITHUB_OUTPUT + echo "Found version labels: ${VERSIONS}" + + - name: Backport commits + id: backport + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }} + run: | + FAILED="" + SUCCESS="" + + for version in ${{ steps.versions.outputs.versions }}; do + echo "::group::Backporting to core/${version}" + + TARGET_BRANCH="core/${version}" + BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${version}" + + # Fetch target branch (fail if doesn't exist) + if ! git fetch origin "${TARGET_BRANCH}"; then + echo "::error::Target branch ${TARGET_BRANCH} does not exist" + FAILED="${FAILED}${version}:branch-missing " + echo "::endgroup::" + continue + fi + + # Create backport branch + git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}" + + # Try cherry-pick + if git cherry-pick "${MERGE_COMMIT}"; then + git push origin "${BACKPORT_BRANCH}" + SUCCESS="${SUCCESS}${version}:${BACKPORT_BRANCH} " + echo "Successfully created backport branch: ${BACKPORT_BRANCH}" + # Return to main (keep the branch, we need it for PR) + git checkout main + else + # Get conflict info + CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',') + git cherry-pick --abort + + echo "::error::Cherry-pick failed due to conflicts" + FAILED="${FAILED}${version}:conflicts:${CONFLICTS} " + + # Clean up the failed branch + git checkout main + git branch -D "${BACKPORT_BRANCH}" + fi + + echo "::endgroup::" + done + + echo "success=${SUCCESS}" >> $GITHUB_OUTPUT + echo "failed=${FAILED}" >> $GITHUB_OUTPUT + + if [ -n "${FAILED}" ]; then + exit 1 + fi + + - name: Create PR for each successful backport + if: steps.backport.outputs.success + env: + GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} + run: | + PR_TITLE="${{ github.event.pull_request.title }}" + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_AUTHOR="${{ github.event.pull_request.user.login }}" + + for backport in ${{ steps.backport.outputs.success }}; do + IFS=':' read -r version branch <<< "${backport}" + + if PR_URL=$(gh pr create \ + --base "core/${version}" \ + --head "${branch}" \ + --title "[backport ${version}] ${PR_TITLE}" \ + --body "Backport of #${PR_NUMBER} to \`core/${version}\`"$'\n\n'"Automatically created by backport workflow." \ + --label "backport" 2>&1); then + + # Extract PR number from URL + PR_NUM=$(echo "${PR_URL}" | grep -o '[0-9]*$') + + if [ -n "${PR_NUM}" ]; then + gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}" + fi + else + echo "::error::Failed to create PR for ${version}: ${PR_URL}" + # Still try to comment on the original PR about the failure + gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`core/${version}\`. Please create the PR manually from branch \`${branch}\`" + fi + done + + - name: Comment on failures + if: failure() && steps.backport.outputs.failed + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_AUTHOR="${{ github.event.pull_request.user.login }}" + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + + for failure in ${{ steps.backport.outputs.failed }}; do + IFS=':' read -r version reason conflicts <<< "${failure}" + + if [ "${reason}" = "branch-missing" ]; then + gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`core/${version}\` does not exist" + + elif [ "${reason}" = "conflicts" ]; then + # Convert comma-separated conflicts back to newlines for display + CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /') + + COMMENT_BODY="@${PR_AUTHOR} Backport to \`core/${version}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`core/${version}\` branch."$'\n\n'"
Conflicting files"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"
" + gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}" + fi + done