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")
+}