Skip to content

Commit a67db18

Browse files
ci(backport): add /backport slash command and cherry-pick workflow
Adds two GitHub Actions workflows: - command-backport.yml: listens for /backport <target> 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/<pr-id>/<target> branch and opens a PR to rc/<target> Branch prefix is configurable via BACKPORT_PREFIX repo variable (default: ncw-). Requires COMMAND_BOT_PAT secret (already present). Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
1 parent fbdad15 commit a67db18

2 files changed

Lines changed: 339 additions & 0 deletions

File tree

.github/workflows/backport.yml

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# SPDX-FileCopyrightText: 2025 IONOS SE and contributors
2+
# SPDX-License-Identifier: MIT
3+
4+
name: Backport Command
5+
6+
on:
7+
issue_comment:
8+
types: [created]
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
init:
15+
runs-on: ubuntu-latest
16+
17+
# On pull requests and if the comment starts with `/backport`
18+
if: github.event.issue.pull_request != '' && startsWith(github.event.comment.body, '/backport')
19+
20+
outputs:
21+
target: ${{ steps.parse.outputs.target }}
22+
base_branch: ${{ steps.parse.outputs.base_branch }}
23+
backport_branch: ${{ steps.parse.outputs.backport_branch }}
24+
25+
steps:
26+
- name: Check actor permission
27+
uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v2
28+
with:
29+
require: write
30+
31+
- name: Check PR is open
32+
if: github.event.issue.state != 'open'
33+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
34+
with:
35+
github-token: ${{ secrets.COMMAND_BOT_PAT }}
36+
script: |
37+
await github.rest.issues.createComment({
38+
owner: context.repo.owner,
39+
repo: context.repo.repo,
40+
issue_number: context.issue.number,
41+
body: '❌ This PR is already closed/merged. Cannot auto-backport — please cherry-pick manually.',
42+
})
43+
core.setFailed('PR is already closed/merged; backport must be performed manually.')
44+
45+
- name: Parse target from comment
46+
id: parse
47+
env:
48+
BACKPORT_PREFIX: ${{ vars.BACKPORT_PREFIX || 'ncw-' }}
49+
COMMENT: ${{ github.event.comment.body }}
50+
run: |
51+
TARGET=$(echo "$COMMENT" | grep -oP "^/backport\s+\K${BACKPORT_PREFIX}[0-9]+")
52+
if [ -z "$TARGET" ]; then
53+
echo "Invalid /backport command. Expected: /backport ${BACKPORT_PREFIX}<N>"
54+
exit 1
55+
fi
56+
echo "target=$TARGET" >> $GITHUB_OUTPUT
57+
echo "base_branch=rc/$TARGET" >> $GITHUB_OUTPUT
58+
echo "backport_branch=backport/${{ github.event.issue.number }}/$TARGET" >> $GITHUB_OUTPUT
59+
60+
- name: Add label to PR
61+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
62+
with:
63+
github-token: ${{ secrets.COMMAND_BOT_PAT }}
64+
script: |
65+
const target = '${{ steps.parse.outputs.target }}'
66+
const label = `backport ${target}`
67+
68+
// Ensure label exists (create if missing)
69+
try {
70+
await github.rest.issues.getLabel({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
name: label,
74+
})
75+
} catch {
76+
await github.rest.issues.createLabel({
77+
owner: context.repo.owner,
78+
repo: context.repo.repo,
79+
name: label,
80+
color: '0075ca',
81+
description: `Backport to rc/${target}`,
82+
})
83+
}
84+
85+
await github.rest.issues.addLabels({
86+
owner: context.repo.owner,
87+
repo: context.repo.repo,
88+
issue_number: context.issue.number,
89+
labels: [label],
90+
})
91+
92+
- name: React with eyes (queued, waiting for merge)
93+
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
94+
with:
95+
token: ${{ secrets.COMMAND_BOT_PAT }}
96+
repository: ${{ github.event.repository.full_name }}
97+
comment-id: ${{ github.event.comment.id }}
98+
reactions: eyes
99+
100+
- name: React with thumbs down on failure
101+
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
102+
if: failure()
103+
with:
104+
token: ${{ secrets.COMMAND_BOT_PAT }}
105+
repository: ${{ github.event.repository.full_name }}
106+
comment-id: ${{ github.event.comment.id }}
107+
reactions: '-1'

0 commit comments

Comments
 (0)