Review and merge stacked pull requests¶
A stacked pull request targets another pull-request branch instead of main.
That makes early review possible, but its green checks describe the current
feature base. After the lower change merges, the child needs a new head and a
fresh main comparison before it can merge safely.
This repository permits squash and rebase merges. In either case, moving the base alone is not enough: the child still contains the parent branch history that reviewers saw. Use the procedure below to remove that history, replay only the child delta, and force GitHub to check a new head SHA.
Before you begin¶
Confirm all of these conditions:
- Bash, Git, and an authenticated GitHub CLI are available.
- The checkout is clean and
originpoints to this repository. - Each pull request targets the branch immediately below it; the bottom one
targets
main. - The branches live in this repository and you may push them with a lease.
- Every commit carries a valid DCO sign-off.
- You may retarget, rewrite, and merge the pull requests.
Record every pull-request number and exact head SHA before merging anything.
The child's rebase needs the parent head SHA that reviewers saw, not the commit
or commits that the merge later places on main.
before: main ------> PR A ------> PR B
1. merge PR A
2. replay only PR B's commits:
after: new main --> restacked PR B
In words: merge PR A, replay only PR B's commits onto the new main, publish a
new child head, wait for the complete check set, then merge PR B.
1. Treat stacked checks as early feedback¶
Wait for these workflows on every level of the stack:
- DCO
- CodeQL
- Dependency review
- workflow security, including actionlint, full action pins, and zizmor.
They run for opened, reopened, synchronized, and edited pull requests even when the base is another feature branch. DCO and dependency review both calculate their range from the pull-request event's current base and head.
A green result on a feature base is not approval to merge the same head into
main. Retargeting changes the comparison and GitHub's synthetic merge commit,
but it may leave the head SHA unchanged. GitHub also does not emit a child
synchronize event merely because its parent branch moved.
If a child becomes conflicted, update its head before continuing.
pull_request workflows do not run for a merge snapshot GitHub cannot create.
2. Capture the parent boundary¶
Set the two pull-request numbers, then record their branches and the exact parent head:
PARENT_HEAD="$(gh pr view "$PARENT_PR" --json headRefOid --jq .headRefOid)"
CHILD_BRANCH="$(gh pr view "$CHILD_PR" --json headRefName --jq .headRefName)"
PARENT_BRANCH="$(gh pr view "$PARENT_PR" --json headRefName --jq .headRefName)"
printf 'parent=%s parent-head=%s child=%s\n' \
"$PARENT_BRANCH" "$PARENT_HEAD" "$CHILD_BRANCH"
Put that output in the review notes. Stop if a value is empty or does not match the pull-request page.
3. Merge only the bottom pull request¶
Merge the lowest pull request after all of its required main checks pass.
Never merge a child into its feature base as a shortcut.
Wait for the merged change to appear on main. If more pull requests remain,
the next one is now the bottom of the stack.
4. Replay the child onto the new main¶
Fetch the new base and child branch. Work from a detached checkout so an existing local branch cannot be overwritten, and create a backup before the rewrite:
git fetch origin main "$CHILD_BRANCH"
ORIGINAL_BRANCH="$(git branch --show-current)"
test -n "$ORIGINAL_BRANCH"
git switch --detach "origin/$CHILD_BRANCH"
OLD_CHILD_HEAD="$(git rev-parse HEAD)"
BACKUP_BRANCH="backup/restack-$CHILD_PR-$(date -u +%Y%m%dT%H%M%SZ)"
git branch "$BACKUP_BRANCH"
git rebase --force-rebase --onto origin/main "$PARENT_HEAD"
NEW_CHILD_HEAD="$(git rev-parse HEAD)"
test "$NEW_CHILD_HEAD" != "$OLD_CHILD_HEAD"
git diff --check origin/main...HEAD
The rebase selects commits after the recorded parent head, drops the parent
history already represented on main, and replays only the child delta.
--force-rebase ensures GitHub receives a different head identity even when a
commit would otherwise be unchanged.
If Git reports a conflict, continue only when the intended child delta is
clear. Otherwise run git rebase --abort and stop. Review
git diff origin/main...HEAD against the pull request's prior intended change
before touching GitHub.
5. Retarget and publish the rewritten head¶
Retarget the pull request, then immediately push the rewrite with an exact lease:
CURRENT_BASE="$(gh pr view "$CHILD_PR" --json baseRefName --jq .baseRefName)"
if [[ "$CURRENT_BASE" != main ]]; then
gh pr edit "$CHILD_PR" --base main
fi
git push \
--force-with-lease="refs/heads/$CHILD_BRANCH:$OLD_CHILD_HEAD" \
origin "HEAD:refs/heads/$CHILD_BRANCH"
GitHub sometimes retargets a child automatically when it deletes the merged
parent branch. Otherwise, gh pr edit emits an edited event. The push emits
synchronize for the new head. Do not merge between those operations.
If the push fails after retargeting, close the pull request while you investigate:
gh pr close "$CHILD_PR"
Never replace the lease with --force. A lease failure means the remote branch
changed after your fetch. Repeat the restack from that new remote head, push the
corrected rewrite, and only then reopen the pull request.
Warning
GitHub stores checks on a commit. Immediately after retargeting, the page
can display a success produced for the previous base. Do not merge until
the child has a different head SHA and all required main checks finish on
that exact SHA.
6. Verify the exact head, then merge¶
Confirm the remote head and watch its checks:
test "$(gh pr view "$CHILD_PR" --json headRefOid --jq .headRefOid)" = \
"$NEW_CHILD_HEAD"
gh pr checks "$CHILD_PR" --watch
Review Files changed again. Confirm each run belongs to the retargeted pull
request and rewritten head. Both the GitHub Actions Analyze Python job and
the GitHub Advanced Security CodeQL result must pass; the analysis job alone
does not prove that code scanning accepted its findings.
Merge the child. Delete the local backup only after the merge and its resulting
main checks succeed:
git switch "$ORIGINAL_BRANCH"
git branch --delete --force "$BACKUP_BRANCH"
Repeat from the bottom upward until the stack is empty.
Why the workflow boundary matters¶
The security workflows use pull_request, not pull_request_target. They
receive no repository secrets or OIDC token. They cannot write repository
contents or pull requests, and the workflows do not use an Actions cache. Both
CodeQL jobs request security-events: write at job scope so the pinned analysis
action can upload results. Their only other token scopes are actions: read and
contents: read.
GitHub can still downgrade a fork pull request's requested write scope to read
only. Dependabot pull requests receive the same treatment. Code scanning
accepts uploads from runs triggered by pull_request, so this exception does
not turn an untrusted pull request into a generally writable workflow. See
GitHub's documentation for
fork token permissions
and
CodeQL uploads from Dependabot pull requests.
The pinned Python CodeQL actions run in no-build mode. They inspect the checked
out source but do not import, test, or otherwise execute application code from
the pull request. DCO reads commit metadata, while the other pinned actions
inspect dependencies and workflow data. A pull request can still change the
workflow being reviewed, so these token limits remain necessary.
Feature branches created before the current triggers reached main may still
contain older workflow definitions. Rebase them before relying on automatic
stack checks. A first-time fork workflow can also wait for explicit maintainer
approval.
If a retarget run never appears, do not merge. Confirm the base change in the timeline. Close and reopen only after checking that repository automation does not treat closure as cancellation. Repeated missing runs are a repository-automation incident.