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 frompackages/engine/src/schema/fleet.ts, byte-pinned bypackages/engine/test/json-schema.test.ts). Looser than the CLI, as every generated schema here is: “one entry per repository” and the placeholder check onaccess.tailscale.hostnameare refinements onlyfactory validateruns. - Example:
examples/factory.fleet.yml— the clankerlabsdev-hostfleet. - Validate:
node packages/engine/bin/factory.js validate factory.yml. The CLI looks atversionand routes:2is a fleet,1is 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 everyenabled: falseentry as skipped.--offlinemakes no GitHub call — entries are listed unresolved with a warning.--jsoncarries the samekind/ok/errors/warnings/host/stackkeys the reusable workflow already reads for a manifest, plusrepos[]andskipped[].
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:
| Form | Resolved from |
|---|---|
./hosts/<name>.yml | a file relative to the fleet file (not the cwd) |
repo:<owner>/<repo>@<ref>:<path>.yml | the 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 object | the 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)
| Key | Type | Default | Meaning |
|---|---|---|---|
url | GitHub URL or owner/name | — | The 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 |
ref | branch/tag or 40-hex sha | — | Where 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 |
devcontainer | repo-relative path | .devcontainer/devcontainer.json | The dev-container file, read at ref through the contents API. .json, no leading /, no .. |
access | object | — (required) | Who may hold a container of this repo on this host, and how they reach it. See below |
enabled | boolean | true | false 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:
- a branch or tag
ref→GET /repos/{owner}/{name}/commits/{ref}withAccept: application/vnd.github.sha, which answers the bare sha. Arefthat already is a 40-hex sha makes no call. 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
| Key | Type | Default | Meaning |
|---|---|---|---|
schedule | five-field cron expression | — | When the dogfood/sync workflow re-reads every repo at its ref and converges the host (factory sync, T205) |
Refusals, in one place
| Condition | Result |
|---|---|
version other than 2 (or 1, which is the v1 manifest) | error |
host neither a ref form nor a valid inline spec | error |
repos empty, or the same repository twice (either spelling) | error |
url not https://github.com/owner/name or owner/name | error |
ref with whitespace, .., a leading -//, or a .lock suffix | error |
devcontainer absolute, climbing (..), or not .json | error |
access missing, or access.github missing / with neither org nor users | error |
access.tailscale.hostname with an unknown placeholder or a dot | error |
sync.schedule not five cron fields | error |
| any unknown key at any level | error |
| a repo with no dev-container file at its ref (online) | error |
a ref the commits API cannot resolve (online) | error |
--offline | warning — nothing fetched; a repo:/stack: host also skipped |