Skip to content

Commit 4cbd706

Browse files
authored
Add GitHub Action for stable backports on merge to master
When a PR merges to master with labels like backport-26.02, the workflow cherry-picks the merged change onto stable/v26.02, pushes a branch, opens a PR, and assigns the original PR author.
1 parent f183f04 commit 4cbd706

2 files changed

Lines changed: 366 additions & 3 deletions

File tree

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

.github/workflows/github-actions.yml

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ jobs:
3030
# Case-insensitive matches for:
3131
# 1. Jira references (trid-12345)
3232
# 2. PR numbers (#1234)
33-
# 3. Squashed commit messages (* message)
33+
# 3. GitHub squash-merge footer lines: "* ... (7-40 hex sha)" — not plain bullets like "* First pass"
3434
run: |
35-
git log --format=%s -n 1 | \
36-
perl -ne 'die "Base ref has invalid commit message" if (/(?:trid|astractl)(?: |-)\d+/ig || /#\d+/ig || /\* \w/ig)'
35+
git log --format=%B -n 1 | \
36+
perl -ne 'die "Base ref has invalid commit message" if (/(?:trid|astractl)(?: |-)\d+/ig || /#\d+/ig || /^\* .*\([0-9a-f]{7,40}\)\s*$/i)'
3737
golangci:
3838
name: linters
3939
runs-on: ubuntu-latest
@@ -97,6 +97,19 @@ jobs:
9797
mkdir ${{ runner.temp }}\${{ runner.os }}-coverage-binary.out
9898
go test -v ./... -covermode=count -- -test.gocoverdir=${{ runner.temp }}\${{ runner.os }}-coverage-binary.out
9999
go tool covdata textfmt -i=${{ runner.temp }}\${{ runner.os }}-coverage-binary.out -o ${{ runner.os }}-coverage.out
100+
- if: runner.os == 'Linux'
101+
name: Free Disk Space (Ubuntu)
102+
uses: jlumbroso/free-disk-space@v1.3.1
103+
with:
104+
# this might remove tools that are actually needed,
105+
# if set to "true" but frees about 6 GB
106+
tool-cache: false
107+
android: true
108+
dotnet: true
109+
haskell: true
110+
large-packages: true
111+
docker-images: false
112+
swap-storage: true
100113
- if: runner.os != 'Windows'
101114
name: Run the tests (not on Windows)
102115
run: |
@@ -200,6 +213,54 @@ jobs:
200213
output: both
201214
thresholds: '75 100'
202215

216+
- name: Ensure coverage report is writable
217+
run: |
218+
if [[ -f code-coverage-results.md ]] && [[ ! -w code-coverage-results.md ]]; then
219+
sudo chown "$(id -un):$(id -gn)" code-coverage-results.md
220+
fi
221+
222+
- name: Wrap full coverage report in collapsible <details>
223+
run: |
224+
set -euo pipefail
225+
if [[ ! -f code-coverage-results.md ]]; then
226+
echo "::error::code-coverage-results.md not found; cannot wrap coverage output."
227+
exit 1
228+
fi
229+
python3 << 'PY'
230+
import html
231+
import re
232+
from pathlib import Path
233+
234+
path = Path("code-coverage-results.md")
235+
text = path.read_text(encoding="utf-8").rstrip()
236+
237+
def line_rate_pct(src: str):
238+
m = re.search(r"Code%20Coverage-(\d+(?:\.\d+)?)%25", src)
239+
if m:
240+
return m.group(1) + "%"
241+
m = re.search(
242+
r"\*\*Summary\*\*\s*\|\s*\*\*(\d+(?:\.\d+)?)%", src, re.I
243+
)
244+
if m:
245+
return m.group(1) + "%"
246+
return None
247+
248+
pct = line_rate_pct(text)
249+
if pct:
250+
summary = f"Code coverage report ({pct} line rate) - click to expand"
251+
else:
252+
summary = "Code coverage report - click to expand"
253+
254+
wrapped = (
255+
"<details>\n<summary>"
256+
+ html.escape(summary)
257+
+ "</summary>\n\n"
258+
+ text
259+
+ "\n</details>\n"
260+
)
261+
path.write_text(wrapped, encoding="utf-8")
262+
PY
263+
203264
- name: Add coverage PR comment
204265
uses: marocchino/sticky-pull-request-comment@v2
205266
if: github.event_name == 'pull_request'

0 commit comments

Comments
 (0)