webhook

package
v0.8.6 Latest Latest
Warning

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

Go to latest
Published: Oct 27, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package webhook is the 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, the same pipeline a manual deploy trigger will use once the HTTP API exists.

Handler is an http.Handler, not a standalone server: the 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 (
	HeaderGitHubEvent       = "X-GitHub-Event"
	HeaderGitLabEvent       = "X-Gitlab-Event"
	HeaderGiteaEventType    = "X-Gitea-Event-Type"
	HeaderBitbucketEventKey = "X-Event-Key"
	HeaderGitLabToken       = "X-Gitlab-Token" //nolint:gosec // header name, not a credential value
	HeaderHubSignature256   = "X-Hub-Signature-256"
	HeaderHubSignature      = "X-Hub-Signature"
	HeaderGitHubDelivery    = "X-GitHub-Delivery"
	HeaderGitLabEventUUID   = "X-Gitlab-Event-Uuid"
	HeaderGiteaDelivery     = "X-Gitea-Delivery"
	HeaderBitbucketRequest  = "X-Request-Uuid"
)

Provider webhook header names, exported so internal/api's multi-app route (git_webhook.go) reads the identical header names this package uses to route and verify a delivery, rather than a second hardcoded copy that could drift out of sync.

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, a later 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 ErrIssueCommentFieldsMissing = errors.New("webhook: issue_comment payload missing required fields")

ErrIssueCommentFieldsMissing is returned when the payload decodes but lacks a field the handler needs.

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 IsIssueCommentEvent

func IsIssueCommentEvent(header http.Header) bool

IsIssueCommentEvent reports whether header is a GitHub issue_comment delivery. Gitea sends GitHub-compatible headers too, so any other forge's event header disqualifies it.

func IsMergeGroupEvent added in v0.2.0

func IsMergeGroupEvent(header http.Header) bool

IsMergeGroupEvent reports whether header names GitHub's merge_group event.

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, GitLab, and Gitea each send an unambiguous event-name header on every delivery (X-GitHub-Event, X-Gitlab-Event, X-Gitea-Event-Type); 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 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, nudger ReconcileNudger, log *slog.Logger) *Handler

New builds a Handler. attempts and recorder may both be nil (see Handler.attempts' own doc comment); notifier and nudger may be nil (see their own doc comments); 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 the HTTP API's job.

func (*Handler) UseSourceCache

func (h *Handler) UseSourceCache(c *sourcecache.Cache)

UseSourceCache routes this handler's fetches through the shared source mirror, so a known commit needs no git host and an outage fails fast.

type IssueCommentEvent

type IssueCommentEvent struct {
	Action        string
	CommentID     int64
	Body          string
	Commenter     string
	CommenterType string
	// PerformedViaApp is true when a GitHub App wrote the comment.
	PerformedViaApp bool
	SenderType      string
	Number          int
	// IsPullRequest is false for a comment on a plain issue.
	IsPullRequest bool
	RepoFullName  string
}

IssueCommentEvent is the part of a GitHub issue_comment delivery that pull request comment commands need.

func ParseIssueCommentEvent

func ParseIssueCommentEvent(body []byte) (IssueCommentEvent, error)

ParseIssueCommentEvent decodes a GitHub issue_comment payload.

type MergeGroupEvent added in v0.2.0

type MergeGroupEvent struct {
	// Action is "checks_requested" when the queue wants checks to run, or
	// "destroyed" when the group is dissolved.
	Action  string
	HeadSHA string
	// HeadRef is the temporary queue branch, a full "refs/heads/..." ref.
	HeadRef string
	// BaseRef is the branch the group merges into, a full "refs/heads/..." ref.
	BaseRef string
	BaseSHA string
}

MergeGroupEvent is the subset of GitHub's merge_group webhook payload a merge queue check needs.

func ParseMergeGroupEvent added in v0.2.0

func ParseMergeGroupEvent(body []byte) (MergeGroupEvent, error)

ParseMergeGroupEvent decodes a GitHub merge_group payload.

func (MergeGroupEvent) BaseBranch added in v0.2.0

func (e MergeGroupEvent) BaseBranch() string

BaseBranch is BaseRef without its "refs/heads/" prefix.

func (MergeGroupEvent) ChecksRequested added in v0.2.0

func (e MergeGroupEvent) ChecksRequested() bool

ChecksRequested reports whether the queue is asking for checks.

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
	// HeadRepoFullName and BaseRepoFullName are the "owner/name" of the
	// repositories the source and target branches live in. Empty when the
	// payload carries none (for example a deleted fork).
	HeadRepoFullName string
	BaseRepoFullName string
	// Reopened marks an Opened action that was a reopened pull request
	// rather than a new one.
	Reopened bool
}

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.

func (PullRequestEvent) IsFork added in v0.2.0

func (e PullRequestEvent) IsFork() bool

IsFork reports whether the pull request comes from a different repository than it targets. It fails closed: an event that names no head or base repository is treated as a fork, since code from an unknown source must not be trusted with secrets.

type PushEvent

type PushEvent struct {
	Ref   string `json:"ref"`
	After string `json:"after"`
	// Before is the commit the branch moved from, the stale-deploy guard's
	// strongest ordering signal.
	Before string `json:"before"`
	// Deleted marks a push that removed the ref (all-zero After on GitHub,
	// GitLab and Gitea, a null new ref on Bitbucket).
	Deleted bool `json:"deleted"`
	// HeadCommitAt is the pushed head commit's timestamp, zero if unknown.
	HeadCommitAt time.Time `json:"-"`
	// HeadMessage and HeadAuthor are the pushed head commit's message and
	// author name, empty when the payload carries none.
	HeadMessage string `json:"-"`
	HeadAuthor  string `json:"-"`
	// RepoNames and RepoURLs identify the repository the push is for
	// (full_name, path_with_namespace, clone/ssh/html URLs). Empty when
	// the payload does not say, which callers must treat as "unknown".
	RepoNames []string `json:"-"`
	RepoURLs  []string `json:"-"`
	// Changed lists the files the pushed commits touched. Nil means the
	// payload did not say (no commit list, or a list the provider truncates).
	Changed []string `json:"-"`
}

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 ParseBitbucketServerPushEvent

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

ParseBitbucketServerPushEvent decodes a repo:refs_changed payload into a PushEvent. The first branch update or add wins; tag and delete changes carry nothing to deploy and yield ErrPushEventFieldsMissing when nothing else is left.

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.

type ReconcileNudger added in v0.2.0

type ReconcileNudger interface {
	Nudge()
}

ReconcileNudger is *reconcile.Engine's own Nudge method, narrowed the same way internal/api.ReconcileNudger is (that interface can't be imported here: internal/api already imports internal/webhook). A push that just deployed a new image calls it so the reconciler doesn't wait up to a full resyncInterval to notice, the same gap that interface's doc comment describes for create/stop/start/restart.

Jump to

Keyboard shortcuts

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