cast github-app create: run the App Manifest flow instead of transcribing it by hand #7
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#7
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Motivation
The GitHub App is the one piece of infrastructure cast cannot reproduce.
Everything else in a Coolify instance is derivable:
cast applybuilds it from the manifest plus the state directory, andcast server addbootstraps a host. But the App — the credential Coolify uses to clone private repos — is created by hand in a browser, its four identifiers copied out of the GitHub UI by eye, its private key downloaded to~/Downloads, and its details fed toscripts/register-github-app.shas a six-variable env pile. Nothing about that survives in state. Rebuild the instance and you redo the hoops from memory.#5 catalogues three footguns this produced in a single live run. They are worth reading as symptoms rather than as the disease: the interface is bad because the tool is transcribing values a human read off a web page, instead of holding them because it obtained them.
The finding: there is no API to create a GitHub App — except one
Worth stating plainly, because it shapes everything below and it is the first thing anyone re-deriving this will get wrong:
POST /apps, no GraphQL mutation. A PAT — however scoped — cannot mint one.ghCLI has noappsubcommand. Shelling out toghto create the App is not buildable at any level of credential.The manifest flow gives us more than the manual path does, not less. Its conversion response is the only moment GitHub ever hands over the private key, the client secret, and the webhook secret together. Today those are scattered across a download folder and a browser tab; from the flow, they arrive in one JSON body that cast can persist deliberately.
Proposal:
cast github-app, two front doors onto one pathcreateruns the manifest flow, obtains the credentials, then falls through into exactly theregistercode path. Two browser clicks, zero transcription.registeradopts credentials you already hold — an App created by hand, or a disaster-recovery restore from a stored PEM. This is #5's subcommand, unchanged in intent.createis the new capability;registeris the floor it lands on. They must not be two implementations.What
createdoes, step by stepPreflight (optional, cheap, worth it). If
ghis onPATHand authenticated,gh api /user/memberships/orgs/<org>— requirerole: admin. Without this the operator completes the entire browser dance and only then learns they cannot create Apps on that org. Skip silently ifghis absent; never require it.Resolve the Coolify-facing name from state, not from a flag.
github_apps.<repo>inenvironments.yamlis the name every latercast applyresolves against. Read it; do not accept a free-form name that can silently disagree with it.--namemay seed the entry when it is absent, but the state file is the authority. This is #5's footgun 1, dissolved structurally rather than validated after the fact.Serve a one-shot loopback page on
127.0.0.1:<port>that auto-submits a form tohttps://github.com/organizations/<org>/settings/apps/new?state=<csrf>(orhttps://github.com/settings/apps/newfor a user account). Single field,manifest, whose value is a JSON string:Clone-only permissions, webhook inactive.
hook_attributes.urlis required by the manifest schema even whenactive: false, so it gets a deliberately dead value. Notedefault_permissionskeys are snake_case (pull_requests, notpull-requestsas the docs' reference page renders them).Use the loopback literal
127.0.0.1, notlocalhost— GitHub's OAuth guidance explicitly prefers it, and the manifest docs are silent on scheme.Operator clicks "Create GitHub App for
<org>". Their browser session is the authentication. GitHub redirects tohttp://127.0.0.1:8765/callback?code=…&state=…; the local server captures it, checksstate, and shuts down.Exchange the code:
POST https://api.github.com/app-manifests/<code>/conversions, noAuthorizationheader. Returnsid,slug,client_id,client_secret,webhook_secret,pem,owner. The code is valid for one hour; treat it as single-use. A404means expired/bad, a422means rate-limited or spammed — both deserve a real error message, since the operator's remedy (re-run and click again) is not obvious from GitHub's wording.Install it. Open
https://github.com/apps/<slug>/installations/new; operator selects the target repo. Always print the URL too — never assume a browser opener exists.Recover
installation_idfrom the App's own key. Mint an RS256 JWT (iatbackdated 60s,exp≤ 10 min out,iss= the client id — GitHub now recommends it over the app id) and callGET /orgs/<org>/installation. This needs no credential beyond the PEM cast just received. Poll with a short backoff: the operator is clicking through a browser while cast waits.Do not trust the
installation_idGitHub appends to asetup_urlredirect — GitHub warns it is spoofable and documents it as a hint only.Register into Coolify — the existing script's two calls, unchanged:
POST /security/keyswith the PEM, thenPOST /github-appswith the ids plus the returned key UUID.webhook_secretcomes from the conversion response; nobody has to invent a placeholder any more (#5's footgun 3).Verify the post-condition:
GET /github-apps/{id}/repositoriesand assert<org>/<repo>is actually in the list. This is the step that matters most. Today a misconfigured App fails silently and surfaces hours later as an unresolvable source atcast applytime; here it is a hard error at creation, next to the thing that caused it.No new dependencies
Node's
node:httpserves the loopback callback;node:crypto'screateSign("RSA-SHA256")signs the JWT. cast stays atyaml+zod. A tool whose philosophy is public tool, private state should not grow an Octokit to do this.Open question: where the secrets land
The conversion response is the only time GitHub yields the PEM, client secret, and webhook secret. Cast must put them somewhere or the DR story is no better than today's.
Consistent with the philosophy, they belong in the state directory the operator points cast at — cast itself stores nothing. But
secrets/is currently per-repo-per-env application env vars (secrets.ts), and age keys do not exist yet in the incubator deployment. Simplest v1: write the PEM to<state>/github-apps/<name>.pem, print the rest, and leave encryption + commit to the operator with a loud note. Worth deciding explicitly rather than defaulting into.Testability boundary — read before planning
An agent cannot exercise this end to end, and the plan must not pretend otherwise:
Agent-testable: manifest construction, the loopback server's redirect capture and
statecheck (drive it withcurl), JWT minting, the conversion exchange and Coolify calls against mocked HTTP. That is most of the surface, but the first real run is an operator's, and the fallback (the manual path, still in the runbook) must stay documented until it succeeds once.Relationships
registeris that issue's subcommand;createis why it is worth building now. The env-var script can become a thin wrapper or be deleted.createresolvesgithub_apps.<repo>from state — and #6 is precisely the fix that lets that key name the right App when two orgs share a repo short name (heavy-duty/incubatorvsclaude-hdb/incubator). Landingcreateon short-name keys would bake the collision in.Unverified
redirect_urlonhttp://127.0.0.1:<port>— the manifest docs are silent on scheme; loopback HTTP is documented only for OAuth redirect URIs. Strong precedent (Probot's setup flow does exactly this), but this is the one assumption the whole design rests on. Validate it first, for free, against a throwaway App on a personal account — Apps can be deleted — before building anything else.code(only the one-hour window is documented).GET /orgs/{org}/installationsvia a user token wants the scope the docs literally spelladmin:read— an odd name, and moot here since step 7 uses the JWT path instead. Noted only so nobody reaches for it.🤖 Generated with Claude Code