▍ humdrum codex / custard v0.3.0
license AGPL-3.0

feat(webhook): mode-aware release trigger (tags or bookmarks)

b3988073753a93c273c10e6c1f63ff37d5c12a33
humdrum <me@humdrum.me> · 2026-06-19 09:07

parent bbac80da

3 files changed

internal/server/release_plan.go +51 −0
@@ -0,0 +1,51 @@
+package server
+
+import (
+	"strings"
+
+	"custard/internal/release"
+)
+
+// couldBeRelease is a cheap, prefix-agnostic prefilter for the webhook: it
+// accepts a semver tag or any branch whose final path segment is a release
+// semver, so ordinary main/feature pushes never spawn a publish goroutine.
+// The exact per-repo mode + prefix are enforced later by releasePlan.
+func couldBeRelease(ref string) bool {
+	if tag, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
+		return release.IsReleaseTag(tag)
+	}
+	if br, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
+		seg := br
+		if i := strings.LastIndexByte(br, '/'); i >= 0 {
+			seg = br[i+1:]
+		}
+		return release.IsReleaseTag(seg)
+	}
+	return false
+}
+
+// releasePlan decides, for a repo's config and a pushed ref, what to archive and
+// publish. A non-empty skip means "ignore this push" with a logged reason.
+func releasePlan(cfg release.RepoConfig, ref string) (archiveRef, version, skip string) {
+	switch cfg.ReleaseSource() {
+	case release.SourceBookmark:
+		br, ok := strings.CutPrefix(ref, "refs/heads/")
+		if !ok {
+			return "", "", "bookmark mode: not a branch ref"
+		}
+		ver, ok := release.BookmarkVersion(br, cfg.Prefix())
+		if !ok {
+			return "", "", "bookmark mode: not a " + cfg.Prefix() + "vX.Y.Z bookmark"
+		}
+		return br, ver, ""
+	default: // SourceTag
+		tag, ok := strings.CutPrefix(ref, "refs/tags/")
+		if !ok {
+			return "", "", "tag mode: not a tag ref"
+		}
+		if !release.IsReleaseTag(tag) {
+			return "", "", "tag mode: not a semver release tag"
+		}
+		return tag, tag, ""
+	}
+}
internal/server/release_plan_test.go +57 −0
@@ -0,0 +1,57 @@
+package server
+
+import (
+	"testing"
+
+	"custard/internal/release"
+)
+
+func TestCouldBeRelease(t *testing.T) {
+	yes := []string{"refs/tags/v1.2.3", "refs/heads/release/v1.2.3", "refs/heads/rel/v0.1.0"}
+	no := []string{"refs/heads/main", "refs/heads/feature/x", "refs/tags/nightly", "refs/heads/release/main"}
+	for _, r := range yes {
+		if !couldBeRelease(r) {
+			t.Errorf("couldBeRelease(%q) = false, want true", r)
+		}
+	}
+	for _, r := range no {
+		if couldBeRelease(r) {
+			t.Errorf("couldBeRelease(%q) = true, want false", r)
+		}
+	}
+}
+
+func cfgWith(source, prefix string) release.RepoConfig {
+	var c release.RepoConfig
+	c.Brew.Enabled = true
+	c.Brew.Source = source
+	c.Brew.BookmarkPrefix = prefix
+	return c
+}
+
+func TestReleasePlan(t *testing.T) {
+	cases := []struct {
+		name, source, prefix, ref string
+		wantArchive, wantVer      string
+		wantSkip                  bool
+	}{
+		{"tag mode tag", release.SourceTag, "", "refs/tags/v1.2.3", "v1.2.3", "v1.2.3", false},
+		{"tag mode ignores branch", release.SourceTag, "", "refs/heads/release/v1.2.3", "", "", true},
+		{"tag mode non-semver tag", release.SourceTag, "", "refs/tags/nightly", "", "", true},
+		{"bookmark mode branch", release.SourceBookmark, "", "refs/heads/release/v1.2.3", "release/v1.2.3", "v1.2.3", false},
+		{"bookmark mode custom prefix", release.SourceBookmark, "rel/", "refs/heads/rel/v0.1.0", "rel/v0.1.0", "v0.1.0", false},
+		{"bookmark mode ignores tag", release.SourceBookmark, "", "refs/tags/v1.2.3", "", "", true},
+		{"bookmark mode wrong prefix", release.SourceBookmark, "", "refs/heads/foo/v1.2.3", "", "", true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			archive, ver, skip := releasePlan(cfgWith(c.source, c.prefix), c.ref)
+			if (skip != "") != c.wantSkip {
+				t.Fatalf("skip = %q, wantSkip=%v", skip, c.wantSkip)
+			}
+			if !c.wantSkip && (archive != c.wantArchive || ver != c.wantVer) {
+				t.Errorf("got archive=%q ver=%q; want %q %q", archive, ver, c.wantArchive, c.wantVer)
+			}
+		})
+	}
+}
internal/server/webhook.go +36 −25
@@ -51,60 +51,70 @@ 		http.Error(w, "bad payload", http.StatusBadRequest)
 		return
 	}
 
-	// Only act on a newly-created tag.
-	tag := strings.TrimPrefix(p.Ref, "refs/tags/")
+	// 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 tag-create"
-	case tag == p.Ref:
-		skip = "not a tag"
-	case !release.IsReleaseTag(tag):
-		skip = "not a semver release tag"
+		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 tag=%s", skip, p.Repository.Name, tag)
+		log.Printf("hook: ignored (%s) repo=%s ref=%s", skip, p.Repository.Name, p.Ref)
 		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)
+	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 tag becomes readable) and, if brew-enabled, publishes to the tap.
-func (s *Server) publishRelease(repo, tag, desc string) {
+// 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, tag); ok {
+		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, tag)
+		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 tag=%s", repo, tag)
+		log.Printf("hook: ignored (not brew-enabled) repo=%s ref=%s", repo, ref)
 		return
 	}
-	// Guard against out-of-order tags: a tag pushed after a higher version
-	// (e.g. re-tagging v0.1.0 once v0.2.0 exists) must not downgrade the
-	// published formula. Only publish when this tag is the repo's highest semver.
-	if !s.store.IsHighestVersion(repo, tag) {
-		log.Printf("hook: ignored (not highest version; %s superseded by a higher tag) repo=%s", tag, repo)
+
+	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,
@@ -112,12 +122,13 @@ 		DLPath:    s.cfg.DLPath,
 		TapRepo:   s.cfg.TapRepo,
 		BaseURL:   s.cfg.BaseURL,
 		Repo:      repo,
-		Tag:       tag,
+		Ref:       archiveRef,
+		Tag:       version,
 		Desc:      desc,
 		Package:   cfg.Brew.Package,
 	})
 	if err != nil {
-		log.Printf("release %s@%s failed: %v", repo, tag, err)
+		log.Printf("release %s@%s failed: %v", repo, ref, err)
 		return
 	}
 	log.Printf("released %s %s to tap", repo, ver)