The fleet file — factory.yml, version: 2

One per host, owned by the host owner. It names the host and the repositories whose developer containers the host runs, each at a pinned ref. The repo’s own container config is no longer here: a repo carries a standard .devcontainer/devcontainer.json (customizations.clankernet for what the standard cannot express — T202/T203), and the fleet file says which repos, at which ref, and who may hold a container for each. Design: docs/plan/devcontainer-pivot-plan.md.

# yaml-language-server: $schema=https://clanker.net/schema/factory-fleet.v2.json
version: 2
host: ./hosts/dev-host.yml # or repo:/stack: form, or the host spec inline
repos:
  - url: https://github.com/clankerlabs/clankerengineer
    ref: develop # branch (resolved to a sha at every read) or a 40-hex sha
    devcontainer: .devcontainer/devcontainer.json # the default
    access: { github: { org: clankerlabs } } # REQUIRED — who may hold a container
  - url: clankerlabs/CLANKERNET # the bare slug is the same repository
    ref: main
    access: { github: { org: clankerlabs } }
sync:
  schedule: "0 6 * * *" # the sync workflow's cron, optional
  • Schema: schema/factory-fleet.v2.json (generated from packages/engine/src/schema/fleet.ts, byte-pinned by packages/engine/test/json-schema.test.ts). Looser than the CLI, as every generated schema here is: “one entry per repository” and the placeholder check on access.tailscale.hostname are refinements only factory validate runs.
  • Example: examples/factory.fleet.yml — the clankerlabs dev-host fleet.
  • Validate: node packages/engine/bin/factory.js validate factory.yml. The CLI looks at version and routes: 2 is a fleet, 1 is the v1 manifest (deprecated). A fleet report names the host, every enabled repo with the sha its ref resolved to and the path that was read, and every enabled: false entry as skipped. --offline makes no GitHub call — entries are listed unresolved with a warning. --json carries the same kind/ok/errors/warnings/host/stack keys the reusable workflow already reads for a manifest, plus repos[] and skipped[].

Why version: 2 and not a new file name

factory.yml stays the name (CLAUDE.md rule 2: nothing else is called that), and version is what tells the two formats apart — the engine reads it before either schema runs, so a v2 file is never reported as “expected version 1” with a page of unrecognised keys, and a v1 file handed to a fleet-only path is refused by kind. A version: 1 file still validates and provisions through this release with a deprecation warning naming factory migrate (T206); v2.0.0 removes the v1 schema (T208).

Fields

Strict objects throughout: an unknown key is an error, not a silent no-op.

version (required)

2.

host (required)

Where the fleet runs. Either a host ref in any of the three forms the v1 manifest’s host.ref takes — resolved by the same code, with the same FACTORY_GITHUB_TOKEN for a private repo: and never --show-secrets for stack: — or the host spec inline:

FormResolved from
./hosts/<name>.ymla file relative to the fleet file (not the cwd)
repo:<owner>/<repo>@<ref>:<path>.ymlthe host owner’s repo through the GitHub contents API; FACTORY_GITHUB_TOKEN if private (the workflow’s checkout_token_from)
stack:<org>/<project>/<stack>the host stack’s non-secret hostSpec output
an objectthe host spec itself, validated by HostSpecSchema in place

--host on the CLI overrides it exactly as it does for a manifest: a host id (hosts/<id>.yml under FACTORY_ROOT), a path to a .yml, or a ref form.

repos[] (required, at least one)

KeyTypeDefaultMeaning
urlGitHub URL or owner/nameThe repository. https://github.com/owner/name (optional .git, trailing /) and the bare slug are ONE identity; the reader returns both (url, slug). A repository listed twice, in either spelling, is refused
refbranch/tag or 40-hex shaWhere the dev-container file is read. A branch means “whatever it is at read time” — the run summary records the sha it resolved to (plan Risks, “Ref drift”); pin a sha for reproducibility
devcontainerrepo-relative path.devcontainer/devcontainer.jsonThe dev-container file, read at ref through the contents API. .json, no leading /, no ..
accessobject— (required)Who may hold a container of this repo on this host, and how they reach it. See below
enabledbooleantruefalse keeps the entry in the file but excludes it from every read: not fetched, not converged, not torn down

How a repo is read (packages/engine/src/fleet/read.ts), two GitHub calls at most, both with FACTORY_GITHUB_TOKEN as the bearer when set:

  1. a branch or tag refGET /repos/{owner}/{name}/commits/{ref} with Accept: application/vnd.github.sha, which answers the bare sha. A ref that already is a 40-hex sha makes no call.
  2. GET /repos/{owner}/{name}/contents/{devcontainer}?ref={sha} as raw text — at the sha, not the branch, so the two answers cannot straddle a push.

Responses are cached per run by URL. A repo with no dev-container file at its ref fails naming the path, the ref and the sha it resolved to:

fleet repo `acme/api`: no `.devcontainer/devcontainer.json` at ref `develop` (b2c…) — every fleet repo carries its dev-container file at the pinned ref

The reader returns the file text; parsing it into the resolved manifest the container engine consumes is T203’s reader, plugged in through packages/engine/src/fleet/parse-devcontainer.ts (it answers { status: "not-wired", task: "T203" } until then).

access

The same block the v1 manifest carried at its top level (factory-yml.md §access), defined once in packages/engine/src/schema/access.ts so both files refuse the same shapes: github.org / github.users, ssh.keySource / ssh.users, and tailscale.hostname (a single-label template; in a fleet only {user}, {repo} and {zone} resolve — ports come from the dev-container file).

Required, and access.github inside it (org or users): an entry that omitted it would authorise everyone the caller repo’s workflow_dispatch does — the same silent widening the v1 schema already refuses for an empty access.github block.

Why access lives here

Two files, two owners (plan Risks). A repo’s devcontainer.json is the repo’s: anyone who can push to the repo can change it, and it stays openable in any editor that speaks the standard. The fleet file is the host owner’s. “Who may hold a container on this host” is a grant of the host owner’s compute, so it is stated by the host owner, in the host owner’s file — a repo cannot grant itself a host by editing its own config. It follows that access is never read from customizations.clankernet (T202 refuses it there), and that an entry without it is not a “default-open” entry but an error.

sync

KeyTypeDefaultMeaning
schedulefive-field cron expressionWhen the dogfood/sync workflow re-reads every repo at its ref and converges the host (factory sync, T205)

Refusals, in one place

ConditionResult
version other than 2 (or 1, which is the v1 manifest)error
host neither a ref form nor a valid inline specerror
repos empty, or the same repository twice (either spelling)error
url not https://github.com/owner/name or owner/nameerror
ref with whitespace, .., a leading -//, or a .lock suffixerror
devcontainer absolute, climbing (..), or not .jsonerror
access missing, or access.github missing / with neither org nor userserror
access.tailscale.hostname with an unknown placeholder or a doterror
sync.schedule not five cron fieldserror
any unknown key at any levelerror
a repo with no dev-container file at its ref (online)error
a ref the commits API cannot resolve (online)error
--offlinewarning — nothing fetched; a repo:/stack: host also skipped