GitHub App setup (frictionless install)¶
This guide creates the Dev Health GitHub App that powers one‑click "Connect
GitHub" (install the App instead of pasting a PAT) and unified GitHub sign‑in.
It is the operator counterpart to GitHub App auth and the
plan in docs/plans/github-app-marketplace.md.
Replace
YOUR_HOSTwith your public base URL. In the Docker stack, web and the ops API sit behind the same host via Traefik. For purely local testing you do not need a public URL for the core flow — see Local testing without a tunnel.
1. Create the App¶
GitHub → Settings → Developer settings → GitHub Apps → New GitHub App.
Identifying and authorizing users¶
| Field | Value |
|---|---|
| User authorization callback URL | https://YOUR_HOST/org/admin/integrations/github-app/callback |
| Request user authorization (OAuth) during installation | ✅ Checked |
| Expire user authorization tokens | ✅ leave checked (the code is used once at install) |
| Enable Device Flow | ⬜ unchecked |
GitHub Apps support up to 10 registered callback URLs. If you register more than
one (e.g. a localhost URL and a production URL), set GITHUB_APP_CALLBACK_URL to
the exact URL for the current environment so the install flow redirects
deterministically — otherwise GitHub uses the first registered callback URL.
If you also use the same GitHub App for Sign in with GitHub, add the Auth.js callback URL too:
https://YOUR_HOST/api/auth/callback/github
You do not need a separate GitHub OAuth App. The GitHub App's Client ID and Client secret are OAuth credentials and can be reused for both the installation flow and social login.
Because OAuth‑during‑install is on, GitHub greys out Setup URL and uses the callback URL above instead — that is expected; leave Setup URL blank.
Post installation¶
- Setup URL — blank (unavailable).
- Redirect on update — ⬜ leave unchecked. The callback requires the
signed
statethat only our "Connect" button supplies; GitHub's update‑redirect would omit it and land on?github_app=error. Repo changes are picked up by the scheduled sync.
Webhook¶
| Field | Value |
|---|---|
| Active | ✅ (uncheck for local testing) |
| Webhook URL | https://YOUR_HOST/api/v1/webhooks/github |
| Secret | a random value — generate with openssl rand -hex 32 |
The same secret must be set as GITHUB_WEBHOOK_SECRET on the ops backend; the
X-Hub-Signature-256 check rejects deliveries that don't match.
Permissions — all Read‑only (this App never writes)¶
Repository
| Permission | Access | Why |
|---|---|---|
| Metadata | Read‑only | Mandatory baseline |
| Contents | Read‑only | Commit history, blame, complexity |
| Pull requests | Read‑only | Review flow, cycle time, rework |
| Issues | Read‑only | Work items + incidents |
| Checks | Read‑only | CI check runs |
| Actions | Read‑only | Workflow runs (CI/CD, DORA) |
| Deployments | Read‑only | Delivery / change‑failure signals |
| Commit statuses | Read‑only | CI status (optional) |
| Dependabot alerts | Read‑only | Security view / compounding‑risk security signals |
| Code scanning alerts | Read‑only | Security view / compounding‑risk security signals |
| Secret scanning alerts | Read‑only | Security view / compounding‑risk security signals |
Dependabot alerts, Code scanning alerts, and Secret scanning alerts are distinct from the Repository security advisories permission and the Security advisory event (see Subscribe to events) — neither of those enables security_alerts sync. After changing permissions on an already‑installed App, the organization must approve the pending permission request before the installation token receives the new scopes.
Organization
| Permission | Access | Why |
|---|---|---|
| Members | Read‑only | Group activity by team / identity mapping |
Account (granted per‑user via the OAuth flow)
| Permission | Access | Why |
|---|---|---|
| Email addresses | Read‑only | Link the installer to their Dev Health account |
Subscribe to events¶
Leave Installation target / Meta / Security advisory unchecked. The
installation event (install / uninstall / suspend / unsuspend) is delivered
automatically and is what the lifecycle handler consumes. (Optional: tick Meta
for a ping when the App is deleted.)
Do not subscribe to push / pull_request here — those drive the legacy
real‑time webhook‑sync path that expects a GITHUB_TOKEN PAT, not the
per‑installation App token. App installs rely on the scheduled sync.
Where can this GitHub App be installed?¶
- "Only on this account" for internal testing.
- "Any account" when onboarding external orgs (also required for the deferred Marketplace listing, CHAOS‑2236).
Click Create GitHub App, then on the App page generate a private key
(downloads a .pem) and generate a client secret — neither is shown until you
generate it.
2. Wire the credentials¶
# ops backend
GITHUB_APP_SLUG=<app URL slug, e.g. dev-health>
GITHUB_APP_ID=<App ID>
GITHUB_APP_CLIENT_ID=<Client ID>
GITHUB_APP_CLIENT_SECRET=<generated client secret>
GITHUB_WEBHOOK_SECRET=<the webhook secret from step 1>
GITHUB_APP_CALLBACK_URL=https://YOUR_HOST/org/admin/integrations/github-app/callback
# backend verification for Sign in with GitHub; use the same Client ID/secret
SOCIAL_GITHUB_CLIENT_ID=<same Client ID>
SOCIAL_GITHUB_CLIENT_SECRET=<same client secret>
# private key — choose ONE (see below)
GITHUB_APP_PRIVATE_KEY_PATH=/run/secrets/github-app.pem
# GITHUB_APP_PRIVATE_KEY="<single-line PEM with \n escapes — see 'Providing the private key' below>"
# web (Sign in with GitHub on the same App)
AUTH_URL=https://YOUR_HOST
AUTH_GITHUB_ID=<same Client ID>
AUTH_GITHUB_SECRET=<same client secret>
compose.yml loads ops/.env into the API/worker services and web/.env into
the web service, but it does not alias these names for you. Copy the same Client
ID/secret into each required variable above.
Providing the private key (PEM)¶
The PEM is multi‑line, which env files handle inconsistently. Two supported ways:
A. File path (recommended). Mount the .pem and point at it. No escaping:
GITHUB_APP_PRIVATE_KEY_PATH=/run/secrets/github-app.pem
B. Inline GITHUB_APP_PRIVATE_KEY. The value may be a single line with
escaped \n (what .env files, Docker env, and most secret managers store);
the backend converts \n back to real newlines, and a PEM that already has real
newlines is left unchanged. Produce the one‑liner with:
# turns the multi-line .pem into a single \n-escaped line
awk 'NF {printf "%s\\n", $0}' github-app.pem
Then in the env file (quote it):
GITHUB_APP_PRIVATE_KEY="<paste the \n-escaped one-liner from the awk command above>"
Docker Compose
env_filedoes not interpret quotes or multi‑line values, so the\n‑escaped single line is the form to use there. Alternatively inject it from a shell that preserves real newlines:export GITHUB_APP_PRIVATE_KEY="$(cat github-app.pem)".
3. Connect¶
In the app: Admin → Integrations → GitHub → "Connect GitHub App". You're sent
to GitHub to install + pick repositories, then returned connected — no tokens to
paste. The callback verifies the installation against GET /user/installations
(installer must have access), links it to your org, and writes the App‑mode
credential the sync pipeline uses.
Local testing without a tunnel¶
Only the webhook needs a publicly reachable URL (GitHub POSTs to it). The
install + OAuth callback works on localhost because GitHub redirects the
browser (which is on your machine), and the backend's OAuth//user/installations
calls are outbound:
- Set User authorization callback URL =
http://localhost:3000/org/admin/integrations/github-app/callback. - If testing Sign in with GitHub, also register
http://localhost:3000/api/auth/callback/github. - Set
GITHUB_APP_CALLBACK_URL=http://localhost:3000/org/admin/integrations/github-app/callbackso the install flow returns to localhost even if a production callback URL is also registered. - Set
AUTH_URL=http://localhost:3000inweb/.envwhen using Docker/reverse proxy local dev. - In Webhook, uncheck "Active" (drops the required URL). You lose only automatic credential‑deactivation on uninstall during the test.
The full Connect → install → credential → sync happy path is testable this way.
Reach for a tunnel (e.g. cloudflared) only to exercise the webhook lifecycle:
cloudflared tunnel --url http://localhost:8000 # ops API
# Webhook URL = https://<tunnel-host>/api/v1/webhooks/github (+ matching GITHUB_WEBHOOK_SECRET)
The callback can stay on localhost, or move it to the tunnel host too.