Module: internal/template (TemplateSource) + internal/registry · Milestone: v2 · Effort: ~2.5w (feature #11)
Turn a personal pile of go:embed'd templates into team infrastructure: a platform team publishes "the way we run services" once (template push oci://ghcr.io/acme/templates:1.4.0), and every developer renders byte-identical compose+Dockerfiles from a digest-pinned lockfile in workspace.yaml. This adds the publish/consume/verify workflow — push, pin, diff, update, lint/test, init — around the remote TemplateSource that spec 02 already abstracts.
Builds on the v1 substrate: the
TemplateSource { Resolve(ref) (fs.FS, error) }interface, the digest→cache mapping underXDG_CACHE_HOMEwith GC/TTL (D15), the deterministic generation pipeline (ARCHITECTURE §3), and the editor JSON Schema that travels with a template (D16). Deferred to v2 because v1's built-in embedded templates andtemplate init|lint|test(spec 07) already cover a solo user; remote distribution only earns its keep once multiple repos must share a curated set, and it carries a supply-chain surface (signed artifacts, trust model) that the four-pillar 1.0 should not block on.
Resolve(ref) dispatches on the ref scheme; all return an fs.FS rooted at a template directory (spec 02 §"Template = a directory"):
| Scheme | Example | When | v2 read path |
|---|---|---|---|
embedded (built-in) |
php.laravel.nginx |
v1 default; ships in the binary | go:embed (always offline) |
oci:// |
oci://ghcr.io/acme/templates:1.4.0 |
the registry workflow | pure-Go ORAS pull → digest cache |
git+https:// / git+ssh:// |
git+ssh://git@github.com/acme/tmpl.git//php?ref=v1.4.0 |
git-native teams; reuses internal/git auth |
shallow clone at ref, then resolve to commit SHA |
file:// |
file://../local-templates/php |
authoring / template diff |
direct fs (never cached, never pinned) |
OCI is the primary post-1.0 path (registry auth, content-addressing, and signatures are first-class there). Git is the lighter alternative for teams already living in a repo; it pins to the resolved commit SHA, not a moving branch/tag.
A render is reproducible across a team only if template content is addressed by digest, not a moving tag. workspace.yaml gains a templates: lockfile block — resolved at template add/update, read at generation:
# workspace.yaml (apiVersion: devstack/v1; additive key, forward-tolerant per spec 14)
templates:
- name: acme # local alias used in `template: acme/php.laravel`
source: oci://ghcr.io/acme/templates
version: "1.4.0" # the human-facing tag (provenance only — NOT used to fetch)
digest: "sha256:9c8b…" # the OCI manifest digest — what is actually fetched/verified
schemaVersion: 3 # the template-bundle schema version (see "what travels")- Generation never resolves a tag. It fetches by
digest; theversionis recorded for human diffs/changelogs only. A moving tag is resolved-and-pinned exactly once (atadd/update), never on the hot path — this is what makesupbyte-stable for the whole team and keeps the CI determinism check (spec 02 AC) green. - Cache key is the digest, never the tag.
XDG_CACHE_HOME/devstack/templates/<sha256>/…(D15). Keying on the tag would let a re-pushed:1.4.0poison every machine's cache silently; keying on the digest makes a re-push a new cache entry that a pinned workspace simply never reaches. GC/TTL evicts by digest; a digest still referenced by any workspace lockfile is pinned against eviction. - Lockfile is committed (it lives in
workspace.yaml). A teammate cloning the repo renders the same bytes offline once the digest is in cache; a cold cache triggers exactly one pull.
CLI surface (extends spec 07)
devstack template init [<dir>] # scaffold template.yaml + build/ + schema + golden fixture (v1, kept)
devstack template lint [<dir>] # schema + param-type + delimiter-collision checks (v1, kept)
devstack template test [<dir>] # golden-render: params fixture → expected output tree (v1, kept)
devstack template push oci://…:<tag> [<dir>] [--sign] # v2: build bundle, push, optionally cosign-sign
devstack template add oci://…:<tag> # resolve tag→digest, verify, write/pin templates: entry
devstack template update [<name>] # re-resolve tag→new digest; show diff; rewrite lockfile (opt --to <tag>)
devstack template diff <name> # render-diff pinned-digest vs latest-tag (or vs file://) — no writes
devstack template ls # list pinned templates + resolved digests + cache state
devstack template verify # re-pull each pinned digest, re-check signature, assert no drift
add/update/verify mutate or read the cache and so take the gofrs/flock (spec 08) around the cache write (concurrent up invocations must not race a half-written cache dir); the write itself is tmp-dir-then-os.Rename atomic, mirroring writeIfChanged. push/lint/test/diff/ls are read-only and lock-free. All carry the --json contract (ARCHITECTURE §7.9).
A bundle is an OCI artifact (or a git subtree) carrying the spec 02 template directory plus the editor schema (D16) so autocomplete/validation stay in lockstep with the template version a workspace actually pins:
template.yaml # extends:, schemaVersion, typed params: with defaults
build/… # file templates (Dockerfiles, conf, scripts)
post_init.yaml # optional
params.schema.json # JSON Schema for this template's params (editor + lint authority)
NOTICE # carried licenses (compose-go Apache-2.0, etc.)
The template-bundle schemaVersion is independent of config apiVersion and state schema_version (spec 14 — a fourth versioned artifact): a newer binary tolerates an older bundle; a bundle whose schemaVersion is newer than the binary understands fails add/generation with a "upgrade devstack to use this template" message (never a silent partial render). An extends: chain may cross bundles — each base is resolved through TemplateSource and pinned by its own digest, so the merge inputs are fully content-addressed.
Pulled templates become build instructions (Dockerfiles, entrypoint scripts) — the same trust model as any compose file (spec 02 gotcha). The registry makes the trust boundary explicit:
- Digest verification is mandatory. A pulled manifest whose computed digest ≠ the pinned
digest:is refused, generation aborts, the cache entry is not written. (Cheap, always-on, no keys required.) - Signature verification is optional but first-class.
template push --signcosign-signs the artifact;verify.cosign: { enabled, keyless|key, identities }inworkspace.yamlmakesadd/update/verify/generation require a valid cosign signature over the manifest digest before trusting it. Pure-Go path viasigstore-goverification (nocosignbinary required); keyless (Fulcio/Rekor) and keyed both supported. A signature failure aborts like a digest mismatch. - Trust is opt-in and loud. With no
verifyblock, devstack pins+verifies the digest only and prints a one-line "templates are digest-pinned but unsigned" notice onadd(mirrors the spec 14 checksum-only self-update warning). Adding a template from an unknown registry never silently grants it build-time execution.
- OCI artifacts need correct media types + a pure-Go client. Use
oras-go/v2(oras.land/oras-go/v2, Apache-2.0, pure-Go — preservesCGO_ENABLED=0); never shell to adocker/orasbinary for pull (the binary may be absent and it muddies offline behavior). Push a customartifactType(application/vnd.devstack.template.v1) with each layer'smediaTypeset so non-devstack tooling treats the artifact as opaque rather than mis-rendering it as an image. - A moving tag breaks determinism. Never fetch by tag at generation — resolve to a digest once and pin it;
:latestis rejected byaddwithout an explicit--allow-floating. - Cache poisoning if the key is the tag. The cache MUST key on the digest (D15); a tag-keyed cache lets a re-pushed tag serve stale-or-malicious content to a pinned workspace. (Restates the §"Digest pinning" rule because getting it wrong silently defeats the whole feature.)
- Private-registry auth rides the existing credential store. ORAS reads
~/.docker/config.json(and OS credential helpers /GITHUB_TOKENfor GHCR) — do not invent a devstack token store. Git templates rideinternal/git's system-git auth (spec 06, D9) — SSH agent /GIT_ASKPASS— for free. - The editor schema must travel with the template (D16): if
params.schema.jsonis not bundled+versioned, a developer pinning1.4.0gets autocomplete for whatever version their editor last cached — schema/template skew. Theyaml-language-servermodeline still points at the config schema; the param schema is consumed bylintand editor param hints. - OCI digests are over the canonical manifest, not the rendered output — verify the pulled bytes match the digest before unpacking into the cache, and unpack atomically (tmp dir + rename) so a
kill -9mid-pull never leaves a half-populated, wrongly-trusted cache dir. - WSL2/9p: template cache lives on the Linux side (ARCHITECTURE §8); never under
/mnt/*. A registry pull is network I/O —doctorshould treat registry unreachability as non-fatal when every pinned digest is already cached (offline-first).
-
template push oci://localhost:5000/t:1.0thentemplate addwrites atemplates:entry with the resolvedsha256:digest; the humanversionis recorded but generation fetches by digest. - Two machines with the same committed lockfile render byte-identical compose+Dockerfiles offline once the digest is cached (determinism check, spec 02).
- Re-pushing the same tag with changed content does not alter a pinned workspace's render until
template updatere-resolves the digest; the new content lands under a new cache key. - A digest mismatch (tampered/corrupt pull) aborts generation, writes nothing to cache, and reports the expected vs actual digest.
- With
verify.cosign.enabled: true, an unsigned or wrongly-signed artifact is refused onaddand generation; a valid signature passes — nocosignbinary required. -
template updateshows a render-diff (old digest → new tag's digest) and rewrites the lockfile only on confirm;--dry-runwrites nothing. - An
extends:chain spanning two OCI bundles pins both digests and merges deterministically. - A bundle with a newer
schemaVersionthan the binary failsaddwith an upgrade message, not a partial render. - Private GHCR pull succeeds using
~/.docker/config.jsoncreds with no devstack-specific token config. - Cache GC evicts an unreferenced digest but never one still pinned by any workspace lockfile.
Consumes internal/template (TemplateSource + extends resolver, spec 02), internal/git (git-scheme auth, spec 06), internal/xdg (digest-keyed cache + GC/TTL, D15), internal/lock (cache-write serialization, spec 08), internal/config (the templates:/verify: schema blocks + unknown-key forward-compat, spec 01/spec 14), internal/cli (the template subtree + --json, spec 07). Consumed by internal/generate (resolves pinned digests as render inputs). Thinner v2 vs full: a thin slice (OCI pull/push via ORAS + digest lockfile + mandatory digest verify + add/update/ls) lands in ~1.5w and already delivers reproducible team sharing; the full version (cosign signing+verification, git-scheme source, template diff render-diff, registry GC integration, bundled param-schema editor wiring) is ~2.5w.
- Q-T — the chosen text-template engine (stdlib vs gonja) is what third-party bundles author against; the registry must pin engine identity so a bundle authored for one engine isn't rendered by another. (See DECISIONS D2.)
- Q-REGISTRY (new) — does
template pushsupport multi-template bundles (one artifact carrying a wholephp.*/node.*family addressed by sub-path) or strictly one template per tag? Multi-template eases atomic team releases and sharedextends:bases but complicates per-template digest pinning and partial cache GC; recommendation: one artifact per template family with sub-path refs (oci://…/templates:1.4.0//php.laravel), pinned by the family digest. Record before wiringpush. - Q-SIGN-TRUST (new) — is cosign keyless (Fulcio/Rekor, requires the developer's machine to reach the public transparency log) acceptable as the default for OSS teams, or must the default be keyed (a team-distributed public key, fully offline-verifiable)? This trades zero-key-management for an online verification dependency; recommendation: keyed by default for offline determinism, keyless opt-in. (Relates to Q-DAEMON's offline-first posture.)