Add `--stagger <duration>` to space out branch pushes and avoid duplicate CI runs
- Dominant language
- Go
- Stars
- 1.5k
- Forks
- 70
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 7
Description
## The problem in one paragraph
`gh stack sync` pushes every branch in the stack at once, in a single atomic push. When that happens, GitHub sometimes sends **two or three "the PR changed" events for the same commit** instead of one. Each extra event starts an extra CI run. Those extra runs get cancelled, but GitHub keeps every check it has ever posted for a commit — so the cancelled ones stay on the pull request's Checks tab forever, showing red. Reviewers open the PR, see red, and think it is broken when it is not.
Adding a short pause between pushes makes the problem go away completely. I measured this, and **about 3 seconds was enough**.
## Why the extra events happen
Every PR in a stack sits on top of the one below it. So when the whole stack is pushed at once, a middle PR has **two** things change at the same moment: its own branch, and the branch it is based on.
GitHub appears to process those two changes in parallel, and both end up announcing "this PR's commits changed." You get a duplicate event for a commit that only changed once.
The bottom PR of a stack never has this problem, because its base is the trunk and the trunk is not part of the push. Every PR above the bottom is affected.
## Why this cannot be worked around in CI
I tried the obvious fixes first. None of them work:
**You cannot filter the duplicate out in a workflow.** I checked what GitHub records for each run, and every run created by the push — the real one and the duplicates, across all our workflows — points at the *same* push. Same event, same commit, same push. There is no field that tells the duplicate apart from the original, so there is nothing to write a condition against.
**Concurrency settings do not help.** A concurrency group can cancel a running job or make it wait, but it cannot stop the duplicate run from being *created* — and the checks appear as soon as the run is created. Turning off `cancel-in-progress` is worse: it doubles the CI bill, and a third event still cancels the queued second one.
**Deleting the runs is the only cleanup GitHub offers,** and deleting CI history to tidy up a display problem is not something we are willing to do.
## Why it matters
- **Reviewers misread healthy PRs as broken.** One of our PRs showed 59 passing checks, 36 cancelled, 8 skipped, and 2 failed. Both failures were old entries already replaced by passing ones — the PR was genuinely green. But you cannot see that from the Checks tab. On a tall stack this happens to every layer, every time we restack.
- **It wastes a lot of CI.** Each duplicate run starts its full set of jobs (about 40 for us) before being cancelled. On a 17-PR stack that is several hundred wasted job starts per restack, on a shared runner pool.
## What I measured
This is a 17-PR stack in a private repository, restacked several times in one day.
**Before — one atomic push of the whole stack:**
Out of 8 branches I sampled, **6 got duplicate CI runs**.
**After — same stack, same branches, pushed one at a time with a pause between each.**
Each pause is its own experiment, because what matters for a given PR is the delay between pushing the branch below it and pushing the branch itself. I used pauses from 2 to 45 seconds. The times below are the real measured intervals, which include the time the push itself took:
| Pause between pushes | Branches | Duplicate runs |
|---|---|---|
| 3 seconds | 1 | 0 |
| 6–7 seconds | 2 | 0 |
| 11–12 seconds | 2 | 0 |
| 16–17 seconds | 2 | 0 |
| 21–22 seconds | 2 | 0 |
| 31 seconds | 2 | 0 |
| 46–47 seconds | 2 | 0 |
**Zero duplicates in 14 tries in a row**, compared with roughly 3-in-4 branches duplicating when pushed all at once. If the old rate still applied, getting 14 clean branches by luck would be about a 1-in-250-million shot.
Two honest caveats:
- I ran the shortest pauses first. If there were some warm-up effect, it would have made the *short* pauses look bad — they were clean, so that does not explain the result.
- This was one restack during a quiet period. I would not claim 3 seconds is a guaranteed floor. What I would claim is that a few seconds is plenty, and a minute is unnecessary.
## What I am asking for
An opt-in flag that keeps today's behaviour by default:
```
gh stack sync --stagger 5s
gh stack submit --stagger 5s
gh stack push --stagger 5s
```
With the flag set, push the branches one at a time from the bottom up, pausing for the given duration between each, instead of pushing them all at once.
For a 15-branch stack at 5 seconds, that is a little over a minute — much cheaper than the CI the duplicate runs burn, and far cheaper than the confusion they cause reviewers.
### The trade-off, stated plainly
Pausing between pushes means giving up the atomic push, so a failure partway through would leave the stack half pushed. That is already how `gh stack push` behaves today — its help text says "Updates are not atomic: a branch may update even if another branch is rejected" — so this would not introduce a new kind of failure. It is a good reason to keep the flag opt-in and to mention the trade-off in its help text.
## Things I considered and ruled out
| Idea | Why it does not work |
|---|---|
| Filter out the duplicate event in the workflow | The duplicate is identical to the original, right down to the push it points at. Nothing to filter on |
| Change the concurrency settings | Cannot stop the run being created, and the checks appear with the run |
| Delete the cancelled runs | Works, but destroys CI history to fix a cosmetic problem |
| Document it and tell reviewers to check elsewhere | We already do. Reviewers look at the Checks tab, not at our contributing guide |
| Write our own staggered push script | This is what we are doing now. It works, but it means stepping outside the tool and losing its safety behaviour — which is why I would rather see it supported |
## Environment
- `gh-stack` v0.1.0
- GitHub.com, self-hosted runners
- Stack size: 17 PRs
Contributor guide
Research direction
Start by tracing the implementations and tests for `gh stack sync`, `gh stack submit`, and `gh stack push`, especially their shared branch-push path, flag handling, and duration parsing. Confirm how branches are ordered and where command help is defined. Done means an opt-in `--stagger` duration pushes bottom-up with pauses while preserving today's default behavior and documenting the partial-push trade-off.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github, go
- Domain
- cli, devtools
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 65/100