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 ¶
- Variables
- func AppDisplayName(brandName, domain string) string
- func SignAppJWT(appID int64, privateKeyPEM []byte, now time.Time) (string, error)
- func ValidatePrivateKeyPEM(pemBytes []byte) error
- type Branch
- type Client
- func (c *Client) APIBaseURL(instanceURL string) string
- func (c *Client) CheckInstanceReachable(ctx context.Context, instanceURL string) error
- func (c *Client) CreateCommitStatus(ctx context.Context, instanceURL, token, owner, repo, sha string, ...) error
- func (c *Client) CreateIssueComment(ctx context.Context, instanceURL, token, owner, repo string, number int, ...) error
- func (c *Client) CreateRepoWebhook(ctx context.Context, instanceURL, token, owner, repo, hookURL, secret string) error
- func (c *Client) ExchangeManifestCode(ctx context.Context, instanceURL, code string) (Credentials, error)
- func (c *Client) GetInstallation(ctx context.Context, instanceURL, appJWT string, installationID int64) (InstallationInfo, error)
- func (c *Client) GetRepo(ctx context.Context, instanceURL, token, owner, repo string) (Repo, error)
- func (c *Client) ListBranches(ctx context.Context, instanceURL, token, owner, repo string) ([]Branch, error)
- func (c *Client) ListInstallationRepos(ctx context.Context, instanceURL, token string) ([]Repo, error)
- func (c *Client) MintInstallationToken(ctx context.Context, instanceURL, appJWT string, installationID int64) (InstallationToken, error)
- type CommitStatusState
- type Credentials
- type HookAttributes
- type InstallationInfo
- type InstallationToken
- type Manifest
- type ManifestConfig
- type Repo
Constants ¶
This section is empty.
Variables ¶
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.
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 ¶
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 ¶
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 ¶
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 Client ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.