|
| 1 | +name: Backport |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request_target: |
| 5 | + types: |
| 6 | + - closed |
| 7 | + workflow_dispatch: |
| 8 | + inputs: |
| 9 | + pr_number: |
| 10 | + description: 'PR number' |
| 11 | + required: true |
| 12 | + type: string |
| 13 | + target_branch: |
| 14 | + description: 'Target branch' |
| 15 | + required: true |
| 16 | + type: string |
| 17 | + |
| 18 | +concurrency: |
| 19 | + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} |
| 20 | + cancel-in-progress: true |
| 21 | + |
| 22 | +jobs: |
| 23 | + backport: |
| 24 | + timeout-minutes: 30 |
| 25 | + if: > |
| 26 | + github.event_name == 'workflow_dispatch' || |
| 27 | + (github.event_name == 'pull_request_target' && |
| 28 | + github.event.pull_request.merged == true && |
| 29 | + ((github.event.action == 'closed') || |
| 30 | + (github.event.action == 'labeled' && startsWith(github.event.label.name, 'backport-to-')))) |
| 31 | + permissions: |
| 32 | + contents: write |
| 33 | + pull-requests: write |
| 34 | + runs-on: ubuntu-latest |
| 35 | + steps: |
| 36 | + - name: Checkout |
| 37 | + uses: actions/checkout@v4 |
| 38 | + with: |
| 39 | + fetch-depth: 0 |
| 40 | + |
| 41 | + - name: Configure Git |
| 42 | + run: | |
| 43 | + git config user.name "github-actions[bot]" |
| 44 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 45 | +
|
| 46 | + - name: Execute Backport |
| 47 | + id: execute-backport |
| 48 | + env: |
| 49 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 50 | + EVENT_NAME: ${{ github.event_name }} |
| 51 | + INPUT_PR_NUMBER: ${{ inputs.pr_number }} |
| 52 | + INPUT_TARGET_BRANCH: ${{ inputs.target_branch }} |
| 53 | + TRIGGER_USER: ${{ github.actor }} |
| 54 | + run: | |
| 55 | + # Helper function to write summary |
| 56 | + write_summary() { |
| 57 | + local status="$1" |
| 58 | + local title="$2" |
| 59 | + local details="$3" |
| 60 | + echo "## $status $title" >> $GITHUB_STEP_SUMMARY |
| 61 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 62 | + echo "$details" >> $GITHUB_STEP_SUMMARY |
| 63 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 64 | + } |
| 65 | +
|
| 66 | + if [ "$EVENT_NAME" == "workflow_dispatch" ]; then |
| 67 | + PR_NUMBER="$INPUT_PR_NUMBER" |
| 68 | + IS_MANUAL="true" |
| 69 | + |
| 70 | + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then |
| 71 | + echo "::error::Invalid PR number: $PR_NUMBER (must be a positive integer)" |
| 72 | + write_summary "❌" "Backport Failed" "**Reason:** Invalid PR number \`$PR_NUMBER\` (must be a positive integer)" |
| 73 | + exit 1 |
| 74 | + fi |
| 75 | + |
| 76 | + if ! [[ "$INPUT_TARGET_BRANCH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then |
| 77 | + echo "::error::Invalid branch name: $INPUT_TARGET_BRANCH (contains invalid characters)" |
| 78 | + write_summary "❌" "Backport Failed" "**Reason:** Invalid branch name \`$INPUT_TARGET_BRANCH\` (contains invalid characters)" |
| 79 | + exit 1 |
| 80 | + fi |
| 81 | + else |
| 82 | + PR_NUMBER="${{ github.event.pull_request.number }}" |
| 83 | + IS_MANUAL="false" |
| 84 | + fi |
| 85 | +
|
| 86 | + echo "Starting Backport for PR #$PR_NUMBER (Triggered by $TRIGGER_USER)" |
| 87 | +
|
| 88 | + if ! PR_DATA=$(gh pr view "$PR_NUMBER" --json mergeCommit,title,files,author,state,labels,body --jq . 2>&1); then |
| 89 | + echo "::error::Failed to fetch PR data: $PR_DATA" |
| 90 | + write_summary "❌" "Backport Failed" "**PR:** #$PR_NUMBER\n\n**Reason:** Failed to fetch PR data\n\n**Error:** \`$PR_DATA\`" |
| 91 | + exit 1 |
| 92 | + fi |
| 93 | +
|
| 94 | + MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid // empty') |
| 95 | + PR_TITLE=$(echo "$PR_DATA" | jq -r .title) |
| 96 | + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login // "ghost"') |
| 97 | + PR_STATE=$(echo "$PR_DATA" | jq -r .state) |
| 98 | + CHANGED_FILES=$(echo "$PR_DATA" | jq -r '.files[].path // empty') |
| 99 | +
|
| 100 | + if [ -z "$PR_AUTHOR" ] || [ "$PR_AUTHOR" == "null" ]; then |
| 101 | + PR_AUTHOR="ghost" |
| 102 | + fi |
| 103 | +
|
| 104 | + PR_TITLE_CLEAN=$(echo "$PR_TITLE" | tr -d '\n\r' | sed 's/["`\\]/ /g' | head -c 200) |
| 105 | + if [ -z "$PR_TITLE_CLEAN" ]; then |
| 106 | + PR_TITLE_CLEAN="Backport PR #$PR_NUMBER" |
| 107 | + fi |
| 108 | +
|
| 109 | + if [ "$PR_STATE" != "MERGED" ]; then |
| 110 | + echo "::error::PR is not merged." |
| 111 | + write_summary "❌" "Backport Failed" "**PR:** #$PR_NUMBER\n\n**Reason:** PR is not merged yet" |
| 112 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', PR #'"$PR_NUMBER"$' is not merged yet.\n\n(cc @'"$TRIGGER_USER"$')' |
| 113 | + exit 0 |
| 114 | + fi |
| 115 | +
|
| 116 | + if [ -z "$MERGE_COMMIT" ] || [ "$MERGE_COMMIT" == "null" ]; then |
| 117 | + echo "::error::Cannot find merge commit for PR #$PR_NUMBER" |
| 118 | + write_summary "❌" "Backport Failed" "**PR:** #$PR_NUMBER\n\n**Reason:** Cannot determine merge commit for this PR" |
| 119 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', cannot determine merge commit for this PR.\n\n(cc @'"$TRIGGER_USER"$')' |
| 120 | + exit 0 |
| 121 | + fi |
| 122 | +
|
| 123 | + if [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | grep -q "^pymilvus/grpc_gen/"; then |
| 124 | + echo "::notice::Skipping restricted paths." |
| 125 | + write_summary "⚠️" "Backport Skipped" "**PR:** #$PR_NUMBER\n\n**Reason:** This PR modifies \`pymilvus/grpc_gen/\`. Please backport manually." |
| 126 | + gh pr comment "$PR_NUMBER" --body $'⚠️ **Backport Skipped**\nHi @'"$PR_AUTHOR"$', this PR modifies `pymilvus/grpc_gen/`. Please backport manually.\n\n(cc @'"$TRIGGER_USER"$')' |
| 127 | + exit 0 |
| 128 | + fi |
| 129 | +
|
| 130 | + TARGET_BRANCHES="" |
| 131 | + LABELED_BRANCHES=$(echo "$PR_DATA" | jq -r '.labels[].name' | grep '^backport-to-' | sed 's/^backport-to-//' || true) |
| 132 | + if [ "$IS_MANUAL" == "true" ]; then |
| 133 | + # Check if the target branch has corresponding label |
| 134 | + if echo "$LABELED_BRANCHES" | grep -qx "$INPUT_TARGET_BRANCH"; then |
| 135 | + TARGET_BRANCHES="$INPUT_TARGET_BRANCH" |
| 136 | + else |
| 137 | + echo "::error::Target branch '$INPUT_TARGET_BRANCH' is not labeled with 'backport-to-$INPUT_TARGET_BRANCH'" |
| 138 | + write_summary "❌" "Backport Failed" "**PR:** #$PR_NUMBER\n\n**Target Branch:** \`$INPUT_TARGET_BRANCH\`\n\n**Reason:** Missing label \`backport-to-$INPUT_TARGET_BRANCH\`\n\n**Available Labels:** \`$LABELED_BRANCHES\`" |
| 139 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', target branch `'"$INPUT_TARGET_BRANCH"$'` is not in the backport labels. Please add label `backport-to-'"$INPUT_TARGET_BRANCH"$'` first.\n\n(cc @'"$TRIGGER_USER"$')' |
| 140 | + exit 0 |
| 141 | + fi |
| 142 | + else |
| 143 | + TARGET_BRANCHES="$LABELED_BRANCHES" |
| 144 | + fi |
| 145 | +
|
| 146 | + if [ -z "$TARGET_BRANCHES" ]; then |
| 147 | + echo "No target branches found." |
| 148 | + write_summary "ℹ️" "No Backport Needed" "**PR:** #$PR_NUMBER\n\n**Reason:** No \`backport-to-*\` labels found on this PR" |
| 149 | + exit 0 |
| 150 | + fi |
| 151 | +
|
| 152 | + BACKPORT_SUCCESS="false" |
| 153 | + BACKPORT_COUNT=0 |
| 154 | + SUMMARY_RESULTS="" |
| 155 | +
|
| 156 | + while IFS= read -r TARGET_BRANCH; do |
| 157 | + [ -z "$TARGET_BRANCH" ] && continue |
| 158 | + TARGET_BRANCH=$(echo "$TARGET_BRANCH" | xargs) |
| 159 | + |
| 160 | + echo "::group::Processing backport to $TARGET_BRANCH" |
| 161 | +
|
| 162 | + if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then |
| 163 | + echo "::error::Target branch '$TARGET_BRANCH' does not exist." |
| 164 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ❌ Failed | Branch does not exist |" |
| 165 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', target branch `'"$TARGET_BRANCH"$'` does not exist.\n\n(cc @'"$TRIGGER_USER"$')' |
| 166 | + echo "::endgroup::" |
| 167 | + continue |
| 168 | + fi |
| 169 | +
|
| 170 | + echo "Checking for existing backport PR..." |
| 171 | + EXISTING_PR=$(gh pr list \ |
| 172 | + --base "$TARGET_BRANCH" \ |
| 173 | + --state all \ |
| 174 | + --search "in:title [Backport $TARGET_BRANCH]" \ |
| 175 | + --author "app/github-actions" \ |
| 176 | + --json number,title \ |
| 177 | + --jq ".[] | select(.title | contains(\"#$PR_NUMBER\")) | .number" \ |
| 178 | + | head -n 1) |
| 179 | +
|
| 180 | + if [ -n "$EXISTING_PR" ]; then |
| 181 | + echo "::notice::Backport PR already exists: #$EXISTING_PR" |
| 182 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ℹ️ Skipped | Already exists: #$EXISTING_PR |" |
| 183 | + gh pr comment "$PR_NUMBER" --body $'ℹ️ **Backport Already Exists**\nHi @'"$PR_AUTHOR"$', a backport PR for `'"$TARGET_BRANCH"$'` already exists: #'"$EXISTING_PR"$'\n\n(cc @'"$TRIGGER_USER"$')' |
| 184 | + echo "::endgroup::" |
| 185 | + continue |
| 186 | + fi |
| 187 | +
|
| 188 | + git reset --hard HEAD |
| 189 | + git clean -fdx |
| 190 | +
|
| 191 | + TARGET_BRANCH_SHORT="${TARGET_BRANCH:0:100}" |
| 192 | + BRANCH_SUFFIX="$(date +%s)-$RANDOM" |
| 193 | + BACKPORT_BRANCH="backport-$PR_NUMBER-to-$TARGET_BRANCH_SHORT-$BRANCH_SUFFIX" |
| 194 | + BACKPORT_BRANCH="${BACKPORT_BRANCH:0:250}" |
| 195 | + |
| 196 | + if ! git fetch origin "$TARGET_BRANCH"; then |
| 197 | + echo "::error::Failed to fetch $TARGET_BRANCH" |
| 198 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ❌ Failed | Failed to fetch branch |" |
| 199 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', failed to fetch target branch `'"$TARGET_BRANCH"$'`.\n\n(cc @'"$TRIGGER_USER"$')' |
| 200 | + echo "::endgroup::" |
| 201 | + continue |
| 202 | + fi |
| 203 | +
|
| 204 | + git checkout -b "$BACKPORT_BRANCH" "origin/$TARGET_BRANCH" |
| 205 | +
|
| 206 | + echo "Attempting cherry-pick of $MERGE_COMMIT..." |
| 207 | + |
| 208 | + if git cherry-pick -x -s "$MERGE_COMMIT" 2>&1; then |
| 209 | + echo "Cherry-pick successful." |
| 210 | + else |
| 211 | + echo "Standard cherry-pick failed, trying with -m 1..." |
| 212 | + git cherry-pick --abort 2>/dev/null || true |
| 213 | + |
| 214 | + if git cherry-pick -x -s -m 1 "$MERGE_COMMIT" 2>&1; then |
| 215 | + echo "Cherry-pick with -m 1 successful." |
| 216 | + else |
| 217 | + echo "::error::Cherry-pick failed due to conflicts." |
| 218 | + git cherry-pick --abort 2>/dev/null || true |
| 219 | + |
| 220 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ❌ Failed | Merge conflicts - manual backport required |" |
| 221 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', I could not cherry-pick this to `'"$TARGET_BRANCH"$'` due to **merge conflicts**. Please backport manually.\n\n(cc @'"$TRIGGER_USER"$')' |
| 222 | + echo "::endgroup::" |
| 223 | + continue |
| 224 | + fi |
| 225 | + fi |
| 226 | +
|
| 227 | + if ! git push origin "$BACKPORT_BRANCH"; then |
| 228 | + echo "::error::Failed to push branch $BACKPORT_BRANCH" |
| 229 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ❌ Failed | Failed to push branch |" |
| 230 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport Failed**\nHi @'"$PR_AUTHOR"$', failed to push backport branch for `'"$TARGET_BRANCH"$'`.\n\n(cc @'"$TRIGGER_USER"$')' |
| 231 | + echo "::endgroup::" |
| 232 | + continue |
| 233 | + fi |
| 234 | + |
| 235 | + PR_BODY="Backport of #$PR_NUMBER to \`$TARGET_BRANCH\`." |
| 236 | + ASSIGNEE_ARG="" |
| 237 | + if [ "$IS_MANUAL" == "true" ]; then |
| 238 | + PR_BODY="Manual backport of #$PR_NUMBER to \`$TARGET_BRANCH\`." |
| 239 | + ASSIGNEE_ARG="--assignee $TRIGGER_USER" |
| 240 | + fi |
| 241 | +
|
| 242 | + if NEW_PR_URL=$(gh pr create \ |
| 243 | + --base "$TARGET_BRANCH" \ |
| 244 | + --head "$BACKPORT_BRANCH" \ |
| 245 | + --title "[Backport $TARGET_BRANCH] $PR_TITLE_CLEAN" \ |
| 246 | + --body "$PR_BODY" \ |
| 247 | + --label "backport" \ |
| 248 | + $ASSIGNEE_ARG 2>&1); then |
| 249 | + |
| 250 | + gh pr comment "$PR_NUMBER" --body $'✅ **Backport Created**\nHi @'"$PR_AUTHOR"$', Backport PR for `'"$TARGET_BRANCH"$'` has been created: '"$NEW_PR_URL"$'\n\n(cc @'"$TRIGGER_USER"$')' |
| 251 | + |
| 252 | + echo "Backport PR created: $NEW_PR_URL" |
| 253 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ✅ Success | $NEW_PR_URL |" |
| 254 | + BACKPORT_SUCCESS="true" |
| 255 | + BACKPORT_COUNT=$((BACKPORT_COUNT + 1)) |
| 256 | + else |
| 257 | + echo "::error::Failed to create backport PR." |
| 258 | + SUMMARY_RESULTS="${SUMMARY_RESULTS}\n| \`$TARGET_BRANCH\` | ❌ Failed | Failed to create PR |" |
| 259 | + git push origin --delete "$BACKPORT_BRANCH" 2>/dev/null || true |
| 260 | + gh pr comment "$PR_NUMBER" --body $'❌ **Backport PR Creation Failed**\nHi @'"$PR_AUTHOR"$', Failed to create backport PR for `'"$TARGET_BRANCH"$'`.\n\nError: '"$NEW_PR_URL"$'\n\n(cc @'"$TRIGGER_USER"$')' |
| 261 | + fi |
| 262 | + |
| 263 | + echo "::endgroup::" |
| 264 | + done < <(echo "$TARGET_BRANCHES") |
| 265 | +
|
| 266 | + # Write final summary |
| 267 | + if [ "$BACKPORT_SUCCESS" == "true" ]; then |
| 268 | + echo "## ✅ Backport Completed" >> $GITHUB_STEP_SUMMARY |
| 269 | + else |
| 270 | + echo "## ❌ Backport Failed" >> $GITHUB_STEP_SUMMARY |
| 271 | + fi |
| 272 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 273 | + echo "**PR:** #$PR_NUMBER" >> $GITHUB_STEP_SUMMARY |
| 274 | + echo "**Title:** $PR_TITLE_CLEAN" >> $GITHUB_STEP_SUMMARY |
| 275 | + echo "**Triggered by:** @$TRIGGER_USER" >> $GITHUB_STEP_SUMMARY |
| 276 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 277 | + echo "### Results" >> $GITHUB_STEP_SUMMARY |
| 278 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 279 | + echo "| Branch | Status | Details |" >> $GITHUB_STEP_SUMMARY |
| 280 | + echo "|--------|--------|---------|" >> $GITHUB_STEP_SUMMARY |
| 281 | + echo -e "$SUMMARY_RESULTS" >> $GITHUB_STEP_SUMMARY |
| 282 | +
|
| 283 | + echo "success=$BACKPORT_SUCCESS" >> $GITHUB_OUTPUT |
| 284 | + echo "count=$BACKPORT_COUNT" >> $GITHUB_OUTPUT |
| 285 | + echo "Backport process completed. Success: $BACKPORT_SUCCESS, Count: $BACKPORT_COUNT" |
| 286 | +
|
| 287 | + - name: Label Original PR |
| 288 | + if: steps.execute-backport.outputs.success == 'true' |
| 289 | + env: |
| 290 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 291 | + INPUT_PR_NUMBER: ${{ inputs.pr_number }} |
| 292 | + EVENT_NAME: ${{ github.event_name }} |
| 293 | + run: | |
| 294 | + if [ "$EVENT_NAME" == "workflow_dispatch" ]; then |
| 295 | + PR_NUMBER="$INPUT_PR_NUMBER" |
| 296 | + else |
| 297 | + PR_NUMBER="${{ github.event.pull_request.number }}" |
| 298 | + fi |
| 299 | + gh pr edit "$PR_NUMBER" --add-label "backported" || echo "::warning::Failed to add 'backported' label" |
0 commit comments