Modular repositories

A repository in a fleet owns everything about itself: how its container runs, what environments it needs and how it ships. The host owner’s fleet file only lists it. This page is the adopter’s guide to the three commands that keep twenty such repositories honest: replicator init starts one, replicator doctor checks one (or all of them), and replicator fleet bump moves them all to a new release.

What a repository owns

FileSays
.devcontainer/devcontainer.jsonWhat a developer container of the repository is (the customizations.replicator block carries the rest)
replicator.config.tsIts environments: stages, sites, names, storage
worker/index.mjsOptional. The edge Worker’s script, when the repository has one
.github/workflows/replicator.ymlA short caller of the shared workflow, pinned to one release

Everything the replicator itself reads is that one file, replicator.config.ts, at the repository’s root: its environments, the secrets they read (a secrets block) and how its static sites are published (a static block). There is no folder and no second file. The two files that stay outside it stay where the tool that reads them looks: the dev-container file in .devcontainer/ (editors and Codespaces open it from there) and the workflows in .github/workflows/ (GitHub runs them from there). The host owner’s fleet file and host specs are the host owner’s, not a repository’s.

Nothing about a repository’s inside is written in the fleet file, and the fleet file is the only place a repository is listed. See The fleet file for the listing and Environments for the second file.

Start a repository: replicator init

In an empty repository:

replicator init --name shop --zone shop.example --profile static-site \
  --account <state account id> --ref <40-character release commit> --release v1.25.0
FlagMeaning
--dir <repo>The repository’s root. Default: the current folder.
--name <app>The app’s name; the state is kept under it. Default: the folder’s name, made lowercase and dashed.
--zone <zone>The DNS zone the stages’ names live in. Default: a placeholder you must replace.
--profile <name>What kind of repository: static-site, worker or site-and-worker (below). Without it, the bare config.
--staticThe same as --profile static-site.
--account <id>The account that holds the state: 32 hex characters. Without it the files carry a placeholder doctor refuses.
--ref <sha>The release commit the caller pins. Without it the caller carries a placeholder doctor refuses.
--release <tag>The release’s name, written as a comment beside each pin.

A profile adds what a kind of repository needs, and every profile declares its secrets in the config:

ProfileAdds
static-sitea static block, and a site (its project and domain) in the environments file
workerworker/index.mjs, and a Worker with a variable, a secret and a domain in the environments file
site-and-workerBoth, in one environments file

Complete, tested copies of each are in Examples.

It writes the files above, each from a template, and prints what to do next. It never overwrites: if any file it would write exists, it writes nothing, lists every one that exists and exits 1. Delete or move them to scaffold again, or edit them by hand.

What comes out is accepted by the commands that read it: replicator validate .devcontainer/devcontainer.json --host <host> passes, replicator infra diff --config replicator.config.ts --stage staging runs, and replicator doctor --dir . finds the files in agreement once the placeholders are replaced. Replace image in the dev-container file with the image your own build publishes.

The caller it writes is a few lines: a diff on every pull request that touches replicator.config.ts, and a deploy you start by hand. Its steps live in the shared workflow, so the caller never carries a copy of them. The only secret it passes is REPLICATOR_STATE_TOKEN.

Check it: replicator doctor

replicator doctor --dir .                  # one repository, offline
replicator doctor --fleet clanker.yml      # every repository of a fleet, read at its pinned ref
replicator doctor --dir . --fleet ../fleet/clanker.yml --json

Each finding is ok, warn or SKEW; the exit code is 1 on any skew, and with --strict also on any warning (replicator doctor --dir . --strict is the form for a pull request check). A finding where two files disagree names both of them, as paths. Every verb’s exit codes are in The command line.

One repository (--dir, nothing leaves the machine):

  • The dev-container file reads as a container, and the environments file loads.
  • Every stage a workflow of the repository targets is a stage the environments file declares, and the workflow’s config path exists.
  • The static block builds into a valid contract, no two targets publish to one project, every target names a project for every environment of the block, and the domains it serves are inside a zone the environments file names.
  • The state account is the same wherever it is written: the environments file’s state.accountId (which the contract is built with), the workflow’s state_account, and, if set, the account the runner’s token belongs to (REPLICATOR_STATE_ACCOUNT). A placeholder account, or a token for another one, is refused.
  • Every release pin in the callers is a 40-character commit, all of them the same one. The scaffold’s placeholder pin, a branch or a tag is refused.
  • The secrets: a name the environments file reads with ctx.secret("…") that the secrets block does not declare, a name read with ctx.secret that the block marks required: false, and a declared name nothing reads (no ctx.secret call, no workflow or script that fetches it) are warnings. An old secrets.schema.json beside a config that has the block is reported as ignored; one that does not parse is a skew. A name the file computes is not judged, because the check reads text.
  • The engine range (below).
  • The output dependencies on other repositories, listed. There are none yet, so the line says so.

Where a value is computed in code (a name built from a stage), the check reads the file’s text and says only what the text says.

A fleet (--fleet, read-only): for each enabled entry it resolves the ref, reads replicator.config.ts at that commit and reports the state account per repository. An unreachable repository is a warning that names it, never a silent skip; a disabled one is listed. Entries are the explicit repos: plus the repositories discover rules join (a plan-only find is listed as not read; a rule whose listing fails is a warning and removes nothing). Then:

  • Two repositories of one organisation sharing an account is fine when they have the same owner, a warning when the owners differ, and a skew when the repositories belong to different organisations: one token opens every secret in an account, so each trust domain needs its own account.
  • Two repositories declaring the same app name in one account would write the same state, which is a skew.
  • Each entry’s engine range is checked.

The engine range

A repository states which engines it was written for:

// .devcontainer/devcontainer.json
"customizations": { "replicator": { "engine": ">=1.25.0 <2.0.0" } }
// replicator.config.ts
export default replicator.app({
  name: "shop",
  engine: ">=1.25.0 <2.0.0" /* … */,
});

A range is comparators joined by spaces, all of which must hold: >=, >, <=, <, =. An engine outside the range refuses the file, naming the file, the range and the engine’s own version, before anything is read or applied. The commands that read the dev-container file (validate, plan, container …) and every replicator infra command do this. Fix it by running the engine from a release inside the range (the caller’s pin), or by moving the range once the repository has been moved. doctor reports both files’ ranges, warns when they differ or when one is missing.

The state account is the boundary

The state bucket has one fixed name per account and one token opens all of it, so repositories that must not read each other’s secrets belong in different accounts. Each repository says which account holds its state (state.accountId) and the runner says which account its token belongs to; they must agree or every replicator infra command refuses. doctor --fleet is how a fleet owner sees which repositories share an account.

Move a fleet to a release: replicator fleet bump

replicator fleet bump --to v1.26.0 --fleet clanker.yml                 # dry run (the default)
replicator fleet bump --to v1.26.0 --fleet clanker.yml --repos acme/api,acme/web
replicator fleet bump --to v1.26.0 --fleet clanker.yml --open-prs      # open the pull requests

It resolves the release tag to its commit, then for every entry of the fleet reads the repository’s caller workflows at its base branch and finds every pin: each uses: …/replicator-*.yml@<ref> (and the shared actions) and each replicator_ref:. Pins the workflow computes are left alone. Every entry gets a line in the report:

RowMeaning
N file(s) on mainThe files and lines that would change, with the old and the new commit
already on <tag>Nothing to do
no caller workflowThe repository has no workflow that pins the engine
not selectedLeft out by --repos (listed, not forgotten)
BLOCKEDIts declared engine range excludes the release. Move the range on purpose, or pass --widen-range to raise an excluded upper bound for you
UNREACHABLEThe repository could not be read, with the reason (a missing token, a deleted branch)
[discovered]Beside a repository’s name: a discover rule found it (an explicit entry for the same repository wins)
not joinedA plan-only rule found it: listed, not bumped, until the rule says join

The fleet is read the way fleet status reads it: the explicit repos: list plus the repositories its discover rules join. A rule whose listing cannot be read gets its own discover … UNREACHABLE line, removes nothing, and makes the command exit 1, because repositories it would have listed were not bumped.

The dry run writes nothing anywhere. With --open-prs it opens one branch (replicator/bump-<tag>) and one pull request per repository, a commit per file, using your own token (FACTORY_GITHUB_TOKEN, or GITHUB_TOKEN) — never a third-party bot. The token needs to write contents, workflows and pull requests in each repository. A pull request already open from that branch is reported, not duplicated. The command exits 1 when any repository was blocked, unreachable or failed, so a script cannot mistake a partial bump for a complete one.

Review each pull request and let the repository’s own checks run before merging: the bump moves the pin, and the repository’s own tests say whether it still works.