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 ref that could be a release. The exact mode // (tag vs bookmark) lives in the repo's .custard.yaml, read in publishRelease // once the pushed objects are readable. skip := "" switch { case p.Event != "branch_tag_create" || !p.Created || p.Deleted: skip = "not a ref-create" case p.Repository.Private: skip = "private repo" case !couldBeRelease(p.Ref): skip = "not a release ref" } 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 ref=%s", skip, p.Repository.Name, p.Ref) writeText(w, http.StatusAccepted, "ignored: "+skip) return } repo := p.Repository.Name go s.publishRelease(repo, p.Ref, p.Repository.Description) writeText(w, http.StatusAccepted, "accepted: "+repo+" "+p.Ref) } // publishRelease reads the repo's opt-in config (retrying while the freshly // pushed ref becomes readable) and, if brew-enabled and the highest version for // its mode, publishes to the tap. func (s *Server) publishRelease(repo, ref, desc string) { // Short ref name to read config at: strip refs/tags/ or refs/heads/. readRef := strings.TrimPrefix(strings.TrimPrefix(ref, "refs/tags/"), "refs/heads/") var cfg release.RepoConfig var ok bool for i := 0; i < 30; i++ { if cfg, ok = release.ReadRepoConfig(s.store, repo, readRef); ok { break } time.Sleep(time.Second) } if !ok { log.Printf("hook: %s@%s — config never became readable; giving up", repo, ref) return } if !cfg.Brew.Enabled { log.Printf("hook: ignored (not brew-enabled) repo=%s ref=%s", repo, ref) return } archiveRef, version, skip := releasePlan(cfg, ref) if skip != "" { log.Printf("hook: ignored (%s) repo=%s ref=%s", skip, repo, ref) return } // Guard against out-of-order versions downgrading the published formula. highest := s.store.IsHighestVersion(repo, version) if cfg.ReleaseSource() == release.SourceBookmark { highest = s.store.IsHighestRelease(repo, cfg.Prefix(), version) } if !highest { log.Printf("hook: ignored (not highest version; %s superseded) repo=%s", version, repo) 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, Ref: archiveRef, Tag: version, Desc: desc, Package: cfg.Brew.Package, }) if err != nil { log.Printf("release %s@%s failed: %v", repo, ref, err) return } log.Printf("released %s %s to tap", repo, ver) } // statusReport is the CLI's signed deploy-status update. type statusReport struct { Repo string `json:"repo"` Commit string `json:"commit"` State string `json:"state"` // preview | prod URL string `json:"url"` } // handleStatus records a deploy-status update from the custard CLI. func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { if s.cfg.StatusSecret == "" { s.notFound(w, r) return } body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if !validSignature(r.Header.Get("X-Custard-Signature"), body, s.cfg.StatusSecret) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var sr statusReport if err := json.Unmarshal(body, &sr); err != nil || sr.Repo == "" || sr.Commit == "" { http.Error(w, "bad payload", http.StatusBadRequest) return } if err := s.status.Set(sr.Repo, sr.State, sr.Commit, sr.URL); err != nil { log.Printf("status set %s@%s failed: %v", sr.Repo, sr.Commit, err) http.Error(w, "store error", http.StatusInternalServerError) return } log.Printf("status: %s %s=%s", sr.Repo, sr.State, sr.Commit[:min(8, len(sr.Commit))]) writeText(w, http.StatusOK, "ok") } // 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") }