· Case Study · 10 min read
Opening 27,000+ pull requests across a repo fleet
How a bash launcher, a GitHub Actions workflow and a directory convention became a fleet-wide update machine 🤖

After migrating 1,273 repositories to GitHub in under an hour, the fleet lived on one platform, named consistently and tagged with topics. That made it something you could program.
Here is what programming it looks like.
Because a migrated fleet drifts. A CI template gets a fix, and 2,700 repos still run the old one. A Dockerfile base image needs a bump, in 400 places. Multiply any one-line change by the fleet, and “quick fix” becomes a quarter.
So I built an auto-updater : one command that opens the same pull request across hundreds of repositories. In ~10 months it has opened 27,653 PRs, 83% merged, with not a single direct push to main, and 23 engineers now trigger it without me.
I also published a minimal, runnable version on my GitHub : gh-auto-updater (with a sample target repo). Everything below links to real code you can fork.
The idea : updates as code
An update is a directory. That’s the whole contract :
auto-updater/├── run.sh # the launcher (framework, written once)├── add-todo-md/ # an updater│ ├── run.sh # the change itself│ └── PR.md # the pitch to humans└── add-fortune-md/ # another updater ├── init.sh # optional : install dependencies ├── run.sh └── PR.mdAn updater’s run.sh runs inside a fresh clone of each target repo and just… changes files. The smallest real one, add-todo-md, is eight meaningful lines :
readonly file='TODO.md'
if [[ ! -f "$file" ]]; then echo '# TODO' > "$file" echo "✅ '$file' created"else echo "❎ '$file' already exists"fiEverything else (cloning, branching, committing, pushing, labeling, opening the PR) is the framework’s job. And one principle rules the whole design :
The bot proposes. Humans decide.
The auto-updater never pushes to main. It opens pull requests, with a human-readable PR.md as the body, and branch protection stays exactly as strict as before. The pull request is what lets mass automation and human review coexist.
The launcher : one loop, every guarantee
The launcher is plain bash : strict mode, a temp dir with an EXIT trap, and one loop. Condensed :
for repo in "${repos[@]}"; do
# fail-fast if repository not found gh repo view "$GITHUB_ORG/$repo" > /dev/null 2>&1 || continue
# delete stale branch (auto-closes the old PR) → last run wins, no zombie PRs branch="feature/auto-updater-$update_name" if gh api "repos/$GITHUB_ORG/$repo/branches/$branch" > /dev/null 2>&1; then gh api -X DELETE "repos/$GITHUB_ORG/$repo/git/refs/heads/$branch" fi
gh repo clone "$GITHUB_ORG/$repo" -- --depth=1 cd "$repo"
# run the updater "$script_dir/$update_name/run.sh" "$repo"
# no changes ? no PR. if [[ -z "$(git status --porcelain)" ]]; then echo "❎ Nothing changed, skipping..." continue fi
git checkout -b "$branch" git add -A git commit -m "✨ Feature : $update_name" git push -u origin "$branch"
gh label create '🤖 auto-updater' -c '#fef2c0' -f gh pr create --label '🤖 auto-updater' \ --title "✨ Feature : $update_name" \ --body-file "$script_dir/$update_name/PR.md"
doneA GitHub Actions workflow wraps it in a workflow_dispatch : pick an updater from a dropdown, type the target repos, press the green button. That button is what people actually use : no laptop setup, no credentials handling, a run log for every launch.
Three small decisions in that loop carry most of the value :
- No-op detection.
git status --porcelainempty → no branch, no PR, no noise. That forces updaters to be idempotent, and idempotent updaters can be re-launched against the entire fleet whenever you want : already-compliant repos are skipped. - Stale-branch delete. Re-running an updater deletes its previous branch (closing the old PR) before opening a fresh one. Last run wins ; nobody reviews outdated proposals.
- The label. Every PR carries
🤖 auto-updater. That’s an audit trail and a free metrics system : one search query returns everything the machine ever proposed, merged or not.
And yes, the demo includes an updater that installs fortune and PRs a fortune cookie into your repos 🥠 See the result in the sample repo.
Scaling it to 2,700 repositories
The public repo is the pattern. At work, the same design runs against a fleet of 2,700+ repos, and scale changed four things :
1. Targeting by topics. The migration assigned a topic to every repo, by pattern, and this is where it pays off. The production workflow takes topics or an explicit repo list : topics: service targets every Spring Boot service, topics: dataform every transformation pipeline. The updater just asks GitHub. And the whole launcher UI is GitHub’s own Run workflow dialog (topics or repos, pick an updater, press the green button) :

2. Fan-out topology : a matrix of matrices. One runner looping over 2,700 repos would take all night and die at the first hiccup. A job matrix is the obvious answer, except GitHub Actions caps a matrix at 256 jobs. A fleet-sized target list doesn’t fit.
The workaround : nest the matrices. The parent workflow chunks the target list (100 repos per chunk, a few lines of jq) and fans each chunk out to a reusable child workflow through a first matrix (fail-fast: false) ; each child then expands its chunk into a second matrix, one job per repo, max-parallel: 5. Every individual matrix stays comfortably under the 256 cap, but multiplied together they cover thousands of repositories. Hundreds in flight, one job log per repo, one repo’s failure hurting nobody else. 800+ repositories from a single launch, three times what a single matrix allows.
And a free bonus : because every repository is its own matrix job, GitHub’s native Re-run failed jobs button becomes fleet-grade tooling. Three repos failed out of 800 ? One click retries exactly those three, not the whole launch, and it is safe because the updaters are idempotent.

Here is what one manual trigger looks like, a fleet-wide update-dataform-versions run : chunked, fanned out, 818 jobs completed in under 17 minutes.

3. A GitHub App instead of a PAT. At this volume, a personal token is both a security smell and a rate-limit bottleneck. The production version mints a short-lived GitHub App installation token (credentials in a cloud secret manager, fetched over OIDC, nothing stored in the repo). PRs and commits are authored by a proper bot identity, so the automation’s output is cleanly separated from human work in every stat.
4. Respecting the invisible rate limiter. GitHub has two kinds of rate limits, and only one of them is honest with you :
- Primary limits are queryable :
gh api rate_limittells you exactly how much REST, GraphQL or search quota remains and when it resets. Those you can plan around. - Secondary limits are not. Creating a PR falls under GitHub’s content-creation limits (roughly 80 content-generating requests per minute, 500 per hour), and none of it appears in the rate-limit API :
$ gh api rate_limit --jq '.resources | keys'["code_search","core","graphql","search", ...]# nothing about creating PRs, issues or comments 🤷The first you hear of a secondary limit is a 403 mid-run : “You have exceeded a secondary rate limit.” You cannot schedule around a budget you cannot see. You can only probe, detect and back off. That’s why the production launcher greps the error text itself (rate limit, secondary limit, abuse detection, too many requests) : the error message is the API.
And that’s exactly why the backoff has jitter. Dozens of matrix jobs create PRs in parallel ; when the hidden limit trips, they all receive the 403 at the same moment. With a fixed backoff they would all retry at the same moment too, and hit the same limit again. Randomized jitter spreads the retries out :
jitter=$(( RANDOM % (delay / 2 + 1) ))sleep "$(( delay + jitter ))"delay=$(( delay * 2 ))(( delay > max_delay )) && delay=$max_delayThe demo’s polite retry loop (10 attempts, 5s) grew into production’s 50 attempts, exponential backoff + jitter, capped at 5 minutes. That little block is the difference between “opened 800 PRs before lunch” and “blocked by abuse detection halfway through the run” 😅
There’s a merge side too : once a wave is reviewed, a companion script walks every open auto-updater PR awaiting your review (approve, squash-merge, delete branch) via a gh search prs loop. Proposal, review and merge stay separate steps, each one logged.
From patches to desired state
The first generation of updaters were patches : fix-chmod-files, add-missing-schema, one-shot fixes launched fleet-wide, then retired. Twenty-eight of them shipped in the first months.
Then a better shape emerged : today the production updaters are per-topic template renderers, one updater per repo type (service, dataform, raw, …), each carrying a full CI/CD template/ directory and a render.sh. Run it against a repo and the repo converges to the template : managed files are re-rendered, repo-owned files are respected. Run it again next month, and only the drift becomes a PR.
If that sounds like configuration management (Ansible for repositories, desired state instead of patches), that’s what it became. The template directories themselves descend directly from the migration’s per-pattern templates.
Try it yourself 🚀
Everything in this post exists as a simpler, self-contained version on my GitHub, with no fleet and no enterprise setup required :
- vspiewak/gh-auto-updater : the framework, with the launcher, the workflow, and two example updaters
- vspiewak/gh-auto-updater-sample-repo : a target repo showing the merged results, fortune cookie included
Your first bot PR is about ten minutes away :
- Fork
gh-auto-updater - Set
GITHUB_ORGinauto-updater/run.shto your username or org - Create a PAT with
reposcope and add it as theGH_AUTO_UPDATER_SECRETActions secret - Go to Actions → ✨ Auto Updater → Run workflow, target one of your repos, pick
add-todo-md - Watch a labeled, reviewable PR appear on the target repo
Writing your own updater is a mkdir : a directory with a run.sh (make it idempotent !) and a PR.md. That’s the whole contribution bar, the same one that got 23 engineers on board at work.
The numbers
Everything below is one GitHub search away, on that label :

The counter moves daily, so it will be stale by the time you read this. As of ~10.6 months after the first PR :
- 27,653 pull requests created, a sustained ~600 PRs/week
- 22,993 merged (83%), through pull requests under branch protection, never a direct push
- 4,632 closed unmerged : superseded waves and per-repo opt-outs, kept as an audit trail rather than deleted
- 1,442 unique repositories touched
- 800+ repositories processed in a single launch, the 818-job run above
- 23 engineers have triggered it (all-time, exhaustive), and 11 have written updater code themselves ; my own share of launches fell from ~85% to ~68% and keeps falling
That last number is the one I care about. Output proves the machine works. Adoption proves the design works : the contribution bar is “a directory with two files”, and most of the updater content is now written by teammates, not by me.
If I had to keep one decision, it would be the pull request. It kept branch protection and human review in place, and it made the automation something teams accepted rather than worked around. The other one is backoff with jitter : at this scale it decides whether a run finishes at all.
The series so far
Post one : move 1,273 repos onto one platform and make them consistent. This post : keep them consistent, at ~600 PRs a week, without a single push to main. The fleet is programmable now.



