fix: never write an env var whose name Coolify injects itself (SOURCE_COMMIT, COOLIFY_*) #56
No reviewers
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#56
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/reserved-env-names"
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?
The trap
Coolify injects
SOURCE_COMMITand theCOOLIFY_*family into an application's runtime environment itself — and it skips its own injection of a name when the application already carries an env var of that name.Verified against the real source, not the vendored OpenAPI spec (which is provably incomplete):
app/Jobs/ApplicationDeploymentJob.php@ v4.1.2,generate_coolify_env_variables, lines 2994–3001:The same
->isEmpty()guard shape wraps eachCOOLIFY_*name at 3002–3028 (COOLIFY_FQDN,COOLIFY_URL,COOLIFY_BRANCH,COOLIFY_RESOURCE_UUID,COOLIFY_CONTAINER_NAME), and again on the preview branch at 2950–2984.isEmpty()is asked of the collection of vars, never of the value. So an application-levelSOURCE_COMMIT— even, especially, an empty one — does not merely fail to help: it suppresses the value Coolify would otherwise have provided. Presence, not value.And it fails green. The deploy succeeds, the health check passes, the container runs — and the only symptom is that
/version, which readsprocess.env.SOURCE_COMMITat request time and which a production cutover is gated on, reportsunknown. (D-266.)Anything that writes env vars can set that trap, and cast is a thing that writes env vars. Before this PR,
grep -rn "SOURCE_COMMIT\|COOLIFY_" src/returned nothing.The rule
Reserved names are
SOURCE_COMMITand anything matching^COOLIFY_. A newsrc/reserved.tsowns the rule, the consequence sentence, and the refusal — and every place cast touches an env var honors it. The value of doing all of them together is that the rule is now a property of cast, not of one code path.1.
resolve/apply— REFUSEThe assertion sits in every manifest read in
resolve.ts—desiredFromManifest,requiredSecrets,manifestResources— not in the verb that writes. Soapply,diff,captureandinventoryall refuse identically, before any write. Refusing in one and reporting in another would letcapturewrite a store for a manifestapplyis guaranteed to refuse: a green run that promises a red one.It reads all template keys, not just the
${…}refs — a bareSOURCE_COMMIT=literal suppresses the injection exactly as well. Presence, not value, exactly asforbidden_var_patternsalready does (envtemplate.ts:77).2.
draft/capture— NEVER COPY ONEisProviderGenerated(draft.ts:113) was the only filter between a live var and a drafted manifest, and it recognizesSERVICE_*and datastore-connection names.SOURCE_COMMITsplits to[SOURCE, COMMIT]— no prefix, no datastore word, no connection word — so it was captured verbatim, with its live value, and drafting a working box reproduced the trap in the new box's manifest.A third provenance,
suppressed, joinscapturedandgeneratedin the disposition machinery: kept out of the emitted template and out of the age store, its live value read into no artifact, listed in UNCAPTURED.md with the reason — the report that exists precisely so what cast declines to carry is said out loud rather than dropped — and shown in the disposition table (→ NOT COPIED (Coolify injects this itself)).capture'sclassifycarries the same assertion at the file. Unreachable through the CLI today (requiredSecretsrefuses first) and kept anyway: the invariant is "cast never carries one", not "the CLI happens to check first", and a capturedSOURCE_COMMITwould sit in the age store — the one artifact a reviewer cannot read. Same forsyncEnvincli.ts, the single function that puts an env var on the wire.3.
diff— PROMOTED OUT OF THE ORPHAN LISTA reserved name on a live resource was reported as a
remove-candidateorphan var — the category whose documented meaning is "apply never removes these; read them by eye" — so it read as cosmetic residue. It is not residue: it is an active suppression of a platform-provided value. It now prints as a FINDING with the consequence attached, and the report is not clean:The scan is over the live side, not over the resources the manifest declares, so it is also caught on an orphan resource — which no
changesentry covers.apply never deletesholds unchanged: cast reports it, the human removes it in the UI.4.
smoke— assertedsmokewrites an env var too. Its probe names are exported and asserted outside the reserved space, so a future rename (COOLIFY_SMOKE_PROBEreads like the natural name) cannot set the trap on a live app — and note the delete at the end would restore nothing, since Coolify only injects at deploy time.Where the rule lives, and why
In cast's own code, not in per-environment state.
forbidden_var_patternsis the neighbouring rule and looks like the obvious home. It is not: that one is policy — an environment's own choice about its own vars, which prod may set harder than staging, and which lives in private state precisely so a product-side change cannot lower its own guard. This one is a fact about Coolify: true on every box, in every environment, for every project. There is no environment in which declaringSOURCE_COMMITis correct, so there must be no file in which it can be permitted.Nothing here touches cast's own config vars (
COOLIFY_BASE_URL,COOLIFY_ACCESS_TOKEN,COOLIFY_READ_ONLY) — read from the operator's local instance file, never written to a resource. The namespace collides; the meaning does not.Tests
19 new tests in
test/reserved.test.ts, one section per path (313 total, up from 294 — all green undernpm run check && npm run build && npm test). All unit tests against fakes; no live instance was contacted.SOURCE_COMMIT_SHA,MY_SOURCE_COMMIT,COOLIFYISH,SERVICE_FQDN_*are not (over-reach is the one way this rule can do harm)COOLIFY_*; refuses on the capture and inventory paths; leaves an ordinary manifest aloneclassifyrefuses rather than reading the live value into the storesuppressed, named in UNCAPTURED.md, and noenv_templateemitted at all when every var was reservedremove-candidate; not clean; renders as a FINDING; found on an orphan resource; none in structural mode; a clean box stays cleanScope
Runtime half only. #46 is the build-time half — the "Include Source Commit in Build" toggle, which gates only the build arg and is not settable via the API. No warning from #46 is implemented here.
Noticed and deliberately not fixed (outside this diff):
diffcan only find a reserved name in full mode, since structural mode reads no env values at all — a box diffed with a standing read-only token will not surface one. Worth a follow-up, but it is a property of the token, not of this rule.Closes #50.