The shared CI workflow needs one line changed, and it lives as a copy in five hundred repositories. Opening the pull requests by hand is a week of clicking. The script somebody wrote last time hit a secondary rate limit at repository three hundred and died without recording which two hundred it had already done.
What you get
You will end up with a fan-out script that dry-runs first, checkpoints after every repository, backs off on a rate limit, and reports each repository as changed, already correct, or in conflict. This is for you if you run the platform for more repositories than anyone can open by hand.
Short answer
Write a script that clones each repository and classifies its workflow file: already correct,
carrying the previous template, or edited by hand. Open one pull request per repository that
carries the previous template, through the gh CLI. Dry-run it over every repository first.
Record each result in a checkpoint after every repository, pause between batches, and back
off on a secondary rate limit. Then verify every repository ended in one of the three states.
You will need
A GitHub organization you administer, the gh CLI
signed in to it, and git. The sample runs against bare repositories it creates on disk, with
Node 22 and git. Verified 2026-09-25 against Node 22.22.2 and git 2.43.0. The rate limits it defends against are the
secondary rate limits
GitHub documents for its REST API.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| gh CLI | One change, once, and a script anyone can read in an hour | Rate limits, checkpoints and conflict states are yours to write, and the script is thrown away with its lessons | The same file will change again next quarter |
| GitHub Actions reusable workflows | The file is a workflow and every repository can call one copy of it | One migration to a thin caller file, which is itself a fan-out, and a version the callers must be moved along | Repositories on another forge, or steps that differ per repository |
| Renovate shared presets | The change is a version bump Renovate already understands | A preset changes what Renovate updates, not the shape of the workflow, and Renovate has to run in every repository | The change is structural: a new job, a renamed step, a different trigger |
| Terraform GitHub provider | The file is configuration the platform team owns outright | State for every managed file, a branch that must already exist, and a commit that lands with no pull request | The repository owners are meant to review and merge the change |
| Voxgig repo-manager | A fleet policy that names the file and re-applies it on every run | A Seneca backend to run and a model to write, in a repository whose README says it holds the scaffold and no services yet | You need the change out this month |
Voxgig maintains repo-manager. This page compares it with the gh CLI, the Terraform GitHub provider, Renovate shared presets, and GitHub Actions reusable workflows.
The loop is the cheapest to start and the only one that ends: once the pull requests are merged, nothing is left running. The other four remove the copy in exchange for something you own afterwards. Terraform owns the file and commits over it. A reusable workflow owns the steps and leaves a one-line caller in each repository. Renovate owns the versions inside the file. repo-manager, as its README puts it, intends to own the policy that says which repositories carry which file.
Classify before you change anything
A blind overwrite destroys the one repository where somebody had a reason to edit the file. So the script keeps the previous version of the template beside the next one, and sorts each repository into one of three states before it touches anything.
/** Which of the three known states a repository is in, from the file its main branch carries. */
export function classify(current, { previous, next }) {
if (current === next) return { state: 'already-correct', detail: 'main already carries the template' }
if (current === previous) return { state: 'change', detail: 'main carries the previous template' }
if (current === null) return { state: 'conflict', detail: 'no workflow file to change' }
return { state: 'conflict', detail: 'the file was edited by hand since the previous template' }
}
A file that matches neither template is a conflict, and a conflict is a state for a person, not one the script resolves. So is a missing file.
Dry-run the whole fleet first
The sample seeds six bare repositories under fleet/ in the states a real fleet is in. Two
carry the previous template, one is already updated, one was edited by hand, one has a stale
branch from an earlier attempt, and one is listed but gone.
node setup.mjs
seeded 5 bare repositories under fleet/ and listed 6 in repos.json (node 22.22.2)
For a real organization, repos.json comes from
gh repo list, whose --json flag takes
nameWithOwner and url, and whose --limit defaults to 30. The dry run then clones and
classifies every repository and writes nothing.
node fanout.mjs --dry-run
dry run: nothing is written, pushed or opened
api-a would change main carries the previous template
api-b would change main carries the previous template
sdk-ts already-correct main already carries the template
docs conflict the file was edited by hand since the previous template
legacy conflict branch ci/node-22 exists with other content
archive failed git clone: fatal: repository 'fleet/archive.git' does not exist
6 repositories: 2 would change, 1 already-correct, 2 conflict, 1 failed
Two repositories will get a pull request, two need a person, and one has to be removed from the list or explained.
Checkpoint after every repository and pause between batches
Each repository is recorded the moment it reaches a state, so a crash resumes at the next one. GitHub asks for requests in series with a pause between mutative ones, and allows about 80 content-creating requests a minute.
for (const repo of repos) {
// A failed repository is retried; a repository in a known state is not touched again.
if (results[repo.name] && results[repo.name].state !== 'failed') continue
if (processed === failAfter) throw new Interrupted(`stopped after ${processed} repositories, as asked; the checkpoint holds them`)
let result
try {
result = await processOne(repo, { dryRun, forge, workDir, sleep, log })
} catch (err) {
result = { state: 'failed', detail: err.message }
}
log(`${repo.name.padEnd(8)} ${result.state.padEnd(16)} ${result.detail}`)
results[repo.name] = result
if (!dryRun) checkpoint.save(checkpointFile, results)
processed++
// GitHub allows about 80 content-creating requests a minute; a pause every batch keeps
// a long run under it without slowing a short one.
if (result.state === 'changed' && ++opened % batch === 0) {
log(` ${batch} pull requests opened, pausing ${batchPause}s`)
await sleep(batchPause * 1000)
}
}
The checkpoint is written to a temporary file and renamed into place, so a crash during the write cannot leave half a JSON file. The defaults are a batch of 25 and a pause of 60 seconds. The run below shrinks both so it finishes in seconds, stops after three repositories on request, and has its first pull request answered with a rate limit.
node fanout.mjs --fail-after 3 --batch 2 --batch-pause 1 --rate-limit-once
rate limited, waiting 1s before attempt 2 of 4
api-a changed pull request api-a#1
api-b changed pull request api-b#1
2 pull requests opened, pausing 1s
sdk-ts already-correct main already carries the template
stopped after 3 repositories, as asked; the checkpoint holds them
The next run reads the checkpoint, skips the three, and finishes the list.
node fanout.mjs
resuming from checkpoint.json: 3 of 6 already in a known state
docs conflict the file was edited by hand since the previous template
legacy conflict branch ci/node-22 exists with other content
archive failed git clone: fatal: repository 'fleet/archive.git' does not exist
6 repositories: 2 changed, 1 already-correct, 2 conflict, 1 failed
The retry policy is the one GitHub’s documentation spells out. Wait the seconds a
retry-after header names, or otherwise at least a minute, doubled on each further refusal,
and give up after a fixed number of attempts.
export async function withBackoff(fn, { attempts = 4, sleep = wait, log = () => {} } = {}) {
let fallback = 60
for (let attempt = 1; ; attempt++) {
try {
return await fn()
} catch (err) {
if (!err.rateLimited || attempt >= attempts) throw err
const seconds = err.retryAfter ?? fallback
if (err.retryAfter === undefined) fallback *= 2
log(` rate limited, waiting ${seconds}s before attempt ${attempt + 1} of ${attempts}`)
await sleep(seconds * 1000)
}
}
}
Open the pull request through gh
The sample’s local forge appends a line to the bare repository. The GitHub forge runs
gh pr create with --repo, --head,
--base, --title and --body, the flags the manual documents for skipping every prompt.
export function githubForge({ exec = execFileSync } = {}) {
return {
async openPullRequest(repo, { head, base, title, body }) {
// repos.json from `gh repo list --json nameWithOwner,url` carries the OWNER/REPO form gh wants.
const args = ['pr', 'create', '--repo', repo.nameWithOwner ?? repo.name, '--head', head, '--base', base, '--title', title, '--body', body]
The branch is pushed before the pull request is opened, and a branch that already exists on the remote is read first. Carrying the change and a pull request, the repository is already correct. Carrying the change alone, a previous run died before its pull request, and this run opens it. Carrying anything else, it is a conflict.
Check it worked
Verification reads every repository back from the remote and confirms it ended in one of the three states, with a pull request for every branch that carries the change. Anything else is a failure to investigate, and the command exits non-zero.
node fanout.mjs --verify
api-a changed ok: branch ci/node-22 carries the template
api-b changed ok: branch ci/node-22 carries the template
sdk-ts already-correct ok: main carries the template
docs conflict ok, needs a person: the file was edited by hand since the previous template
legacy conflict ok, needs a person: branch ci/node-22 exists with other content
archive failed investigate: git clone: fatal: repository 'fleet/archive.git' does not exist
6 repositories: 5 in a known state, 1 to investigate
The tests cover the classifier and the backoff schedule with an injected clock. A third runs
the whole sequence against a fleet seeded in a temporary directory, including a rerun with
the checkpoint deleted that opens no second pull request. A fourth stops a run between the
push and the pull request, and the next run opens it. A fifth reads repos.json as
gh repo list writes it.
node --test fanout.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1792.668492
When it goes wrong
The run dies at repository three hundred with a 403 or 429 and a message about a secondary
rate limit. The documentation says there is no way to check the status of that limit. The
defences are the ones built in: serial requests, a pause per batch, and a wait that honors
retry-after.
The checkpoint says a repository changed, but its pull request was closed and the branch deleted. Delete the checkpoint and run again. The remote is the other memory: a repository whose branch is gone gets a fresh pull request, and one whose branch survives is already correct.
A repository in the list no longer exists, was renamed, or was archived. The clone fails, the
state is failed, and verification lists it under investigate. Fix the list, not the script.
Weigh the copy against not copying at all
Every other row in the table removes the reason to run the loop again. A
reusable workflow
puts the steps in one repository and leaves each caller with a uses: line naming it at a
tag.2 The fan-out then happens once, to install the caller, and the
GitHub Actions manager in
Renovate opens the pull request that moves a caller to the next tag.3
The Terraform resource
takes the repository, the path, the content and a branch that must already exist, and
overwrite_on_create decides whether an existing file is replaced.4 A shared Renovate
preset is a default.json that every repository names in extends, and it governs the
versions Renovate bumps inside the workflow, not the workflow’s shape.
When not to do this
Do not run the loop without a dry run over the whole list, and do not skip reading the dry run. A conflict in the list is a repository somebody edited on purpose, and the loop’s job is to leave it alone and say so.
Do not overwrite a file that differs from the previous template. The classifier exists so the script never has to guess what a hand edit meant.
Do not manage a workflow through Terraform when the repository owners are expected to review the change. The resource commits over the file with no pull request.
Do not wait for repo-manager for a change you need this month. Its README describes a fleet policy engine and says the project starts empty, with the model, environments, and tests in place and no entities or services yet. That says where the design is heading and not what it does today.
Do not retry a rate-limited request at once, or in parallel. The documented penalty for continuing through a secondary rate limit is a ban, and a ban on the platform team’s token stops every repository at once.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2 and git 2.43.0. Every output block is what the command
preceding it printed, run against bare repositories the sample creates on disk. Nothing on
this page was run against GitHub: the gh pr create adapter was written from the manual and
not exercised, and the rate limit in the transcript is simulated by the sample’s local forge.
Footnotes
-
The rate limits page sets secondary limits at no more than 100 concurrent requests, 900 points a minute per endpoint, 80 content-generating requests a minute and 500 an hour. It then says the limits are subject to change without notice, that a secondary limit may be met for undisclosed reasons, and that there is no way to check its status. The reader is asked to stay under a line that is unmeasurable, unannounced and movable, with no instrument except the 403 that says it was crossed. ↩︎ Back to text
-
The reusable workflows page allows a chain of ten workflows: the top-level caller and up to nine levels of reusable workflows, illustrated with a chain that ends at
called-workflow-9.yml. It adds that permissions can only be maintained or reduced along the chain, never elevated. Nine levels of indirection is a great deal of structure for a file that used to be copied. ↩︎ Back to text -
The Renovate presets page resolves
github>abc/footo adefault.jsonin that repository, and tells anyone sharing arenovate.jsonto rename it. Its GitHub Actions manager gives a job-leveluses:pointing atowner/repo/.github/workflows/<file>.yml@<ref>the typeworkflow, and every otheruses:the typeaction, so the two can be pinned differently. A caller of a reusable workflow is therefore a dependency to Renovate, which is the whole reason the migration to callers only has to be done once. ↩︎ Back to text -
The resource documentation says the branch must already exist and will only be created automatically if
autocreate_branchis set, and marksautocreate_branchdeprecated in favor of a separategithub_branchresource. The argument that creates the branch is deprecated on the page that requires the branch to exist. That is one way to say a file resource should stay out of the branch business. ↩︎ Back to text