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#12
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/slug-keying"
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?
Closes #11. Closes #6.
The bug (#11)
diffcould not tell "this project does not exist" from "this project is empty" — both came back as[]fromfetchLive. That is correct forapply(a first apply legitimately creates the project and its environment) and quietly wrong fordiff:computeDiff(desired, [])means every desired resource is missing, which renders as a confident full-create plan.So a
diffaimed at a name that does not exist reported a clean-looking plan that verified nothing.This is the same shape of lie #10 closed — an unverifiable read that answers absent and invites a create — reached through the project name instead of the team. And it matters more now than it did then: with a single Root Team, every token is stamped
team_id = 0, soassertTeamcan never fire. It is no longer guarding this class of bug at all.Two roads, not one
While fixing it, the environment turned out to be the same trap. cast names environments after
--env, but a project built by hand in the Coolify UI is named whatever someone typed — and Coolify's own default environment isproduction, notprod. A real project with a differently-named environment 404s onGET /projects/{uuid}/{env}, which also collapsed to[].Both are now gated.
What changed
fetchLivereturns aLiveLookupunion. Absence is its own answer, not a value that happens to equal empty. Keeping the distinction in the type is what letsdiffandapplytake opposite — and both correct — positions on it.diffrefuses (exit 2) and names what it looked for, where that name came from, and what exists instead.applykeeps today's tolerant behaviour. That split is the entire point:applyis allowed to be the thing that brings a project into existence;diffis only ever a claim about something that already does.--project <name>overrides the repo-derived project name for an instance that names it differently. It overrides only that — secrets stay keyed by the repo, a state-repo convention we own. PreviouslyrepoShortsilently did four unrelated jobs.#6, same root cause
github_appsis now resolved by full<org>/<repo>slug, falling back to a bare<repo>key so existing state files keep working.A short name is unique only within an org, so two orgs' same-named repos collapsed onto one entry — and whichever App was bound there would be used to clone both, silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds.
Verification
Unit tests pin both halves (12 new; 98 pass,
npm run checkclean). But the gate is only worth having if the binary refuses, so it was driven end-to-end against a fake Coolify serving box B's shape — resources that exist, under a project name cast would never guess from the repo slug:A. The project does not exist → refuses, exit 2:
B. Same command, pointed at the real name → a real diff, exit 0:
C. Project exists, environment is
productionnotstaging→ refuses, exit 2:Before this change, all three printed a full-create plan and exited 0.
Why this is not a weakening of #10
The
team:binding stays required and the assert still runs before the first read on every command. An inert gate is cheap, and it is what catches the first mis-minted token the day a second team exists. This adds the second gate that a single-team topology leaves uncovered — it does not replace the first.Not in scope
Moving the project name into a bindings-level
projects:map (keyed by full slug, alongsidegithub_apps) is probably the better long-term home and is what a multi-project state repo will want.--projectis deliberately the smaller step: it is non-breaking, it needs noenvironments.yamlschema change, and it can stay as the escape hatch afterwards.🤖 Generated with Claude Code