package server import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "strings" "time" "custard/internal/release" ) // hookPayload is the subset of Soft Serve's webhook JSON we use. type hookPayload struct { Event string `json:"event"` Repository struct { Name string `json:"name"` Description string `json:"description"` Private bool `json:"private"` } `json:"repository"` Ref string `json:"ref"` Created bool `json:"created"` Deleted bool `json:"deleted"` } // handleReleaseHook receives Soft Serve's branch_tag_create webhook and, for a // brew-enabled public repo tagged vX.Y.Z, publishes a formula to the tap. // Disabled (404) unless a webhook secret is configured. func (s *Server) handleReleaseHook(w http.ResponseWriter, r *http.Request) { if s.cfg.WebhookSecret == "" { s.notFound(w, r) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if !validSignature(r.Header.Get("X-Softserve-Signature"), body, s.cfg.WebhookSecret) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var p hookPayload if err := json.Unmarshal(body, &p); err != nil { http.Error(w, "bad payload", http.StatusBadRequest) return } // Only act on a newly-created tag. tag := strings.TrimPrefix(p.Ref, "refs/tags/") skip := "" switch { case p.Event != "branch_tag_create" || !p.Created || p.Deleted: skip = "not a tag-create" case tag == p.Ref: skip = "not a tag" case !release.IsReleaseTag(tag): skip = "not a semver release tag" case p.Repository.Private: skip = "private repo" } log.Printf("hook: repo=%s ref=%q event=%s created=%v deleted=%v", p.Repository.Name, p.Ref, p.Event, p.Created, p.Deleted) if skip != "" { log.Printf("hook: ignored (%s) repo=%s tag=%s", skip, p.Repository.Name, tag) writeText(w, http.StatusAccepted, "ignored: "+skip) return } // Publish in the background: respond now so we don't hold Soft Serve's // delivery connection while git finishes migrating the pushed objects (the // new tag can take several seconds to become readable by a fresh git open). repo := p.Repository.Name go s.publishRelease(repo, tag, p.Repository.Description) writeText(w, http.StatusAccepted, "accepted: "+repo+" "+tag) } // publishRelease reads the repo's opt-in config (retrying while the freshly // pushed tag becomes readable) and, if brew-enabled, publishes to the tap. func (s *Server) publishRelease(repo, tag, desc string) { var cfg release.RepoConfig var ok bool for i := 0; i < 30; i++ { if cfg, ok = release.ReadRepoConfig(s.store, repo, tag); ok { break } time.Sleep(time.Second) } if !ok { log.Printf("hook: %s@%s — config never became readable; giving up", repo, tag) return } if !cfg.Brew.Enabled { log.Printf("hook: ignored (not brew-enabled) repo=%s tag=%s", repo, tag) return } ver, err := release.Publish(release.Options{ Store: s.store, ReposPath: s.cfg.ReposPath, DLPath: s.cfg.DLPath, TapRepo: s.cfg.TapRepo, BaseURL: s.cfg.BaseURL, Repo: repo, Tag: tag, Desc: desc, Package: cfg.Brew.Package, }) if err != nil { log.Printf("release %s@%s failed: %v", repo, tag, err) return } log.Printf("released %s %s to tap", repo, ver) } // validSignature checks the "sha256=" HMAC over the raw body. func validSignature(header string, body []byte, secret string) bool { want, ok := strings.CutPrefix(header, "sha256=") if !ok { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) got := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(got), []byte(want)) } func writeText(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(code) _, _ = io.WriteString(w, msg+"\n") }