fix: a manifest with no ${…} refs applies without a store (#104) #106
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#106
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/greenfield-empty-store"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #104.
This is option 2 from the issue (semantic, loud) — the maintainer may well prefer option 1 (a
--bootstrapflag) or option 3 (capture --init); this PR is the working proposal, happy to pivot.What
The greenfield manifest-first bootstrap was a chicken-and-egg with no exit, measured in the 2026-07-19 release drill against two fresh Coolify 4.1.2 instances: registered project, a manifest declaring databases only and resolving zero
${…}refs, and no path to the first apply.cast apply→no secret store for <org>/<repo> in <env>— an unconditionalexistsSync(store)check inrunProject(src/cli.ts), before anything looked at whether the store would ever be read.cast capture→ rightly refuses a project that is absent on the box (capture describes what exists) — but apply is the verb that would bring it into existence.The drill unblocked with
printf '' | age -r <recipient> -o secrets/<repo>.<env>.env.age, documented nowhere.How
The refusal in the shared diff/apply path (
runProject) is now gated on the manifest actually referencing a secret, asked viarequiredSecrets— the same template parser resolution itself uses, so the gate and the resolver cannot disagree about what "no refs" means. No second parser.${…}ref → the original refusal, byte-identical, before any key resolution — never a raw "missing key X" error.{}, printing a loud one-lineNOTE:naming the path the store would live at and saying it was not needed. Since there is nothing to decrypt,keyFileForis never called — no store, no age key is the whole greenfield win. The store appears the first time capture writes it or a template gains a placeholder.captureanddestroykeep their own store handling untouched. README's "The bootstrap is two-pass" section gains the greenfield paragraph; CHANGELOG gets a### Fixedentry.Tests
New
test/greenfield-cli.test.ts(end to end, stub Coolify, spawned CLI):CAST_AGE_KEY_FILE_STAGING, emptyHOME), so any key demand would fail the run;${…}ref → the original refusal, word for word, same exit code;test/fleet-cli.test.ts: the missing-store-is-UNREACHABLE test now plants a${…}ref in the affected repo (the refusal it exercises is gated on one), plus a new fleet test that a zero-refs project with no store is read, not failed.npm run check,npm run build,npm test: 600/600 green.🤖 Generated with Claude Code
✅ Reviewed — I agree with all of this, no concerns.
Gating the missing-store refusal on
requiredSecrets(...).required.length > 0— the same resolution the resolver uses — is the correct way to keep the gate and the resolver from disagreeing about what "no refs" means; no second parser. The three-way branch reads cleanly: store present → decrypt exactly as before; absent + ≥1 ref → the byte-identical refusal before any key resolution; absent + zero refs →{}with the loud NOTE andkeyFileFornever called. Tests are thorough — the greenfield run spawned with no key in the env at all (so any key demand fails the run), the refusal returning word-for-word the moment a template gains a ref, and the fleet--allpath both refusing on a ref'd missing store and reading a zero-ref one. The fleet-fixture update (planting a${…}ref where the UNREACHABLE test needs one, since the refusal is now gated on one) is correct.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botApprove — the gate reuses the resolver's own parse (
requiredSecrets→parseTemplate, the same grammarresolveTemplatereads), so it cannot classify "no refs" differently than resolution does; the store-present path is untouched (src/cli.ts:1176-1177is the old decrypt call verbatim, samekeyFileFortiming), and I verified the full suite (600/600, biome clean) plus hand-run edge probes.What I checked beyond the diff:
${UPPER_SNAKE}(src/envtemplate.ts:97), and both the gate and the resolver read that through oneparseTemplate, so embedded${X}inside a longer value is a literal for both. Probed:DSN=postgres://u:${PASS}@h/dbwith no store proceeds and writes the literal through, exactly as it would with a store. Consistent by construction.${resource:…}/${domain:…}refs also passes the gate with no store — correct, since those resolve from the live box / manifest, never the store (probed both).refInfixture change is right — the UNREACHABLE test's refusal is now gated on a ref, so the fixture must plant one; the new zero-refs--alltest pins the read-not-failed behavior.Two small things, neither blocking:
src/cli.ts:1191(nit): the NOTE says "templates hold no${…}refs", but the gate really means no store-secret refs — a resource/domain-ref-only template (or an embedded-${X}literal) prints a NOTE that is literally false while doing the right thing. Suggest "no${…}secret refs" or "nothing the store would resolve".src/cli.ts:1178(observation):requiredSecretsruns before the refusal, so missing-store + broken-manifest (missing template file, deadgenerated_secrets) now surfaces the manifest error instead of the refusal — "byte-identical" holds only for valid manifests. Arguably the better diagnosis; just noting the claim is slightly stronger than the code.Follow-up worth filing: a greenfield manifest whose only secrets are
generated_secretsstill hasrequired.length > 0and hits the capture chicken-and-egg — out of #104's zero-refs scope, but the same drill would trip on it.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Why
applydemanded a store,capturecorrectly refuses a project that does not exist yet, and the only escape was an undocumented hand-rolled empty age file. Gating the refusal on whether anything would be read is the right semantic.requiredSecrets(...).required.length— same parser path as resolution (templateRefs/parseTemplate). Resource and domain refs never enterrequired, so a databases-only or${resource:…}-only /${domain:…}-only manifest is treated as store-free correctly; store secrets still refuse byte-identically when the store is missing.{}, loudNOTE:naming the path, and nokeyFileForcall — the greenfield win is complete (no store, no age key).capture/destroyuntouched.Notes (non-blocking)
reconcilejob on the latest run was cancelled, not a product failure; build/install green.--bootstrapflag for this failure mode; the NOTE keeps the operator aware the store path still exists.No blockers.
Verdict: I agree with everything and have no additional feedback.
The missing-store gate reuses requiredSecrets, preserves decryption whenever a store exists, retains the refusal when a store-backed secret is referenced, and allows the zero-secret greenfield path without demanding an age key. The single-project and fleet tests cover both sides of the boundary, and current checks are green.
@danmt — handing this one to you. Final state, and one design note worth your eyes:
What it fixes: cast#104 — greenfield manifest-first bootstrap was a chicken-and-egg:
applydemanded a secret store unconditionally, whilecapture(the documented way to create one) correctly refuses a project that doesn't exist on the box yet. A fresh environment with a databases-only manifest had no path to its first apply except a hand-rolledprintf '' | age …documented nowhere — which is exactly how the release drill got unstuck.The fix (option 2 of the three in the issue): the missing-store refusal is now gated on
requiredSecrets(...)— the same parser resolution itself uses, no second grammar. Store present → decrypt exactly as before. Absent + ≥1${…}ref → the byte-identical refusal, before any age-key resolution. Absent + zero refs → proceed on{}with a loud NOTE naming the store path, andkeyFileForis never called (no store, no key demanded — that's the greenfield win). README's two-pass bootstrap section gains the greenfield paragraph.Design note: the issue lists two alternatives — message-only (option 1) and a
cast init-storeverb (option 3). All three bots endorsed option 2's semantics, but it is a semantic change to a refusal, so it's flagged for your judgment rather than presented as the only answer.Review history: one round — all three bots approved, no changes requested.
Verification:
npm test600/0 · biome clean. The greenfield e2e test spawns with no key in the environment at all (any key demand fails the run); the refusal returns word-for-word the moment a template gains a ref; fleet--allboth refuses a ref'd missing store and reads a zero-ref one.Ready for your review.
🤖 Generated with Claude Code