github

package
v0.9.3 Latest Latest
Warning

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

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

Documentation

Overview

Package github talks to GitHub's App and Runner Scale Set APIs.

Onboarding uses the App Manifest flow rather than asking an operator to click through app creation by hand. Two reasons, and the second is the important one: hand registration is roughly fifteen steps and a known adoption barrier for comparable tools, and — because the manifest declares the permission set — every billet deployment ends up with provably identical, minimal permissions instead of whatever the operator happened to tick.

Index

Constants

View Source
const ManifestTTL = time.Hour

ManifestTTL is GitHub's limit on the whole handshake: register, redirect, exchange. The exchange fails outright past it, so the CLI says so up front rather than letting an operator wander off mid-flow.

Variables

View Source
var ErrAppUnverifiable = errors.New(
	"github: could not verify the App (network or GitHub unavailable)")

ErrAppUnverifiable wraps failures that say NOTHING about the credential — DNS, timeouts, GitHub's own 5xx — so `billet check` can report them as advisory rather than fatal. The three-valued contract: a definite verdict about the App is fatal either way, and "could not tell" is never collapsed into either.

View Source
var ErrCredentialPreserved = errors.New("the App key was preserved")

ErrCredentialPreserved marks a failure in which the App's private key was NOT lost: it exists on disk, and the error carrying this sentinel says where.

The correct recovery advice is opposite in the two cases. A key that was never stored means the App is unusable and should be deleted on GitHub; a key stored somewhere unexpected means the App must be KEPT and the file moved. The wrong instruction destroys a credential GitHub will not re-issue.

View Source
var ErrCredentialUncertain = errors.New("billet could not verify whether the App key was saved")

ErrCredentialUncertain marks a failure where billet could not determine whether the key survived — an inspection that itself failed, rather than one that found nothing.

It suppresses the same destructive advice ErrCredentialPreserved does, and promises less. Reporting an unverifiable file as preserved would send the operator to a path that may be empty; reporting it as lost would have them delete an App whose key may be sitting right there. Neither is honest, so there are three outcomes rather than two.

View Source
var ErrNotInstalled = errors.New("github: app is not installed on the organization")

ErrNotInstalled means the app exists but is not installed on the organization. Distinct from any other failure because the remedy is a browser visit, not a retry or a credential fix.

View Source
var ErrRunnerGroupNotFound = errors.New("github: runner group not found")

ErrRunnerGroupNotFound reports that no runner group carries a given name.

Functions

func Permissions

func Permissions() map[string]string

Permissions returns a copy of the permission set billet requests.

func RegistrationURL

func RegistrationURL(org, state string) string

RegistrationURL is where the browser POSTs the manifest form.

It must be a form POST, not a redirect: the manifest travels in the request body as a `manifest` field, which is why the CLI serves a self-submitting page rather than simply opening a URL.

func SignAppJWT

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

SignAppJWT mints a GitHub App JWT (RS256) from the app's PEM private key.

Written against the standard library rather than pulling in a JWT dependency: the whole of it is a header, two claims and one signature, and billet holds this key precisely because it is the most sensitive thing in a deployment.

func ValidatePrivateKey

func ValidatePrivateKey(pemBytes []byte) error

ValidatePrivateKey reports whether a PEM is a usable App key.

Exported so `billet check` can prove the configured key WORKS rather than merely exists. A truncated PEM — what an interrupted write leaves behind — is otherwise not discovered until the first API call, long after the operator has been told the deployment is healthy.

Types

type APIError

type APIError struct {
	Status  int
	Message string
	// RateLimited marks a throttle: GitHub's primary limit answers 403 and its
	// secondary limit 429, and neither says anything about the credential —
	// reporting a throttled 403 as "GitHub refused the App credential" sends
	// the operator to their key file over a wait. Detected from the message,
	// because the body is what this type carries; the message shapes are
	// GitHub's documented rate-limit responses.
	RateLimited bool
}

APIError is a non-2xx GitHub response, keeping the status so a caller can tell a verdict about the credential (401, 403) from GitHub being unable to answer (5xx) — a distinction `billet check` must not collapse.

func (*APIError) Error

func (e *APIError) Error() string

type App

type App struct {
	ID            int64  `json:"id"`
	Slug          string `json:"slug"`
	NodeID        string `json:"node_id"`
	Name          string `json:"name"`
	HTMLURL       string `json:"html_url"`
	PEM           string `json:"pem"`
	WebhookSecret string `json:"webhook_secret"`
	ClientID      string `json:"client_id"`
	ClientSecret  string `json:"client_secret"`

	Owner struct {
		Login string `json:"login"`
	} `json:"owner"`
}

App is what GitHub hands back once the manifest is converted. It carries credentials, so it must never be logged.

func ConvertManifest

func ConvertManifest(ctx context.Context, client *http.Client, code string) (*App, error)

ConvertManifest exchanges the temporary code for the app's credentials.

The code is single-use and short-lived, so a failure here is terminal for that attempt: the operator has to re-run the flow rather than retry the exchange.

func (*App) Forget

func (a *App) Forget()

Forget blanks the credentials billet does not keep.

Onboard returns this struct after the key is on disk, and only the App ID and installation ID are ever stored. The webhook secret and client secret have no consumer at all — billet registers an INACTIVE webhook and implements no OAuth flow — so carrying them further is holding secrets for no reason. The PEM goes with them: its one job is finished by the time this is called.

func (App) Format

func (a App) Format(s fmt.State, verb rune)

Format makes EVERY verb safe, not just the ones fmt.Stringer covers.

fmt consults Stringer only for %v, %s, %q, %x and %X. An unrecognised verb for a struct — %d is the easy one to reach for — falls back to formatting the fields recursively and prints the private key inside its own bad-verb diagnostic. fmt.Formatter takes precedence for all verbs, so no verb renders the raw struct.

It does NOT cover an App reached through an unexported field of another struct: fmt uses reflection there and cannot call methods on a value it may not interface. The claim is "every direct formatting is safe", not "an App can never be printed".

func (App) GoString

func (a App) GoString() string

GoString covers %#v, which does not consult String.

func (*App) InstallURL

func (a *App) InstallURL() string

InstallURL is where the operator installs the freshly created app. Creating an app does NOT install it, and the installation is where the installation ID — which billet cannot work without — comes from.

func (App) LogValue

func (a App) LogValue() slog.Value

LogValue is what slog asks for before falling back to reflection, so a text handler redacts for the same reason the JSON one does.

func (App) MarshalJSON

func (a App) MarshalJSON() ([]byte, error)

MarshalJSON keeps credentials out of anything that serializes this struct.

fmt.Formatter covers direct formatting and nothing else. slog's JSON handler uses encoding/json, which reads the exported fields and emits `pem`, `webhook_secret` and `client_secret` verbatim — so `logger.Info("created", "app", app)` was a full private-key disclosure into wherever the logs go.

Only MARSHALING is redirected. Decoding GitHub's response still populates every field, which is what onboarding needs.

func (App) String

func (a App) String() string

String redacts every credential this struct carries.

App is exactly the kind of value that ends up in a debug print, a wrapped error or a log line during a bad afternoon, and a plain %v would otherwise emit the App's private key. Implementing String and GoString makes the default formatting verbs safe; code that deliberately wants a field still reads it.

type HookAttributes

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

HookAttributes configures the app's webhook.

type Installation

type Installation struct {
	ID      int64 `json:"id"`
	Account struct {
		Login string `json:"login"`
		Type  string `json:"type"`
	} `json:"account"`
	Permissions  map[string]string `json:"permissions"`
	RepositoryCt int               `json:"-"`
	// SuspendedAt is non-nil when the installation is suspended — GitHub's way
	// of disabling an App without uninstalling it. A suspended installation
	// answers this endpoint with a matching id and matching permissions while
	// every installation-token request fails, so verification must refuse it.
	SuspendedAt *time.Time `json:"suspended_at"`
}

Installation identifies an app installation on an account.

func GetOrgInstallation

func GetOrgInstallation(ctx context.Context, client *http.Client, appID int64, privateKeyPEM []byte, org string) (*Installation, error)

GetOrgInstallation resolves the installation id for an organization, authenticating as the app itself.

This is the fallback for when the post-install redirect does not arrive — an operator who closes the tab, or an install completed on a different machine. Without it, onboarding would dead-end on a value the operator has no straightforward way to look up.

func VerifyAppAt

func VerifyAppAt(
	ctx context.Context, client *http.Client, base string,
	appID int64, privateKeyPEM []byte, org string, installationID int64,
) (*Installation, error)

VerifyAppAt proves the configured App LIVE: the key signs a JWT GitHub accepts, the App is installed on the organization (and not suspended), the installation id matches the config, and the granted permissions are exactly what billet requested — every mismatch fatal, in both directions, matching PermissionMismatches' own contract (an extra permission falsifies "billet cannot read your code" just as a missing one breaks registration later). base selects the API host — empty means api.github.com; a test fake or a GitHub Enterprise Server deployment passes its own.

func WaitForOrgInstallation

func WaitForOrgInstallation(ctx context.Context, client *http.Client, appID int64, privateKeyPEM []byte, org string, every time.Duration) (*Installation, error)

WaitForOrgInstallation polls until the app is installed or ctx is done.

Used when the post-install redirect never arrives. Polling rather than waiting on the callback alone because the operator may finish the install in a different browser, or on a different machine entirely.

func (*Installation) PermissionMismatches

func (i *Installation) PermissionMismatches() []string

PermissionMismatches reports every way the installation's effective permissions differ from what billet requested, in BOTH directions.

An operator can edit an app's permissions between creating it and installing it, and each direction fails differently:

  • Missing or downgraded: runner registration fails later, with an error that never mentions permissions.
  • Unexpected: billet holds access it publicly claims not to have. An app edited to add `contents` or `actions` is exactly the case that would make "billet cannot read your code" false while onboarding reported success.

Results are sorted so the diagnostic is stable across runs — Go randomizes map iteration, and an error message that reorders itself is one nobody can diff.

type Manifest

type Manifest struct {
	Name string `json:"name,omitempty"`
	// URL is required by GitHub even for an app that serves no web surface.
	URL            string            `json:"url"`
	HookAttributes *HookAttributes   `json:"hook_attributes,omitempty"`
	RedirectURL    string            `json:"redirect_url,omitempty"`
	SetupURL       string            `json:"setup_url,omitempty"`
	Description    string            `json:"description,omitempty"`
	Public         bool              `json:"public"`
	DefaultEvents  []string          `json:"default_events,omitempty"`
	Permissions    map[string]string `json:"default_permissions,omitempty"`
	// SetupOnUpdate keeps the installation callback firing when an operator
	// changes which repositories the app can see, so a re-scoped install still
	// lands back here.
	SetupOnUpdate bool `json:"setup_on_update,omitempty"`
}

Manifest is the app registration billet asks GitHub to create. Field order follows GitHub's documented parameter table.

func NewManifest

func NewManifest(name, redirectURL, setupURL string) Manifest

NewManifest builds billet's manifest. redirectURL and setupURL point at the loopback server the CLI runs for the duration of onboarding.

type OnboardOptions

type OnboardOptions struct {
	Org string
	// Name pre-fills the app name. GitHub app names are globally unique, so this
	// is a suggestion the operator edits on GitHub's own page.
	Name string
	// OpenBrowser is called with each URL the operator must visit. Returning an
	// error is not fatal: the URL is printed for manual use, which is what makes
	// the flow work over SSH.
	OpenBrowser func(context.Context, string) error
	// OnAppCreated is called the moment the app's credentials exist, BEFORE the
	// installation step. Required.
	//
	// The ordering is the point: GitHub registers the app during the browser redirect
	// and the private key is returned exactly once by the conversion, so persisting it
	// at the END would let any failure in the installation phase leave a real
	// registered app whose only key had been discarded. Returning an error here aborts,
	// because continuing produces that same orphan.
	OnAppCreated func(*App) error

	// Log receives human-facing progress. Required.
	Log func(format string, args ...any)
	// Client is optional; a sane default is used when nil.
	Client *http.Client
	// InstallPoll is how often to check whether the install finished, used when
	// the post-install redirect never arrives.
	InstallPoll time.Duration

	// Port fixes the loopback callback port. Zero picks a free one.
	//
	// It exists for the remote case: onboarding a CI host over SSH is the normal
	// way this runs, and there the callback listens on the SERVER's loopback
	// while the browser is on a laptop, where 127.0.0.1 means the laptop. That
	// needs `ssh -L`, and a forward needs a port known in advance.
	Port int
	// contains filtered or unexported fields
}

OnboardOptions configures the manifest flow.

type Onboarding

type Onboarding struct {
	App          *App
	Installation *Installation
}

Onboarding result. Credentials live here only long enough to be written to disk by the caller.

func Onboard

func Onboard(ctx context.Context, opts OnboardOptions) (*Onboarding, error)

Onboard runs the whole flow: register the app from a manifest, exchange the code for credentials, then wait for the operator to install it.

Two browser steps, because GitHub genuinely has two: creating an app does NOT install it, and the installation is where the installation ID comes from. Anything claiming this is one click is describing only the first half.

type RunnerGroupPolicyClient

type RunnerGroupPolicyClient interface {
	ValidateTrustedRunnerGroup(ctx context.Context, groupID int, wantWorkflows []string) error
	// ValidateRunnerGroupReach asks only whether a group can be assigned a job
	// at all. It is the half of the trusted validation that applies to EVERY
	// tier, including an untrusted one, which lives in the default group and was
	// asked nothing.
	ValidateRunnerGroupReach(ctx context.Context, groupID int) error
	InspectScaleSetRunner(ctx context.Context, runnerName string, runnerID int64) (RunnerRecovery, error)
	// FindRunnerGroupID resolves a name so a caller holding only the App
	// credentials can validate a group, which is what lets `billet check` reach
	// the same verdict the server does without an Actions-tenant client.
	FindRunnerGroupID(ctx context.Context, name string) (int, bool, error)
}

RunnerGroupPolicyClient reads the GitHub-side state needed to protect pools. Its implementation is hidden so credential-bearing values cannot be copied out and rendered without the redaction methods below.

func NewRunnerGroupPolicyClient

func NewRunnerGroupPolicyClient(org string, appID, installationID int64,
	privateKey []byte,
) RunnerGroupPolicyClient

NewRunnerGroupPolicyClient builds a client for GitHub.com's organization API.

func NewRunnerGroupPolicyClientAt

func NewRunnerGroupPolicyClientAt(base, org string, appID, installationID int64,
	privateKey []byte,
) RunnerGroupPolicyClient

NewRunnerGroupPolicyClientAt builds a client for a GitHub Enterprise API base. AN EMPTY BASE MEANS THE REAL GITHUB, which is what every production caller passes and what no test ever did.

`cmd/billet`'s githubAPIBase is a var whose zero value selects the default, so a test can point it at a fake; VerifyAppAt honours that and this did not. The result was a URL with no scheme or host — `Post "/app/installations/…"` — so the runner-group check FAILED on every real deployment and passed in every test, because the tests are the only callers that set a base.

That check exists because a misconfigured runner group was the first failure two operators hit on a fresh host, and it could not have caught one.

type RunnerRecovery

type RunnerRecovery struct {
	RunnerID int64
	Present  bool
	Busy     bool
}

RunnerRecovery reports whether an exact legacy scale-set registration exists and is still busy. A zero value means it is absent.

Jump to

Keyboard shortcuts

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