Skip to content

Commit 55437a2

Browse files
authored
Merge pull request #1318 from supertokens/ci/backport-workflow-clean
ci: backport workflow
2 parents 73356bb + d5fccb4 commit 55437a2

1 file changed

Lines changed: 232 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+
# Backports one or more commits onto older release branches by cherry-pick and
2+
# opens a PR per target branch.
3+
#
4+
# Release lines in this repo are `major.minor` branches (e.g. 12.0, 11.4, ... 3.0);
5+
# `master` is the tip. Backporting = cherry-picking a landed commit onto those older
6+
# branches. This workflow is manual (workflow_dispatch) on purpose: the operator picks
7+
# the exact commits and targets, and nothing about the source PR signals that a
8+
# (possibly embargoed) backport is in flight.
9+
#
10+
# Notes on the two required PR checks (see pr-checks.yml):
11+
# - semantic-pull-request: the PR title must be a conventional-commit string. We take
12+
# it from the source commit's subject, which is already semantic, so this passes.
13+
# - changelog-enforcer: the backport adds a real `[X.Y.Z]` CHANGELOG section in a
14+
# separate release commit, so the enforcer passes on its own. The `skip_changelog`
15+
# input stays only as an escape hatch (default off).
16+
#
17+
# Per target branch the result is TWO stacked commits: the cherry-pick(s), then a single
18+
# `chore` release commit that bumps build.gradle and inserts the changelog entry. Only
19+
# patches are backported, so the version bump is always a patch increment of that line.
20+
#
21+
# Auth: uses secrets.GH_TOKEN (a PAT). GITHUB_TOKEN-created PRs do NOT trigger further
22+
# workflows, so backport PRs would open with no CI — the PAT avoids that.
23+
24+
name: Backport
25+
26+
on:
27+
workflow_dispatch:
28+
inputs:
29+
commits:
30+
description: "Commit SHA(s) to cherry-pick, in order (space- or comma-separated). Leave blank to use 'pr'."
31+
required: false
32+
type: string
33+
pr:
34+
description: "PR number to backport (resolves to its merge/squash commit). Ignored when 'commits' is set."
35+
required: false
36+
type: string
37+
target_branches:
38+
description: "Target release branches, comma-separated. e.g. 11.4,11.3,11.2"
39+
required: true
40+
type: string
41+
draft:
42+
description: "Open the backport PRs as drafts."
43+
type: boolean
44+
default: false
45+
skip_changelog:
46+
description: "Also attach the Skip-Changelog label (normally unneeded — the backport writes a real CHANGELOG entry)."
47+
type: boolean
48+
default: false
49+
50+
permissions:
51+
contents: write
52+
pull-requests: write
53+
54+
jobs:
55+
prepare:
56+
runs-on: ubuntu-latest
57+
outputs:
58+
targets: ${{ steps.parse.outputs.targets }}
59+
commits: ${{ steps.resolve.outputs.commits }}
60+
steps:
61+
- uses: actions/checkout@v4
62+
with:
63+
fetch-depth: 0
64+
token: ${{ secrets.GH_TOKEN }}
65+
66+
- id: parse
67+
name: Parse target branches into a JSON matrix
68+
run: |
69+
set -euo pipefail
70+
raw='${{ inputs.target_branches }}'
71+
json=$(printf '%s' "$raw" | tr ', ' '\n' | grep -v '^$' | jq -R . | jq -s -c .)
72+
if [ "$json" = "[]" ]; then
73+
echo "::error::No target branches provided."; exit 1
74+
fi
75+
echo "targets=$json" >> "$GITHUB_OUTPUT"
76+
echo "Targets: $json"
77+
78+
- id: resolve
79+
name: Resolve commits (explicit 'commits' or from 'pr')
80+
env:
81+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
82+
run: |
83+
set -euo pipefail
84+
if [ -n '${{ inputs.commits }}' ]; then
85+
commits=$(printf '%s' '${{ inputs.commits }}' | tr ',' ' ' | xargs)
86+
elif [ -n '${{ inputs.pr }}' ]; then
87+
# Prefer the squash/merge commit; fall back to the PR's individual commits.
88+
merge=$(gh pr view '${{ inputs.pr }}' --json mergeCommit -q '.mergeCommit.oid' 2>/dev/null || true)
89+
if [ -n "$merge" ] && [ "$merge" != "null" ]; then
90+
commits="$merge"
91+
else
92+
commits=$(gh pr view '${{ inputs.pr }}' --json commits -q '.commits[].oid' | xargs)
93+
fi
94+
else
95+
echo "::error::Provide either 'commits' or 'pr'."; exit 1
96+
fi
97+
if [ -z "$commits" ]; then echo "::error::Could not resolve any commits."; exit 1; fi
98+
echo "commits=$commits" >> "$GITHUB_OUTPUT"
99+
echo "Commits to cherry-pick (in order): $commits"
100+
101+
backport:
102+
needs: prepare
103+
runs-on: ubuntu-latest
104+
strategy:
105+
# One conflicting target must not abort the others.
106+
fail-fast: false
107+
matrix:
108+
target: ${{ fromJson(needs.prepare.outputs.targets) }}
109+
steps:
110+
- uses: actions/checkout@v4
111+
with:
112+
fetch-depth: 0
113+
token: ${{ secrets.GH_TOKEN }}
114+
115+
- name: Configure git identity
116+
run: |
117+
git config user.name "supertokens-backport-bot"
118+
git config user.email "backport-bot@supertokens.io"
119+
120+
- name: Cherry-pick onto ${{ matrix.target }}, bump patch version, open a PR
121+
env:
122+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
123+
TARGET: ${{ matrix.target }}
124+
COMMITS: ${{ needs.prepare.outputs.commits }}
125+
DRAFT: ${{ inputs.draft }}
126+
SKIP_CHANGELOG: ${{ inputs.skip_changelog }}
127+
ACTOR: ${{ github.actor }}
128+
run: |
129+
set -euo pipefail
130+
131+
# 1. The target branch must actually exist upstream.
132+
if ! git ls-remote --exit-code --heads origin "$TARGET" >/dev/null 2>&1; then
133+
echo "::error::Target branch '$TARGET' does not exist on origin."
134+
echo "### ❌ \`$TARGET\`: no such release branch" >> "$GITHUB_STEP_SUMMARY"
135+
exit 1
136+
fi
137+
git fetch --no-tags origin "$TARGET"
138+
139+
# 2. Fresh backport branch off the target tip (deterministic name -> re-runnable).
140+
first_sha=$(printf '%s' "$COMMITS" | awk '{print $1}')
141+
short=$(git rev-parse --short "$first_sha")
142+
bp_branch="backport/$TARGET/$short"
143+
git checkout -B "$bp_branch" "origin/$TARGET"
144+
145+
# 3. Cherry-pick the backported commit(s). -x records the source SHA.
146+
if ! git cherry-pick -x $COMMITS; then
147+
git cherry-pick --abort || true
148+
{
149+
echo "### ⚠️ Backport to \`$TARGET\` hit conflicts — resolve manually"
150+
echo ""
151+
echo '```bash'
152+
echo "git fetch origin $TARGET && git checkout -b $bp_branch origin/$TARGET"
153+
echo "git cherry-pick -x $COMMITS"
154+
echo "# resolve conflicts, bump the patch version + changelog by hand, then:"
155+
echo "git push origin $bp_branch"
156+
echo '```'
157+
} >> "$GITHUB_STEP_SUMMARY"
158+
echo "::error::Cherry-pick onto $TARGET conflicted; see the job summary."
159+
exit 1
160+
fi
161+
162+
# 4. Compute the next PATCH version from the target line's build.gradle.
163+
# Only patches are backported, so a patch increment is always correct.
164+
cur=$(grep -m1 -E '^version[[:space:]]*=' build.gradle | sed -E 's/.*"([^"]+)".*/\1/')
165+
base=${cur%%-*} # drop any -canary / pre-release suffix
166+
IFS='.' read -r MA MI PA <<< "$base"
167+
if [ "$MA.$MI" != "$TARGET" ]; then
168+
echo "::error::build.gradle version ($cur) is not on the $TARGET line; refusing to guess a version."
169+
exit 1
170+
fi
171+
new_ver="$MA.$MI.$((PA + 1))"
172+
today=$(date -u +%Y-%m-%d)
173+
echo "Next patch on $TARGET: $cur -> $new_ver"
174+
175+
# 5. Bump the first top-level `version = "..."` line in build.gradle.
176+
sed -i -E "0,/^version[[:space:]]*=.*/s//version = \"$new_ver\"/" build.gradle
177+
178+
# 6. Insert a CHANGELOG section above the newest existing versioned entry.
179+
# Bullets = backported commit subjects, minus the conventional-commit prefix.
180+
{
181+
echo "## [$new_ver] - $today"
182+
echo ""
183+
for sha in $COMMITS; do
184+
git log -1 --format=%s "$sha" | sed -E 's/^[a-z]+(\([^)]*\))?!?:[[:space:]]*//; s/^/- /'
185+
done
186+
echo ""
187+
} > /tmp/entry.md
188+
awk '
189+
!ins && /^## \[[0-9]/ { while ((getline l < "/tmp/entry.md") > 0) print l; close("/tmp/entry.md"); ins=1 }
190+
{ print }
191+
END { if (!ins) { while ((getline l < "/tmp/entry.md") > 0) print l } }
192+
' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
193+
194+
# 7. The version bump + changelog is a SEPARATE commit, stacked on the backport.
195+
git add build.gradle CHANGELOG.md
196+
git commit -m "chore: bump version to $new_ver and update changelog"
197+
198+
# 8. Push (backport commit[s] + the release commit).
199+
git push --force-with-lease origin "$bp_branch"
200+
201+
# 9. Open the PR. Title comes from the source subject (already semantic).
202+
subject=$(git log -1 --format=%s "$first_sha")
203+
labels="backport"
204+
[ "$SKIP_CHANGELOG" = "true" ] && labels="$labels,Skip-Changelog"
205+
# Best-effort: make sure the labels exist (ignore failures / insufficient perms).
206+
for l in $(printf '%s' "$labels" | tr ',' ' '); do
207+
gh label create "$l" >/dev/null 2>&1 || true
208+
done
209+
210+
cat > /tmp/backport-body.md <<EOF
211+
Automated backport of \`$COMMITS\` onto \`$TARGET\`, released as **$new_ver**.
212+
213+
- Cherry-picked with \`-x\` — each commit message carries its source SHA.
214+
- A separate \`chore\` commit bumps \`build.gradle\` to \`$new_ver\` and adds the \`[$new_ver]\` CHANGELOG section.
215+
216+
Triggered from **${{ github.workflow }}** by @$ACTOR.
217+
218+
> Reviewer: confirm the generated CHANGELOG bullets read well for the \`$TARGET\` line.
219+
EOF
220+
221+
draft_flag=""
222+
[ "$DRAFT" = "true" ] && draft_flag="--draft"
223+
224+
gh pr create \
225+
--base "$TARGET" \
226+
--head "$bp_branch" \
227+
--title "$subject (backport to $TARGET)" \
228+
--body-file /tmp/backport-body.md \
229+
--label "$labels" \
230+
$draft_flag
231+
232+
echo "### ✅ \`$TARGET\`: backport PR opened as $new_ver (\`$bp_branch\`)" >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)