cast github-app create: run the App Manifest flow instead of transcribing it by hand #7

Closed
opened 2026-07-12 12:36:37 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-12 12:36:37 +00:00 (Migrated from github.com)

Motivation

The GitHub App is the one piece of infrastructure cast cannot reproduce.

Everything else in a Coolify instance is derivable: cast apply builds it from the manifest plus the state directory, and cast server add bootstraps 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 to scripts/register-github-app.sh as 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:

  • There is no REST endpoint that creates a GitHub App. No POST /apps, no GraphQL mutation. A PAT — however scoped — cannot mint one.
  • The gh CLI has no app subcommand. Shelling out to gh to create the App is not buildable at any level of credential.
  • The only programmatic path is the App Manifest flow: a browser form POST, whose authentication is the operator's existing GitHub session, followed by an unauthenticated code exchange. It is how Coolify's own "Create GitHub App" button works.

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 path

cast github-app create <org>/<repo> --state <dir> [--name <app-name>] [--port 8765]
cast github-app register <org>/<repo> --state <dir> \
    --app-id <id> --installation-id <id> --client-id <id> \
    --client-secret-stdin --private-key <path.pem> [--webhook-secret <value>]
  • create runs the manifest flow, obtains the credentials, then falls through into exactly the register code path. Two browser clicks, zero transcription.
  • register adopts 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.

create is the new capability; register is the floor it lands on. They must not be two implementations.

What create does, step by step

  1. Preflight (optional, cheap, worth it). If gh is on PATH and authenticated, gh api /user/memberships/orgs/<org> — require role: admin. Without this the operator completes the entire browser dance and only then learns they cannot create Apps on that org. Skip silently if gh is absent; never require it.

  2. Resolve the Coolify-facing name from state, not from a flag. github_apps.<repo> in environments.yaml is the name every later cast apply resolves against. Read it; do not accept a free-form name that can silently disagree with it. --name may 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.

  3. Serve a one-shot loopback page on 127.0.0.1:<port> that auto-submits a form to https://github.com/organizations/<org>/settings/apps/new?state=<csrf> (or https://github.com/settings/apps/new for a user account). Single field, manifest, whose value is a JSON string:

    {
      "name": "hdb-coolify-prod",
      "url": "https://github.com/<org>/<repo>",
      "hook_attributes": { "url": "https://example.invalid/unused", "active": false },
      "redirect_url": "http://127.0.0.1:8765/callback",
      "public": false,
      "default_events": [],
      "default_permissions": { "contents": "read", "metadata": "read" }
    }
    

    Clone-only permissions, webhook inactive. hook_attributes.url is required by the manifest schema even when active: false, so it gets a deliberately dead value. Note default_permissions keys are snake_case (pull_requests, not pull-requests as the docs' reference page renders them).

    Use the loopback literal 127.0.0.1, not localhost — GitHub's OAuth guidance explicitly prefers it, and the manifest docs are silent on scheme.

  4. Operator clicks "Create GitHub App for <org>". Their browser session is the authentication. GitHub redirects to http://127.0.0.1:8765/callback?code=…&state=…; the local server captures it, checks state, and shuts down.

  5. Exchange the code: POST https://api.github.com/app-manifests/<code>/conversions, no Authorization header. Returns id, slug, client_id, client_secret, webhook_secret, pem, owner. The code is valid for one hour; treat it as single-use. A 404 means expired/bad, a 422 means 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.

  6. 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.

  7. Recover installation_id from the App's own key. Mint an RS256 JWT (iat backdated 60s, exp ≤ 10 min out, iss = the client id — GitHub now recommends it over the app id) and call GET /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_id GitHub appends to a setup_url redirect — GitHub warns it is spoofable and documents it as a hint only.

  8. Register into Coolify — the existing script's two calls, unchanged: POST /security/keys with the PEM, then POST /github-apps with the ids plus the returned key UUID. webhook_secret comes from the conversion response; nobody has to invent a placeholder any more (#5's footgun 3).

  9. Verify the post-condition: GET /github-apps/{id}/repositories and 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 at cast apply time; here it is a hard error at creation, next to the thing that caused it.

No new dependencies

Node's node:http serves the loopback callback; node:crypto's createSign("RSA-SHA256") signs the JWT. cast stays at yaml + 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:

  • The form POST needs a logged-in browser session. There is no headless path.
  • Registration writes to a live Coolify instance, which is operator-only territory.

Agent-testable: manifest construction, the loopback server's redirect capture and state check (drive it with curl), 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

  • Supersedes #5. register is that issue's subcommand; create is why it is worth building now. The env-var script can become a thin wrapper or be deleted.
  • Wants #6 first. create resolves github_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/incubator vs claude-hdb/incubator). Landing create on short-name keys would bake the collision in.

Unverified

  1. redirect_url on http://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.
  2. Single-use-ness of the manifest code (only the one-hour window is documented).
  3. GET /orgs/{org}/installations via a user token wants the scope the docs literally spell admin: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

## Motivation The GitHub App is the one piece of infrastructure cast cannot reproduce. Everything else in a Coolify instance is derivable: `cast apply` builds it from the manifest plus the state directory, and `cast server add` bootstraps 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 to `scripts/register-github-app.sh` as 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: - There is **no REST endpoint** that creates a GitHub App. No `POST /apps`, no GraphQL mutation. A PAT — however scoped — cannot mint one. - The `gh` CLI has **no `app` subcommand**. Shelling out to `gh` to create the App is not buildable at any level of credential. - The **only** programmatic path is the [App Manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest): a browser form POST, whose authentication is the operator's existing GitHub session, followed by an unauthenticated code exchange. It is how Coolify's own "Create GitHub App" button works. 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 path ``` cast github-app create <org>/<repo> --state <dir> [--name <app-name>] [--port 8765] cast github-app register <org>/<repo> --state <dir> \ --app-id <id> --installation-id <id> --client-id <id> \ --client-secret-stdin --private-key <path.pem> [--webhook-secret <value>] ``` - **`create`** runs the manifest flow, obtains the credentials, then falls through into exactly the `register` code path. Two browser clicks, zero transcription. - **`register`** adopts 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. `create` is the new capability; `register` is the floor it lands on. They must not be two implementations. ### What `create` does, step by step 1. **Preflight (optional, cheap, worth it).** If `gh` is on `PATH` and authenticated, `gh api /user/memberships/orgs/<org>` — require `role: admin`. Without this the operator completes the entire browser dance and only then learns they cannot create Apps on that org. Skip silently if `gh` is absent; never *require* it. 2. **Resolve the Coolify-facing name from state, not from a flag.** `github_apps.<repo>` in `environments.yaml` is the name every later `cast apply` resolves against. Read it; do not accept a free-form name that can silently disagree with it. `--name` may *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. 3. **Serve a one-shot loopback page** on `127.0.0.1:<port>` that auto-submits a form to `https://github.com/organizations/<org>/settings/apps/new?state=<csrf>` (or `https://github.com/settings/apps/new` for a user account). Single field, `manifest`, whose value is a JSON string: ```json { "name": "hdb-coolify-prod", "url": "https://github.com/<org>/<repo>", "hook_attributes": { "url": "https://example.invalid/unused", "active": false }, "redirect_url": "http://127.0.0.1:8765/callback", "public": false, "default_events": [], "default_permissions": { "contents": "read", "metadata": "read" } } ``` Clone-only permissions, webhook inactive. `hook_attributes.url` is required by the manifest schema even when `active: false`, so it gets a deliberately dead value. Note `default_permissions` keys are **snake_case** (`pull_requests`, not `pull-requests` as the docs' reference page renders them). Use the loopback literal `127.0.0.1`, **not `localhost`** — GitHub's OAuth guidance explicitly prefers it, and the manifest docs are silent on scheme. 4. **Operator clicks "Create GitHub App for `<org>`".** Their browser session is the authentication. GitHub redirects to `http://127.0.0.1:8765/callback?code=…&state=…`; the local server captures it, checks `state`, and shuts down. 5. **Exchange the code:** `POST https://api.github.com/app-manifests/<code>/conversions`, **no `Authorization` header**. Returns `id`, `slug`, `client_id`, `client_secret`, `webhook_secret`, `pem`, `owner`. The code is valid for **one hour**; treat it as single-use. A `404` means expired/bad, a `422` means 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. 6. **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. 7. **Recover `installation_id` from the App's own key.** Mint an RS256 JWT (`iat` backdated 60s, `exp` ≤ 10 min out, `iss` = the **client id** — GitHub now recommends it over the app id) and call `GET /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_id` GitHub appends to a `setup_url` redirect — GitHub warns it is spoofable and documents it as a hint only. 8. **Register into Coolify** — the existing script's two calls, unchanged: `POST /security/keys` with the PEM, then `POST /github-apps` with the ids plus the returned key UUID. `webhook_secret` comes from the conversion response; nobody has to invent a placeholder any more (#5's footgun 3). 9. **Verify the post-condition:** `GET /github-apps/{id}/repositories` and 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 at `cast apply` time; here it is a hard error at creation, next to the thing that caused it. ## No new dependencies Node's `node:http` serves the loopback callback; `node:crypto`'s `createSign("RSA-SHA256")` signs the JWT. cast stays at `yaml` + `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: - The form POST needs a logged-in browser session. There is no headless path. - Registration writes to a live Coolify instance, which is operator-only territory. Agent-testable: manifest construction, the loopback server's redirect capture and `state` check (drive it with `curl`), 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 - **Supersedes #5.** `register` is that issue's subcommand; `create` is why it is worth building now. The env-var script can become a thin wrapper or be deleted. - **Wants #6 first.** `create` resolves `github_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/incubator` vs `claude-hdb/incubator`). Landing `create` on short-name keys would bake the collision in. ## Unverified 1. **`redirect_url` on `http://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. 2. Single-use-ness of the manifest `code` (only the one-hour window is documented). 3. `GET /orgs/{org}/installations` via a user token wants the scope the docs literally spell `admin: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](https://claude.com/claude-code)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#7
No description provided.