Skip to content

CI rollout

node ~/.claude/scripts/fleet/ci-rollout.js takes the farm runner from one repo to every repo that should have it, and says why for every repo it leaves alone. It works through the per-repo tools: the declared CI locus in each provision manifest, runner.sh install for the runner, and ci-locus.js reconcile for the repo variable. Nothing is written without --apply. (Back to reference index.)

The policy is operator data at config/ci-rollout.json in the operator repo, validated by schemas/ci-rollout.schema.json and graded by check-operator.js:

{
"_meta": { "version": 1 },
"farm_owners": ["<account>"],
"hosted_owners": ["<other-account>"],
"active_since": "2026-01-01",
"runner_machine": "farm-01"
}
Key Meaning
farm_owners Accounts whose private, active repos with CI move to the farm.
hosted_owners Accounts that stay on GitHub-hosted Actions. Each of their managed repos is declared hosted, so a later fleet-wide reconcile cannot move it.
active_since The dormancy criterion: a repo last pushed before this date is not converted. Default: 1 January of the current year.
runner_machine The machines.json id that runners are registered on. apply needs it to register a runner.
runner_label The label that converted repos are declared with. Default: self-hosted-bob.

With no policy file, farm_owners is the operator’s forge.owner and nothing is declared hosted. Any other key is refused. A name-prefix rule cannot be expressed in the policy at all.

Every repo of every policy owner is listed from gh repo list at run time. The first rule that matches decides the class:

Rule Class Why
archived skip Archived repos run nothing.
owner in hosted_owners hosted Declared hosted: separate account and billing.
public (live visibility) excluded A self-hosted runner on a public repo lets any fork PR run code on the fleet.
owner not in farm_owners skip Outside the policy.
manifest declares hosted skip An explicit declaration wins. This is also how a rollback stays rolled back.
no workflows skip Nothing to run.
last push before active_since skip Dormant. The last push and the criterion are printed.
otherwise farm

The split never reads a repo’s name. Repos named for one account’s product often live under another account, so a name rule would misroute them.

A repo is matched to its provision manifest by the manifest checkout’s origin remote. When the checkout is on another machine, the match falls back to the exact repo name, marked ~. The fallback is refused when that name exists under more than one listed owner, because the manifest cannot say which repo it means. Run the rollout on the machine that has the checkout instead.

Terminal window
node ~/.claude/scripts/fleet/ci-rollout.js inventory [--owner <o>]... [--policy <file>] [--json]
node ~/.claude/scripts/fleet/ci-rollout.js apply [--owner <o>]... [--repo <owner/name>] [--policy <file>] [--apply] [--json]
node ~/.claude/scripts/fleet/ci-rollout.js verify [--owner <o>]... [--repo <owner/name>] [--policy <file>] [--json]
node ~/.claude/scripts/fleet/ci-rollout.js rollback --repo <owner/name> [--policy <file>] [--apply] [--json]

apply converges each farm or hosted repo in three steps. Each step reads the repo first and reports ok when it is already right, so a second pass writes nothing:

Step Farm Hosted
declare _meta.ci.locus = farm in provisions/<project>.json (only _meta.ci changes) _meta.ci.locus = hosted
register runner.sh install --machine <runner_machine> --repo <slug> --label <label> when no runner carries the label —
reconcile ci-locus.js reconcile --apply → CI_RUNNER_LABEL = <label> CI_RUNNER_LABEL deleted

Excluded and skipped repos are listed with their reason and never touched. A farm or hosted repo with no manifest is blocked: run bob ready in its checkout, then re-run. Manifest edits land in the operator repo’s working tree. Commit them there.

verify names, for each farm repo, a successful run with a job on a runner carrying the label. A green run on hosted does not count. A converted repo is done only when it is verified. The check also catches a repo whose workflows still say runs-on: ubuntu-latest: setting the variable changes nothing there, so no run ever lands on the farm. Patch those workflows to runs-on: ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }} (Workflow Authoring), push, and verify again.

rollback moves one repo back to hosted: it declares hosted and reconciles the variable away. The runner stays registered; runner.sh remove --machine <id> --repo <slug> removes it.

Command 0 1 2 3 4
inventory listed — usage or invalid policy — an owner could not be listed
apply converged or applied would change (dry run) usage or invalid policy a repo failed, or a public repo still points at the farm not measured
verify every farm repo verified some unverified usage or invalid policy — not measured
rollback converged or applied would change (dry run) usage blocked or failed the repo could not be read

--json prints one bob-ci-rollout/1 document.

This dry run used farm_owners: [paulirv], hosted_owners: [example-co], active_since: 2026-01-01 and runner_machine: farm-01. It covered 84 repos:

  • Farm (22): 1 converged (bigbrain), 11 would change (10 need a runner) and 10 blocked with no manifest.
  • Hosted (8): 4 would be declared hosted and 4 are blocked. flows is blocked because its manifest matches flows under both accounts by name.
  • Excluded (5): the public repos acfbak, loclook, loopalert, remote-decision-bridge and zipcity.
  • Skipped (49): no workflows, dormant, or already declared hosted.

The live apply is attended: it writes runner registrations and repo variables across more than 20 repos.