Skip to content

Commit 782c8d9

Browse files
authored
[feature] Added reusable backport workflow #501
Automates cherry-picking fixes to stable branches via [backport X.Y] in commit messages or /backport X.Y comments, with conflict notification. Closes #501
1 parent 24e9775 commit 782c8d9

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

.github/workflows/backport.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Backport fixes to stable branch
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- main
8+
issue_comment:
9+
types: [created]
10+
11+
concurrency:
12+
group: backport-${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: false
14+
15+
permissions:
16+
contents: write
17+
pull-requests: write
18+
19+
jobs:
20+
backport-on-push:
21+
if: github.event_name == 'push'
22+
uses: ./.github/workflows/reusable-backport.yml
23+
with:
24+
commit_sha: ${{ github.sha }}
25+
secrets:
26+
app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
27+
private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}
28+
29+
backport-on-comment:
30+
if: >
31+
github.event_name == 'issue_comment' &&
32+
github.event.issue.pull_request &&
33+
github.event.issue.pull_request.merged_at != null &&
34+
github.event.issue.state == 'closed' &&
35+
contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) &&
36+
startsWith(github.event.comment.body, '/backport')
37+
uses: ./.github/workflows/reusable-backport.yml
38+
with:
39+
pr_number: ${{ github.event.issue.number }}
40+
comment_body: ${{ github.event.comment.body }}
41+
secrets:
42+
app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
43+
private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
name: Backport fixes to stable branch
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
commit_sha:
7+
required: false
8+
type: string
9+
default: ""
10+
pr_number:
11+
required: false
12+
type: number
13+
default: 0
14+
comment_body:
15+
required: false
16+
type: string
17+
default: ""
18+
secrets:
19+
app_id:
20+
required: true
21+
private_key:
22+
required: true
23+
24+
jobs:
25+
parse:
26+
runs-on: ubuntu-latest
27+
outputs:
28+
branches: ${{ steps.extract.outputs.branches }}
29+
sha: ${{ steps.extract.outputs.sha }}
30+
steps:
31+
- name: Extract backport targets
32+
id: extract
33+
env:
34+
GH_TOKEN: ${{ github.token }}
35+
COMMIT_SHA: ${{ inputs.commit_sha }}
36+
PR_NUMBER: ${{ inputs.pr_number }}
37+
COMMENT_BODY: ${{ inputs.comment_body }}
38+
REPO: ${{ github.repository }}
39+
run: |
40+
if [ -n "$COMMIT_SHA" ]; then
41+
SHA="$COMMIT_SHA"
42+
COMMIT_MSG=$(gh api "repos/$REPO/git/commits/$SHA" --jq '.message')
43+
BRANCHES=$(echo "$COMMIT_MSG" | sed -n 's/.*\[backport[:[:space:]]\+\([^]]*\)\].*/\1/p' | tr '\n' ',' | sed 's/,$//')
44+
else
45+
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state,mergeCommit)
46+
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
47+
if [ "$PR_STATE" != "MERGED" ]; then
48+
echo "PR #$PR_NUMBER is not merged, skipping"
49+
echo "branches=[]" >> $GITHUB_OUTPUT
50+
echo "sha=" >> $GITHUB_OUTPUT
51+
exit 0
52+
fi
53+
SHA=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
54+
BRANCHES=$(echo "$COMMENT_BODY" | sed -n 's|.*/backport[[:space:]]\+\([^[:space:]]*\).*|\1|p' | tr '\n' ',' | sed 's/,$//')
55+
fi
56+
57+
if [ -z "$BRANCHES" ]; then
58+
echo "branches=[]" >> $GITHUB_OUTPUT
59+
else
60+
echo "branches=$(echo "$BRANCHES" | tr ',' '\n' | jq -R . | jq -sc .)" >> $GITHUB_OUTPUT
61+
fi
62+
echo "sha=$SHA" >> $GITHUB_OUTPUT
63+
64+
backport:
65+
needs: parse
66+
if: needs.parse.outputs.branches != '' && needs.parse.outputs.branches != '[]' && needs.parse.outputs.sha != ''
67+
runs-on: ubuntu-latest
68+
strategy:
69+
fail-fast: false
70+
matrix:
71+
branch: ${{ fromJSON(needs.parse.outputs.branches) }}
72+
steps:
73+
- name: Generate GitHub App Token
74+
id: generate-token
75+
uses: actions/create-github-app-token@v1
76+
with:
77+
app-id: ${{ secrets.app_id }}
78+
private-key: ${{ secrets.private_key }}
79+
80+
- uses: actions/checkout@v4
81+
with:
82+
fetch-depth: 0
83+
token: ${{ steps.generate-token.outputs.token }}
84+
85+
- name: Configure Git
86+
run: |
87+
git config user.name 'OpenWISP Companion'
88+
git config user.email 'support@openwisp.io'
89+
90+
- name: Cherry-pick and create PR
91+
env:
92+
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
93+
SHA: ${{ needs.parse.outputs.sha }}
94+
BRANCH: ${{ matrix.branch }}
95+
REPO: ${{ github.repository }}
96+
run: |
97+
SHA="${{ needs.parse.outputs.sha }}"
98+
if [ -z "$SHA" ]; then
99+
echo "No SHA to cherry-pick, skipping"
100+
exit 0
101+
fi
102+
if ! git check-ref-format --branch "$BRANCH" > /dev/null 2>&1; then
103+
echo "::error::Invalid branch name '$BRANCH'"
104+
exit 1
105+
fi
106+
if ! git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
107+
echo "::error::Target branch '$BRANCH' does not exist"
108+
exit 1
109+
fi
110+
if ! PR_DATA=$(gh api "repos/$REPO/commits/$SHA/pulls" --jq '.[0] | {number, title}'); then
111+
echo "::warning::Could not fetch PR data for $SHA, proceeding with commit-based backport branch"
112+
PR_DATA="null"
113+
fi
114+
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number // empty')
115+
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title // "Fix from master"')
116+
if [ -z "$PR_NUMBER" ]; then
117+
BACKPORT_BRANCH="backport/commit-${SHA:0:7}-to-${BRANCH}"
118+
BODY="Backport of commit $SHA to \`$BRANCH\`."
119+
PR_TITLE="Backport commit ${SHA:0:7} to $BRANCH"
120+
else
121+
BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${BRANCH}"
122+
123+
BODY="Backport of #$PR_NUMBER to \`$BRANCH\`."
124+
EXISTING=$(gh pr list --base "$BRANCH" --state open --json number,headRefName \
125+
--jq ".[] | select(.headRefName | startswith(\"backport/${PR_NUMBER}-to-${BRANCH}-\")) | .number" | head -1)
126+
if [ -n "$EXISTING" ]; then
127+
echo "Backport PR #$EXISTING already exists"
128+
gh pr comment "$PR_NUMBER" --body "Backport to \`$BRANCH\` already exists: #$EXISTING"
129+
exit 0
130+
fi
131+
fi
132+
BACKPORT_BRANCH="${BACKPORT_BRANCH}-$(date +%s)"
133+
git checkout -b "$BACKPORT_BRANCH" "origin/$BRANCH"
134+
CHERRY_PICK_STATUS=0
135+
git cherry-pick -x "$SHA" || CHERRY_PICK_STATUS=$?
136+
if [ $CHERRY_PICK_STATUS -eq 0 ]; then
137+
if ! git push origin "$BACKPORT_BRANCH"; then
138+
echo "::error::Failed to push $BACKPORT_BRANCH"
139+
exit 1
140+
fi
141+
if ! gh pr create \
142+
--base "$BRANCH" \
143+
--head "$BACKPORT_BRANCH" \
144+
--title "[backport] #${PR_NUMBER}: $PR_TITLE (to $BRANCH)" \
145+
--body "$BODY"; then
146+
echo "::error::Failed to create PR"
147+
git push origin --delete "$BACKPORT_BRANCH" || true
148+
exit 1
149+
fi
150+
echo "## ✅ Backport Successful" >> $GITHUB_STEP_SUMMARY
151+
echo "- **Source**: #$PR_NUMBER" >> $GITHUB_STEP_SUMMARY
152+
echo "- **Target Branch**: $BRANCH" >> $GITHUB_STEP_SUMMARY
153+
echo "- **Backport Branch**: $BACKPORT_BRANCH" >> $GITHUB_STEP_SUMMARY
154+
elif git diff --cached --quiet; then
155+
echo "Commit $SHA appears to already be in $BRANCH (empty cherry-pick)"
156+
git cherry-pick --abort || true
157+
exit 0
158+
else
159+
git cherry-pick --abort || true
160+
{
161+
echo "❌ Cherry-pick to \`$BRANCH\` failed due to conflicts. Please backport manually:"
162+
echo ""
163+
echo "\`\`\`bash"
164+
echo "git fetch origin $BRANCH"
165+
echo "git checkout -b $BACKPORT_BRANCH origin/$BRANCH"
166+
echo "git cherry-pick -x $SHA"
167+
echo "# resolve conflicts"
168+
echo "git cherry-pick --continue"
169+
echo "git push origin $BACKPORT_BRANCH"
170+
echo "\`\`\`"
171+
} > /tmp/backport-comment.md
172+
[ -n "$PR_NUMBER" ] && gh pr comment "$PR_NUMBER" --body-file /tmp/backport-comment.md
173+
echo "## ❌ Backport Failed" >> $GITHUB_STEP_SUMMARY
174+
echo "- **Source**: #$PR_NUMBER" >> $GITHUB_STEP_SUMMARY
175+
echo "- **Target Branch**: $BRANCH" >> $GITHUB_STEP_SUMMARY
176+
echo "- **Error**: Cherry-pick failed due to conflicts" >> $GITHUB_STEP_SUMMARY
177+
exit 1
178+
fi

docs/developer/reusable-github-utils.rst

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,67 @@ example:
115115
git checkout $VERSION
116116
git reset --hard origin/master
117117
git push origin $VERSION --force-with-lease
118+
119+
Backport Fixes to Stable Branch
120+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
121+
122+
This re-usable workflow automates cherry-picking fixes from ``master`` or
123+
``main`` to stable release branches.
124+
125+
It supports two triggers:
126+
127+
- **Commit message**: Add ``[backport X.Y]`` or ``[backport: X.Y]`` to the
128+
squash merge commit body to automatically backport when merged to
129+
``master`` or ``main``.
130+
- **Comment**: Comment ``/backport X.Y`` on a merged PR (org members
131+
only).
132+
133+
If the cherry-pick fails due to conflicts, the bot comments on the PR with
134+
manual resolution steps. If the target branch does not exist or the PR is
135+
not yet merged, the workflow exits safely without failing.
136+
137+
.. code-block:: yaml
138+
139+
name: Backport fixes to stable branch
140+
141+
on:
142+
push:
143+
branches:
144+
- master
145+
- main
146+
issue_comment:
147+
types: [created]
148+
149+
concurrency:
150+
group: backport-${{ github.workflow }}-${{ github.ref }}
151+
cancel-in-progress: false
152+
153+
permissions:
154+
contents: write
155+
pull-requests: write
156+
157+
jobs:
158+
backport-on-push:
159+
if: github.event_name == 'push'
160+
uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master
161+
with:
162+
commit_sha: ${{ github.sha }}
163+
secrets:
164+
app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
165+
private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}
166+
167+
backport-on-comment:
168+
if: >
169+
github.event_name == 'issue_comment' &&
170+
github.event.issue.pull_request &&
171+
github.event.issue.pull_request.merged_at != null &&
172+
github.event.issue.state == 'closed' &&
173+
contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) &&
174+
startsWith(github.event.comment.body, '/backport')
175+
uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master
176+
with:
177+
pr_number: ${{ github.event.issue.number }}
178+
comment_body: ${{ github.event.comment.body }}
179+
secrets:
180+
app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
181+
private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

0 commit comments

Comments
 (0)