-
Notifications
You must be signed in to change notification settings - Fork 0
252 lines (212 loc) · 10.9 KB
/
rebase-stack.yml
File metadata and controls
252 lines (212 loc) · 10.9 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
# Rebase Stacked PRs
#
# Problem:
# When using stacked PRs (main -> PR1 -> PR2 -> PR3), merging PR1 via
# squash or rebase causes GitHub to retarget PR2's base to main. However,
# PR2's branch still contains PR1's original commits, so its diff shows
# both PR1 and PR2 changes — a broken diff that confuses reviewers.
#
# Solution:
# This workflow triggers when any PR is merged and automatically:
# 1. Finds all open PRs whose base branch is the merged PR's head branch
# (i.e., the next PR in the stack).
# 2. Rebases each child PR onto the merged PR's base (e.g., main), using
# "git rebase --onto" to replay only the child's own commits.
# 3. Walks the full chain recursively — if PR2 is rebased, PR3 (based on
# PR2) is also rebased onto the new PR2, and so on to any depth.
# 4. Validates the diff is identical before and after rebase — if the
# rebase silently altered code, it refuses to force-push.
# 5. If a rebase hits conflicts, it leaves a comment with manual fix
# instructions and stops processing that chain.
# 6. Deletes the merged PR's head branch after all child PRs have been
# retargeted and rebased. If the chain failed, the branch is kept
# to avoid closing child PRs whose base was not yet updated.
#
# Why "rebase --onto" instead of "--fork-point":
# GitHub Actions runs on a fresh clone with no reflog, so --fork-point
# (which arh uses locally) cannot detect fork points. Instead, we use
# the merged PR's head SHA from the event payload as the explicit old
# base, achieving the same result.
#
# Example: main -> PR1(branch1, C1) -> PR2(branch2, C2) -> PR3(branch3, C3)
# PR1 merges into main:
# 1. rebase --onto origin/main <old-branch1-sha> branch2 (replays C2)
# 2. rebase --onto <new-branch2-sha> <old-branch2-sha> branch3 (replays C3)
# 3. Delete branch1
# Result: main -> PR2(branch2, C2') -> PR3(branch3, C3')
name: Rebase Stacked PRs
on:
pull_request:
types:
- closed
permissions:
checks: write
contents: write
pull-requests: write
jobs:
rebase-stack:
name: Rebase Stack
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Fetch full history so rebase --onto works correctly.
fetch-depth: 0
# Use a personal access token (stored as STACK_REBASE_TOKEN) so the
# force-push below is attributed to a user and triggers downstream
# workflows (CI). Pushes authenticated with the default GITHUB_TOKEN
# are intentionally ignored by GitHub's workflow trigger to prevent
# recursive runs, which would leave rebased PRs without a CI signal.
token: ${{ secrets.STACK_REBASE_TOKEN }}
- name: Rebase stacked PRs
env:
GH_TOKEN: ${{ secrets.STACK_REBASE_TOKEN }}
MERGED_HEAD: ${{ github.event.pull_request.head.ref }}
MERGED_BASE: ${{ github.event.pull_request.base.ref }}
MERGED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# rebase_chain walks the stack depth-first, rebasing each child PR
# onto its new base and recursing into grandchildren.
#
# It uses "git rebase --onto" to replay only a branch's OWN commits:
# git rebase --onto <new_base> <old_base_sha> <branch>
# This takes the commits between old_base_sha and branch tip, and
# replays them onto new_base — discarding the parent's commits that
# the child was carrying.
#
# Args:
# $1 - lookup_base: branch name to find child PRs (gh pr list --base)
# $2 - rebase_onto: SHA or ref to rebase onto
# $3 - old_base_sha: SHA of the old base tip; commits between this and
# the child branch tip are the child's own commits
# $4 - new_pr_base: branch name to set as the PR's new base in GitHub
rebase_chain() {
local lookup_base="$1"
local rebase_onto="$2"
local old_base_sha="$3"
local new_pr_base="$4"
# Find open PRs whose base branch matches the lookup branch.
local prs
prs=$(gh pr list \
--base "$lookup_base" \
--state open \
--json number,headRefName \
--jq '.[] | "\(.number) \(.headRefName)"')
if [ -z "$prs" ]; then
return 0
fi
while IFS=' ' read -r pr_number pr_branch; do
echo ""
echo "=== Rebasing PR #${pr_number} (${pr_branch}) ==="
echo " onto: ${rebase_onto}"
echo " old base SHA: ${old_base_sha}"
git fetch origin "$pr_branch"
git checkout -B "$pr_branch" "origin/$pr_branch"
# Save the pre-rebase tip. When we recurse into grandchildren,
# this becomes their old_base_sha (the boundary between this
# branch's commits and the grandchild's commits).
local old_child_tip
old_child_tip=$(git rev-parse HEAD)
# Capture the patch (code changes only) before rebase. After
# rebase we compare this to the new patch — a correct rebase
# must produce an identical diff. If it doesn't, the rebase
# silently altered code and we refuse to force-push.
local diff_before
diff_before=$(git diff "$old_base_sha"..HEAD)
# Replay only this branch's own commits onto the new base.
if ! git rebase --onto "$rebase_onto" "$old_base_sha" "$pr_branch" 2>&1; then
echo "::warning::Rebase failed for PR #${pr_number} (${pr_branch})."
git rebase --abort 2>/dev/null || true
# Leave instructions for manual resolution.
local comment_body
comment_body=$(cat <<EOF
:warning: **Automatic stack rebase failed**
This PR could not be automatically rebased after its base PR was merged. The rebase hit conflicts that need manual resolution.
**To fix manually:**
\`\`\`bash
git fetch origin
git checkout ${pr_branch}
git rebase --onto origin/${new_pr_base} ${old_base_sha} ${pr_branch}
# resolve conflicts, then:
git push --force-with-lease
\`\`\`
Then update this PR's base branch:
\`\`\`bash
gh pr edit ${pr_number} --base ${new_pr_base}
\`\`\`
EOF
)
gh pr comment "$pr_number" --body "$comment_body"
echo "::warning::Stopping chain at PR #${pr_number} due to conflicts."
return 1
fi
local new_child_tip
new_child_tip=$(git rev-parse HEAD)
echo " rebased: ${old_child_tip} -> ${new_child_tip}"
# Safety check: verify the rebase preserved the exact same code
# changes. The diff of the branch's own commits against its base
# must be identical before and after rebase. If not, the rebase
# altered code (e.g., bad conflict auto-resolution) and we refuse
# to force-push.
local diff_after
diff_after=$(git diff "$rebase_onto"..HEAD)
if [ "$diff_before" != "$diff_after" ]; then
echo "::error::Diff mismatch after rebase for PR #${pr_number} (${pr_branch})!"
echo " The rebase changed the code content. Refusing to force-push."
gh pr comment "$pr_number" --body ":stop_sign: **Automatic stack rebase aborted — diff mismatch**
The rebase of \`$pr_branch\` completed without conflicts, but the resulting code diff does not match the original. This means the rebase silently altered code content. The branch was **not** force-pushed.
Please rebase manually and verify the changes are correct."
return 1
fi
echo " diff validated: content unchanged after rebase"
# Push the rebased branch. --force-with-lease fails if someone
# else pushed to this branch concurrently, preventing data loss.
if ! git push --force-with-lease origin "$pr_branch" 2>&1; then
echo "::warning::Force push failed for PR #${pr_number} (${pr_branch})."
gh pr comment "$pr_number" --body ":warning: **Automatic stack rebase failed**
The rebase succeeded but force-push failed for \`$pr_branch\`. This may be due to a concurrent push. Please rebase manually."
return 1
fi
# Point the PR at the correct base branch in GitHub.
gh pr edit "$pr_number" --base "$new_pr_base"
echo " PR #${pr_number} base updated to '${new_pr_base}'."
# Recurse into grandchildren: PRs whose base is this child's
# branch. They need to rebase onto the NEW child tip (post-rebase),
# using the OLD child tip as their fork point. Their GitHub base
# stays as pr_branch since that branch still exists.
rebase_chain "$pr_branch" "$new_child_tip" "$old_child_tip" "$pr_branch" || return 1
done <<< "$prs"
return 0
}
echo "Merged PR: ${MERGED_HEAD} -> ${MERGED_BASE}"
echo "Merged head SHA: ${MERGED_HEAD_SHA}"
git fetch origin "$MERGED_BASE"
# Kick off the recursive rebase. Immediate children of the merged PR
# get rebased onto MERGED_BASE, using MERGED_HEAD_SHA as the old
# fork point (the tip of the now-merged branch before it was deleted).
# "|| rebase_result=$?" prevents set -e from aborting — we always
# want to clean up the merged branch regardless of rebase outcome.
rebase_result=0
rebase_chain \
"$MERGED_HEAD" \
"origin/$MERGED_BASE" \
"$MERGED_HEAD_SHA" \
"$MERGED_BASE" \
|| rebase_result=$?
# Delete the merged PR's head branch only if the rebase chain
# succeeded. If it failed, some child PRs may still have this
# branch as their base — deleting it would cause GitHub to close
# those child PRs.
echo ""
if [ "$rebase_result" -eq 0 ]; then
echo "Deleting merged branch: $MERGED_HEAD"
git push origin --delete "$MERGED_HEAD" 2>/dev/null || echo "Branch already deleted."
echo "=== All stacked PRs rebased successfully ==="
else
echo "Keeping merged branch '$MERGED_HEAD' to avoid closing child PRs whose base was not updated."
echo "=== Rebase chain stopped due to conflicts ==="
fi