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)