Skip to content

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:

<!-- gh-fleet:campaign=pin-actions -->

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.

gh fleet apply --close-noop

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.

gh fleet status --json counts --jq '.counts'
gh fleet status --exit-status

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 merge does;
  • 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:

gh fleet status --format markdown | gh issue comment 42 --body-file -

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:

gh fleet status --format html > pin-actions.html

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:

gh fleet retry

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, default squash.
  • --auto turns 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 10 merges at most ten PRs per run, to roll out in batches. It picks the same PRs whatever order the API answers in.
  • --keep-branches keeps the branches of merged PRs. By default they are deleted, as close does.
  • --dry-run writes nothing, and --json prints one result per PR with its action and reason.

It asks for confirmation unless you pass --yes.

Closing a campaign

gh fleet close --comment "Superseded by #42"

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.