-
Notifications
You must be signed in to change notification settings - Fork 279
302 lines (277 loc) · 13.4 KB
/
Copy pathbackport-on-merge.yml
File metadata and controls
302 lines (277 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# When a PR is merged into master, open backport PRs for each matching label.
#
# Labels must be named backport-yy.mm: two-digit year and two-digit month (date-based
# release line), for example backport-26.02 -> stable/v26.02 (February 2026 line).
# Tertiary / patch releases are tags from that stable line, not separate stable branch names.
#
# Multiple backport-* labels create one backport PR per target branch.
# The new PR is assigned to the merged PR author (PR opener).
name: Backport on merge to master
on:
pull_request:
types: [closed]
branches:
- master
permissions:
contents: write
pull-requests: write
concurrency:
group: backport-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
prepare:
name: Resolve backport targets
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
outputs:
targets: ${{ steps.resolve.outputs.targets }}
merge_sha: ${{ github.event.pull_request.merge_commit_sha }}
pr_number: ${{ github.event.pull_request.number }}
pr_url: ${{ github.event.pull_request.html_url }}
assignee: ${{ github.event.pull_request.user.login }}
steps:
- name: Collect stable branches from labels
id: resolve
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
pr="${{ github.event.pull_request.number }}"
mapfile -t labels < <(gh api "repos/${repo}/pulls/${pr}" --jq '.labels[].name')
branches=()
for label in "${labels[@]}"; do
if [[ "$label" =~ ^backport- ]]; then
if [[ "$label" =~ ^backport-([0-9]{2}\.(0[1-9]|1[0-2]))$ ]]; then
ver="${BASH_REMATCH[1]}"
branches+=("stable/v${ver}")
else
echo "::error::Invalid backport label '${label}'. Use backport-yy.mm (yy=two-digit year, mm=01-12), e.g. backport-26.02 -> stable/v26.02."
exit 1
fi
fi
done
if [[ ${#branches[@]} -eq 0 ]]; then
echo "No backport-* labels; skipping."
echo 'targets=[]' >> "$GITHUB_OUTPUT"
exit 0
fi
# Deduplicate while preserving order
declare -A seen
unique=()
for b in "${branches[@]}"; do
if [[ -z "${seen[$b]:-}" ]]; then
seen[$b]=1
unique+=("$b")
fi
done
printf 'targets=%s\n' "$(printf '%s\n' "${unique[@]}" | jq -R . | jq -s -c .)" >> "$GITHUB_OUTPUT"
backport:
name: Backport to ${{ matrix.stable_branch }}
needs: prepare
if: needs.prepare.outputs.targets != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
stable_branch: ${{ fromJson(needs.prepare.outputs.targets) }}
steps:
- name: Skip if backport PR already exists
id: skip
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
orig="${{ needs.prepare.outputs.pr_number }}"
base="${{ matrix.stable_branch }}"
head_branch="backport/pr-${orig}/${base}"
any_n=$(gh pr list --repo "$repo" --head "$head_branch" --base "$base" --state all --json number --jq 'length')
if [[ "$any_n" -gt 0 ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::notice::A PR already exists for head \`${head_branch}\` into \`${base}\` (any state, count=${any_n}); skipping."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Verify target branch exists
id: verify
if: steps.skip.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
branch="${{ matrix.stable_branch }}"
enc_branch=$(printf '%s' "$branch" | sed 's|/|%2F|g')
if ! gh api "repos/${repo}/branches/${enc_branch}" --silent 2>/dev/null; then
echo "::error::Branch '${branch}' does not exist on ${repo}. Create it or fix the backport-yy.mm label (e.g. backport-26.02 -> stable/v26.02)."
exit 1
fi
- uses: actions/checkout@v5
if: steps.skip.outputs.skip != 'true'
with:
ref: master
fetch-depth: 0
token: ${{ github.token }}
- name: Configure git author for cherry-picks
if: steps.skip.outputs.skip != 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Cherry-pick merged change onto stable branch
id: cherry
if: steps.skip.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ needs.prepare.outputs.merge_sha }}
PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
stable="${{ matrix.stable_branch }}"
head_branch="backport/pr-${PR_NUMBER}/${stable}"
# Full history + merge tip so ancestry checks and cherry-picks have the objects they need.
git fetch origin master
git fetch origin "$MERGE_SHA" || true
git fetch origin "$stable"
git checkout -B "$head_branch" "origin/${stable}"
# Two parents => true merge commit on master. Pick the first parent as mainline (-m 1).
parents=$(git show --no-patch --format=%P "$MERGE_SHA")
set -- $parents
if [[ $# -ge 2 ]]; then
echo "Merge commit detected; cherry-picking with mainline parent (-m 1)."
git cherry-pick -m 1 "$MERGE_SHA"
else
# Single parent: only "squash" or "rebase" style merges (no merge commit).
# Never use github.event.pull_request.base.sha for commit ranges on pull_request closed:
# base.sha is the base branch HEAD in the webhook, often post-merge, so ranges go empty or wrong.
# Never infer rebase chains with MERGE_SHA~N..MERGE_SHA using only PR commit count: after squash,
# the API still lists many pre-squash commits but master has one new commit; MERGE~N would grab
# N unrelated commits on master.
# PR commit OIDs from the API: since 2020-07, GitHub lists commits in chronological order along the
# head branch (oldest first). Cherry-pick must replay in that same order; do not reverse.
# api_n counts commits GitHub lists on the PR (pre-squash branch history), not "commits on master after squash".
if ! commits_json=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json commits); then
echo "::error::Could not list commits for PR #${PR_NUMBER} from GitHub API."
exit 1
fi
oids_raw=$(jq -r '.commits[].oid' <<<"$commits_json")
# Command substitution + mapfile on an empty string would yield one spurious empty element;
# empty jq output is valid (treat as no PR commits from API).
if [[ -n "$oids_raw" ]]; then
mapfile -t oids <<< "$oids_raw"
else
oids=()
fi
# Ensure each OID exists locally (some PR SHAs are not reachable from master until fetched).
for oid in "${oids[@]}"; do
git fetch origin "$oid" 2>/dev/null || true
done
# How many commit OIDs GitHub returned (length of oids); drives the branches below.
api_n=${#oids[@]}
# Empty commits list from the API: treat like unknown; replay MERGE_SHA (GitHub's recorded merge result).
if [[ "$api_n" -eq 0 ]]; then
echo "::notice::No PR commits from API; cherry-picking merge commit only."
git cherry-pick "$MERGE_SHA"
# Exactly one PR commit in the API: no multi-commit ancestry decision needed; MERGE_SHA is the
# replay unit for both squash and single-commit rebase (the single result commit on master).
elif [[ "$api_n" -eq 1 ]]; then
echo "Single-parent merge with one PR commit; cherry-picking merge commit onto ${stable}."
git cherry-pick "$MERGE_SHA"
else
# Multi-commit PR: decide squash vs rebase using git ancestry, not api_n alone.
anc=()
for oid in "${oids[@]}"; do
# Squash: old PR OIDs are not ancestors of MERGE_SHA. Rebase: rebased OIDs on master are.
if git cat-file -e "${oid}^{commit}" 2>/dev/null && git merge-base --is-ancestor "$oid" "$MERGE_SHA" 2>/dev/null; then
anc+=("$oid")
fi
done
if [[ ${#anc[@]} -eq 0 ]]; then
# Typical squash merge, or objects missing: one cherry-pick of the squashed result is correct/safe.
echo "::notice::No PR commit SHAs are ancestors of ${MERGE_SHA} (squash merge or commits not in fetched history). Cherry-picking merge commit only."
git cherry-pick "$MERGE_SHA"
elif [[ ${#anc[@]} -eq "$api_n" ]]; then
# Every listed PR commit is on the history to MERGE_SHA: replay them in order (rebase-and-merge).
echo "All ${api_n} PR commit(s) are ancestors of ${MERGE_SHA}; cherry-picking in chronological order."
for oid in "${oids[@]}"; do
git cherry-pick "$oid"
done
else
# Partial ancestry (odd API vs repo state): do not guess; pick merge SHA only.
echo "::notice::Only ${#anc[@]} of ${api_n} PR commits are ancestors of ${MERGE_SHA}; ambiguous. Cherry-picking merge commit only."
git cherry-pick "$MERGE_SHA"
fi
fi
fi
- name: Push backport branch
if: steps.skip.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
head_branch="backport/pr-${{ needs.prepare.outputs.pr_number }}/${{ matrix.stable_branch }}"
remote_ref="refs/heads/${head_branch}"
remote_sha="$(git ls-remote --heads origin "$head_branch" | awk '{print $1}')"
if [[ -n "$remote_sha" ]]; then
echo "Remote branch ${head_branch} already exists at ${remote_sha}; updating with --force-with-lease."
git push --force-with-lease="${remote_ref}:${remote_sha}" -u origin "HEAD:${remote_ref}"
else
git push -u origin "HEAD:${remote_ref}"
fi
- name: Open backport pull request
if: steps.skip.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
orig="${{ needs.prepare.outputs.pr_number }}"
title=$(gh pr view "$orig" --repo "$repo" --json title --jq .title)
url="${{ needs.prepare.outputs.pr_url }}"
assignee="${{ needs.prepare.outputs.assignee }}"
base="${{ matrix.stable_branch }}"
head_branch="backport/pr-${orig}/${base}"
merge_sha="${{ needs.prepare.outputs.merge_sha }}"
body_file=$(mktemp)
{
printf '%s\n\n' "Automated backport of ${url}."
printf '%s\n' "- **Original PR:** #${orig}"
printf '%s\n' "- **Merge commit:** \`${merge_sha}\`"
printf '%s\n' "- **Target:** \`${base}\`"
printf '\n%s\n' "If this cherry-pick needs manual fixes, push to \`${head_branch}\`."
} >"$body_file"
# gh pr create does not support --json/--jq (cli/cli#12622); parse number from URL.
pr_url=$(gh pr create \
--repo "$repo" \
--base "$base" \
--head "$head_branch" \
--title "[Backport ${base}] ${title}" \
--body-file "$body_file")
pr_url="${pr_url//$'\r'/}"
pr_url="${pr_url//$'\n'/}"
if [[ "$pr_url" =~ /pull/([0-9]+) ]]; then
new_num="${BASH_REMATCH[1]}"
else
echo "::error::Could not parse PR number from gh pr create output: ${pr_url}" >&2
exit 1
fi
echo "Opened backport PR #${new_num}"
if ! gh pr edit "$new_num" --repo "$repo" --add-assignee "$assignee" 2>/dev/null; then
echo "::notice::Could not assign @${assignee}; add them manually on the backport PR."
fi
- name: Comment on original PR when cherry-pick fails
if: failure() && steps.skip.outputs.skip != 'true' && steps.cherry.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
orig="${{ needs.prepare.outputs.pr_number }}"
base="${{ matrix.stable_branch }}"
head_branch="backport/pr-${orig}/${base}"
body=$(printf '%s\n\n%s\n\n%s' \
":warning: **Backport bot failed** for \`${base}\` (cherry-pick conflict or error)." \
"The backport branch is usually **not** pushed until the cherry-pick succeeds; there may be no remote branch yet." \
"Create branch \`${head_branch}\` from \`${base}\`, cherry-pick the merge commit, and open a PR—or resolve conflicts and run git push -u origin ${head_branch} if that branch already exists on the remote.")
gh pr comment "$orig" --repo "$repo" --body "$body"