GitHub integration
The GitHub integration connects a workspace to a GitHub account through a GitHub App installation. Once connected, Orunbase ingests GitHub webhooks into a verified inbox, normalizes them into provider-neutral scm.* events on the platform event log, links repositories to projects, and brokers short-lived, repo-scoped installation tokens so your automation never holds a long-lived GitHub credential.
The integration is gated by the feature.integrations.github entitlement — calls on plans without it return 412 precondition_failed with an upgrade pointer. See Plans & entitlements.
The integrations surface is no longer GitHub-only: Slack, Cloudflare, Supabase, and the AI providers connect through the same hub — see the integration catalog and connecting providers for the OAuth and token-paste connect postures, and the credential broker for scoped, mint-at-resolve credentials. This page covers the GitHub App integration specifically.
Connect a GitHub account
A connection binds one GitHub App installation to a workspace (or to a parent account, shared across its workspaces):
POST /v1/organizations/{orgId}/integrations/github/connect(permissionorganization.integration.connect) creates apendingconnection and returns aninstallUrlcarrying a signed, single-use state token. The console opens this in a popup.- The user installs the App on their GitHub organization and picks repositories (
allorselected). - GitHub redirects back to the platform's setup ingress (
/ingress/github/setup). The workspace binding comes only from the signed state — the platform verifies the state, verifies the installation as the App, then binds and activates the connection and emitsintegration.connected. An installation arriving without valid state is recorded as orphaned and never auto-bound: the flow fails closed.
List and manage connections with GET /v1/organizations/{orgId}/integrations and GET|PATCH|DELETE …/integrations/{connectionId} (ids are int_…). A connection tracks provider-side lifecycle too: suspending or uninstalling the App on GitHub moves it to suspended / revoked. Connections owned by a parent account can be shared with child workspaces either automatically (shareMode: "auto") or per-workspace via admission grants (…/integrations/{connectionId}/grants).
A GitHub App installation stays globally unique across all workspaces — one installation binds one tenant, because an inbound webhook must route each event to exactly one workspace. (Slack team_ids work the same way.) This is a property of webhook-routed providers specifically: for Cloudflare and Supabase, the same provider account may back one connection per workspace — see connecting providers.
The inbound webhook inbox
GitHub delivers webhooks to /ingress/github/webhook. This ingress is deliberately minimal — verify, insert, ack:
- The
x-hub-signature-256HMAC is verified over the raw request bytes, with a constant-time compare, before any parsing. A bad signature is an immediate401with no detail. - The verified payload is inserted into a durable inbox, keyed by GitHub's delivery id — redeliveries are acknowledged as no-op duplicates.
- Everything else happens asynchronously: a drain attributes each delivery to a connection, normalizes it, and emits the resulting event in the same transaction that marks the row
emitted— exactly-once by construction, with bounded retries for transient failures.
Inspect the inbox per connection with GET …/integrations/{connectionId}/deliveries (status received → attributed → emitted | skipped | failed), and re-run normalization from the persisted payload with POST …/deliveries/{deliveryId}/replay — replay never re-trusts the wire.
Normalized scm.* events
Supported GitHub events are projected into compact, versioned, provider-neutral events on the event log — consumable through outbound webhooks and visible in the audit trail:
| GitHub event | Normalized event type |
|---|---|
push | scm.push |
pull_request (opened) | scm.pull_request.opened |
pull_request (synchronize, edited, reopened, ready_for_review) | scm.pull_request.updated |
pull_request (closed, merged) | scm.pull_request.merged |
pull_request (closed, not merged) | scm.pull_request.closed |
check_run (completed) | scm.check.completed |
release (published) | scm.release.published |
create / delete (branch, tag) | scm.branch.created, scm.branch.deleted, scm.tag.created |
Every payload carries the repository identity (provider, rename-stable externalId, fullName) plus workspace scope; when a repo link matches, the event is enriched with the linked projectId and the environment resolved from the branch → environment map. Events outside this taxonomy are marked skipped, never failed. Raw provider payloads stay in the inbox — they never cross the public API.
Link repositories to projects
A repo link binds a project to one repository on a connection — the tenancy anchor for event enrichment and token issuance:
| Method | Path | Description |
|---|---|---|
GET | …/integrations/{connectionId}/repositories | Browse repositories the installation can see |
GET / POST | /v1/organizations/{orgId}/projects/{projectId}/repo-links | List / create links (permission project.repo_link.write, entitlement limit.repo_links) |
PATCH / DELETE | …/projects/{projectId}/repo-links/{repoLinkId} | Update branchEnvMap / defaultBranch, or unlink |
branchEnvMap maps provider branches to the project's environments (e.g. {"main": "prod", "staging": "stage"}) and is validated against live environments at write time.
Create a repository
POST …/integrations/{connectionId}/repositories/create (permission organization.integration.manage) makes a new private repository on the connection and hands back the same shape the picker returns, so the blueprint flow can name a repo and wire it without leaving the page.
Which credential does the creating depends on who owns the namespace, because GitHub draws that line at the endpoint:
| Installed on | Endpoint | Credential |
|---|---|---|
| An organization | POST /orgs/{org}/repos | The installation token — the owner is named in the path, so the App creates it |
| A personal account | POST /user/repos | The person's own user access token — the owner is read off the token's identity, and an installation token has no user behind it |
So creating in a personal account needs one extra thing: the account's owner authorizes the Orunbase GitHub App once. When no grant is on file, the create returns 409 app_cannot_create with details.reason: "user_authorization_required" and a details.authorizeUrl — the console renders it as an Authorize GitHub button and retries the create when the popup closes. POST …/integrations/{connectionId}/github/authorize returns the same URL directly.
The grant is a ghu_… user access token (with its ghr_… refresh) held as an encrypted, write-only custody row bound to the connection, and it is used for exactly one thing: creating a repository in that person's account. Agent pushes and every other operating credential still come from the installation's own scoped tokens. It is filed only when the authorizing GitHub user is the account the installation sits on, and it is deleted with the connection.
Two more outcomes worth knowing:
details.reason: "app_cannot_create"withdetails.grant: "administration:write"— the person authorized, but the App itself lacks Administration: Read & write. Grant it on the App and approve the updated permissions on the installation.installationAccesson a successful personal-account create — the repository was created by the person, so an installation limited to selected repositories does not cover it, and the agent pushes with the App's access rather than theirs. The response carries the installation's settings URL; add the repository there. (An installation set to all repositories covers it automatically.)
Every refusal also carries details.createUrl: GitHub's own new-repository form, prefilled with the name and owner, for anyone who would rather not authorize.
App registration requirements
For in-place personal-account creation to be available in an environment, the GitHub App registration needs:
-
Administration: Read & write repository permission (also what organization creation needs).
-
Both of these registered as Callback URLs — a GitHub App accepts several:
https://{api-host}/ingress/github/oauth— required. The consent flow sends this as itsredirect_uri, and GitHub refuses anyredirect_urithat is not registered. This is the one to check first if Authorize GitHub lands on a GitHub error page instead of a consent screen.https://{api-host}/ingress/github/setup— the install callback, which also captures the grant when authorization happens during installation.
Order does not matter: an installation-shaped callback (one carrying
installation_id) is routed to the install path whichever URL it arrives on. -
GITHUB_APP_CLIENT_IDandGITHUB_APP_CLIENT_SECRETset as worker secrets. -
Optionally Request user authorization (OAuth) during installation — with it on, the grant lands during install and personal-account creation works with no extra click. This affects future installations only; an account that installed the App before you enabled it still has no grant on file and takes the Authorize GitHub click once.
Without these the door stays closed rather than broken: the create returns the same structured 409 with only the create-on-GitHub fallback. If the Authorize GitHub button appears at all, the client id/secret and redirect base are already configured — the consent URL is only built when they are.
The installation-token broker
POST /v1/organizations/{orgId}/integrations/github/token exchanges your Orunbase credential for a short-lived, repo-scoped GitHub installation token (permission organization.integration.token.issue):
curl -X POST https://api.orunbase.com/v1/organizations/org_2f8a1c9e/integrations/github/token \
-H "Authorization: Bearer $ORUN_CLOUD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"repositories": ["123456789"],
"permissions": { "contents": "read", "checks": "write" }
}'
{
"data": {
"token": "ghs_…",
"expiresAt": "2026-07-02T10:14:03Z",
"repositories": ["123456789"],
"permissions": { "contents": "read", "checks": "write" }
},
"meta": { "requestId": "req_01j9x7", "cursor": null }
}
const minted = await client.integrations.issueGithubToken("org_2f8a1c9e", {
repositories: ["123456789"],
permissions: { contents: "read", checks: "write" },
});
// minted.token is reveal-once — use it and let it expire.
The broker is deny-by-default at every layer:
- Every requested repository (1–20 provider ids) must match an active repo link owned by your workspace — a sibling workspace's repo is denied even on a shared connection — and all must resolve to one connection.
- Requested permissions (1–10 entries,
read|write) must be a subset of what the App was actually granted;writerequires a granted write. - On
shareMode: "granted"connections, your workspace must hold an active admission grant. - The token is minted fresh from GitHub, scoped down by GitHub itself, and returned exactly once — never cached, never logged. TTL is ≤ 1 hour (GitHub's native installation-token expiry).
- Every issuance is audited as
integration.token.issuedwith the actor, repositories, and permissions — never the token.
From the CLI:
orun-cloud integrations github token \
--repos=123456789 \
--permissions=contents:read,checks:write \
--output=json
The token prints exactly once and is never stored.
Orunbase also uses this seam internally in the other direction: run results are written back to GitHub as check runs and commit statuses through the same scoped-token machinery, without the state plane ever seeing the App credential.