Contents
GitHub made stacked pull requests generally available on 6 October 2026, after a public preview that began on 30 July. The pitch is review: split a large change into ordered layers, review each on its own, merge them together.
Since August I have merged several stacks in public cronai-labs repositories. Three things happened after I pressed merge that the announcements cover in a sentence or two at most: the stack landed as one push, CI and the release saw only the tip, and a linked branch closed an issue that no keyword named. Everything below is public; run the git commands in a clone of the repository they name.
One push, not six
In August I merged a six-PR stack in schriftsatz, a Markdown-to-PDF tool, in one go. The repository allows squash merges only. All six squash commits landed on main in a single ref update: the events API, queried right after the merge, showed one PushEvent, 3dc75f3..67bdc39 (recorded in the release workflow).
The query, for your own repository:
gh api repos/OWNER/REPO/events --paginate \
--jq '.[] | select(.type=="PushEvent" and .payload.ref=="refs/heads/main")
| "\(.created_at) \(.payload.before[0:7])..\(.payload.head[0:7])"'
The events API returns at most 30 days and 300 events, so the same query returns nothing for August now. The Actions history still shows the consequence:
# in a clone: git clone https://github.com/cronai-labs/schriftsatz && cd schriftsatz
for c in $(git rev-list 3dc75f3..67bdc39); do
echo "${c:0:7} $(gh api "repos/cronai-labs/schriftsatz/actions/runs?head_sha=$c" --jq .total_count)"
done
# 67bdc39 3 <- CI, CodeQL, Release: the tip only
# 113b5ee 0
# 0a00357 0
# 49ef535 0
# 7ebe70f 0
# e670dfd 0
A nine-PR stack in okf-vault-kit on 16 September did the same: one push, one CI run, on the tip. Its push, 38d9a57..f4c212d, is still in the events window as I write this and drops out on 16 October. GitHub's docs on merging a stack do say the layers "land on the base branch together as a single operation". In practice that operation is a single push event: anything on on: push runs once, for the tip.
Neither repository uses a merge queue, and with one this may not hold. The GA changelog says a stack lands through the queue "as a single merge group", and the same docs page lets a merge group run up to 50 percent over its configured size to keep a stack together, moving a bigger stack to the next group as a single unit. The README of GitHub's gh stack CLI extension, v0.2.1, says queued layers "may land in separate groups". I haven't measured which.
One release is the feature
The release workflow in schriftsatz opens with "Merging to main IS the release" and runs after each CI run on main. One push meant one CI run, one release run and one version: v0.3.0, whose notes list all six pull requests. Six changelog entries, one version, and none of the intermediate states was ever released. For continuous release, that is the right shape for one coherent change.
Merging the same stack by hand is different. Each merge is its own push and queues its own CI run, and each CI run that passes can start a release run for a commit that is no longer the tip. That is why the workflow has this step (comments removed):
- name: Release the tip of main, or nothing
id: tip
env:
TRIGGER_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -eu
tip="$(git rev-parse HEAD)"
echo "sha=$tip" >>"$GITHUB_OUTPUT"
if [ -z "${TRIGGER_SHA:-}" ] || [ "$tip" = "$TRIGGER_SHA" ]; then
echo "current=true" >>"$GITHUB_OUTPUT"
exit 0
fi
echo "::notice::main is at $tip, this run is for $TRIGGER_SHA — a later run covers it"
echo "current=false" >>"$GITHUB_OUTPUT"
A native stack merge doesn't take the deferral path by itself; only another push to main in the minutes before its release run starts would send it there. I kept the step anyway: a ruleset in that repository forbids updating or deleting tags, with no bypass, so a version spent on a state nobody asked to ship could not be withdrawn without first switching the ruleset off.
What CI on the tip does not cover
The five intermediate commits were never built as commits on main. CI after the merge tested the tip and nothing else.
Before the merge, each layer did get CI: GitHub evaluates every pull request in a stack "as if it targets the base of the stack" (CI for stacked pull requests). The useful question is whether what landed is what was tested. Squash commits are new commits, but their trees can be compared:
# in the schriftsatz clone
git fetch origin refs/pull/49/head
git rev-parse 'FETCH_HEAD^{tree}' '7ebe70f^{tree}'
# a0a95716e89abe9a8a8345f0ee18cd0aee2c8b02
# a0a95716e89abe9a8a8345f0ee18cd0aee2c8b02
All six layers match their PR heads, and each of those heads passed ci-required, the single aggregate check the branch ruleset requires. That is what the stack's linear-history requirement buys: each layer sat on the head of the one below and the bottom one on the tip of main. Had a lower branch or main moved, GitHub would have required a rebase, and passing checks on the rebased heads, before the merge.
Pre-merge CI doesn't cover jobs that run only on a push to main, or anything a test reads from outside the tree: the clock, the network, a dependency without a lockfile. For those, the tip is the only commit that was ever exercised.
Squash merges and retargeting
With squash-only merges, each layer still becomes its own commit on main with its own (#n) suffix, and history stays linear: git log --merges v0.2.2..v0.3.0 prints nothing. One oddity: after the merge, gh pr view 49 -R cronai-labs/schriftsatz still names the layer below as its base, although its commit sits on main. Don't use baseRefName to find out where a merged layer went.
Merging only the lower layers is the case that used to hurt. After a squash, the child branch still carries its parent's original commits. A plain rebase skips a commit whose patch is already upstream, so a single-commit parent squashed onto an unchanged base is skipped; with several commits in the parent, none of them matches the squash, and every one is replayed. The docs say the next pull request is rebased onto the stack base automatically. The stacks in this note all merged whole, so they don't exercise that path. Outside a GitHub-managed stack, the manual step remains:
git rebase --onto origin/main <parent-tip-before-squash> <child-branch>
git push --force-with-lease
Issues that close themselves
alfred-little-snitch-control #21 was an audit with several workstreams. I created a branch for the first one with gh issue develop, and PR #23 said what it was: "Part of #21 — the CI contract. Ruleset, repo settings and tag protection follow in a second step". No closing keyword anywhere. #23 merged at 21:20:16 UTC; #21 closed at 21:20:17.
A branch created from an issue is linked to it, and the docs say a linked pull request closes its issue when it merges into the default branch. No keyword is needed, and its absence doesn't stop the close. Check before merging:
gh pr view 23 -R hashkode/alfred-little-snitch-control \
--json closingIssuesReferences --jq '.closingIssuesReferences[].number'
# 21
If an issue needs more than one PR, give it sub-issues and branch from those. If a PR should not close anything, skip gh issue develop and name the branch by hand.
If the check already shows an issue you want to keep open, I have no tested way to remove it. The GraphQL API has removeCloseIssueReferences, which removes manually linked pull requests from an issue but not keyword references, and deleteLinkedBranch, which unlinks a branch. The docs say only manually linked pull requests can be unlinked by hand. I haven't tested whether a linked branch counts as a manual link, or whether either mutation undoes its connection. What remains is reopening the issue after the merge.
Two readings that didn't hold
Every layer of the six-PR schriftsatz stack carried Closes #n and came from a linked branch. Queried right after the PRs were created, closingIssuesReferences was filled for the bottom layer, based on main, and empty for the one above it. GitHub's page on linking pull requests to issues supplied an explanation: keywords "are interpreted only when the pull request targets the repository's default branch." I wrote down that the base branch was the cause.
That theory was wrong. An hour later, layers with unchanged non-default bases had closing references too, and all 7 issues closed within seconds of the stack merge, including #62, which only a keyword in #58 named.
My second reading was latency: wait, and it fills in. The timelines don't support that either. An issue's connected event records when a pull request was linked to it. Across the 19 stacked PRs that came from linked branches (the six-PR stack, a later two-PR schriftsatz stack, #65/#67, and the okf-vault-kit stacks #24–#35 and #37/#39):
- the 5 opened against
mainwere connected within 2 seconds of being opened; - 12 of the other 14 were connected within 2 seconds of an edit to their title or body;
- the 2 that were never edited were connected only after they had merged.
A third okf-vault-kit stack, #16/#18/#20, used Closes #n on branches that weren't linked to their issues. None of those 3 issues ever got a connected event, and all 3 closed at the merge.
Pushing to the branch or marking the PR ready for review did not trigger a connection. One of the 2 unedited layers is okf-vault-kit #33, whose body said "Refs #32" and that #32 stays open. It merged at 19:30:50; the connection appeared at 19:30:52:
gh api repos/cronai-labs/okf-vault-kit/issues/32/timeline \
--jq '.[] | select(.event=="connected") | .created_at'
# 2026-09-16T19:30:52Z
Issue #32 is still open, and #33 lists it under closingIssuesReferences anyway. So a linked branch closed #21 against my intent and left #32 open, as I intended. Of the PRs in this note, those are the only 2 from a linked branch without a closing keyword: in the one that closed, the connection existed before the merge; in the one that stayed open, it appeared 2 seconds after.
The keyword PRs behaved differently. okf-vault-kit #35, the other unedited layer, said "Closes #34". Its connection also appeared 2 seconds after the merge, and #34 closed in the same second. #62 and the 3 issues of the keyword-only stack never had a connection at all, and all of them closed.
I don't know the mechanism; 19 PRs give me a correlation, not an explanation. The working rule doesn't need one: read closingIssuesReferences after the last edit and right before merging, never straight after gh pr create. An empty list didn't stop a keyword from closing its issue here, but it makes the check useless: re-save the body and look again a few seconds later.