Tracking pull requests¶
Opening the PRs is half the job. This guide covers re-running a campaign safely, following the PRs to merged, recovering from failures and closing a campaign down.
Idempotent by design¶
apply finds each repo's existing PR in two ways: by the head branch fleet/<name>, and, for any repo not found that way, by a search for the hidden marker gh fleet adds to every PR body:
What happens next depends on what it finds:
| Existing PR | apply does |
|---|---|
| none | runs the change and opens a PR |
| open | re-runs the change, pushes if the result differs, and updates the title and body if they changed. Missing labels are added. |
| merged | skips the repo |
| closed without merging | leaves it alone, unless you pass --reopen |
Running the same apply twice reports unchanged the second time. If GitHub refuses to reopen a closed PR, for example because its branch was recreated, gh fleet opens a new one.
When the campaign branch already exists, gh fleet rebases it onto the base branch before running your script. If the rebase conflicts, the branch is reset to the base and the script runs again, since the script is the source of truth.
When the change has landed¶
If an open PR's change is already on the base branch, apply reports noop and says so. Pass --close-noop to close such PRs with a comment instead.
Following progress¶
status refreshes every target's PR from GitHub and prints one row per repo:
$ gh fleet status -f fleet.yaml
REPO STATUS PR CHECKS REVIEW NOTE
acme/api merged https://github.com/acme/api/pull/1 success approved
acme/billing pr open https://github.com/acme/billing/pull/1 success approved
acme/docs-site skipped - - - no push access
acme/gateway pr open https://github.com/acme/gateway/pull/1 failure review required
acme/web pr open https://github.com/acme/web/pull/1 pending review required
acme/worker noop - - -
3 pr open · 1 merged · 1 noop · 1 skipped
API quota: core 4,871/5,000 · graphql 4,962/5,000
CHECKS is the rollup of the PR's status checks and REVIEW is its review decision. NOTE carries the last error for a failed repo, or the reason for a skipped, noop or closed one. The remaining API quota is printed at the end. PRs are read in batched GraphQL queries of 50 repos.
For scripts, --json prints a report and --jq filters it. --exit-status sets the exit code from the result (1 if a repo failed, 8 while any still needs work), so a script or CI job can wait on a campaign. Scripting and CI has the details.
Sharing progress¶
status can also print a progress report for people instead of a table. It shows:
- how many repos are done;
- what needs attention: failed runs, failing checks, merge conflicts and requested changes;
- the open PRs, each with what it waits on, giving the same reasons
mergedoes; - the merged PRs;
- everything else, folded away.
--format markdown prints it as Markdown, which renders in an issue, a PR, a wiki page or chat. To post it as a comment on a tracking issue:
Here is a report for a pin-actions campaign part of the way through, as the comment shows it and as Markdown:
# pin-actions
Pin every third-party GitHub Action to a full commit SHA
**5 of 12 repos done (41%)** `████░░░░░░`
3 merged · 6 pr open · 1 failed · 2 noop · 1 skipped
## Needs attention
| Repo | PR | Problem |
| --- | --- | --- |
| acme/ledger | [#7](https://github.com/acme/ledger/pull/7) | merge conflict |
| acme/payments | [#54](https://github.com/acme/payments/pull/54) | changes requested |
| acme/worker | [#31](https://github.com/acme/worker/pull/31) | checks failing |
| acme/legacy-api | - | step resolve: gh api repos/acme/internal-action/commits/v2: HTTP 404 |
## Open
| Repo | PR | Checks | Review | Waiting on |
| --- | --- | --- | --- | --- |
| acme/gateway | [#23](https://github.com/acme/gateway/pull/23) | success | approved | ready to merge |
| acme/notify | [#12](https://github.com/acme/notify/pull/12) | success | review required | needs review |
| acme/search | [#9](https://github.com/acme/search/pull/9) | pending | review required | checks running |
## Merged
- acme/api [#41](https://github.com/acme/api/pull/41)
- acme/billing [#17](https://github.com/acme/billing/pull/17)
- acme/web [#88](https://github.com/acme/web/pull/88)
<details><summary>3 other repos</summary>
| Repo | Status | Note |
| --- | --- | --- |
| acme/cli | noop | no unpinned actions |
| acme/docs-site | noop | no unpinned actions |
| acme/archive | skipped | no push access |
</details>
_Generated 2026-10-26 14:00 UTC by [gh fleet](https://github.com/rossedman/gh-fleet)._
--format html prints the same report as one self-contained page, with light and dark themes, to publish or attach:
Open the example page on its own. Both examples are generated by the same code as gh fleet status, so they always match what it prints.
"Done" counts merged PRs and repos that needed no change. Skipped repos aren't counted.
Recovering from failures¶
A failure in one repo never stops the run. The repo is recorded as failed with its error, gh fleet exits non-zero at the end, and status shows the error. Fix the script or the cause, then:
retry runs apply again for only the repos whose last run failed, that never ran, or that changed but were not pushed. Repos with a PR, merged or closed PRs and noops are left alone. It takes the same flags as apply.
Capping open PRs¶
limits.maxOpenPRs stops new PRs once that many campaign PRs are open, counting the ones already open. Repos held back show changed with maxOpenPRs reached; not pushed. As PRs merge, retry opens the next ones.
Rolling out in waves¶
limits.waves rolls a change out in stages, so a problem shows up in a few repos before it reaches all of them:
limits:
waves:
- repos: [acme/canary-api, acme/canary-web] # canaries first
- size: 10 # then the next 10 repos
- size: 25% # then a quarter of all targets
# and then everything else
Each wave takes its named repos, then the next size targets in name order. Targets left after the last wave form a final wave. apply opens PRs only for the current wave: the first one with a repo that isn't merged or unchanged yet. Repos in later waves are held as pending, with a note such as wave 3 of 4: starts when wave 2 is done (7 of 10 merged or unchanged), so status shows why. Run apply again (or retry) after the current wave merges to start the next.
$ gh fleet apply --yes
Wave 1 of 4: 2 repos now; 48 repos wait for it.
pr open acme/canary-api https://github.com/acme/canary-api/pull/9 created
pr open acme/canary-web https://github.com/acme/canary-web/pull/4 created
pending acme/billing wave 2 of 4: starts when wave 1 is done (0 of 2 merged or unchanged)
...
plan follows the same waves, so it only clones and runs the repos apply would touch next, and its output stays readable however big the campaign is. --wave 3 plans a later wave ahead of time, and --all-waves plans every target. Working out the current wave means looking up the campaign's PRs, the same batched lookup apply makes.
$ gh fleet plan
Wave 2 of 4: planning 10 repos; 40 repos in other waves (--wave N or --all-waves plans them)
...
A canary PR closed without merging stops the rollout there until you decide what to do. PRs already open in later waves keep being updated, and --repo acts on the repos you name regardless of waves. Waves combine with merge: gh fleet merge --auto merges each wave as it passes, and the next apply starts the next one.
Merging¶
merge merges the campaign's open PRs that are ready, and says what each of the others waits on:
$ gh fleet merge --dry-run
pr open acme/web https://github.com/acme/web/pull/1 waiting: checks running
pr open acme/api https://github.com/acme/api/pull/1 would merge
1 would merge · 1 waiting
$ gh fleet merge --auto --yes
pr open acme/web https://github.com/acme/web/pull/1 auto-merge on
merged acme/api https://github.com/acme/api/pull/1
1 merged · 1 set to auto-merge
A PR is merged when GitHub reports it mergeable, its checks pass and it is approved. By default both the approval and the checks are needed even in repos whose branch protection doesn't require them, so a campaign never merges something nobody looked at. --allow-unreviewed and --allow-no-checks relax that for repos you trust. The merge names the head commit gh fleet checked, so a PR that gets a new push in the meantime waits for the next run.
The others wait, with the reason:
| Reason | What to do |
|---|---|
checks running, needs review |
Wait, or pass --auto so GitHub merges it when they pass. |
checks failing |
Fix the change and run apply again. |
merge conflict |
Run apply again: it rebases the branch on the base. |
behind its base |
Pass --update-branch to merge the base into the branch. |
changes requested, draft |
Up to the repo's owners. |
Useful flags:
--method merge|squash|rebase, defaultsquash.--autoturns on GitHub's auto-merge for PRs that aren't ready yet. Auto-merge only waits for what branch protection requires, so it isn't used where the repo requires no review or no checks, unless you pass the--allow-flags.--max 10merges at most ten PRs per run, to roll out in batches. It picks the same PRs whatever order the API answers in.--keep-brancheskeeps the branches of merged PRs. By default they are deleted, asclosedoes.--dry-runwrites nothing, and--jsonprints one result per PR with its action and reason.
It asks for confirmation unless you pass --yes.
Closing a campaign¶
close closes every open PR the campaign opened and deletes their branches. It leaves merged and already-closed PRs alone and asks for confirmation unless you pass --yes. Use --keep-branches to close the PRs but keep the branches, and --repo to close only some.
Local state¶
Local state is a cache plus error history. GitHub is the source of truth for pull requests.
| What | Where |
|---|---|
| Per-campaign state | $XDG_STATE_HOME/gh-fleet/<name>.json |
| Script logs, one per repo | $XDG_STATE_HOME/gh-fleet/<name>/logs/<owner>__<repo>.log |
XDG_STATE_HOME falls back to ~/.local/state. The state file records, for each repo, its status, PR number and URL, checks, review, and last error. Diffs and command output are never stored.
gh fleet list shows every campaign with state and where its file lives. A name from that list can stand in for --file in any command:
$ gh fleet list
CAMPAIGN UPDATED REPOS FILE
pin-actions 2026-10-25 17:54:33 3 pr open · 1 merged · 1 noop · 1 skipped fleet.yaml
You can delete the state file and status still works, because it finds PRs by branch and marker. Only the error history is lost.