How-to › Model, configure and generate

How to push one CI template change to five hundred repositories#

Fan one workflow change out over hundreds of repositories with a dry run, a checkpoint and three known end states, against four ways to stop copying the file.

Audience
Platform team
Level
advanced
Topic
Generate code and projects
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
gh CLIOne change, once, and a script anyone can read in an hourRate limits, checkpoints and conflict states are yours to write, and the script is thrown away with its lessonsThe same file will change again next quarter
GitHub Actions reusable workflowsThe file is a workflow and every repository can call one copy of itOne migration to a thin caller file, which is itself a fan-out, and a version the callers must be moved alongRepositories on another forge, or steps that differ per repository
Renovate shared presetsThe change is a version bump Renovate already understandsA preset changes what Renovate updates, not the shape of the workflow, and Renovate has to run in every repositoryThe change is structural: a new job, a renamed step, a different trigger
Terraform GitHub providerThe file is configuration the platform team owns outrightState for every managed file, a branch that must already exist, and a commit that lands with no pull requestThe repository owners are meant to review and merge the change
Voxgig repo-managerA fleet policy that names the file and re-applies it on every runA Seneca backend to run and a model to write, in a repository whose README says it holds the scaffold and no services yetYou 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.

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

  1. 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

  2. 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

  3. The Renovate presets page resolves github>abc/foo to a default.json in that repository, and tells anyone sharing a renovate.json to rename it. Its GitHub Actions manager gives a job-level uses: pointing at owner/repo/.github/workflows/<file>.yml@<ref> the type workflow, and every other uses: the type action, 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

  4. The resource documentation says the branch must already exist and will only be created automatically if autocreate_branch is set, and marks autocreate_branch deprecated in favor of a separate github_branch resource. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.