webhook

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: 21 Imported by: 0

Documentation

Overview

Package webhook is TASKS.md 1.5's git integration: a GitHub push-webhook receiver that verifies the request signature, extracts the pushed commit SHA, fetches the repository at that SHA to a local checkout, and hands the result to internal/deploy's Pipeline (TASKS.md 1.4), the same pipeline a manual deploy trigger will use once the HTTP API (1.9) exists.

Handler is an http.Handler, not a standalone server: TASKS.md 1.9's HTTP API package mounts it at whatever path it chooses, so this package must never call http.ListenAndServe itself.

Scope boundary: Config is deliberately static, single-app configuration (one secret, one repo, one branch, one spec.Service). This phase's exit criterion is a single app, thesvg.org, deploying from a git push; this package does not build a multi-tenant "which repo maps to which app.yaml" registry, since designing that now would be exactly the speculative-ahead-of-a-real-second-app work the project's engineering standards warn against. A second app arriving is the trigger to revisit this, not a hypothesis about one arriving.

That second app has now arrived: internal/api's own multi-app git source registry (git_sources.go, git_webhook.go) reuses this package's verified signature/payload logic (VerifySignature, ParsePushEvent, MaxPayloadBytes, Config.TargetRef) rather than duplicating it, but lives in internal/api because turning a stored app back into a full spec.Service already lives there too (specServiceFromDesired). Handler and Config above are unchanged and still back the original single-app, env-var-configured path.

Index

Constants

View Source
const DefaultBranch = "main"

DefaultBranch is used when Config.Branch is empty.

View Source
const MaxPayloadBytes = maxPayloadBytes

MaxPayloadBytes is maxPayloadBytes, exported so a per-app git source webhook receiver outside this package (internal/api's multi-app route, TASKS.md 1.7's follow-up: see that package's own git_webhook.go doc comment for why the multi-app handler lives there instead of here) applies GitHub's own protocol size limit identically, rather than picking its own number.

Variables

View Source
var ErrPullRequestEventFieldsMissing = errors.New("webhook: pull request payload missing required fields")

ErrPullRequestEventFieldsMissing is returned by a provider-specific parse function when the payload decodes as valid JSON but is missing a field this package needs, or names an action outside this package's normalized vocabulary (normalizePullRequestAction).

View Source
var ErrPushEventFieldsMissing = errors.New("webhook: payload missing ref or after")

ErrPushEventFieldsMissing is returned by ParsePushEvent when body decodes as valid JSON but is missing ref or after, distinct from a json.Unmarshal failure so ServeHTTP (and internal/api's multi-app equivalent) can keep reporting the two cases with their own, more specific messages.

Functions

func IsPullRequestEvent

func IsPullRequestEvent(header http.Header) bool

IsPullRequestEvent reports whether header names a provider's own pull request / merge request event, so a caller can route to ParsePullRequestEventForProvider instead of ParsePushEventForProvider before looking at the body at all. GitHub and GitLab both send an unambiguous event-name header on every delivery (X-GitHub-Event, X-Gitlab-Event); Bitbucket reuses X-Event-Key, already read for its own push detection (ParsePushEventForProvider), just with a "pullrequest:"-prefixed value instead of "repo:push".

func VerifySignature

func VerifySignature(secret, body []byte, header string) bool

VerifySignature checks header against an HMAC-SHA256 of body keyed by secret, GitHub's X-Hub-Signature-256 scheme (docs.github.com/webhooks: "validating-webhook-deliveries"). Comparison uses hmac.Equal, constant-time by construction, rather than a plain byte-slice or string comparison, since a timing-observable comparison here would let an attacker recover a valid signature byte by byte.

Types

type AttemptStore

type AttemptStore interface {
	SaveDeployAttempt(ctx context.Context, a store.DeployAttempt) error
	FinishDeployAttempt(ctx context.Context, id, status string, finishedAt time.Time, errMsg string) error
}

AttemptStore is the narrow store surface Handler needs to record one deploy_attempts row per triggering push: this package is the third (and, per docs-local/research/deploy-attempt-id-and-log-persistence.md's own framing, the most important) of the three real deploy-trigger paths this history exists for. An unattended push-to-deploy that fails with no replayable record defeats the point of having a log viewer at all. *store.DB satisfies this structurally.

type Config

type Config struct {
	// Secret is the shared HMAC secret configured on the GitHub webhook,
	// used to verify the X-Hub-Signature-256 header. Required: a Handler
	// built with an empty Secret rejects every request, since an empty
	// secret would make the signature check meaningless.
	Secret []byte
	// RepoURL is the git remote this handler clones on a triggering
	// push, e.g. "https://github.com/org/thesvg.org.git". Deliberately
	// server-side configuration, never taken from the webhook payload:
	// only the commit SHA (a hash, not a URL) comes from the untrusted
	// request, so a forged payload cannot redirect a clone at an
	// attacker-controlled remote.
	RepoURL string
	// Branch triggers a deploy on push. Pushes to any other branch are
	// accepted (200 OK, so GitHub does not retry) but do not deploy.
	// Defaults to DefaultBranch when empty.
	Branch string
	// ServiceName, Service, and ImageRepo are passed straight through to
	// deploy.Request; see internal/deploy for what each means. Ignored
	// when Services (below) is non-empty.
	ServiceName string
	Service     spec.Service
	ImageRepo   string

	// Services, when non-empty, switches a triggering push from a
	// single-service Deploy call to a multi-service DeploySpec fan-out
	// (deploy.MultiRequest): ServiceName above becomes the app name every
	// fanned-out service links to, and ImageRepo above becomes the image
	// repo base every fanned-out service tags under. This is still
	// single-app, env-var-configured Config, exactly like every other
	// field here (the package doc comment's own scope boundary): Services
	// is one more static, operator-configured value, not a per-push
	// choice, so it does not reopen the "no multi-tenant registry" design
	// decision that comment already makes.
	Services map[string]spec.Service
}

Config is everything needed to map a validated GitHub push into a deploy.Request. See the package doc comment for why this is single-app, not a multi-tenant registry.

func (Config) TargetRef

func (c Config) TargetRef() string

TargetRef is targetRef, exported so a per-app Config resolved outside this package (internal/api's multi-app webhook route) can compute the identical "refs/heads/<branch>" ref-matching target this package's own ServeHTTP already checks pushes against, without duplicating DefaultBranch's fallback logic a second time.

type DeployNotifier

type DeployNotifier interface {
	Dispatch(ctx context.Context, resourceID string, ev alerting.DeployOutcome)
}

DeployNotifier is the narrow surface Handler needs to fire a deploy-outcome notification (Slack/Discord/Telegram/generic-webhook/ email, wave-2 roadmap item #5) once a push-triggered deploy reaches a terminal state: distinct from this package's own git-integration concern, this is deliberately just "hand the finished outcome to whatever internal/alerting.DeployDispatcher does with it," the same narrow-consumer-interface shape AttemptStore above already uses. *alerting.DeployDispatcher satisfies this structurally. nil is valid: Handler.notifier's own doc comment covers what a nil value does.

type Deployer

type Deployer interface {
	Deploy(ctx context.Context, req deploy.Request, progress func(build.ProgressEvent)) (string, error)
	// DeploySpec is the multi-service fan-out entry point, exercised only
	// when Config.Services is non-empty (see Config's own doc comment).
	// Same *deploy.Pipeline satisfies both methods.
	DeploySpec(ctx context.Context, req deploy.MultiRequest, progress func(serviceKey string, ev build.ProgressEvent)) ([]deploy.ServiceOutcome, error)
}

Deployer is the narrow surface this package needs from internal/deploy.Pipeline, so tests can fake it without a real BuildKit connection or Docker daemon, the same narrow-interface pattern internal/deploy and internal/reconcile/application already use. *deploy.Pipeline satisfies this.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler is an http.Handler that receives GitHub push event payloads, verifies them, and triggers a deploy through Deployer when the push targets Config.Branch.

func New

func New(cfg Config, deployer Deployer, attempts AttemptStore, recorder *deploylog.Recorder, notifier DeployNotifier, log *slog.Logger) *Handler

New builds a Handler. attempts and recorder may both be nil (see Handler.attempts' own doc comment); notifier may be nil (see Handler.notifier's own doc comment); log defaults to slog.Default() if nil.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler. Response codes: 200 for a successfully triggered or correctly-ignored (wrong branch) push, 400 for a malformed payload, 401 for a missing or invalid signature, 500 with a non-leaky message if the deploy itself fails. Deploy progress is logged via slog, not streamed in the response: GitHub expects a fast response and does not wait for one, actual progress streaming to a UI is TASKS.md 1.9's job.

type PullRequestAction

type PullRequestAction string

PullRequestAction is a normalized pull-request lifecycle event, collapsing each provider's own action vocabulary (GitHub's opened/reopened/synchronize/closed, GitLab's open/reopen/update/ close/merge, Bitbucket's created/updated/fulfilled/rejected event keys) down to the three this package's callers actually branch on.

const (
	// PullRequestOpened covers a pull request being opened or reopened:
	// a fresh preview deploy target.
	PullRequestOpened PullRequestAction = "opened"
	// PullRequestSynchronize covers new commits pushed to an
	// already-open pull request: redeploy its existing preview.
	PullRequestSynchronize PullRequestAction = "synchronize"
	// PullRequestClosed covers a pull request being closed, whether
	// merged or not: tear its preview down either way.
	PullRequestClosed PullRequestAction = "closed"
)

type PullRequestEvent

type PullRequestEvent struct {
	Action PullRequestAction
	Number int
	// HeadRef is the pull request's source branch name, not a full
	// "refs/heads/"-prefixed ref: unlike PushEvent.Ref, no provider's
	// pull request payload carries one, and a preview deploy only ever
	// needs the branch name to name/label the preview, not to match it
	// against a target ref.
	HeadRef string
	HeadSHA string
	// BaseRef is the pull request's target branch name, compared against
	// a connected git source's own Branch the same way PushEvent.Ref is
	// compared against Config.TargetRef: a pull request against any
	// other branch is ignored.
	BaseRef string
}

PullRequestEvent is the subset of a provider's pull/merge request webhook payload this package needs to drive a preview environment.

func ParsePullRequestEventForProvider

func ParsePullRequestEventForProvider(body []byte, header http.Header) (PullRequestEvent, error)

ParsePullRequestEventForProvider dispatches to the right provider's own payload shape based on header, the same header-driven dispatch IsPullRequestEvent already uses to decide this function should even be called.

type PushEvent

type PushEvent struct {
	Ref   string `json:"ref"`
	After string `json:"after"`
}

PushEvent is the subset of GitHub's push event payload this package needs. See https://docs.github.com/en/webhooks/webhook-events-and-payloads#push. Exported (renamed from the pre-existing unexported pushEvent) so internal/api's multi-app webhook route can decode the identical payload shape through ParsePushEvent below, rather than redeclaring it.

func ParseBitbucketPushEvent

func ParseBitbucketPushEvent(body []byte) (PushEvent, error)

ParseBitbucketPushEvent decodes body as a Bitbucket repo:push webhook payload, normalizing it into the same PushEvent shape ParsePushEvent produces for GitHub/GitLab: Ref gets Bitbucket's bare branch name prefixed with "refs/heads/" so Config.TargetRef's own comparison keeps working unchanged regardless of which provider sent the push. Picks the first change carrying a non-null New (the common case is exactly one), and fails with ErrPushEventFieldsMissing if every change in the payload is a branch delete (New null throughout): there is no commit to deploy in that case, the same "nothing meaningful to act on" reasoning that error already carries for a GitHub/GitLab payload missing ref or after.

func ParsePushEvent

func ParsePushEvent(body []byte) (PushEvent, error)

ParsePushEvent decodes body as a GitHub push event payload, requiring both Ref and After to be non-empty (see ServeHTTP's own doc comment: only the commit SHA and target ref matter to this package, everything else in GitHub's much larger push payload is ignored).

func ParsePushEventForProvider

func ParsePushEventForProvider(body []byte, eventKeyHeader string) (PushEvent, error)

ParsePushEventForProvider dispatches to ParseBitbucketPushEvent when eventKeyHeader (the request's own X-Event-Key value) is Bitbucket's "repo:push", or ParsePushEvent otherwise: GitHub sends no such header (X-GitHub-Event instead, a different name this function deliberately never reads, so a GitHub delivery always falls through to the default case) and GitLab's own X-Gitlab-Event value ("Push Hook") never matches "repo:push" either. Centralizing the branch here, rather than in internal/api's own handler, keeps every provider's payload shape knowledge inside this package, the same "this package owns verified signature/payload logic" boundary handleGitPushWebhook's own doc comment already establishes.

Jump to

Keyboard shortcuts

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