▍ humdrum codex / custard
license AGPL-3.0

feat: webhook-driven auto-publish to the Homebrew tap (TASK-006)

fe6753f93102369e1aa4350ffe2867b616ff48be
humdrum <me@humdrum.me> · 2026-06-17 23:33

parent b712d71d

feat: webhook-driven auto-publish to the Homebrew tap (TASK-006)

Push a semver tag → custard publishes a new formula, no manual script.
- POST /hooks/release: verifies Soft Serve's HMAC signature, gates on
  branch_tag_create + semver tag + public repo + .custard.yaml opt-in
- internal/release: archive tag → tarball in /dl → render source-build formula
  (license auto-detected) → commit to the tap bare repo on disk
- publishes asynchronously with a retry, riding out the post-push git object
  migration lag before the new tag is readable
- config: WEBHOOK_SECRET / DL_PATH / TAP_REPO; deploy.sh ships an /etc/custard.env
  (root-only secret) + ReadWritePaths carve-out so only the tap + dl are writable
- README: .custard.yaml opt-in + webhook registration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

8 files changed

README.md +29 −0
@@ -116,4 +116,33 @@ ```
 
 Formulas build from source (`depends_on "go"`) — no per-arch bottles, no GitHub.
 
+### Auto-release on tag (webhook)
+
+custard can publish automatically when you push a tag — no manual script. Opt a repo in with a
+`.custard.yaml` at its root:
+
+```yaml
+brew:
+  enabled: true
+  package: .        # go build path; default "."
+```
+
+Configure custard with a `WEBHOOK_SECRET` (set it in `deploy.env`), then register a Soft Serve
+webhook on the repo:
+
+```bash
+ssh soft repo webhook create <repo> https://your-host/hooks/release \
+  -e branch_tag_create -c json -s "$WEBHOOK_SECRET"
+```
+
+Now releasing is just:
+
+```bash
+git tag -a v0.2.0 -m "..." && git push origin v0.2.0
+```
+
+The tag (must be semver `vX.Y.Z`) fires the webhook → custard verifies the HMAC signature,
+archives the tag, writes the tarball, and pushes an updated formula to the tap. `scripts/brew-release.sh`
+remains as a manual fallback.
+
 See `PLAN.md` for the phased build plan.
- → Phase-5-deploy-on-droplet-Caddy-systemd.md +5 −5
@@ -1,10 +1,10 @@
 ---
 id: TASK-005
 title: 'Phase 5: deploy on droplet (Caddy + systemd)'
-status: "\U0001F7E6 Backlog"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-17 23:44'
-updated_date: '2026-06-18 00:24'
+updated_date: '2026-06-18 05:48'
 labels:
   - feature
 dependencies: []
@@ -20,7 +20,7 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Caddyfile: auto-TLS; reverse-proxy /git/<repo>.git/* -> Soft Serve HTTP :23232; serve /dl/*
-- [ ] #2 systemd unit for custard with REPOS_PATH pointed at Soft Serve repos dir
-- [ ] #3 Public read of all non-hidden repos verified live; git clone via /git/* works
+- [x] #1 Caddyfile: auto-TLS; reverse-proxy /git/<repo>.git/* -> Soft Serve HTTP :23232; serve /dl/*
+- [x] #2 systemd unit for custard with REPOS_PATH pointed at Soft Serve repos dir
+- [x] #3 Public read of all non-hidden repos verified live; git clone via /git/* works
 <!-- AC:END -->
- → Webhook-driven-auto-publish-to-the-Homebrew-tap.md +30 −0
@@ -0,0 +1,30 @@
+---
+id: TASK-006
+title: Webhook-driven auto-publish to the Homebrew tap
+status: "\U0001F7E2 In progress"
+assignee: []
+created_date: '2026-06-18 05:53'
+updated_date: '2026-06-18 05:59'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 6000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a semver tag is pushed to a brew-enabled repo, custard auto-publishes a new formula to the homebrew-tap — no manual brew-release.sh. Soft Serve fires a branch_tag_create webhook (signed) to a custard endpoint; custard archives the tag, writes the tarball to /dl, renders the formula, and commits it to the tap. Per-repo opt-in via a .custard.yaml file. Built in, works out-of-box for any custard deployer.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Soft Serve branch_tag_create webhook (signed w/ secret) → custard endpoint POST /hooks/release
+- [ ] #2 Endpoint verifies the HMAC signature against a configured secret; rejects bad/unsigned
+- [ ] #3 Only semver tags (vX.Y.Z) trigger a release; non-semver/branch events ignored
+- [ ] #4 Per-repo opt-in: .custard.yaml at repo root (brew.enabled: true, optional package path); no file or disabled = skipped
+- [ ] #5 Release runs in custard (Go): archive tag → tarball to /dl → sha256 → render formula → commit to homebrew-tap bare repo
+- [ ] #6 Re-publishing same version overwrites cleanly; logged
+- [ ] #7 Docs: how to enable a repo + how to register the webhook (ssh soft repo webhook create)
+<!-- AC:END -->
deploy/deploy.sh +18 −2
@@ -20,6 +20,9 @@ : "${RUN_USER:?set RUN_USER in deploy.env}"
 : "${REPOS_PATH:?set REPOS_PATH in deploy.env}"
 SOFT_SERVE_DB="${SOFT_SERVE_DB:-}"
 SOFT_SERVE_BACKEND="${SOFT_SERVE_BACKEND:-}"
+WEBHOOK_SECRET="${WEBHOOK_SECRET:-}"
+TAP_REPO="${TAP_REPO:-homebrew-tap}"
+DL_PATH="/var/lib/custard/dl"
 
 echo "==> building static linux/amd64 binary"
 command -v templ >/dev/null 2>&1 && templ generate
@@ -56,7 +59,8 @@
 [Service]
 User=${RUN_USER}
 Group=${RUN_USER}
-ExecStart=/usr/local/bin/custard --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git${dbflag}
+EnvironmentFile=-/etc/custard.env
+ExecStart=/usr/local/bin/custard --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git --tap-repo ${TAP_REPO} --dl-path ${DL_PATH}${dbflag}
 Restart=on-failure
 RestartSec=2
 NoNewPrivileges=true
@@ -64,6 +68,8 @@ ProtectSystem=full
 ProtectHome=true
 PrivateTmp=true
 ReadOnlyPaths=${REPOS_PATH}
+# custard writes only the tap repo (formula commits) and the download dir.
+ReadWritePaths=${REPOS_PATH}/${TAP_REPO}.git ${DL_PATH}
 
 [Install]
 WantedBy=multi-user.target
@@ -74,6 +80,16 @@ scp -q /tmp/custard-linux "$REMOTE:/usr/local/bin/custard.new"
 scp -q /tmp/custard.service "$REMOTE:/etc/systemd/system/custard.service"
 scp -q /tmp/custard.Caddyfile "$REMOTE:/tmp/custard.Caddyfile"
 
+# Webhook secret → root-only env file (not process args). Empty file if unset.
+if [ -n "$WEBHOOK_SECRET" ]; then
+	printf 'WEBHOOK_SECRET=%s\n' "$WEBHOOK_SECRET" > /tmp/custard.env
+else
+	: > /tmp/custard.env
+fi
+scp -q /tmp/custard.env "$REMOTE:/tmp/custard.env"
+# Download dir owned by the run user (custard writes), world-readable (Caddy serves).
+ssh "$REMOTE" "install -d -o ${RUN_USER} -g ${RUN_USER} -m 755 ${DL_PATH} && install -m 600 /tmp/custard.env /etc/custard.env && rm -f /tmp/custard.env"
+
 echo "==> installing on remote"
 ssh "$REMOTE" 'bash -seu' <<'REMOTE_EOF'
 if ! command -v caddy >/dev/null 2>&1; then
@@ -83,7 +99,7 @@   curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
   curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
   apt-get update -q >/dev/null 2>&1 && apt-get install -y -q caddy >/dev/null 2>&1
 fi
-install -d -o caddy -g caddy /etc/caddy /var/lib/custard/dl
+install -d -o caddy -g caddy /etc/caddy
 mv /tmp/custard.Caddyfile /etc/caddy/Caddyfile
 mv /usr/local/bin/custard.new /usr/local/bin/custard
 chmod +x /usr/local/bin/custard
internal/config/config.go +6 −0
@@ -13,6 +13,9 @@ 	ListenAddr    string // host:port to listen on
 	BaseURL       string // public base URL (for absolute links, feeds)
 	SoftServeHTTP string // Soft Serve HTTP clone base, reverse-proxied at /git in prod
 	SoftServeDB   string // path to soft-serve.db; when set, only public repos are served
+	WebhookSecret string // shared secret for the Soft Serve release webhook (empty disables it)
+	DLPath        string // directory where release tarballs are written (served at /dl)
+	TapRepo       string // bare repo name of the Homebrew tap (default "homebrew-tap")
 }
 
 // Load parses flags (with env fallbacks) and returns the config.
@@ -23,6 +26,9 @@ 	flag.StringVar(&c.ListenAddr, "addr", env("LISTEN_ADDR", ":8080"), "listen address")
 	flag.StringVar(&c.BaseURL, "base-url", env("BASE_URL", "http://localhost:8080"), "public base URL")
 	flag.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://localhost:23232"), "git clone base shown in the UI footer (e.g. https://your-host/git)")
 	flag.StringVar(&c.SoftServeDB, "soft-serve-db", env("SOFT_SERVE_DB", ""), "path to soft-serve.db; when set, only public (non-private, non-hidden) repos are served")
+	flag.StringVar(&c.WebhookSecret, "webhook-secret", env("WEBHOOK_SECRET", ""), "shared secret for the Soft Serve release webhook (empty disables /hooks/release)")
+	flag.StringVar(&c.DLPath, "dl-path", env("DL_PATH", "/var/lib/custard/dl"), "directory where release tarballs are written")
+	flag.StringVar(&c.TapRepo, "tap-repo", env("TAP_REPO", "homebrew-tap"), "bare repo name of the Homebrew tap")
 	flag.Parse()
 	return c
 }
internal/release/release.go +277 −0
@@ -0,0 +1,277 @@
+// Package release publishes a tagged repo to the self-hosted Homebrew tap:
+// archive the tag → tarball in the download dir → render a source-build formula
+// → commit it to the tap repo. It is the in-process equivalent of
+// scripts/brew-release.sh, driven by the Soft Serve tag webhook.
+package release
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"text/template"
+
+	"custard/internal/gitread"
+	"custard/internal/license"
+	"gopkg.in/yaml.v3"
+)
+
+// RepoConfig is the opt-in `.custard.yaml` at a repo's root.
+type RepoConfig struct {
+	Brew struct {
+		Enabled bool   `yaml:"enabled"`
+		Package string `yaml:"package"` // go build path, default "."
+	} `yaml:"brew"`
+}
+
+var configNames = []string{".custard.yaml", ".custard.yml"}
+
+// ReadRepoConfig loads `.custard.yaml` at ref. ok=false when absent/invalid.
+func ReadRepoConfig(s *gitread.Store, repo, ref string) (RepoConfig, bool) {
+	var c RepoConfig
+	for _, fn := range configNames {
+		data, err := s.Blob(repo, ref, fn)
+		if err != nil {
+			continue
+		}
+		if yaml.Unmarshal(data, &c) == nil {
+			return c, true
+		}
+	}
+	return c, false
+}
+
+// semverTag matches vMAJOR.MINOR.PATCH (no prerelease) — the only tags we ship.
+var semverTag = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
+
+// IsReleaseTag reports whether a tag name should trigger a release.
+func IsReleaseTag(tag string) bool { return semverTag.MatchString(tag) }
+
+// Options drives a single publish.
+type Options struct {
+	Store     *gitread.Store
+	ReposPath string // bare repos dir (for git archive + tap repo)
+	DLPath    string // tarball output dir
+	TapRepo   string // tap bare repo name (e.g. homebrew-tap)
+	BaseURL   string // e.g. https://codex.humdrum.me
+	Repo      string // repo name
+	Tag       string // vX.Y.Z
+	Desc      string // repo description (from webhook payload)
+	Package   string // go build path; default "."
+}
+
+// Publish archives the tag, writes the tarball, renders the formula, and commits
+// it to the tap. Returns the published version on success.
+func Publish(o Options) (string, error) {
+	ver := strings.TrimPrefix(o.Tag, "v")
+	pkg := o.Package
+	if pkg == "" {
+		pkg = "."
+	}
+	bare := filepath.Join(o.ReposPath, o.Repo+".git")
+	tarName := fmt.Sprintf("%s-%s.tar.gz", o.Repo, ver)
+
+	if err := os.MkdirAll(o.DLPath, 0o755); err != nil {
+		return "", err
+	}
+	tarPath := filepath.Join(o.DLPath, tarName)
+	if err := gitArchive(bare, o.Repo+"-"+ver, o.Tag, tarPath); err != nil {
+		return "", err
+	}
+	sha, err := sha256File(tarPath)
+	if err != nil {
+		return "", err
+	}
+
+	f := formula{
+		Class:    className(o.Repo),
+		Repo:     o.Repo,
+		Desc:     formulaDesc(o.Desc),
+		Homepage: o.BaseURL + "/r/" + o.Repo,
+		URL:      o.BaseURL + "/dl/" + tarName,
+		SHA:      sha,
+		License:  detectLicense(o.Store, o.Repo, o.Tag),
+		Package:  pkg,
+	}
+	rendered, err := f.render()
+	if err != nil {
+		return "", err
+	}
+	if err := commitToTap(o, rendered); err != nil {
+		return "", err
+	}
+	return ver, nil
+}
+
+func gitArchive(bareRepo, prefix, tag, outPath string) error {
+	out, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	cmd := exec.Command("git", "-C", bareRepo, "archive", "--format=tar.gz", "--prefix="+prefix+"/", tag)
+	cmd.Stdout = out
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("git archive %s@%s: %w", bareRepo, tag, err)
+	}
+	return nil
+}
+
+func commitToTap(o Options, formulaText string) error {
+	tapBare := filepath.Join(o.ReposPath, o.TapRepo+".git")
+	tmp, err := os.MkdirTemp("", "custard-tap-")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(tmp)
+
+	if out, err := exec.Command("git", "clone", "--quiet", tapBare, tmp).CombinedOutput(); err != nil {
+		return fmt.Errorf("clone tap: %w: %s", err, out)
+	}
+	fdir := filepath.Join(tmp, "Formula")
+	if err := os.MkdirAll(fdir, 0o755); err != nil {
+		return err
+	}
+	if err := os.WriteFile(filepath.Join(fdir, o.Repo+".rb"), []byte(formulaText), 0o644); err != nil {
+		return err
+	}
+
+	host := o.BaseURL
+	if u, err := url.Parse(o.BaseURL); err == nil && u.Host != "" {
+		host = u.Host
+	}
+	git := func(args ...string) error {
+		c := exec.Command("git", append([]string{"-C", tmp}, args...)...)
+		c.Env = append(os.Environ(),
+			"GIT_AUTHOR_NAME=custard", "GIT_AUTHOR_EMAIL=custard@"+host,
+			"GIT_COMMITTER_NAME=custard", "GIT_COMMITTER_EMAIL=custard@"+host)
+		if out, err := c.CombinedOutput(); err != nil {
+			return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, out)
+		}
+		return nil
+	}
+	if err := git("add", "Formula/"+o.Repo+".rb"); err != nil {
+		return err
+	}
+	// Nothing staged (identical formula) → no-op, not an error.
+	if exec.Command("git", "-C", tmp, "diff", "--cached", "--quiet").Run() == nil {
+		return nil
+	}
+	if err := git("commit", "-m", o.Repo+" "+o.Tag); err != nil {
+		return err
+	}
+	return git("push", "origin", "HEAD")
+}
+
+func sha256File(path string) (string, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// detectLicense returns an SPDX id for the formula, or "" if undetectable.
+func detectLicense(s *gitread.Store, repo, ref string) string {
+	_, data, ok := s.LicenseFile(repo, ref)
+	if !ok {
+		return ""
+	}
+	spdx := license.Detect(data).SPDX
+	// GNU licenses are deprecated as bare ids in brew; prefer the -or-later form.
+	switch spdx {
+	case "GPL-3.0", "LGPL-3.0", "AGPL-3.0", "GPL-2.0":
+		return spdx + "-or-later"
+	}
+	return spdx
+}
+
+func className(repo string) string {
+	var b strings.Builder
+	up := true
+	for _, r := range repo {
+		if r == '-' || r == '_' {
+			up = true
+			continue
+		}
+		if up && r >= 'a' && r <= 'z' {
+			r -= 32
+		}
+		up = false
+		b.WriteRune(r)
+	}
+	return b.String()
+}
+
+// formulaDesc cleans a repo description for `brew audit`: no trailing period,
+// under 80 chars (dropping a parenthetical / em-dash clause, then truncating).
+func formulaDesc(d string) string {
+	d = strings.ReplaceAll(strings.TrimSpace(d), `"`, "")
+	if d == "" {
+		d = "Command-line application"
+	}
+	d = strings.TrimRight(d, ".")
+	if len(d) > 78 {
+		if i := strings.LastIndex(d, " ("); i > 0 {
+			d = strings.TrimSpace(d[:i])
+		}
+	}
+	if len(d) > 78 {
+		if i := strings.Index(d, " — "); i > 0 {
+			d = strings.TrimSpace(d[:i])
+		}
+	}
+	if len(d) > 78 {
+		d = d[:78]
+		if i := strings.LastIndex(d, " "); i > 0 {
+			d = d[:i]
+		}
+	}
+	return strings.TrimRight(d, " —-")
+}
+
+type formula struct {
+	Class, Repo, Desc, Homepage, URL, SHA, License, Package string
+}
+
+var formulaTmpl = template.Must(template.New("f").Parse(`# Generated by custard — do not edit by hand.
+class {{.Class}} < Formula
+  desc "{{.Desc}}"
+  homepage "{{.Homepage}}"
+  url "{{.URL}}"
+  sha256 "{{.SHA}}"
+{{- if .License}}
+  license "{{.License}}"
+{{- end}}
+
+  depends_on "go" => :build
+
+  def install
+    system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}"), "{{.Package}}"
+  end
+
+  test do
+    assert_path_exists bin/"{{.Repo}}"
+  end
+end
+`))
+
+func (f formula) render() (string, error) {
+	var b strings.Builder
+	if err := formulaTmpl.Execute(&b, f); err != nil {
+		return "", err
+	}
+	return b.String(), nil
+}
internal/server/server.go +1 −0
@@ -57,6 +57,7 @@ 	mux.HandleFunc("GET /r/{repo}/blob/{ref}/{path...}", s.handleBlob)
 	mux.HandleFunc("GET /r/{repo}/raw/{ref}/{path...}", s.handleRaw)
 	mux.HandleFunc("GET /r/{repo}/issues", s.handleIssues)
 	mux.HandleFunc("GET /r/{repo}/issues/{id}", s.handleIssue)
+	mux.HandleFunc("POST /hooks/release", s.handleReleaseHook)
 
 	staticFS, _ := fs.Sub(web.Static, "static")
 	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS)))
internal/server/webhook.go +135 −0
@@ -0,0 +1,135 @@
+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=<hex>" 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")
+}