-
Notifications
You must be signed in to change notification settings - Fork 1
feat(api): add GitHub webhook receiver for evented refresh #1317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jonnii
merged 1 commit into
main
from
jonnii/20260623025415/add-GitHub-webhook-receiver-for-evented-refresh
Jun 24, 2026
+366
β32
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
internal/api/handlers/webhook.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "log/slog" | ||
| "net/http" | ||
| "time" | ||
|
|
||
| "github.com/getstackit/stackit/internal/api/githubwebhook" | ||
| ) | ||
|
|
||
| // maxWebhookBody caps the request body the webhook receiver will read. GitHub | ||
| // push payloads are well under this; the limit stops a forged or malformed | ||
| // request from forcing an unbounded read before the signature is even checked. | ||
| const maxWebhookBody = 1 << 20 // 1 MiB | ||
|
|
||
| // webhookSyncTimeout bounds the background fetch+refresh kicked off by a | ||
| // delivery, so a stuck remote can't leak goroutines. | ||
| const webhookSyncTimeout = 2 * time.Minute | ||
|
|
||
| // RepoSyncer mirror-fetches and refreshes a single managed repo by its GitHub | ||
| // coordinates. The concrete implementation is *reposync.Syncer; the handler | ||
| // takes the narrow interface so handlers stays decoupled from reposync. | ||
| type RepoSyncer interface { | ||
| SyncRepo(ctx context.Context, owner, name string) error | ||
| } | ||
|
|
||
| // WebhookHandler receives GitHub webhook deliveries at | ||
| // POST /api/v1/webhooks/github and turns a push into a refresh of the matching | ||
| // managed checkout. It is the low-latency complement to the interval sync loop; | ||
| // the loop remains as a backstop for missed deliveries and for metadata-ref | ||
| // pushes, which GitHub does not deliver push events for. | ||
| // | ||
| // The route bypasses the session/CSRF gate (GitHub can't carry either) and is | ||
| // authenticated solely by the HMAC signature, so it must fail closed when no | ||
| // secret is configured β otherwise it would be an open refresh trigger. | ||
| type WebhookHandler struct { | ||
| secret string | ||
| syncer RepoSyncer | ||
| } | ||
|
|
||
| // NewWebhookHandler wires a webhook receiver. An empty secret or nil syncer | ||
| // leaves the endpoint disabled (it 404s), so it is safe to mount | ||
| // unconditionally and gate purely on configuration. | ||
| func NewWebhookHandler(secret string, syncer RepoSyncer) *WebhookHandler { | ||
| return &WebhookHandler{secret: secret, syncer: syncer} | ||
| } | ||
|
|
||
| func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| // Disabled when unconfigured: 404 so the endpoint is indistinguishable from | ||
| // a server that never had it, rather than advertising a misconfigured hook. | ||
| if h.secret == "" || h.syncer == nil { | ||
| http.NotFound(w, r) | ||
| return | ||
| } | ||
|
|
||
| body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBody)) | ||
| if err != nil { | ||
| http.Error(w, "read body", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| if !githubwebhook.Verify(h.secret, body, r.Header.Get(githubwebhook.SignatureHeader)) { | ||
| http.Error(w, "invalid signature", http.StatusUnauthorized) | ||
| return | ||
| } | ||
|
|
||
| switch r.Header.Get(githubwebhook.EventHeader) { | ||
| case "push": | ||
| h.handlePush(w, body) | ||
| case "ping": | ||
| // GitHub sends ping once when the hook is created; acknowledge it. | ||
| w.WriteHeader(http.StatusNoContent) | ||
| default: | ||
| // Event types we don't act on (the App should only subscribe to push, | ||
| // but be tolerant). Acknowledge so GitHub doesn't retry. | ||
| w.WriteHeader(http.StatusNoContent) | ||
| } | ||
| } | ||
|
|
||
| func (h *WebhookHandler) handlePush(w http.ResponseWriter, body []byte) { | ||
| owner, name, ok := githubwebhook.ParsePush(body) | ||
| if !ok { | ||
| // Verified but not a recognizable push payload: acknowledge so GitHub | ||
| // doesn't retry a delivery we can't act on. | ||
| w.WriteHeader(http.StatusNoContent) | ||
| return | ||
| } | ||
|
|
||
| // A mirror-fetch is slow and GitHub expects a prompt response, so ack | ||
| // immediately and run the sync in the background, detached from the request. | ||
| // The inbound rate limiter bounds how often this fans out; a follow-up adds | ||
| // per-repo coalescing so a burst of pushes collapses to one fetch. | ||
| go func() { | ||
| ctx, cancel := context.WithTimeout(context.Background(), webhookSyncTimeout) | ||
| defer cancel() | ||
| if err := h.syncer.SyncRepo(ctx, owner, name); err != nil { | ||
| slog.Warn("webhook: sync failed", "owner", owner, "name", name, "error", err) | ||
| } | ||
| }() | ||
| w.WriteHeader(http.StatusAccepted) | ||
| } |
132 changes: 132 additions & 0 deletions
internal/api/handlers/webhook_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/hmac" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/getstackit/stackit/internal/api/githubwebhook" | ||
| ) | ||
|
|
||
| type fakeSyncer struct { | ||
| mu sync.Mutex | ||
| calls []string | ||
| done chan struct{} | ||
| } | ||
|
|
||
| func newFakeSyncer() *fakeSyncer { | ||
| return &fakeSyncer{done: make(chan struct{}, 1)} | ||
| } | ||
|
|
||
| func (f *fakeSyncer) SyncRepo(_ context.Context, owner, name string) error { | ||
| f.mu.Lock() | ||
| f.calls = append(f.calls, owner+"/"+name) | ||
| f.mu.Unlock() | ||
| select { | ||
| case f.done <- struct{}{}: | ||
| default: | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (f *fakeSyncer) called() []string { | ||
| f.mu.Lock() | ||
| defer f.mu.Unlock() | ||
| return append([]string(nil), f.calls...) | ||
| } | ||
|
|
||
| func sign(secret string, body []byte) string { | ||
| mac := hmac.New(sha256.New, []byte(secret)) | ||
| mac.Write(body) | ||
| return "sha256=" + hex.EncodeToString(mac.Sum(nil)) | ||
| } | ||
|
|
||
| const testSecret = "webhook-secret" | ||
|
|
||
| func pushBody() []byte { | ||
| return []byte(`{"repository":{"name":"widget","full_name":"octo/widget","owner":{"login":"octo"}}}`) | ||
| } | ||
|
|
||
| // webhookRequest builds a POST signed with testSecret for the given event | ||
| // type and body. | ||
| func webhookRequest(event string, body []byte) *http.Request { | ||
| req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github", strings.NewReader(string(body))) | ||
| req.Header.Set(githubwebhook.EventHeader, event) | ||
| req.Header.Set(githubwebhook.SignatureHeader, sign(testSecret, body)) | ||
| return req | ||
| } | ||
|
|
||
| func TestWebhookHandler_DisabledWithoutSecret(t *testing.T) { | ||
| t.Parallel() | ||
| h := NewWebhookHandler("", newFakeSyncer()) | ||
| rr := httptest.NewRecorder() | ||
| h.ServeHTTP(rr, webhookRequest("push", pushBody())) | ||
| require.Equal(t, http.StatusNotFound, rr.Code, "an unconfigured receiver must look like it doesn't exist") | ||
| } | ||
|
|
||
| func TestWebhookHandler_RejectsBadSignature(t *testing.T) { | ||
| t.Parallel() | ||
| sy := newFakeSyncer() | ||
| h := NewWebhookHandler(testSecret, sy) | ||
|
|
||
| body := pushBody() | ||
| req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github", strings.NewReader(string(body))) | ||
| req.Header.Set(githubwebhook.EventHeader, "push") | ||
| req.Header.Set(githubwebhook.SignatureHeader, sign("wrong-secret", body)) | ||
|
|
||
| rr := httptest.NewRecorder() | ||
| h.ServeHTTP(rr, req) | ||
| require.Equal(t, http.StatusUnauthorized, rr.Code) | ||
| require.Empty(t, sy.called(), "an unverified delivery must not trigger a sync") | ||
| } | ||
|
|
||
| func TestWebhookHandler_PushTriggersSync(t *testing.T) { | ||
| t.Parallel() | ||
| sy := newFakeSyncer() | ||
| h := NewWebhookHandler(testSecret, sy) | ||
|
|
||
| rr := httptest.NewRecorder() | ||
| h.ServeHTTP(rr, webhookRequest("push", pushBody())) | ||
| require.Equal(t, http.StatusAccepted, rr.Code) | ||
|
|
||
| // The sync runs in a background goroutine; wait for it to land. | ||
| select { | ||
| case <-sy.done: | ||
| case <-time.After(2 * time.Second): | ||
| t.Fatal("SyncRepo was not called for a verified push") | ||
| } | ||
| require.Equal(t, []string{"octo/widget"}, sy.called()) | ||
| } | ||
|
|
||
| func TestWebhookHandler_PingAcknowledgedWithoutSync(t *testing.T) { | ||
| t.Parallel() | ||
| sy := newFakeSyncer() | ||
| h := NewWebhookHandler(testSecret, sy) | ||
|
|
||
| rr := httptest.NewRecorder() | ||
| h.ServeHTTP(rr, webhookRequest("ping", []byte(`{"zen":"hi"}`))) | ||
| require.Equal(t, http.StatusNoContent, rr.Code) | ||
| require.Empty(t, sy.called(), "ping must not trigger a sync") | ||
| } | ||
|
|
||
| func TestWebhookHandler_UnparseablePushAcknowledged(t *testing.T) { | ||
| t.Parallel() | ||
| sy := newFakeSyncer() | ||
| h := NewWebhookHandler(testSecret, sy) | ||
|
|
||
| body := []byte(`{"zen":"no repository here"}`) | ||
| rr := httptest.NewRecorder() | ||
| h.ServeHTTP(rr, webhookRequest("push", body)) | ||
| // Verified but unactionable: ack so GitHub stops retrying, no sync. | ||
| require.Equal(t, http.StatusNoContent, rr.Code) | ||
| require.Empty(t, sy.called()) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.