githubapp

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 9, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package githubapp implements the GitHub App manifest registration flow, App-level JWT signing (RS256, per GitHub's own App authentication spec), and the small slice of the GitHub REST API this control plane needs once an App is connected: minting installation access tokens, listing an installation's repositories, and listing a repository's branches.

This package holds no store or internal/secrets dependency: it is a pure GitHub API client plus manifest/JWT construction, the same "narrow, single-purpose, no upward dependency" shape internal/backup's S3 client wrapper keeps. internal/api owns orchestration (persisting credentials, resolving the private key back out, deciding ability tiers); this package only knows how to talk to github.com and api.github.com.

Deliberately does not implement webhook delivery handling: this control plane's manifest declares a webhook URL (GitHub's manifest schema requires hook_attributes.url) but sets hook_attributes.active to false, so GitHub never actually sends a delivery there. Building real webhook-driven auto-deploy is explicit, separate, future scope, not something this package pretends to support by declaring an active endpoint it doesn't implement.

Index

Constants

This section is empty.

Variables

View Source
var ErrInstallationNotFound = errors.New("githubapp: installation not found")

ErrInstallationNotFound wraps the API error GitHub returns for an installation_id that no longer exists (the App was fully uninstalled), the GetInstallation counterpart to ErrPermissionDenied. GitHub signals this with 404 Not Found; callers check errors.Is(err, ErrInstallationNotFound) rather than a status code.

View Source
var ErrPermissionDenied = errors.New("githubapp: permission denied by github")

ErrPermissionDenied wraps the API error GitHub returns for a request the installation's granted permissions don't allow, e.g. CreateRepoWebhook on an installation that predates the App requesting "Repository hooks: write" (manifest.go's own doc comment). GitHub signals this with 403 Forbidden, not a distinct error code; callers check errors.Is(err, ErrPermissionDenied) rather than a status code.

Functions

func AppDisplayName

func AppDisplayName(brandName, domain string) string

AppDisplayName derives a GitHub-App-name-safe, deployment-unique display name from the platform's own brand name and its public domain, e.g. "<brand> (deploy.example.com)". GitHub App names must be unique across the whole of GitHub, and brandName alone is not: every control plane built from this same codebase would otherwise try to register an App with the identical name. Appending the operator's own domain, which is unique by construction (DNS ownership), resolves that without inventing a random suffix that would make the App harder for the operator to recognize in their own GitHub settings.

func SignAppJWT

func SignAppJWT(appID int64, privateKeyPEM []byte, now time.Time) (string, error)

SignAppJWT builds and RS256-signs a GitHub App JWT for appID, using privateKeyPEM (the App's own PEM-encoded RSA private key, as returned by the manifest code-exchange and decrypted from internal/secrets immediately before this call). now is the caller's current time, passed in rather than read via time.Now() here so tests can assert on exact claim values without a clock race.

iss is the numeric App ID formatted as a decimal string: GitHub's docs accept either the App ID or the client ID here, this always uses the App ID specifically, matching what internal/api's callers already have on hand (store.GitHubAppConnection.AppID) without an extra lookup.

The returned string is the compact JWS serialization (base64url(header).base64url(claims).base64url(signature), no padding), exactly what every "Authorization: Bearer <jwt>" call this package makes expects.

func ValidatePrivateKeyPEM

func ValidatePrivateKeyPEM(pemBytes []byte) error

ValidatePrivateKeyPEM reports whether pemBytes parses as an RSA private key, without returning the parsed key itself: callers that only need to confirm a pasted key is usable before storing it (the manual-entry connect flow) have no reason to hold the parsed key in memory any longer than parseRSAPrivateKeyPEM's own call frame does.

Types

type Branch

type Branch struct {
	Name      string
	CommitSHA string
}

Branch is one branch of one repository.

type Client

type Client struct {
	HTTP    *http.Client
	BaseURL string
}

Client is a small, purpose-built GitHub REST API client covering only what this control plane needs: manifest code exchange, installation lookup, installation token minting, and repository/branch listing. Not a general-purpose GitHub SDK.

func NewClient

func NewClient() *Client

NewClient returns a Client pointed at the real api.github.com, with a bounded per-request timeout: every call this client makes is a single small JSON request/response, not a long-lived stream, so a generous fixed timeout (rather than relying solely on ctx) guards against a hung connection blocking an HTTP handler indefinitely.

func (*Client) APIBaseURL

func (c *Client) APIBaseURL(instanceURL string) string

APIBaseURL derives the REST API root for instanceURL: github.com itself routes through the dedicated api.github.com host (defaultBaseURL, or Client.BaseURL when a test has overridden it), but a GitHub Enterprise Server instance has no such split host, its REST API lives under its own domain at /api/v3 (https://docs.github.com/en/enterprise-server/rest/about-the-rest-api). instanceURL is treated as already-normalized (no trailing slash, a real https URL): callers validate that once, at connect time, so this pure derivation doesn't have to.

func (*Client) CheckInstanceReachable

func (c *Client) CheckInstanceReachable(ctx context.Context, instanceURL string) error

CheckInstanceReachable calls the unauthenticated GET /meta endpoint (present on both api.github.com and a GHES instance's own /api/v3), turning a GitHub Enterprise Server base URL an operator mistyped, or one this control plane simply can't route to, into an immediate, actionable save-time error instead of a silent failure the first time the manifest flow's redirect actually needs it. Not called for github.com itself (APIBaseURL's own default case): that host's reachability is not this control plane's to diagnose.

func (*Client) CreateCommitStatus

func (c *Client) CreateCommitStatus(ctx context.Context, instanceURL, token, owner, repo, sha string, state CommitStatusState, targetURL, description, statusContext string) error

CreateCommitStatus sets a commit status on sha of owner/repo, authenticated with an installation access token the same way CreateRepoWebhook is. targetURL and description are both optional, matching GitHub's own documented shape for this endpoint.

func (*Client) CreateIssueComment

func (c *Client) CreateIssueComment(ctx context.Context, instanceURL, token, owner, repo string, number int, body string) error

CreateIssueComment posts a new comment on issue/pull request number of owner/repo, authenticated with an installation access token the same way CreateRepoWebhook is. GitHub's REST API has no distinct "pull request comment" endpoint: a PR is also an issue, and this is the same endpoint used for both.

func (*Client) CreateRepoWebhook

func (c *Client) CreateRepoWebhook(ctx context.Context, instanceURL, token, owner, repo, hookURL, secret string) error

CreateRepoWebhook registers a push webhook on owner/repo pointed at hookURL, authenticated with an installation access token. Distinct from the App's own hook_attributes (see this package's own doc comment): that's a single, App-wide webhook GitHub never delivers to (Active: false); this is a real, per-repo classic webhook, the same mechanism GitLab's and Bitbucket's own CreateProjectWebhook/ CreateRepoWebhook use. Requires "Repository hooks: write" (manifest.go's DefaultManifestConfig); on an installation that predates that permission, GitHub rejects this with 403 (ErrPermissionDenied), which callers should degrade around rather than treat as fatal.

func (*Client) ExchangeManifestCode

func (c *Client) ExchangeManifestCode(ctx context.Context, instanceURL, code string) (Credentials, error)

ExchangeManifestCode exchanges the one-time code GitHub's manifest flow redirect carries for the newly created App's real credentials. Per GitHub's documented behavior for this endpoint, no Authorization header is sent: the code itself, freshly minted by GitHub and usable exactly once, is the only credential this call needs. If GitHub ever starts requiring one, adding a personal-access-token Authorization header here is the documented fix.

func (*Client) GetInstallation

func (c *Client) GetInstallation(ctx context.Context, instanceURL, appJWT string, installationID int64) (InstallationInfo, error)

GetInstallation looks up an installation by ID, authenticated as the App itself (appJWT, from SignAppJWT) rather than as the installation: this is the one call in this client that happens before an installation access token exists to use instead.

func (*Client) GetRepo

func (c *Client) GetRepo(ctx context.Context, instanceURL, token, owner, repo string) (Repo, error)

GetRepo looks up a single repository by owner/repo, authenticated with an installation access token the same way ListInstallationRepos is.

func (*Client) ListBranches

func (c *Client) ListBranches(ctx context.Context, instanceURL, token, owner, repo string) ([]Branch, error)

ListBranches lists every branch of owner/repo, authenticated with an installation access token the same way ListInstallationRepos is.

func (*Client) ListInstallationRepos

func (c *Client) ListInstallationRepos(ctx context.Context, instanceURL, token string) ([]Repo, error)

ListInstallationRepos lists every repository accessible to token (an installation access token from MintInstallationToken), across as many pages as GitHub returns full pages for.

func (*Client) MintInstallationToken

func (c *Client) MintInstallationToken(ctx context.Context, instanceURL, appJWT string, installationID int64) (InstallationToken, error)

MintInstallationToken exchanges an App-level JWT for a short-lived installation access token, authenticated as the App (Authorization: Bearer <appJWT>), the same scheme GetInstallation uses and the last call in this client that needs the App JWT rather than the resulting installation token.

type CommitStatusState

type CommitStatusState string

CommitStatusState is GitHub's own documented "state" enum for POST .../statuses/{sha}.

const (
	// CommitStatusPending marks a commit status as still in progress.
	CommitStatusPending CommitStatusState = "pending"
	// CommitStatusSuccess marks a commit status as succeeded.
	CommitStatusSuccess CommitStatusState = "success"
	// CommitStatusFailure marks a commit status as failed.
	CommitStatusFailure CommitStatusState = "failure"
)

type Credentials

type Credentials struct {
	AppID         int64
	ClientID      string
	ClientSecret  string
	WebhookSecret string
	PrivateKeyPEM string
	Slug          string
	HTMLURL       string
}

Credentials is ExchangeManifestCode's result: the real App identity and its three secrets, plus a couple of non-secret display fields (Slug, HTMLURL) internal/api uses to build the immediate install-prompt redirect. The caller (internal/api's handleGitHubAppCallback) must encrypt ClientSecret/WebhookSecret/ PrivateKeyPEM through internal/secrets.Manager before this value goes out of scope; this type itself does nothing to protect them, the same "plaintext exists only transiently in memory, on this one path" shape every other secret-bearing request/response type in this codebase already has (createBackupTargetRequest, internal/api's own SecretSetter callers).

type HookAttributes

type HookAttributes struct {
	URL    string `json:"url"`
	Active bool   `json:"active"`
}

HookAttributes is Manifest.HookAttributes. url is a required field of GitHub's manifest schema even when webhooks are never actually delivered (active: false below achieves that): see this package's own doc comment.

type InstallationInfo

type InstallationInfo struct {
	ID           int64
	AppID        int64
	AccountLogin string
	SuspendedAt  string
}

InstallationInfo is GetInstallation's result: just enough of GitHub's Installation object to record who the App is installed for (AccountLogin) and to defend against a cross-App installation_id (AppID, checked by the caller against the App's own stored ID before trusting this installation at all: see handleGitHubAppInstalled's own doc comment for why that check exists). SuspendedAt is non-empty when an org admin suspended the installation without uninstalling it: GitHub keeps returning 200 for a suspended installation, only access tokens and API calls made with them start failing, so this is the only signal that distinguishes "suspended" from "installed" at this call.

type InstallationToken

type InstallationToken struct {
	Token     string
	ExpiresAt time.Time
}

InstallationToken is MintInstallationToken's result: a short-lived (~1 hour per GitHub's docs) credential scoped to exactly one installation. internal/api mints a fresh one per repo/branch-listing request rather than caching and reusing one across requests: see ListInstallationRepos/ListBranches's own callers in internal/api for why that's the deliberate, simpler choice here.

type Manifest

type Manifest struct {
	Name                  string            `json:"name"`
	URL                   string            `json:"url"`
	HookAttributes        HookAttributes    `json:"hook_attributes"`
	RedirectURL           string            `json:"redirect_url"`
	SetupURL              string            `json:"setup_url"`
	Public                bool              `json:"public"`
	DefaultEvents         []string          `json:"default_events"`
	DefaultPermissions    map[string]string `json:"default_permissions"`
	RequestOAuthOnInstall bool              `json:"request_oauth_on_install"`
}

Manifest is the JSON body GitHub's manifest flow expects as the hidden form's "manifest" field value (https://github.com/settings/apps/new?state=...). Field names and json tags match GitHub's documented schema exactly; see this package's own doc comment for which fields this control plane populates and why.

func BuildManifest

func BuildManifest(appName, baseURL string, cfg ManifestConfig) Manifest

BuildManifest builds the manifest for a fresh App registration. appName is the App's display name on GitHub, expected to already be brand-derived and deployment-unique by the caller (internal/api's handleStartGitHubAppRegistration appends the operator's own primary domain to brand.Brand.Name, since App names must be unique across all of GitHub and a bare brand name would collide across every operator running this same control plane). baseURL is this control plane's own public, reachable origin (https://<primary domain>, no trailing slash): every URL below is baseURL plus a fixed, already-routed path.

cfg supplies the permissions and webhook events requested (ManifestConfig's own doc comment covers why these two are the config-driven fields and nothing else here is). HookAttributes.Active stays false regardless of cfg.DefaultEvents: no route in this codebase handles a delivery to hook_attributes.url yet (see this package's own doc comment), so activating it would just mean GitHub sends webhooks into a 404. DefaultEvents can still be configured in advance of that route existing; it has no effect until Active is wired to it alongside the real handler. Public is false: this App is registered for one operator's own account/org, not for other GitHub users to discover and install. RequestOAuthOnInstall is false: this integration only ever authenticates as the App/installation, never as the installing GitHub user, so there is no use for a user-level OAuth token.

type ManifestConfig

type ManifestConfig struct {
	DefaultPermissions map[string]string `yaml:"default_permissions"`
	DefaultEvents      []string          `yaml:"default_events"`
}

ManifestConfig is the operator-tunable subset of BuildManifest's output: which permissions and webhook events a newly-registered App requests. Everything else in Manifest (name, urls) is derived from baseURL/brand and has no business being config-driven, since it must always match this control plane's own actual routes; permissions and events are the one part an operator might genuinely need to extend (e.g. a future feature needing pull_requests:write) without waiting on a code change and recompile.

func DefaultManifestConfig

func DefaultManifestConfig() ManifestConfig

DefaultManifestConfig is what BuildManifest requests: contents:read + metadata:read (BuildManifest's own doc comment explains why) plus repository_hooks:write, so a freshly registered App can call Client.CreateRepoWebhook to auto-register a repo's push webhook. No webhook events. Returned by LoadManifestConfig whenever no config file is present.

GitHub does not retroactively grant a new permission to an existing installation, so an App installed before this permission was added here keeps failing CreateRepoWebhook with ErrPermissionDenied until reinstalled; see internal/api's handleUseGitHubRepoAsSource for how that's handled rather than treated as fatal.

func LoadManifestConfig

func LoadManifestConfig(path string) (ManifestConfig, error)

LoadManifestConfig reads path as YAML and returns the resulting ManifestConfig. Unlike internal/brand.Load's brand.yaml, a missing file here is not an error: it returns DefaultManifestConfig() unchanged, since customizing App permissions/events is an optional, advanced knob most operators never need to touch.

type Repo

type Repo struct {
	FullName      string
	Name          string
	OwnerLogin    string
	Private       bool
	DefaultBranch string
	HTMLURL       string
}

Repo is one repository this control plane's GitHub App installation can access, the subset of GitHub's Repository object internal/api's repo picker actually needs.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL