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

Source: https://voxgig.com/howto/push-one-ci-change-to-many-repositories

- Audience: platform-team
- Level: advanced
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://cli.github.com/manual/gh_repo_list)
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](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
GitHub documents for its REST API.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [gh CLI](https://cli.github.com/manual/gh_pr_create) | 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](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-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](https://docs.renovatebot.com/config-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](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file) | 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](https://github.com/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.

```js title="fanout.mjs"
/** 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.

```bash
node setup.mjs
```

```text output
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`](https://cli.github.com/manual/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.

```bash
node fanout.mjs --dry-run
```

```text output
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.

```js title="fanout.mjs"
  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.

```bash
node fanout.mjs --fail-after 3 --batch 2 --batch-pause 1 --rate-limit-once
```

```text output
  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.

```bash
node fanout.mjs
```

```text output
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.

```js title="fanout.mjs"
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`](https://cli.github.com/manual/gh_pr_create) with `--repo`, `--head`,
`--base`, `--title` and `--body`, the flags the manual documents for skipping every prompt.

```js title="forge.mjs"
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.

```bash
node fanout.mjs --verify
```

```text output
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.

```bash
node --test fanout.test.mjs
```

```text output
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](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows)
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](https://docs.renovatebot.com/modules/manager/github-actions/) in
Renovate opens the pull request that moves a caller to the next tag.[^3]

The [Terraform resource](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file)
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

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

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

[^1]: The [rate limits page](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
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.

[^2]: The [reusable workflows page](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows)
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.

[^3]: The Renovate [presets page](https://docs.renovatebot.com/config-presets/) 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](https://docs.renovatebot.com/modules/manager/github-actions/) 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.

[^4]: The [resource documentation](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file)
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.