Skip to main content

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.

GitHub is one provider among several

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):

  1. POST /v1/organizations/{orgId}/integrations/github/connect (permission organization.integration.connect) creates a pending connection and returns an installUrl carrying a signed, single-use state token. The console opens this in a popup.
  2. The user installs the App on their GitHub organization and picks repositories (all or selected).
  3. 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 emits integration.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-256 HMAC is verified over the raw request bytes, with a constant-time compare, before any parsing. A bad signature is an immediate 401 with 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 receivedattributedemitted | 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 eventNormalized event type
pushscm.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.

A repo link binds a project to one repository on a connection — the tenancy anchor for event enrichment and token issuance:

MethodPathDescription
GET…/integrations/{connectionId}/repositoriesBrowse repositories the installation can see
GET / POST/v1/organizations/{orgId}/projects/{projectId}/repo-linksList / 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 onEndpointCredential
An organizationPOST /orgs/{org}/reposThe installation token — the owner is named in the path, so the App creates it
A personal accountPOST /user/reposThe 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" with details.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.
  • installationAccess on 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 its redirect_uri, and GitHub refuses any redirect_uri that 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_ID and GITHUB_APP_CLIENT_SECRET set 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; write requires 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.issued with 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.

note

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.