Skip to content

Commit 5539be8

Browse files
committed
ci(rebase-stack): skip native gh-stacks, re-enable for the rest
## Summary ### Why? PR #443 disabled this workflow outright (`if: false`) when the repo moved to GitHub's native stacked PRs. That was too blunt: native stacks and hand-rolled `arh` chains coexist here today. Stacks #470, #472 and #474 are native, while #460#461#462#463#476 is a plain chain whose every member reports `stack: null`. With the workflow off, merging any PR in a hand-rolled chain leaves the children still carrying their parent's commits — precisely the broken-diff problem this workflow was written to fix. GitHub owns the lifecycle of its own stacks: on a partial merge it rebases and retargets the surviving members itself, so the workflow must not touch those. It only needs to tell the two apart rather than give up on both. ### What? Re-enables the job and skips only the PRs GitHub actually owns, instead of switching the whole workflow off. A `detect_stack` helper resolves native-stack membership from `GET /repos/{owner}/{repo}/pulls/{n}`. It queries the REST API at run time rather than reading `github.event.pull_request.stack` off the webhook payload, so a PR added to a stack after the merge event was queued is still recognised. Membership survives merge — a merged member still reports its stack — which is what makes the lookup meaningful at this point in the lifecycle. The check is applied **per child PR**, not to the merged PR. GitHub only ever rebases a stack's own members; a PR that targets a member's head branch without joining the stack is invisible to that machinery. Skipping the whole chain whenever the merged PR happened to be a stack member would therefore strand such a PR with exactly the broken diff this workflow exists to prevent. `rebase_chain` always runs; each child that is a stack member is skipped and not recursed into, while its siblings rebase normally. Membership of the merged PR is logged for context and gates nothing. Presence is tested on the `stack` object itself rather than on `stack.number`. Probing a sub-field means a stack object arriving without that field reads as "standalone" and gets force-pushed — the unsafe direction to fail in. The number is used only for logging. An unreadable response still falls back to standalone, since the Stacks API 404s when the feature is not enabled for a repo, which is exactly when the rebase is wanted. `cleanup_orphaned_merged_branches` still runs on every merge, including those where every child turned out to be GitHub's to rebase. This workflow therefore remains the sole owner of head-branch deletion, and native-stack head branches are reaped here too, each on the first merge after GitHub has retargeted its successors off it. This depends on "Automatically delete head branches" being OFF, as the header has always documented — otherwise GitHub retargets children to `main` before the job runs and the non-native path silently no-ops on a green job. The setting has been turned off on the repo. ## Test Plan ✅ YAML parses; job `if` and step `env` verified after the edit. ✅ `bash -n` clean on the extracted `run:` block under bash 5.2, the version Actions runners use. Note for future edits: macOS `/bin/bash` 3.2 reports a spurious `syntax error near ';;'` on this script — it cannot parse the heredoc nested in `$( )`, so it blames a line far from the real construct. ✅ `actionlint` v1.7.7 — clean. ✅ `zizmor` v1.25.2 (`--no-online-audits`, the version CI pins) — no findings, 2 ignored and 5 suppressed, confirming the existing `.github/zizmor.yml` exceptions still cover the file. ✅ `yamlfmt` v0.16.0 `-lint` — clean. ✅ `detect_stack` exercised against live PRs in this repo: | PR | `.stack` | Result | | --- | --- | --- | | #467 | stack #470, position 4/6, merged | native → skipped | | #468 | stack #470, position 5/6, open | native → skipped | | #404 | stack #472 | native → skipped | | #457 | stack #475, size 1 | native → skipped | | #443 | `null` | standalone → rebased | | #460 | `null` | standalone → rebased | | #999999 | HTTP 404 | warns, treated as standalone, no `set -euo pipefail` abort | ✅ The sub-field trap verified directly: a synthetic `{"stack":{"id":51488,"position":4,"size":6}}` with no `number` reads as standalone under `.stack.number // empty`, and as in-a-stack under the expression shipped here. ✅ Observed reference case — the merge of #467 (position 4 of stack #470). GitHub retargeted #468 from `preetam/stovepipe-buildsignal-outcome` to `main` and left #469 on #468's branch, confirming both that GitHub handles its own members and that a merged member retains its `stack` object. The rebase itself can only be exercised post-merge. On the next merge of a hand-rolled chain (the runway series is the live case) watch for `=== Stack rebase complete ===` and a child retargeted with only its own commits; on a native-stack merge watch for each member child logging `skipped: belongs to native stack #N`, followed by the branch sweep. The first sweep will also reap `preetam/stovepipe-buildsignal-outcome`, which is currently orphaned — it merged after auto-delete was turned off and has no open dependents.
1 parent 0223d89 commit 5539be8

1 file changed

Lines changed: 111 additions & 14 deletions

File tree

.github/workflows/rebase-stack.yml

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,58 @@
88
#
99
# Solution:
1010
# This workflow triggers when any PR is merged and automatically:
11-
# 1. Finds all open PRs whose base branch is the merged PR's head branch
11+
# 1. Checks each candidate PR for GitHub *native* stack membership (the
12+
# `stack` object on the pull request resource — see NATIVE STACKS below)
13+
# and leaves those to GitHub, which rebases and retargets the members of
14+
# its own stacks. Everything else — standalone PRs, and hand-rolled chains
15+
# where each PR simply targets the one below it, as `arh` produces — is
16+
# rebased here.
17+
# 2. Finds all open PRs whose base branch is the merged PR's head branch
1218
# (i.e., the next PR in the stack).
13-
# 2. Rebases each child PR onto the merged PR's base (e.g., main), using
14-
# "git rebase --onto" to replay only the child's own commits.
15-
# 3. Walks the full chain recursively — if PR2 is rebased, PR3 (based on
19+
# 3. Rebases each child PR onto the merged PR's base (e.g., main), using
20+
# "git rebase --onto" to replay only the child's own commits. A child that
21+
# is itself a native-stack member is skipped, for the same reason as (1).
22+
# 4. Walks the full chain recursively — if PR2 is rebased, PR3 (based on
1623
# PR2) is also rebased onto the new PR2, and so on to any depth.
17-
# 4. Validates the diff is identical before and after rebase — if the
24+
# 5. Validates the diff is identical before and after rebase — if the
1825
# rebase silently altered code, it refuses to force-push.
19-
# 5. If a rebase hits conflicts, it leaves a comment with manual fix
26+
# 6. If a rebase hits conflicts, it leaves a comment with manual fix
2027
# instructions and stops processing that chain.
21-
# 6. Sweeps merged head branches and deletes any that no open PR still
28+
# 7. Sweeps merged head branches and deletes any that no open PR still
2229
# references (as base or head). This deletes the just-merged branch once
2330
# its children are retargeted, keeps it when an immediate child rebase
2431
# failed (that child still bases on it), and — because the sweep runs on
2532
# every merge — also reaps branches stranded by an earlier conflicted run
2633
# after their children were manually rebased and retargeted.
2734
#
35+
# NATIVE STACKS (github/gh-stack):
36+
# A pull request that is part of a GitHub native stack carries a non-null
37+
# `stack` object (id, number, size, position, base). GitHub owns those PRs:
38+
# on a partial merge it rebases and retargets the remaining MEMBERS of the
39+
# stack itself. Rebasing them here would race that machinery and force-push
40+
# over its work, so they are skipped.
41+
#
42+
# The check is applied per CHILD PR, not to the merged PR. GitHub only ever
43+
# touches a stack's own members; a PR that targets a member's head branch
44+
# without joining the stack is invisible to it. Skipping the whole chain just
45+
# because the merged PR happened to be a stack member would strand such a PR
46+
# with exactly the broken diff this workflow exists to prevent — so the chain
47+
# always runs and each child is judged on its own membership. Membership of
48+
# the merged PR is logged for context and gates nothing.
49+
#
50+
# Membership survives merge (a merged member still reports its stack), which
51+
# is what makes the lookup meaningful at this point in the lifecycle. It hits
52+
# the REST API at run time rather than reading the webhook payload, so a PR
53+
# added to a stack after the merge event was queued is still recognised.
54+
#
55+
# Presence is tested on the `stack` object itself, not on a sub-field such as
56+
# `stack.number`: a stack object arriving without the field it was probed for
57+
# would read as "standalone" and get force-pushed. An unreadable response is
58+
# deliberately treated as standalone — the Stacks API 404s when the feature
59+
# is not enabled for the repo, and that is precisely when the rebase is
60+
# wanted — but that fallback must be reached only by a genuinely absent
61+
# stack, never by an unexpected shape.
62+
#
2863
# Why "rebase --onto" instead of "--fork-point":
2964
# GitHub Actions runs on a fresh clone with no reflog, so --fork-point
3065
# (which arh uses locally) cannot detect fork points. Instead, we use
@@ -48,6 +83,11 @@
4883
# the branch and the child bases untouched, so this job can find the
4984
# children, rebase them, retarget their bases, and finally delete the merged
5085
# branch itself — for both stacked and non-stacked PRs.
86+
#
87+
# This holds for native-stack merges too. The sweep runs on EVERY merge,
88+
# including those where every child turned out to be GitHub's to rebase, so
89+
# native-stack head branches are reaped here as well — each one on the first
90+
# merge after GitHub has retargeted its successors off it.
5191

5292
name: Rebase Stacked PRs
5393

@@ -82,10 +122,11 @@ permissions:
82122
jobs:
83123
rebase-stack:
84124
name: Rebase Stack
85-
# DISABLED: this workflow is turned off in favor of GitHub's native stacked
86-
# PR support. The file is kept (not removed) so it can be re-enabled by
87-
# restoring the condition below to `github.event.pull_request.merged == true`.
88-
if: false && github.event.pull_request.merged == true
125+
# Runs on every merge. Native-stack membership is checked per child PR
126+
# inside the job, not here: a single merge can have both stack members
127+
# (GitHub's to rebase) and children outside the stack (ours), and the
128+
# branch sweep has to run either way.
129+
if: github.event.pull_request.merged == true
89130
runs-on: ubuntu-latest
90131
# Scope the privileged PAT to a protected Environment restricted to `main`
91132
# so the secret cannot be used from any other ref/context.
@@ -105,6 +146,7 @@ jobs:
105146
- name: Rebase stacked PRs
106147
env:
107148
GH_TOKEN: ${{ secrets.STACK_REBASE_TOKEN }}
149+
MERGED_PR_NUMBER: ${{ github.event.pull_request.number }}
108150
MERGED_HEAD: ${{ github.event.pull_request.head.ref }}
109151
MERGED_BASE: ${{ github.event.pull_request.base.ref }}
110152
MERGED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -114,6 +156,36 @@ jobs:
114156
git config user.name "github-actions[bot]"
115157
git config user.email "github-actions[bot]@users.noreply.github.com"
116158
159+
# detect_stack sets STACK_NUMBER to the GitHub native stack number the
160+
# given PR belongs to, or "" when it is standalone. GitHub rebases and
161+
# retargets the members of its own stacks, so a non-empty STACK_NUMBER
162+
# means "hands off — this PR is not ours to rebase".
163+
#
164+
# Membership is decided by `.stack` being non-null rather than by
165+
# reading `.stack.number`, so a stack object that lacked the probed
166+
# field could not read as standalone and get force-pushed; the number
167+
# is only ever used for logging, hence the "?" fallback.
168+
#
169+
# An unreadable response is deliberately treated as standalone: the
170+
# Stacks API returns 404 when the feature is not enabled for the repo,
171+
# and that is precisely the case where the rebase below is wanted.
172+
# The result is written to a global instead of stdout so the warning
173+
# cannot end up captured as the value by the caller.
174+
STACK_NUMBER=""
175+
detect_stack() {
176+
local pr_number="$1"
177+
local out
178+
STACK_NUMBER=""
179+
180+
if ! out=$(gh api "repos/{owner}/{repo}/pulls/${pr_number}" \
181+
--jq 'if .stack == null then "" else "\(.stack.number // "?")" end' 2>&1); then
182+
echo "::warning::Could not read stack membership for PR #${pr_number}; treating it as standalone. Details: ${out}"
183+
return 0
184+
fi
185+
186+
STACK_NUMBER="$out"
187+
}
188+
117189
# rebase_chain walks the stack depth-first, rebasing each child PR
118190
# onto its new base and recursing into grandchildren.
119191
#
@@ -150,8 +222,19 @@ jobs:
150222
151223
while IFS=' ' read -r pr_number pr_branch; do
152224
echo ""
153-
echo "=== Rebasing PR #${pr_number} (${pr_branch}) ==="
154-
echo " onto: ${rebase_onto}"
225+
echo "=== PR #${pr_number} (${pr_branch}) ==="
226+
227+
# A child that belongs to a native stack is rebased and retargeted
228+
# by GitHub itself. Skip it — and do NOT recurse into it, since
229+
# GitHub walks the rest of that stack. Siblings are unaffected, so
230+
# this is a `continue`, not a failure.
231+
detect_stack "$pr_number"
232+
if [ -n "$STACK_NUMBER" ]; then
233+
echo " skipped: belongs to native stack #${STACK_NUMBER}; GitHub owns its rebase."
234+
continue
235+
fi
236+
237+
echo " rebasing onto: ${rebase_onto}"
155238
echo " old base SHA: ${old_base_sha}"
156239
157240
git fetch origin "$pr_branch"
@@ -322,11 +405,22 @@ jobs:
322405
return 0
323406
}
324407
325-
echo "Merged PR: ${MERGED_HEAD} -> ${MERGED_BASE}"
408+
echo "Merged PR: #${MERGED_PR_NUMBER} ${MERGED_HEAD} -> ${MERGED_BASE}"
326409
echo "Merged head SHA: ${MERGED_HEAD_SHA}"
327410
328411
git fetch origin "$MERGED_BASE"
329412
413+
# Note whether the merged PR was itself a native-stack member. This is
414+
# context for the log and nothing more: GitHub retargets that stack's
415+
# own members, but any child that merely targets this branch without
416+
# being in the stack is invisible to GitHub and still ours to fix, so
417+
# the chain below runs regardless and each child is judged on its own
418+
# membership.
419+
detect_stack "$MERGED_PR_NUMBER"
420+
if [ -n "$STACK_NUMBER" ]; then
421+
echo "PR #${MERGED_PR_NUMBER} belongs to native stack #${STACK_NUMBER}; GitHub retargets its fellow members. Any child outside that stack is still rebased below."
422+
fi
423+
330424
# Kick off the recursive rebase. Immediate children of the merged PR
331425
# get rebased onto MERGED_BASE, using MERGED_HEAD_SHA as the old
332426
# fork point (the tip of the now-merged branch before it was deleted).
@@ -352,4 +446,7 @@ jobs:
352446
# deletes MERGED_HEAD on a clean run, KEEPS it when an immediate child
353447
# rebase failed (that child still bases on it), and cleans up branches
354448
# stranded by earlier conflicted runs once their children were fixed.
449+
# For a native-stack merge it is likewise conservative: if GitHub has
450+
# not retargeted the successors off MERGED_HEAD yet, the branch is kept
451+
# and reaped by the sweep on a later merge.
355452
cleanup_orphaned_merged_branches

0 commit comments

Comments
 (0)