|
| 1 | +# SPDX-FileCopyrightText: 2025 IONOS SE and contributors |
| 2 | +# SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +name: Backport |
| 5 | + |
| 6 | +on: |
| 7 | + pull_request_target: |
| 8 | + types: [closed] |
| 9 | + |
| 10 | +permissions: |
| 11 | + contents: read |
| 12 | + |
| 13 | +jobs: |
| 14 | + backport: |
| 15 | + runs-on: ubuntu-latest |
| 16 | + |
| 17 | + # Only run when the PR was actually merged and has backport labels |
| 18 | + if: > |
| 19 | + github.event.pull_request.merged == true && |
| 20 | + contains(toJson(github.event.pull_request.labels), '"backport ') |
| 21 | +
|
| 22 | + strategy: |
| 23 | + fail-fast: false |
| 24 | + matrix: |
| 25 | + # We parse labels dynamically in a prior step; this matrix is populated |
| 26 | + # via a separate job that outputs the list. |
| 27 | + label: ${{ fromJson(needs.collect-labels.outputs.labels) }} |
| 28 | + |
| 29 | + needs: collect-labels |
| 30 | + |
| 31 | + steps: |
| 32 | + - name: Checkout repository |
| 33 | + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| 34 | + with: |
| 35 | + token: ${{ secrets.COMMAND_BOT_PAT }} |
| 36 | + fetch-depth: 0 |
| 37 | + |
| 38 | + - name: Setup git identity |
| 39 | + run: | |
| 40 | + git config user.name 'nextcloud-command' |
| 41 | + git config user.email 'nextcloud-command@users.noreply.github.com' |
| 42 | +
|
| 43 | + - name: Determine branches |
| 44 | + id: branches |
| 45 | + env: |
| 46 | + BACKPORT_PREFIX: ${{ vars.BACKPORT_PREFIX || 'ncw-' }} |
| 47 | + run: | |
| 48 | + LABEL="${{ matrix.label }}" |
| 49 | + # Strip "backport " prefix from label → target (e.g. "ncw-6") |
| 50 | + TARGET="${LABEL#backport }" |
| 51 | + BASE_BRANCH="rc/${TARGET}" |
| 52 | + BACKPORT_BRANCH="backport/${{ github.event.pull_request.number }}/${TARGET}" |
| 53 | + echo "target=$TARGET" >> $GITHUB_OUTPUT |
| 54 | + echo "base_branch=$BASE_BRANCH" >> $GITHUB_OUTPUT |
| 55 | + echo "backport_branch=$BACKPORT_BRANCH" >> $GITHUB_OUTPUT |
| 56 | +
|
| 57 | + - name: Verify target branch exists |
| 58 | + id: verify |
| 59 | + run: | |
| 60 | + BASE_BRANCH="${{ steps.branches.outputs.base_branch }}" |
| 61 | + if ! git ls-remote --exit-code --heads origin "$BASE_BRANCH" > /dev/null 2>&1; then |
| 62 | + echo "exists=false" >> $GITHUB_OUTPUT |
| 63 | + echo "Branch '$BASE_BRANCH' does not exist." |
| 64 | + else |
| 65 | + echo "exists=true" >> $GITHUB_OUTPUT |
| 66 | + fi |
| 67 | +
|
| 68 | + - name: Comment if target branch missing |
| 69 | + if: steps.verify.outputs.exists == 'false' |
| 70 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 71 | + with: |
| 72 | + github-token: ${{ secrets.COMMAND_BOT_PAT }} |
| 73 | + script: | |
| 74 | + await github.rest.issues.createComment({ |
| 75 | + owner: context.repo.owner, |
| 76 | + repo: context.repo.repo, |
| 77 | + issue_number: ${{ github.event.pull_request.number }}, |
| 78 | + body: `❌ Backport to \`${{ steps.branches.outputs.base_branch }}\` failed: branch does not exist.`, |
| 79 | + }) |
| 80 | +
|
| 81 | + - name: Create backport branch and cherry-pick |
| 82 | + id: cherry-pick |
| 83 | + if: steps.verify.outputs.exists == 'true' |
| 84 | + run: | |
| 85 | + BASE_BRANCH="${{ steps.branches.outputs.base_branch }}" |
| 86 | + BACKPORT_BRANCH="${{ steps.branches.outputs.backport_branch }}" |
| 87 | + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" |
| 88 | +
|
| 89 | + git fetch origin "$BASE_BRANCH" "${{ github.event.pull_request.base.ref }}" |
| 90 | + git checkout -b "$BACKPORT_BRANCH" "origin/$BASE_BRANCH" |
| 91 | +
|
| 92 | + # Cherry-pick all commits from the PR (excluding the merge commit itself) |
| 93 | + COMMITS=$(git log --reverse --pretty=format:"%H" \ |
| 94 | + "origin/${{ github.event.pull_request.base.ref }}..${{ github.event.pull_request.head.sha }}") |
| 95 | +
|
| 96 | + if [ -z "$COMMITS" ]; then |
| 97 | + # Fallback: cherry-pick the merge commit |
| 98 | + git cherry-pick -x "$MERGE_COMMIT" |
| 99 | + else |
| 100 | + echo "$COMMITS" | xargs git cherry-pick -x |
| 101 | + fi |
| 102 | +
|
| 103 | + git push origin "$BACKPORT_BRANCH" |
| 104 | +
|
| 105 | + - name: Create backport PR |
| 106 | + id: create-pr |
| 107 | + if: steps.cherry-pick.outcome == 'success' |
| 108 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 109 | + env: |
| 110 | + PR_TITLE: ${{ github.event.pull_request.title }} |
| 111 | + PR_AUTHOR: ${{ github.event.pull_request.user.login }} |
| 112 | + with: |
| 113 | + github-token: ${{ secrets.COMMAND_BOT_PAT }} |
| 114 | + script: | |
| 115 | + const target = '${{ steps.branches.outputs.target }}' |
| 116 | + const baseBranch = '${{ steps.branches.outputs.base_branch }}' |
| 117 | + const backportBranch = '${{ steps.branches.outputs.backport_branch }}' |
| 118 | + const originalTitle = process.env.PR_TITLE |
| 119 | + const originalNumber = ${{ github.event.pull_request.number }} |
| 120 | + const originalAuthor = process.env.PR_AUTHOR |
| 121 | +
|
| 122 | + const { data: pr } = await github.rest.pulls.create({ |
| 123 | + owner: context.repo.owner, |
| 124 | + repo: context.repo.repo, |
| 125 | + title: `[backport ${target}] ${originalTitle}`, |
| 126 | + head: backportBranch, |
| 127 | + base: baseBranch, |
| 128 | + body: `Backport of #${originalNumber} to \`${baseBranch}\`.\n\nOriginal PR by @${originalAuthor}.`, |
| 129 | + }) |
| 130 | +
|
| 131 | + core.setOutput('pr_url', pr.html_url) |
| 132 | + return pr.number |
| 133 | +
|
| 134 | + - name: Remove backport label |
| 135 | + if: steps.create-pr.outcome == 'success' |
| 136 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 137 | + env: |
| 138 | + MATRIX_LABEL: ${{ matrix.label }} |
| 139 | + with: |
| 140 | + github-token: ${{ secrets.COMMAND_BOT_PAT }} |
| 141 | + script: | |
| 142 | + try { |
| 143 | + await github.rest.issues.removeLabel({ |
| 144 | + owner: context.repo.owner, |
| 145 | + repo: context.repo.repo, |
| 146 | + issue_number: ${{ github.event.pull_request.number }}, |
| 147 | + name: process.env.MATRIX_LABEL, |
| 148 | + }) |
| 149 | + } catch (e) { |
| 150 | + // Label already removed or not found — ignore |
| 151 | + } |
| 152 | +
|
| 153 | + - name: React thumbs up on backport comment |
| 154 | + if: steps.create-pr.outcome == 'success' |
| 155 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 156 | + with: |
| 157 | + github-token: ${{ secrets.COMMAND_BOT_PAT }} |
| 158 | + script: | |
| 159 | + const target = '${{ steps.branches.outputs.target }}' |
| 160 | + // Find the /backport comment for this target and react |
| 161 | + const comments = await github.rest.issues.listComments({ |
| 162 | + owner: context.repo.owner, |
| 163 | + repo: context.repo.repo, |
| 164 | + issue_number: ${{ github.event.pull_request.number }}, |
| 165 | + }) |
| 166 | + const backportComment = comments.data.find(c => |
| 167 | + c.body.trim().startsWith(`/backport ${target}`) |
| 168 | + ) |
| 169 | + if (backportComment) { |
| 170 | + await github.rest.reactions.createForIssueComment({ |
| 171 | + owner: context.repo.owner, |
| 172 | + repo: context.repo.repo, |
| 173 | + comment_id: backportComment.id, |
| 174 | + content: '+1', |
| 175 | + }) |
| 176 | + } |
| 177 | +
|
| 178 | + - name: React thumbs down and comment on failure |
| 179 | + if: always() && steps.verify.outputs.exists == 'true' && (steps.cherry-pick.outcome == 'failure' || steps.create-pr.outcome == 'failure') |
| 180 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 181 | + with: |
| 182 | + github-token: ${{ secrets.COMMAND_BOT_PAT }} |
| 183 | + script: | |
| 184 | + const target = '${{ steps.branches.outputs.target }}' |
| 185 | + const baseBranch = '${{ steps.branches.outputs.base_branch }}' |
| 186 | + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` |
| 187 | +
|
| 188 | + // Comment failure details on the PR |
| 189 | + await github.rest.issues.createComment({ |
| 190 | + owner: context.repo.owner, |
| 191 | + repo: context.repo.repo, |
| 192 | + issue_number: ${{ github.event.pull_request.number }}, |
| 193 | + body: `❌ Backport to \`${baseBranch}\` failed (likely a cherry-pick conflict). [View run](${runUrl})\n\nPlease backport manually.`, |
| 194 | + }) |
| 195 | +
|
| 196 | + // React -1 on the /backport comment |
| 197 | + const comments = await github.rest.issues.listComments({ |
| 198 | + owner: context.repo.owner, |
| 199 | + repo: context.repo.repo, |
| 200 | + issue_number: ${{ github.event.pull_request.number }}, |
| 201 | + }) |
| 202 | + const backportComment = comments.data.find(c => |
| 203 | + c.body.trim().startsWith(`/backport ${target}`) |
| 204 | + ) |
| 205 | + if (backportComment) { |
| 206 | + await github.rest.reactions.createForIssueComment({ |
| 207 | + owner: context.repo.owner, |
| 208 | + repo: context.repo.repo, |
| 209 | + comment_id: backportComment.id, |
| 210 | + content: '-1', |
| 211 | + }) |
| 212 | + } |
| 213 | +
|
| 214 | + collect-labels: |
| 215 | + runs-on: ubuntu-latest |
| 216 | + if: > |
| 217 | + github.event.pull_request.merged == true && |
| 218 | + contains(toJson(github.event.pull_request.labels), '"backport ') |
| 219 | + outputs: |
| 220 | + labels: ${{ steps.collect.outputs.labels }} |
| 221 | + |
| 222 | + steps: |
| 223 | + - name: Collect backport labels |
| 224 | + id: collect |
| 225 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 226 | + with: |
| 227 | + script: | |
| 228 | + const labels = context.payload.pull_request.labels |
| 229 | + .map(l => l.name) |
| 230 | + .filter(name => name.startsWith('backport ')) |
| 231 | + core.setOutput('labels', JSON.stringify(labels)) |
| 232 | + return labels |
0 commit comments