feat(webhook): vendor-specific event parsing (Gitea / GitHub / GitLab)

The /api/webhook/workloads/{secret} ingress now short-circuits on a
recognized X-*-Event header before falling back to the generic
simple-body parser. Vendor parsers populate fields the generic
parser cannot (image digest, GitEvent.Vendor, registry host).

internal/webhook/vendor_parsers.go covers:
- Gitea package events (X-Gitea-Event: package, container type)
- GitHub registry_package + package events (CONTAINER package_type)
- GitHub / Gitea push events with vendor stamping
- GitLab Push Hook + Tag Push Hook with path_with_namespace mapping

When a vendor parser claims a request (ok=true), it's authoritative
— a malformed Gitea package payload surfaces as an error rather
than silently re-parsing as generic. The generic {image} /
{ref + repository.full_name} fallback stays in place for legacy
CIs that send those shapes.

Coverage: internal/webhook/vendor_parsers_test.go +
inbound_event_test.go (round-trip through buildInboundEvent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:17:53 +03:00
parent 8d6a527a2b
commit 82d32181ba
4 changed files with 1080 additions and 2 deletions
+301 -2
View File
@@ -13,10 +13,20 @@ import (
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Local aliases — keep the interface surface small and avoid leaking
// plugin types into every consumer of the webhook package's API.
type (
pluginWorkload = plugin.Workload
pluginIntent = plugin.DeploymentIntent
pluginDeps = plugin.Deps
)
// signatureHeader is the canonical Gitea/GitHub-compatible header name for
@@ -138,6 +148,14 @@ type SiteSyncTriggerer interface {
Deploy(ctx context.Context, siteID string, force bool) error
}
// PluginDispatcher is what the plugin-workload webhook handler needs from
// the deployer: the canonical Source-dispatch entry point plus access to
// the same Deps bundle so Trigger.Match can read store / crypto.
type PluginDispatcher interface {
DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error
PluginDeps() pluginDeps
}
// Payload is the expected JSON body for a project webhook request.
type Payload struct {
// Image is the full image reference including tag, e.g.
@@ -226,6 +244,7 @@ type Handler struct {
store *store.Store
deployer DeployTriggerer
sites SiteSyncTriggerer
plugins PluginDispatcher // optional; nil disables /workloads/{secret}
// Site sync coordination — webhooks fire syncs in the background; Drain
// blocks until those goroutines finish, so a graceful shutdown does not
@@ -258,6 +277,13 @@ func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
h.sites = s
}
// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
// is called the /workloads/{secret} route returns 503 — preventing partial
// initialization from silently dropping deploys.
func (h *Handler) SetPluginDispatcher(d PluginDispatcher) {
h.plugins = d
}
// Drain cancels in-flight site syncs and waits for their goroutines to exit.
// Safe to call from a graceful-shutdown path.
func (h *Handler) Drain() {
@@ -269,11 +295,13 @@ func (h *Handler) Drain() {
//
// Routes:
//
// POST /{secret} — per-project deploy trigger
// POST /sites/{secret} — per-site sync trigger
// POST /{secret} — per-project deploy trigger (legacy)
// POST /sites/{secret} — per-site sync trigger (legacy)
// POST /workloads/{secret} — plugin-native workload trigger
func (h *Handler) Route() chi.Router {
r := chi.NewRouter()
r.Post("/sites/{secret}", h.handleSiteWebhook)
r.Post("/workloads/{secret}", h.handlePluginWorkloadWebhook)
r.Post("/{secret}", h.handleWebhook)
return r
}
@@ -646,3 +674,274 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
"success": true, "sync": true, "site": site.Name,
})
}
// handlePluginWorkloadWebhook processes an inbound webhook for a
// plugin-native workload.
//
// URL: POST /api/webhook/workloads/{secret}
//
// The secret resolves to exactly one workload row whose Source +
// Trigger kinds determine how the payload is interpreted. The body
// shape is the same as the legacy project/site webhooks (Image for
// registry pushes, Ref for git pushes) — Gitea / GitHub / generic
// registry CIs can target this URL without payload changes. The
// workload's configured Trigger plugin then decides whether the event
// fires a deploy.
func (h *Handler) handlePluginWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "workload",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.plugins == nil {
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "plugin dispatcher not wired"
respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired")
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
wl, err := h.store.GetWorkloadByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: workload lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
if wl.SourceKind == "" || wl.TriggerKind == "" {
// Legacy workload row whose secret happens to also be valid on the
// legacy path. Tell the caller they hit the wrong route rather
// than silently 404-ing — avoids head-scratching.
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "workload is legacy; use the project or site route"
respondWebhookError(w, http.StatusBadRequest, "workload is not plugin-native")
return
}
delivery.TargetID = wl.ID
delivery.TargetName = wl.Name
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(wl.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(wl.WebhookSigningSecret, header, verified, attempted)
if wl.WebhookRequireSignature && !verified {
slog.Warn("webhook: workload signature required but invalid/missing", "workload", wl.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: workload bad signature", "workload", wl.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
evt, err := buildInboundEvent(body, r.Header)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = err.Error()
respondWebhookError(w, http.StatusBadRequest, err.Error())
return
}
trig, err := plugin.GetTrigger(wl.TriggerKind)
if err != nil {
slog.Warn("webhook: trigger plugin not registered",
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger plugin missing"
respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing")
return
}
pwl := toPluginWorkload(wl)
intent, err := trig.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
if err != nil {
slog.Warn("webhook: trigger match error",
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger match error"
respondWebhookError(w, http.StatusInternalServerError, "trigger match error")
return
}
if intent == nil {
delivery.Detail = "trigger declined (no match)"
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "workload": wl.Name,
"reason": "trigger declined",
})
return
}
if intent.TriggeredAt.IsZero() {
intent.TriggeredAt = time.Now().UTC()
}
if intent.TriggeredBy == "" {
intent.TriggeredBy = "webhook"
}
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
slog.Warn("webhook: plugin dispatch failed",
"workload", wl.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "dispatch failed; see server logs"
respondWebhookError(w, http.StatusInternalServerError, "dispatch failed; see server logs")
return
}
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("reason=%s ref=%s", intent.Reason, intent.Reference)
slog.Info("webhook: triggered plugin deploy",
"workload", wl.Name, "trigger", wl.TriggerKind, "reason", intent.Reason)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"workload": wl.Name, "reference": intent.Reference,
})
}
// buildInboundEvent normalizes the incoming webhook body into the
// plugin.InboundEvent shape. The dispatch order is:
//
// 1. Empty body → manual event (used by the test-trigger UI button).
// 2. Vendor-specific parsers (Gitea package, GitHub registry_package,
// GitHub/Gitea/GitLab push) — short-circuit on a recognized
// X-*-Event header. Vendor parsers can fully populate richer fields
// (image digest, vendor tag, branch) the generic parser cannot.
// 3. Generic simple-body parser: top-level `image` for registry pushes,
// top-level `ref` for git pushes. This is what the legacy webhook
// CIs already send and what the operator-facing API surface
// documents.
//
// RawBody and Headers are always attached so trigger plugins can do
// their own vendor-specific parsing if they need fields outside this
// normalized envelope.
func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, error) {
evt := plugin.InboundEvent{
RawBody: body,
Headers: headers,
}
if len(body) == 0 {
evt.Kind = "manual"
evt.Manual = &plugin.ManualEvent{Actor: "webhook"}
return evt, nil
}
// Try vendor-specific parsers first. A vendor parser claiming the
// request (ok=true) is authoritative — we don't fall through even
// if it returned an error, because the operator's CI is sending a
// known vendor payload and silently re-parsing as generic would
// hide the real cause.
if res := tryVendorParsers(body, headers); res.ok {
if res.err != nil {
return plugin.InboundEvent{}, res.err
}
res.event.RawBody = body
res.event.Headers = headers
return res.event, nil
}
// Generic simple-body fallback: covers the canonical `{image: ...}`
// and `{ref: ..., repository: {...}}` payloads documented in
// docs/webhooks.md.
var probe struct {
Image string `json:"image"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
}
if probe.Image != "" {
parsed, err := ParseImageRef(probe.Image)
if err != nil {
return plugin.InboundEvent{}, fmt.Errorf("invalid image reference")
}
evt.Kind = "image-push"
evt.Image = &plugin.ImagePushEvent{
Registry: parsed.Registry,
Repo: parsed.FullName(),
Tag: parsed.Tag,
}
return evt, nil
}
gitEvt, err := parseGenericGitPush(body)
if err != nil {
// "missing ref" here means we got JSON with neither `image` nor
// `ref` — surface the operator-facing message documented in
// docs/webhooks.md rather than the lower-level parser error.
if strings.Contains(err.Error(), "missing ref") {
return plugin.InboundEvent{}, fmt.Errorf("payload must include either 'image' or 'ref'")
}
return plugin.InboundEvent{}, err
}
gitEvt.RawBody = body
gitEvt.Headers = headers
return gitEvt, nil
}
// toPluginWorkload mirrors the api-layer converter but kept local so the
// webhook package does not depend on internal/api. Inlining is cheap and
// avoids elevating that converter to a shared package.
func toPluginWorkload(w store.Workload) plugin.Workload {
var faces []plugin.PublicFace
if w.PublicFaces != "" {
_ = json.Unmarshal([]byte(w.PublicFaces), &faces)
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
+98
View File
@@ -0,0 +1,98 @@
package webhook
import (
"net/http"
"testing"
)
func TestBuildInboundEventEmpty(t *testing.T) {
evt, err := buildInboundEvent(nil, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Kind != "manual" {
t.Errorf("empty body should be manual, got %q", evt.Kind)
}
if evt.Manual == nil || evt.Manual.Actor != "webhook" {
t.Errorf("expected manual.Actor=webhook, got %+v", evt.Manual)
}
}
func TestBuildInboundEventImagePush(t *testing.T) {
body := []byte(`{"image":"registry.example.com/owner/app:v1.2.3"}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Kind != "image-push" || evt.Image == nil {
t.Fatalf("expected image-push, got kind=%q image=%+v", evt.Kind, evt.Image)
}
if evt.Image.Registry != "registry.example.com" {
t.Errorf("Registry=%q want registry.example.com", evt.Image.Registry)
}
if evt.Image.Repo != "owner/app" {
t.Errorf("Repo=%q want owner/app", evt.Image.Repo)
}
if evt.Image.Tag != "v1.2.3" {
t.Errorf("Tag=%q want v1.2.3", evt.Image.Tag)
}
}
func TestBuildInboundEventGitPush(t *testing.T) {
body := []byte(`{
"ref":"refs/heads/main",
"after":"abc123",
"repository":{"full_name":"owner/repo","clone_url":"https://example.com/owner/repo.git"},
"pusher":{"name":"alice"}
}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Kind != "git-push" || evt.Git == nil {
t.Fatalf("expected git-push, got kind=%q git=%+v", evt.Kind, evt.Git)
}
if evt.Git.Repo != "owner/repo" {
t.Errorf("Repo=%q want owner/repo", evt.Git.Repo)
}
if evt.Git.Branch != "main" {
t.Errorf("Branch=%q want main", evt.Git.Branch)
}
if evt.Git.CommitSHA != "abc123" {
t.Errorf("CommitSHA=%q want abc123", evt.Git.CommitSHA)
}
if evt.Git.Pusher != "alice" {
t.Errorf("Pusher=%q want alice", evt.Git.Pusher)
}
}
func TestBuildInboundEventGitTag(t *testing.T) {
body := []byte(`{"ref":"refs/tags/v2.0.0","after":"deadbeef"}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Kind != "git-tag" {
t.Errorf("kind=%q want git-tag", evt.Kind)
}
if evt.Git == nil || evt.Git.Tag != "v2.0.0" {
t.Errorf("expected tag=v2.0.0, got %+v", evt.Git)
}
if evt.Git.Branch != "" {
t.Errorf("tag event should not have Branch, got %q", evt.Git.Branch)
}
}
func TestBuildInboundEventInvalidJSON(t *testing.T) {
if _, err := buildInboundEvent([]byte(`not json`), http.Header{}); err == nil {
t.Fatal("expected error on bad JSON")
}
}
func TestBuildInboundEventMissingFields(t *testing.T) {
// Body present but neither image nor ref — caller must get a useful
// error pointing at the missing field rather than a silent default.
if _, err := buildInboundEvent([]byte(`{"foo":"bar"}`), http.Header{}); err == nil {
t.Fatal("expected error on payload without image or ref")
}
}
+390
View File
@@ -0,0 +1,390 @@
package webhook
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Vendor headers we recognize for short-circuit dispatch. The header
// presence (not just value) is the trigger — vendors version event names
// over time so matching on value alone is brittle.
const (
headerGiteaEvent = "X-Gitea-Event"
headerGiteaDelivery = "X-Gitea-Delivery"
headerGitHubEvent = "X-GitHub-Event"
headerGitLabEvent = "X-Gitlab-Event"
)
// vendorParseResult bundles the parsed event with an "ok" flag distinct
// from the error: ok=false means "this parser doesn't apply to the
// request, try the next one"; ok=true with err!=nil means "this parser
// applied but the payload was malformed."
type vendorParseResult struct {
event plugin.InboundEvent
ok bool
err error
}
// tryVendorParsers runs each vendor-specific parser in order and returns
// the first one that recognizes the payload. ok=false means no vendor
// parser claimed the request — caller should fall back to the generic
// simple-body parser.
func tryVendorParsers(body []byte, headers http.Header) vendorParseResult {
for _, fn := range []func([]byte, http.Header) vendorParseResult{
parseGiteaPackageEvent,
parseGitHubPackageEvent,
parseGitHubPushEvent,
parseGiteaPushEvent,
parseGitLabPushEvent,
} {
res := fn(body, headers)
if res.ok {
return res
}
}
return vendorParseResult{}
}
// parseGiteaPackageEvent recognizes Gitea container-registry package
// pushes. Distinguishing fingerprint: X-Gitea-Event: package and a body
// with package.type == "container".
//
// Reference: https://docs.gitea.com/usage/webhooks
//
// Body excerpt:
//
// {
// "action": "created",
// "package": {
// "name": "my-app",
// "type": "container",
// "version": "v1.2.3",
// "owner": {"login": "alexei"},
// "repository": {"full_name": "alexei/my-app"}
// }
// }
func parseGiteaPackageEvent(body []byte, headers http.Header) vendorParseResult {
if headers.Get(headerGiteaEvent) != "package" {
return vendorParseResult{}
}
var probe struct {
Action string `json:"action"`
Package struct {
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"`
Owner struct {
Login string `json:"login"`
Username string `json:"username"`
} `json:"owner"`
Repository struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
} `json:"repository"`
} `json:"package"`
Sender struct {
Login string `json:"login"`
Username string `json:"username"`
} `json:"sender"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return vendorParseResult{ok: true, err: fmt.Errorf("invalid Gitea package JSON")}
}
if !strings.EqualFold(probe.Package.Type, "container") {
// Non-container package (npm, maven, generic, etc.) — Tinyforge
// only deploys containers today, so drop these as not-applicable.
// Return ok=true so we don't fall through to the simple-body
// parser and accidentally pick up the "version" field as a ref.
return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package type %q is not a container", probe.Package.Type)}
}
if probe.Package.Name == "" || probe.Package.Version == "" {
return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package event missing name/version")}
}
owner := probe.Package.Owner.Login
if owner == "" {
owner = probe.Package.Owner.Username
}
repo := probe.Package.Name
if owner != "" {
repo = owner + "/" + probe.Package.Name
}
registry := registryHostFromGiteaRepoURL(probe.Package.Repository.HTMLURL)
evt := plugin.InboundEvent{
Kind: "image-push",
Image: &plugin.ImagePushEvent{
Registry: registry,
Repo: repo,
Tag: probe.Package.Version,
},
}
return vendorParseResult{event: evt, ok: true}
}
// registryHostFromGiteaRepoURL extracts the host portion of a Gitea
// repository URL. Gitea container registry lives on the same host, so
// the repo URL host is a reliable proxy for the registry host.
func registryHostFromGiteaRepoURL(repoURL string) string {
if repoURL == "" {
return ""
}
// Strip scheme.
if i := strings.Index(repoURL, "://"); i >= 0 {
repoURL = repoURL[i+3:]
}
if i := strings.Index(repoURL, "/"); i >= 0 {
repoURL = repoURL[:i]
}
return repoURL
}
// parseGitHubPackageEvent recognizes GitHub Container Registry (ghcr.io)
// publish events. Two event names exist depending on the year the webhook
// was registered: "package" and "registry_package". Both share the same
// body shape under registry_package.
//
// Reference:
// - https://docs.github.com/en/webhooks/webhook-events-and-payloads#package
// - https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
//
// Body excerpt:
//
// {
// "action": "published",
// "registry_package": {
// "name": "my-app",
// "namespace": "owner",
// "package_type": "CONTAINER",
// "package_version": {
// "container_metadata": { "tag": { "name": "v1.2.3" } },
// "name": "sha256:..."
// },
// "registry": { "url": "https://ghcr.io/..." }
// }
// }
func parseGitHubPackageEvent(body []byte, headers http.Header) vendorParseResult {
ev := headers.Get(headerGitHubEvent)
if ev != "package" && ev != "registry_package" {
return vendorParseResult{}
}
// GitHub sends the body under either "package" or "registry_package"
// depending on event name; tolerate both by probing for whichever is
// populated.
var probe struct {
Action string `json:"action"`
Package json.RawMessage `json:"package"`
RegistryPackage json.RawMessage `json:"registry_package"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package JSON")}
}
raw := probe.RegistryPackage
if len(raw) == 0 {
raw = probe.Package
}
if len(raw) == 0 {
return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing package body")}
}
var pkg struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
PackageType string `json:"package_type"`
PackageVersion struct {
Name string `json:"name"`
Version string `json:"version"`
ContainerMetadata struct {
Tag struct {
Name string `json:"name"`
} `json:"tag"`
} `json:"container_metadata"`
} `json:"package_version"`
Registry struct {
URL string `json:"url"`
} `json:"registry"`
}
if err := json.Unmarshal(raw, &pkg); err != nil {
return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package payload")}
}
if !strings.EqualFold(pkg.PackageType, "CONTAINER") && !strings.EqualFold(pkg.PackageType, "container") {
return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package type %q is not a container", pkg.PackageType)}
}
tag := pkg.PackageVersion.ContainerMetadata.Tag.Name
digest := ""
// PackageVersion.Name is "sha256:..." for container versions.
if strings.HasPrefix(pkg.PackageVersion.Name, "sha256:") {
digest = pkg.PackageVersion.Name
}
if tag == "" && digest == "" {
return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing tag and digest")}
}
repo := pkg.Name
if pkg.Namespace != "" {
repo = pkg.Namespace + "/" + pkg.Name
}
evt := plugin.InboundEvent{
Kind: "image-push",
Image: &plugin.ImagePushEvent{
Registry: registryHostFromGitHubRegistry(pkg.Registry.URL),
Repo: repo,
Tag: tag,
Digest: digest,
},
}
return vendorParseResult{event: evt, ok: true}
}
// registryHostFromGitHubRegistry pulls the host from a GitHub registry
// URL ("https://ghcr.io/..." → "ghcr.io"). Defaults to "ghcr.io" since
// that's the only host GitHub uses for container packages today.
func registryHostFromGitHubRegistry(rawURL string) string {
host := registryHostFromGiteaRepoURL(rawURL) // same scheme/host stripping
if host == "" {
return "ghcr.io"
}
return host
}
// parseGitHubPushEvent handles the standard GitHub push event with a
// vendor annotation. The generic parser already covers the body shape
// (ref / after / repository.full_name / pusher.name) — this exists so
// the resulting GitEvent.Vendor is populated and trigger plugins can
// distinguish GitHub from Gitea without re-parsing headers.
func parseGitHubPushEvent(body []byte, headers http.Header) vendorParseResult {
if headers.Get(headerGitHubEvent) != "push" {
return vendorParseResult{}
}
evt, err := parseGenericGitPush(body)
if err != nil {
return vendorParseResult{ok: true, err: err}
}
if evt.Git != nil {
evt.Git.Vendor = "github"
}
return vendorParseResult{event: evt, ok: true}
}
// parseGiteaPushEvent — same idea, for Gitea. Gitea push events share
// shape with GitHub's (Gitea explicitly modeled them).
func parseGiteaPushEvent(body []byte, headers http.Header) vendorParseResult {
if headers.Get(headerGiteaEvent) != "push" {
return vendorParseResult{}
}
evt, err := parseGenericGitPush(body)
if err != nil {
return vendorParseResult{ok: true, err: err}
}
if evt.Git != nil {
evt.Git.Vendor = "gitea"
}
return vendorParseResult{event: evt, ok: true}
}
// parseGitLabPushEvent handles GitLab's push event, which uses a
// different body shape than GitHub/Gitea:
//
// {
// "ref": "refs/heads/main",
// "after": "abc123",
// "user_username": "alice",
// "project": { "path_with_namespace": "owner/repo" }
// }
//
// Reference: https://docs.gitlab.com/user/project/integrations/webhook_events/
func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult {
ev := headers.Get(headerGitLabEvent)
if ev != "Push Hook" && ev != "Tag Push Hook" {
return vendorParseResult{}
}
var probe struct {
Ref string `json:"ref"`
After string `json:"after"`
UserName string `json:"user_username"`
UserNameAlt string `json:"user_name"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
GitHTTPURL string `json:"git_http_url"`
} `json:"project"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitLab push JSON")}
}
if probe.Ref == "" {
return vendorParseResult{ok: true, err: fmt.Errorf("GitLab push event missing ref")}
}
pusher := probe.UserName
if pusher == "" {
pusher = probe.UserNameAlt
}
evt := plugin.InboundEvent{
Kind: "git-push",
Git: &plugin.GitEvent{
Vendor: "gitlab",
Repo: probe.Project.PathWithNamespace,
Ref: probe.Ref,
CommitSHA: probe.After,
Pusher: pusher,
},
}
if strings.HasPrefix(probe.Ref, "refs/heads/") {
evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/")
}
if strings.HasPrefix(probe.Ref, "refs/tags/") {
evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/")
evt.Kind = "git-tag"
}
return vendorParseResult{event: evt, ok: true}
}
// parseGenericGitPush extracts a GitEvent from a GitHub/Gitea-style push
// body. Shared by the GitHub and Gitea vendor wrappers and by the
// generic fallback in buildInboundEvent.
func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
var probe struct {
Ref string `json:"ref"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
} `json:"repository"`
Repo string `json:"repo"`
Pusher struct {
Name string `json:"name"`
Username string `json:"username"`
} `json:"pusher"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return plugin.InboundEvent{}, fmt.Errorf("invalid git push JSON")
}
if probe.Ref == "" {
return plugin.InboundEvent{}, fmt.Errorf("git push event missing ref")
}
repo := probe.Repository.FullName
if repo == "" {
repo = probe.Repo
}
pusher := probe.Pusher.Name
if pusher == "" {
pusher = probe.Pusher.Username
}
evt := plugin.InboundEvent{
Kind: "git-push",
Git: &plugin.GitEvent{
Repo: repo,
Ref: probe.Ref,
CommitSHA: probe.After,
Pusher: pusher,
},
}
if strings.HasPrefix(probe.Ref, "refs/heads/") {
evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/")
}
if strings.HasPrefix(probe.Ref, "refs/tags/") {
evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/")
evt.Kind = "git-tag"
}
return evt, nil
}
+291
View File
@@ -0,0 +1,291 @@
package webhook
import (
"net/http"
"strings"
"testing"
)
func headerWith(k, v string) http.Header {
h := http.Header{}
h.Set(k, v)
return h
}
func TestParseGiteaPackageEvent_Container(t *testing.T) {
body := []byte(`{
"action": "created",
"package": {
"name": "my-app",
"type": "container",
"version": "v1.2.3",
"owner": {"login": "alexei"},
"repository": {"full_name": "alexei/my-app", "html_url": "https://git.example.com/alexei/my-app"}
}
}`)
res := parseGiteaPackageEvent(body, headerWith(headerGiteaEvent, "package"))
if !res.ok {
t.Fatalf("expected ok=true")
}
if res.err != nil {
t.Fatalf("unexpected err: %v", res.err)
}
if res.event.Kind != "image-push" || res.event.Image == nil {
t.Fatalf("kind=%q image=%+v", res.event.Kind, res.event.Image)
}
if res.event.Image.Repo != "alexei/my-app" {
t.Errorf("Repo=%q want alexei/my-app", res.event.Image.Repo)
}
if res.event.Image.Tag != "v1.2.3" {
t.Errorf("Tag=%q want v1.2.3", res.event.Image.Tag)
}
if res.event.Image.Registry != "git.example.com" {
t.Errorf("Registry=%q want git.example.com", res.event.Image.Registry)
}
}
func TestParseGiteaPackageEvent_NonContainerRejected(t *testing.T) {
body := []byte(`{"action": "created", "package": {"name": "lib", "type": "npm", "version": "1.0.0"}}`)
res := parseGiteaPackageEvent(body, headerWith(headerGiteaEvent, "package"))
if !res.ok {
t.Fatalf("expected ok=true (claimed)")
}
if res.err == nil || !strings.Contains(res.err.Error(), "container") {
t.Errorf("expected container-type error, got %v", res.err)
}
}
func TestParseGiteaPackageEvent_NoHeaderSkips(t *testing.T) {
res := parseGiteaPackageEvent([]byte(`{}`), http.Header{})
if res.ok {
t.Errorf("expected ok=false when header missing")
}
}
func TestParseGitHubPackageEvent_RegistryPackage(t *testing.T) {
body := []byte(`{
"action": "published",
"registry_package": {
"name": "my-app",
"namespace": "owner",
"package_type": "CONTAINER",
"package_version": {
"name": "sha256:deadbeef",
"container_metadata": {"tag": {"name": "v2.0.0"}}
},
"registry": {"url": "https://ghcr.io/owner/my-app"}
}
}`)
res := parseGitHubPackageEvent(body, headerWith(headerGitHubEvent, "registry_package"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Image == nil || res.event.Image.Tag != "v2.0.0" {
t.Errorf("Tag mismatch: %+v", res.event.Image)
}
if res.event.Image.Digest != "sha256:deadbeef" {
t.Errorf("Digest=%q want sha256:deadbeef", res.event.Image.Digest)
}
if res.event.Image.Repo != "owner/my-app" {
t.Errorf("Repo=%q want owner/my-app", res.event.Image.Repo)
}
if res.event.Image.Registry != "ghcr.io" {
t.Errorf("Registry=%q want ghcr.io", res.event.Image.Registry)
}
}
func TestParseGitHubPackageEvent_PackageAlias(t *testing.T) {
// Older webhooks deliver under "package" with event name "package".
body := []byte(`{
"action": "published",
"package": {
"name": "img",
"namespace": "org",
"package_type": "container",
"package_version": {"container_metadata": {"tag": {"name": "latest"}}},
"registry": {"url": "https://ghcr.io/"}
}
}`)
res := parseGitHubPackageEvent(body, headerWith(headerGitHubEvent, "package"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Image.Repo != "org/img" {
t.Errorf("Repo=%q want org/img", res.event.Image.Repo)
}
if res.event.Image.Tag != "latest" {
t.Errorf("Tag=%q want latest", res.event.Image.Tag)
}
}
func TestParseGitHubPushEvent_StampsVendor(t *testing.T) {
body := []byte(`{
"ref": "refs/heads/main",
"after": "abc",
"repository": {"full_name": "owner/repo"},
"pusher": {"name": "alice"}
}`)
res := parseGitHubPushEvent(body, headerWith(headerGitHubEvent, "push"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Git == nil || res.event.Git.Vendor != "github" {
t.Errorf("Vendor=%q want github (git=%+v)", "", res.event.Git)
}
if res.event.Git.Branch != "main" {
t.Errorf("Branch=%q want main", res.event.Git.Branch)
}
}
func TestParseGiteaPushEvent_StampsVendor(t *testing.T) {
body := []byte(`{
"ref": "refs/tags/v1.0",
"after": "deadbeef",
"repository": {"full_name": "alexei/app"},
"pusher": {"username": "alexei"}
}`)
res := parseGiteaPushEvent(body, headerWith(headerGiteaEvent, "push"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Kind != "git-tag" {
t.Errorf("Kind=%q want git-tag", res.event.Kind)
}
if res.event.Git.Vendor != "gitea" {
t.Errorf("Vendor=%q want gitea", res.event.Git.Vendor)
}
if res.event.Git.Tag != "v1.0" {
t.Errorf("Tag=%q want v1.0", res.event.Git.Tag)
}
if res.event.Git.Pusher != "alexei" {
t.Errorf("Pusher=%q want alexei", res.event.Git.Pusher)
}
}
func TestParseGitLabPushEvent(t *testing.T) {
body := []byte(`{
"ref": "refs/heads/develop",
"after": "feedface",
"user_username": "bob",
"project": {"path_with_namespace": "group/proj", "git_http_url": "https://gitlab.example.com/group/proj.git"}
}`)
res := parseGitLabPushEvent(body, headerWith(headerGitLabEvent, "Push Hook"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Git.Vendor != "gitlab" {
t.Errorf("Vendor=%q want gitlab", res.event.Git.Vendor)
}
if res.event.Git.Repo != "group/proj" {
t.Errorf("Repo=%q want group/proj", res.event.Git.Repo)
}
if res.event.Git.Branch != "develop" {
t.Errorf("Branch=%q want develop", res.event.Git.Branch)
}
if res.event.Git.Pusher != "bob" {
t.Errorf("Pusher=%q want bob", res.event.Git.Pusher)
}
}
func TestParseGitLabTagPushEvent(t *testing.T) {
body := []byte(`{
"ref": "refs/tags/v3",
"after": "abc",
"user_name": "carol",
"project": {"path_with_namespace": "g/p"}
}`)
res := parseGitLabPushEvent(body, headerWith(headerGitLabEvent, "Tag Push Hook"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Kind != "git-tag" || res.event.Git.Tag != "v3" {
t.Errorf("Tag mismatch: kind=%q git=%+v", res.event.Kind, res.event.Git)
}
if res.event.Git.Pusher != "carol" {
t.Errorf("Pusher=%q want carol (user_name fallback)", res.event.Git.Pusher)
}
}
func TestBuildInboundEvent_GiteaPackageRouted(t *testing.T) {
body := []byte(`{
"action": "created",
"package": {
"name": "svc",
"type": "container",
"version": "v9",
"owner": {"login": "alexei"},
"repository": {"html_url": "https://git.example.com/alexei/svc"}
}
}`)
evt, err := buildInboundEvent(body, headerWith(headerGiteaEvent, "package"))
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Image == nil || evt.Image.Tag != "v9" {
t.Errorf("expected Gitea-routed image-push, got %+v", evt)
}
// RawBody and Headers should round-trip even via the vendor branch.
if len(evt.RawBody) == 0 {
t.Errorf("RawBody should be attached")
}
if http.Header(evt.Headers).Get(headerGiteaEvent) != "package" {
t.Errorf("Headers not attached")
}
}
func TestBuildInboundEvent_FallbackToGeneric(t *testing.T) {
// No vendor header — generic simple-body parser still works.
body := []byte(`{"image":"reg.example.com/x/y:tag"}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Image == nil || evt.Image.Tag != "tag" {
t.Errorf("generic fallback failed: %+v", evt)
}
}
func TestBuildInboundEvent_GenericRefStillSupported(t *testing.T) {
body := []byte(`{"ref":"refs/heads/main","repository":{"full_name":"a/b"},"after":"sha"}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Kind != "git-push" || evt.Git == nil || evt.Git.Branch != "main" {
t.Errorf("generic ref parse failed: %+v", evt)
}
// Generic path does NOT stamp a vendor — only the vendor-header
// paths do, so trigger plugins can tell them apart.
if evt.Git.Vendor != "" {
t.Errorf("generic parser should leave Vendor empty, got %q", evt.Git.Vendor)
}
}
func TestBuildInboundEvent_VendorErrorDoesNotFallThrough(t *testing.T) {
// A request with the Gitea header but a non-container package should
// error out cleanly rather than fall through to the generic parser
// (which would silently accept the body as JSON-with-no-fields).
body := []byte(`{"package":{"name":"x","type":"npm","version":"1.0"}}`)
_, err := buildInboundEvent(body, headerWith(headerGiteaEvent, "package"))
if err == nil {
t.Fatal("expected error for non-container Gitea package")
}
if !strings.Contains(err.Error(), "container") {
t.Errorf("error should mention container, got %v", err)
}
}
func TestRegistryHostFromGiteaRepoURL(t *testing.T) {
cases := []struct{ in, out string }{
{"https://git.example.com/owner/repo", "git.example.com"},
{"http://localhost:3000/o/r", "localhost:3000"},
{"git.example.com/owner/repo", "git.example.com"},
{"", ""},
}
for _, c := range cases {
got := registryHostFromGiteaRepoURL(c.in)
if got != c.out {
t.Errorf("registryHostFromGiteaRepoURL(%q) = %q, want %q", c.in, got, c.out)
}
}
}