From a67db18063e9fb702ab7ccba09505c3a920fa151 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 31 Mar 2026 15:56:12 +0200 Subject: [PATCH] ci(backport): add /backport slash command and cherry-pick workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two GitHub Actions workflows: - command-backport.yml: listens for /backport comments on PRs, validates actor permissions, adds a backport label, and reacts with 👀 - backport.yml: triggers on PR merge, cherry-picks commits to a new backport// branch and opens a PR to rc/ Branch prefix is configurable via BACKPORT_PREFIX repo variable (default: ncw-). Requires COMMAND_BOT_PAT secret (already present). Signed-off-by: Misha M.-Kupriyanov --- .github/workflows/backport.yml | 232 +++++++++++++++++++++++++ .github/workflows/command-backport.yml | 107 ++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 .github/workflows/backport.yml create mode 100644 .github/workflows/command-backport.yml diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000000000..6785d7957e1a7 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: 2025 IONOS SE and contributors +# SPDX-License-Identifier: MIT + +name: Backport + +on: + pull_request_target: + types: [closed] + +permissions: + contents: read + +jobs: + backport: + runs-on: ubuntu-latest + + # Only run when the PR was actually merged and has backport labels + if: > + github.event.pull_request.merged == true && + contains(toJson(github.event.pull_request.labels), '"backport ') + + strategy: + fail-fast: false + matrix: + # We parse labels dynamically in a prior step; this matrix is populated + # via a separate job that outputs the list. + label: ${{ fromJson(needs.collect-labels.outputs.labels) }} + + needs: collect-labels + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.COMMAND_BOT_PAT }} + fetch-depth: 0 + + - name: Setup git identity + run: | + git config user.name 'nextcloud-command' + git config user.email 'nextcloud-command@users.noreply.github.com' + + - name: Determine branches + id: branches + env: + BACKPORT_PREFIX: ${{ vars.BACKPORT_PREFIX || 'ncw-' }} + run: | + LABEL="${{ matrix.label }}" + # Strip "backport " prefix from label → target (e.g. "ncw-6") + TARGET="${LABEL#backport }" + BASE_BRANCH="rc/${TARGET}" + BACKPORT_BRANCH="backport/${{ github.event.pull_request.number }}/${TARGET}" + echo "target=$TARGET" >> $GITHUB_OUTPUT + echo "base_branch=$BASE_BRANCH" >> $GITHUB_OUTPUT + echo "backport_branch=$BACKPORT_BRANCH" >> $GITHUB_OUTPUT + + - name: Verify target branch exists + id: verify + run: | + BASE_BRANCH="${{ steps.branches.outputs.base_branch }}" + if ! git ls-remote --exit-code --heads origin "$BASE_BRANCH" > /dev/null 2>&1; then + echo "exists=false" >> $GITHUB_OUTPUT + echo "Branch '$BASE_BRANCH' does not exist." + else + echo "exists=true" >> $GITHUB_OUTPUT + fi + + - name: Comment if target branch missing + if: steps.verify.outputs.exists == 'false' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + body: `❌ Backport to \`${{ steps.branches.outputs.base_branch }}\` failed: branch does not exist.`, + }) + + - name: Create backport branch and cherry-pick + id: cherry-pick + if: steps.verify.outputs.exists == 'true' + run: | + BASE_BRANCH="${{ steps.branches.outputs.base_branch }}" + BACKPORT_BRANCH="${{ steps.branches.outputs.backport_branch }}" + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + + git fetch origin "$BASE_BRANCH" "${{ github.event.pull_request.base.ref }}" + git checkout -b "$BACKPORT_BRANCH" "origin/$BASE_BRANCH" + + # Cherry-pick all commits from the PR (excluding the merge commit itself) + COMMITS=$(git log --reverse --pretty=format:"%H" \ + "origin/${{ github.event.pull_request.base.ref }}..${{ github.event.pull_request.head.sha }}") + + if [ -z "$COMMITS" ]; then + # Fallback: cherry-pick the merge commit + git cherry-pick -x "$MERGE_COMMIT" + else + echo "$COMMITS" | xargs git cherry-pick -x + fi + + git push origin "$BACKPORT_BRANCH" + + - name: Create backport PR + id: create-pr + if: steps.cherry-pick.outcome == 'success' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + const target = '${{ steps.branches.outputs.target }}' + const baseBranch = '${{ steps.branches.outputs.base_branch }}' + const backportBranch = '${{ steps.branches.outputs.backport_branch }}' + const originalTitle = process.env.PR_TITLE + const originalNumber = ${{ github.event.pull_request.number }} + const originalAuthor = process.env.PR_AUTHOR + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[backport ${target}] ${originalTitle}`, + head: backportBranch, + base: baseBranch, + body: `Backport of #${originalNumber} to \`${baseBranch}\`.\n\nOriginal PR by @${originalAuthor}.`, + }) + + core.setOutput('pr_url', pr.html_url) + return pr.number + + - name: Remove backport label + if: steps.create-pr.outcome == 'success' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + MATRIX_LABEL: ${{ matrix.label }} + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + name: process.env.MATRIX_LABEL, + }) + } catch (e) { + // Label already removed or not found — ignore + } + + - name: React thumbs up on backport comment + if: steps.create-pr.outcome == 'success' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + const target = '${{ steps.branches.outputs.target }}' + // Find the /backport comment for this target and react + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + }) + const backportComment = comments.data.find(c => + c.body.trim().startsWith(`/backport ${target}`) + ) + if (backportComment) { + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: backportComment.id, + content: '+1', + }) + } + + - name: React thumbs down and comment on failure + if: always() && steps.verify.outputs.exists == 'true' && (steps.cherry-pick.outcome == 'failure' || steps.create-pr.outcome == 'failure') + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + const target = '${{ steps.branches.outputs.target }}' + const baseBranch = '${{ steps.branches.outputs.base_branch }}' + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + + // Comment failure details on the PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + body: `❌ Backport to \`${baseBranch}\` failed (likely a cherry-pick conflict). [View run](${runUrl})\n\nPlease backport manually.`, + }) + + // React -1 on the /backport comment + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + }) + const backportComment = comments.data.find(c => + c.body.trim().startsWith(`/backport ${target}`) + ) + if (backportComment) { + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: backportComment.id, + content: '-1', + }) + } + + collect-labels: + runs-on: ubuntu-latest + if: > + github.event.pull_request.merged == true && + contains(toJson(github.event.pull_request.labels), '"backport ') + outputs: + labels: ${{ steps.collect.outputs.labels }} + + steps: + - name: Collect backport labels + id: collect + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const labels = context.payload.pull_request.labels + .map(l => l.name) + .filter(name => name.startsWith('backport ')) + core.setOutput('labels', JSON.stringify(labels)) + return labels diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml new file mode 100644 index 0000000000000..70d539699eb00 --- /dev/null +++ b/.github/workflows/command-backport.yml @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025 IONOS SE and contributors +# SPDX-License-Identifier: MIT + +name: Backport Command + +on: + issue_comment: + types: [created] + +permissions: + contents: read + +jobs: + init: + runs-on: ubuntu-latest + + # On pull requests and if the comment starts with `/backport` + if: github.event.issue.pull_request != '' && startsWith(github.event.comment.body, '/backport') + + outputs: + target: ${{ steps.parse.outputs.target }} + base_branch: ${{ steps.parse.outputs.base_branch }} + backport_branch: ${{ steps.parse.outputs.backport_branch }} + + steps: + - name: Check actor permission + uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v2 + with: + require: write + + - name: Check PR is open + if: github.event.issue.state != 'open' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '❌ This PR is already closed/merged. Cannot auto-backport — please cherry-pick manually.', + }) + core.setFailed('PR is already closed/merged; backport must be performed manually.') + + - name: Parse target from comment + id: parse + env: + BACKPORT_PREFIX: ${{ vars.BACKPORT_PREFIX || 'ncw-' }} + COMMENT: ${{ github.event.comment.body }} + run: | + TARGET=$(echo "$COMMENT" | grep -oP "^/backport\s+\K${BACKPORT_PREFIX}[0-9]+") + if [ -z "$TARGET" ]; then + echo "Invalid /backport command. Expected: /backport ${BACKPORT_PREFIX}" + exit 1 + fi + echo "target=$TARGET" >> $GITHUB_OUTPUT + echo "base_branch=rc/$TARGET" >> $GITHUB_OUTPUT + echo "backport_branch=backport/${{ github.event.issue.number }}/$TARGET" >> $GITHUB_OUTPUT + + - name: Add label to PR + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.COMMAND_BOT_PAT }} + script: | + const target = '${{ steps.parse.outputs.target }}' + const label = `backport ${target}` + + // Ensure label exists (create if missing) + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + }) + } catch { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: '0075ca', + description: `Backport to rc/${target}`, + }) + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [label], + }) + + - name: React with eyes (queued, waiting for merge) + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + with: + token: ${{ secrets.COMMAND_BOT_PAT }} + repository: ${{ github.event.repository.full_name }} + comment-id: ${{ github.event.comment.id }} + reactions: eyes + + - name: React with thumbs down on failure + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + if: failure() + with: + token: ${{ secrets.COMMAND_BOT_PAT }} + repository: ${{ github.event.repository.full_name }} + comment-id: ${{ github.event.comment.id }} + reactions: '-1'