fix: resolve the GitHub App only when the manifest declares applications (#103) #107
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#107
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/lazy-github-app"
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 #103.
What
Found live in the 2026-07-19 release drill against two real Coolify 4.1.2 instances: a manifest declaring only databases (
applications: {}) rendered its plan (2 change(s)) and then died in preflight withno GitHub App bound for dan-claude-bot/drill-widget.runProjectresolved the server AND the GitHub App unconditionally before building the executor, sogithubAppNameForthrew over a binding nothing in the run would ever have used — a GitHub App is read by exactly one call, the application create (POST /applications/private-github-app); databases and services never touch it. That gated infra-only projects (databases/services shared by a fleet's other projects) behind the GitHub-App browser-registration ceremony.How
runProjectresolves the App — binding lookup andclient.githubAppUuidboth — only when the desired state contains at least one application, and passesnullthrough otherwise.githubAppUuidfield is typedstring | null; its single consumer (the application create) guards the null with cast's own internal error. Unreachable by construction — a plan can only create resources the desired state holds — but a null slipping onto the wire would otherwise surface as a Coolify 422 about somebody else's field.Tests
test/github-app-cli.test.ts(new, end to end against a stub Coolify): a databases-only manifest withgithub_apps: {}applies all the way through (applied + redeployed: cache) and the stub never sees a/github-appsrequest; a manifest WITH an application still refuses on the missing binding, pinning the exactno GitHub App boundmessage and that nothing was created.test/wire.test.ts: a null-App executor creates a database sending nogithub_app_uuid, and refuses an application create with the internal error before anything reaches the create route.Suite: 600 tests across 34 files, all passing.
npm run check(biome) andnpm run buildclean.🤖 Generated with Claude Code
✅ Reviewed — I agree with all of this, no concerns.
Keying the App resolution off
desired.some((d) => d.kind === "application")rather than the plan's changes is the right choice — a manifest that declares an app keeps the missing-binding refusal even on a clean plan, since that binding is state the next create will need. TypinggithubAppUuidasstring | nulland guarding its single consumer (the application create) with cast's own internal error is the correct belt-and-suspenders: unreachable by construction, but a null slipping onto the wire would otherwise surface as an opaque Coolify 422 about somebody else's field. Tests pin both directions end-to-end (databases-only applies and the stub never sees a/github-appsrequest; an app manifest still refuses on the missing binding, nothing created) plus the wire-level null-App executor. Clean.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botApprove — the laziness is keyed correctly, the refusal for app-bearing manifests is preserved exactly (clean plan or not), and both directions are pinned end-to-end. Verified against the full suite: 600/600 passing, build and biome clean.
Specifics checked:
src/cli.ts:1291-1293— gating ondesired.some((d) => d.kind === "application")covers both failure modes behind one guard: the missing-binding throw fromgithubAppNameForand thenot found in Coolify: github appthrow fromclient.githubAppUuid. Both still fire in preflight, beforebuildExecutorand any write, so the error for app manifests is as early and as clear as before. Keying offdesiredrather thanreport.changesis the right call for the clean-plan case.src/cli.ts:3413is the only consumer ofctx.githubAppUuidanywhere insrc/— updates, database/service creates, and backups never read it — sostring | nullplus the guard atsrc/cli.ts:3403is sound. The "unreachable by construction" claim holds:applynever deletes orphans, so no application-shaped change can reach the executor from an applications-freedesired.--all) route through the samerunProject(src/cli.ts:1466,1478), andapply --resourceis refused up front, so no partial-plan path can subvert the gate.test/github-app-cli.test.tsasserts the load-bearing absence (hitsnever contains/github-apps), not just exit code 0 — that pins the resolution being skipped, not merely the create. The with-application case pins the exact refusal text and that nothing was created.No changes requested.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Why
POST /applications/private-github-app). Resolving it unconditionally made infra-only manifests (databases / services,applications: {}) die in preflight over a binding nothing would use — exactly the drill failure.desired.some(d => d.kind === "application")) rather than the plan's change set is the correct conservatism: a clean plan for an app-bearing manifest still refuses a missing binding, because the next create will need it.githubAppUuid: string | nullplus the internal guard at the single consumer is the right belt-and-suspenders: unreachable by construction, but a null on the wire would become a Coolify 422 about someone else's field./github-apps; app-bearing still refuses with the historical message and creates nothing). Wire tests pin null-App database create and the internal refuse on application create.No blockers.
Verdict: I agree with everything and have no additional feedback.
GitHub App resolution is now limited to desired states containing applications, while application-bearing manifests retain the original preflight refusal even on a clean plan. The nullable executor context is guarded at its sole application-create consumer, end-to-end tests cover both directions, and current checks are green.
@danmt — handing this one to you. Final state:
What it fixes: cast#103 —
applyresolved the GitHub App (binding lookup +client.githubAppUuid) unconditionally in preflight, even for a manifest declaring zero applications. A databases-only project rendered its plan and then died onno GitHub App bound— in the drill this blocked the whole A→B promotion until a placeholder App row was seeded on both instances, pure ceremony for a resource the apply never touches.The fix: resolution is keyed off the desired state —
desired.some(d => d.kind === "application")— not the plan's changes, so a manifest that declares an app keeps the missing-binding refusal even on a clean plan (that binding is state the next create will need). The executor'sgithubAppUuidis retypedstring | null, with the null guarded at its single consumer (the application-create POST) by an internal error, unreachable by construction — a null slipping onto the wire would otherwise surface as an opaque Coolify 422.Review history: one round — all three bots approved, no changes requested; claude-bot specifically endorsed keying off desired state rather than the plan.
Verification:
npm test600/0 · biome clean. End-to-end against the stub: databases-only manifest applies with zero/github-appsrequests observed; an app-declaring manifest still refuses word-for-word with nothing created; wire-level tests pin the null-typed executor.No open questions from the round. Ready for your review.
🤖 Generated with Claude Code