jj Bookmark-Mode Releases Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a repo publish Homebrew releases and show version labels from a git bookmark (jj branch like release/v1.2.3) instead of a git tag, so pure-jj repos work — without touching the existing tag path.
Architecture: A per-repo .custard.yaml brew.source flag selects one release mode: tag (default, current behavior) or bookmark. In bookmark mode the webhook triggers off a refs/heads/<prefix>vX.Y.Z push, git archive runs against that branch, the downgrade guard compares against the highest release bookmark, and the version pill / refs page read the bookmark's tip commit subject as its label. Modes never mix; switching is an explicit config flip. internal/gitread stays go-git-only and config-agnostic (the prefix is passed in); mode is decided in the release/server layers.
Tech Stack: Go 1.26, go-git v5, gopkg.in/yaml.v3, a-h/templ, stdlib net/http, git CLI (already used by git archive/tap push).
Global Constraints
- Module path
custard; Go1.26.4. Verbatim fromgo.mod. internal/gitreadis the only package that touches go-git, and it must not importinternal/releaseorinternal/config(release imports gitread — importing back creates a cycle). Mode/prefix are passed into gitread as plain strings.- Status/version vocabulary unchanged. Release versions are
vMAJOR.MINOR.PATCH, no prerelease — samesemverTagregex (^v\d+\.\d+\.\d+$) as today. - Default
brew.sourceistag: every existing repo must behave exactly as before with no config edit. - Default
brew.bookmark_prefixisrelease/. templ generateMUST run after editingweb/templates/templates.templ; the build uses the generated_templ.go.- New tests shell out to the real
gitCLI for fixtures (no go-git write API is used in this codebase); follow the table-driven style ofinternal/backlog/backlog_test.go.
File Structure
internal/release/release.go— Modify. AddSource/BookmarkPrefixconfig fields +ReleaseSource()/Prefix()normalizers +SourceTag/SourceBookmarkconsts +BookmarkVersion()parser; addOptions.Ref(archive ref) and use it inPublish/gitArchive/detectLicense.internal/release/release_test.go— Create. Unit tests for config defaults,ReleaseSource/Prefix,BookmarkVersion, andPublisharchiving a branch ref.internal/gitread/gitread.go— Modify. AddReleaseBookmarks,LatestRelease,IsHighestRelease.internal/gitread/gitread_test.go— Create. Git-fixture helper + tests for the three new functions.internal/server/webhook.go— Modify. SynchronouscouldBeReleaseprefilter; rewritepublishReleaseto read config, pick mode, and call a new purereleasePlandecision function.internal/server/release_plan.go— Create. PurereleasePlan(cfg, ref) (archiveRef, version string, skip string)— the mode/ref decision, unit-testable without git.internal/server/release_plan_test.go— Create. Table-driven tests forcouldBeReleaseandreleasePlan.internal/server/server.go— Modify.meta()uses a mode-awarelatestReleasehelper for the version pill;handleRefspopulatesRefsPage.Releasesin bookmark mode.web/templates/templates.templ— Modify.RefsPagegainsReleases []gitread.Ref;Refstemplate renders a "Releases" section.docs/JUJUTSU.md— Create. jj usage against Soft Serve + how each jj op shows up in custard.docs/MIGRATION.md— Modify. Add a "git tags → jj bookmarks" switchover section.README.md— Modify. Link the new doc next to MANUAL/MIGRATION (lines ~176-179).
Task 1: Release config — source flag, prefix, bookmark version parser
Files:
- Modify:
internal/release/release.go:26-36(RepoConfig), add helpers +BookmarkVersionnearIsReleaseTag(:68-72) - Test:
internal/release/release_test.go(create)
Interfaces:
-
Produces:
const SourceTag = "tag",const SourceBookmark = "bookmark"RepoConfig.Brew.Source string(yamlsource),RepoConfig.Brew.BookmarkPrefix string(yamlbookmark_prefix)func (c RepoConfig) ReleaseSource() string— normalized,""→tagfunc (c RepoConfig) Prefix() string— normalized,""→release/func BookmarkVersion(short, prefix string) (string, bool)—release/v1.2.3,release/→v1.2.3,true
-
Step 1: Write the failing test
Create internal/release/release_test.go:
package release
import "testing"
func TestReleaseSourceAndPrefix(t *testing.T) {
var c RepoConfig
if got := c.ReleaseSource(); got != SourceTag {
t.Errorf("default ReleaseSource = %q, want %q", got, SourceTag)
}
if got := c.Prefix(); got != "release/" {
t.Errorf("default Prefix = %q, want release/", got)
}
c.Brew.Source = "bookmark"
c.Brew.BookmarkPrefix = "rel/"
if got := c.ReleaseSource(); got != SourceBookmark {
t.Errorf("ReleaseSource = %q, want %q", got, SourceBookmark)
}
if got := c.Prefix(); got != "rel/" {
t.Errorf("Prefix = %q, want rel/", got)
}
}
func TestBookmarkVersion(t *testing.T) {
cases := []struct {
short, prefix, want string
ok bool
}{
{"release/v1.2.3", "release/", "v1.2.3", true},
{"rel/v0.1.0", "rel/", "v0.1.0", true},
{"release/v1.2.3", "rel/", "", false}, // wrong prefix
{"release/main", "release/", "", false}, // not semver
{"main", "release/", "", false}, // no prefix
{"release/v1.2", "release/", "", false}, // not 3-part
}
for _, c := range cases {
got, ok := BookmarkVersion(c.short, c.prefix)
if ok != c.ok || got != c.want {
t.Errorf("BookmarkVersion(%q,%q) = %q,%v; want %q,%v", c.short, c.prefix, got, ok, c.want, c.ok)
}
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/release/ -run 'TestReleaseSourceAndPrefix|TestBookmarkVersion' -v
Expected: FAIL — undefined: SourceTag, c.ReleaseSource undefined, undefined: BookmarkVersion.
- Step 3: Write minimal implementation
In internal/release/release.go, extend the Brew struct (lines 27-30):
Brew struct {
Enabled bool `yaml:"enabled"`
Package string `yaml:"package"` // go build path, default "."
Source string `yaml:"source"` // "tag" (default) | "bookmark"
BookmarkPrefix string `yaml:"bookmark_prefix"` // bookmark mode; default "release/"
} `yaml:"brew"`
Add below IsReleaseTag (after line 72):
// Release source modes. A repo uses exactly one; switching is an explicit edit.
const (
SourceTag = "tag" // releases come from vX.Y.Z git tags (default)
SourceBookmark = "bookmark" // releases come from <prefix>vX.Y.Z bookmarks (jj)
)
// ReleaseSource returns the normalized brew source ("tag" or "bookmark").
func (c RepoConfig) ReleaseSource() string {
if c.Brew.Source == SourceBookmark {
return SourceBookmark
}
return SourceTag
}
// Prefix returns the normalized bookmark prefix (default "release/").
func (c RepoConfig) Prefix() string {
if c.Brew.BookmarkPrefix == "" {
return "release/"
}
return c.Brew.BookmarkPrefix
}
// BookmarkVersion extracts the vX.Y.Z version from a release bookmark's short
// name under prefix. ok=false unless it matches prefix + a semver release tag.
func BookmarkVersion(short, prefix string) (string, bool) {
rest, ok := strings.CutPrefix(short, prefix)
if !ok || !IsReleaseTag(rest) {
return "", false
}
return rest, true
}
(strings is already imported.)
- Step 4: Run test to verify it passes
Run: go test ./internal/release/ -run 'TestReleaseSourceAndPrefix|TestBookmarkVersion' -v
Expected: PASS.
- Step 5: Commit
git add internal/release/release.go internal/release/release_test.go
git commit -m "feat(release): add brew.source flag + bookmark version parsing"
Task 2: gitread — release bookmark listing + downgrade guard
Files:
- Modify:
internal/gitread/gitread.go(add afterIsHighestVersion, line 229) - Test:
internal/gitread/gitread_test.go(create — also establishes the git-fixture helper)
Interfaces:
-
Consumes: existing
Ref{Name,Hash,Message},parseSemver,semverLess,firstLine,s.open. -
Produces:
func (s *Store) ReleaseBookmarks(name, prefix string) ([]Ref, error)— branches underprefixwhose suffix is a semver, returned as version-named Refs (Name = vX.Y.Z,Hash= tip commit hash,Message= tip commit subject), sorted semver-descending.func (s *Store) LatestRelease(name, prefix string) (Ref, bool)— highest release bookmark.func (s *Store) IsHighestRelease(name, prefix, version string) bool—version >= highest release bookmark(ties ok); non-semverversion→ false.
-
Step 1: Write the failing test
Create internal/gitread/gitread_test.go:
package gitread
import (
"os/exec"
"path/filepath"
"testing"
)
// newBareWithReleases builds a bare repo named "<root>/proj.git" containing one
// commit plus the given release branches (each pointing at its own commit whose
// subject is "ship <branch>"). Returns the Store rooted at the temp dir.
func newBareWithReleases(t *testing.T, branches ...string) *Store {
t.Helper()
root := t.TempDir()
work := filepath.Join(root, "work")
run := func(dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
c.Env = append(c.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
run(root, "init", "-q", "-b", "main", "work")
run(work, "commit", "-q", "--allow-empty", "-m", "init")
for _, b := range branches {
run(work, "checkout", "-q", "-b", b)
run(work, "commit", "-q", "--allow-empty", "-m", "ship "+b)
run(work, "checkout", "-q", "main")
}
run(work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))
return New(root)
}
func TestReleaseBookmarks(t *testing.T) {
s := newBareWithReleases(t, "release/v1.0.0", "release/v1.2.0", "release/v0.9.0", "feature/x")
got, err := s.ReleaseBookmarks("proj", "release/")
if err != nil {
t.Fatalf("ReleaseBookmarks: %v", err)
}
want := []string{"v1.2.0", "v1.0.0", "v0.9.0"} // semver-descending, feature/x excluded
if len(got) != len(want) {
t.Fatalf("got %d refs %v, want %v", len(got), got, want)
}
for i, w := range want {
if got[i].Name != w {
t.Errorf("ReleaseBookmarks[%d].Name = %q, want %q", i, got[i].Name, w)
}
}
if got[0].Message != "ship release/v1.2.0" {
t.Errorf("Message = %q, want tip subject", got[0].Message)
}
}
func TestLatestReleaseAndGuard(t *testing.T) {
s := newBareWithReleases(t, "release/v1.0.0", "release/v1.2.0")
latest, ok := s.LatestRelease("proj", "release/")
if !ok || latest.Name != "v1.2.0" {
t.Fatalf("LatestRelease = %q,%v; want v1.2.0,true", latest.Name, ok)
}
if !s.IsHighestRelease("proj", "release/", "v1.2.0") {
t.Error("v1.2.0 should be highest (tie)")
}
if !s.IsHighestRelease("proj", "release/", "v2.0.0") {
t.Error("v2.0.0 should be highest")
}
if s.IsHighestRelease("proj", "release/", "v1.1.0") {
t.Error("v1.1.0 must NOT be highest (downgrade guard)")
}
empty := newBareWithReleases(t)
if !empty.IsHighestRelease("proj", "release/", "v1.0.0") {
t.Error("first release should be highest when none exist")
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/gitread/ -run 'TestReleaseBookmarks|TestLatestReleaseAndGuard' -v
Expected: FAIL — s.ReleaseBookmarks undefined, etc.
- Step 3: Write minimal implementation
In internal/gitread/gitread.go, add after IsHighestVersion (line 229):
// ReleaseBookmarks returns the repo's release bookmarks: branches under prefix
// whose remaining name is a semver, as version-named Refs (Name = vX.Y.Z, Hash =
// tip commit, Message = tip commit subject), sorted semver-descending.
func (s *Store) ReleaseBookmarks(name, prefix string) ([]Ref, error) {
repo, err := s.open(name)
if err != nil {
return nil, err
}
bs, err := repo.Branches()
if err != nil {
return nil, err
}
var out []Ref
_ = bs.ForEach(func(r *plumbing.Reference) error {
short := r.Name().Short()
rest, ok := strings.CutPrefix(short, prefix)
if !ok {
return nil
}
if _, ok := parseSemver(rest); !ok {
return nil
}
ref := Ref{Name: rest, Hash: r.Hash().String()}
if c, err := repo.CommitObject(r.Hash()); err == nil {
ref.Message = firstLine(c.Message)
}
out = append(out, ref)
return nil
})
sort.Slice(out, func(i, j int) bool {
vi, _ := parseSemver(out[i].Name)
vj, _ := parseSemver(out[j].Name)
return semverLess(vj, vi) // descending
})
return out, nil
}
// LatestRelease returns the highest-semver release bookmark, ok=false if none.
func (s *Store) LatestRelease(name, prefix string) (Ref, bool) {
bm, err := s.ReleaseBookmarks(name, prefix)
if err != nil || len(bm) == 0 {
return Ref{}, false
}
return bm[0], true
}
// IsHighestRelease reports whether version is >= the highest release bookmark
// (ties allowed). Guards bookmark-mode publishes against out-of-order pushes.
// A non-semver version is never highest.
func (s *Store) IsHighestRelease(name, prefix, version string) bool {
v, ok := parseSemver(version)
if !ok {
return false
}
latest, have := s.LatestRelease(name, prefix)
if !have {
return true
}
lv, _ := parseSemver(latest.Name)
return !semverLess(v, lv)
}
(plumbing, sort, strings already imported.)
- Step 4: Run test to verify it passes
Run: go test ./internal/gitread/ -run 'TestReleaseBookmarks|TestLatestReleaseAndGuard' -v
Expected: PASS.
- Step 5: Commit
git add internal/gitread/gitread.go internal/gitread/gitread_test.go
git commit -m "feat(gitread): list release bookmarks + bookmark downgrade guard"
Task 3: Publish — archive an arbitrary ref (branch or tag)
Files:
- Modify:
internal/release/release.go:75-128(Options + Publish),:130-143(gitArchive call site only) - Test:
internal/release/release_test.go(append)
Interfaces:
-
Consumes:
gitread.Store(license read),BookmarkVersion/config from Task 1. -
Produces:
Options.Ref string— the git refgit archiveruns against (a tag or a release branch). When empty, falls back toOptions.Tag(preserves the tag-mode call).Options.Tagremains thevX.Y.Zversion used for tarball name, formula URL, and tap commit message. -
Step 1: Write the failing test
Append to internal/release/release_test.go. Do not repeat the package release clause — merge these imports into the file's existing single import block (add os, os/exec, path/filepath, strings, custard/internal/gitread; testing is already there), then append the functions below:
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
c.Env = append(c.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
// TestPublishArchivesBranchRef verifies bookmark mode: archive the release
// BRANCH while naming the tarball/formula by the VERSION.
func TestPublishArchivesBranchRef(t *testing.T) {
root := t.TempDir()
work := filepath.Join(root, "work")
gitRun(t, root, "init", "-q", "-b", "main", "work")
if err := os.WriteFile(filepath.Join(work, "go.mod"), []byte("module proj\n"), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, work, "add", ".")
gitRun(t, work, "commit", "-q", "-m", "init")
gitRun(t, work, "checkout", "-q", "-b", "release/v1.2.3")
gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "ship v1.2.3")
gitRun(t, work, "checkout", "-q", "main")
gitRun(t, work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))
// A bare tap repo to receive the formula.
tap := filepath.Join(root, "homebrew-tap.git")
gitRun(t, root, "init", "-q", "--bare", "homebrew-tap.git")
// Seed the tap with one commit so HEAD exists for cloning.
tapWork := filepath.Join(root, "tapwork")
gitRun(t, root, "clone", "-q", "homebrew-tap.git", "tapwork")
if err := os.WriteFile(filepath.Join(tapWork, "README"), []byte("tap\n"), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, tapWork, "add", ".")
gitRun(t, tapWork, "commit", "-q", "-m", "seed")
gitRun(t, tapWork, "push", "-q", "origin", "HEAD:master")
dl := filepath.Join(root, "dl")
ver, err := Publish(Options{
Store: gitread.New(root),
ReposPath: root,
DLPath: dl,
TapRepo: "homebrew-tap",
BaseURL: "https://example.test",
Repo: "proj",
Ref: "release/v1.2.3", // archive the branch
Tag: "v1.2.3", // version
Desc: "a thing",
Package: ".",
})
if err != nil {
t.Fatalf("Publish: %v", err)
}
if ver != "1.2.3" {
t.Errorf("ver = %q, want 1.2.3", ver)
}
if _, err := os.Stat(filepath.Join(dl, "proj-1.2.3.tar.gz")); err != nil {
t.Errorf("tarball not written: %v", err)
}
}
func TestPublishDefaultsRefToTag(t *testing.T) {
// With Ref empty, Publish must archive Tag (tag-mode back-compat). A bogus
// repo path makes git archive fail; assert the error names the tag, proving
// Tag was used as the archive ref.
_, err := Publish(Options{
Store: gitread.New(t.TempDir()), ReposPath: t.TempDir(), DLPath: t.TempDir(),
Repo: "nope", Tag: "v9.9.9",
})
if err == nil || !strings.Contains(err.Error(), "v9.9.9") {
t.Fatalf("expected archive error naming v9.9.9, got %v", err)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/release/ -run 'TestPublishArchivesBranchRef|TestPublishDefaultsRefToTag' -v
Expected: FAIL — unknown field Ref in struct literal.
- Step 3: Write minimal implementation
In internal/release/release.go, add Ref to Options (after line 81, Repo):
Repo string // repo name
Ref string // git ref to archive (tag or release branch); defaults to Tag
Tag string // vX.Y.Z — the version (tarball name, formula, commit msg)
In Publish (lines 89-104), resolve the archive ref and use it for archive + license:
func Publish(o Options) (string, error) {
ver := strings.TrimPrefix(o.Tag, "v")
archiveRef := o.Ref
if archiveRef == "" {
archiveRef = o.Tag
}
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, archiveRef, tarPath); err != nil {
return "", err
}
And change the license read (line 117) from o.Tag to archiveRef:
License: detectLicense(o.Store, o.Repo, archiveRef),
(The tap commit message at line 185 keeps using o.Tag — the version — which is correct.)
- Step 4: Run test to verify it passes
Run: go test ./internal/release/ -v
Expected: PASS (all release tests).
- Step 5: Commit
git add internal/release/release.go internal/release/release_test.go
git commit -m "feat(release): archive an arbitrary ref, decoupled from the version"
Task 4: Webhook — mode-aware trigger + decision function
Files:
- Create:
internal/server/release_plan.go - Create:
internal/server/release_plan_test.go - Modify:
internal/server/webhook.go:54-124
Interfaces:
-
Consumes:
release.RepoConfig,release.SourceBookmark,release.IsReleaseTag,release.BookmarkVersion,cfg.ReleaseSource(),cfg.Prefix(). -
Produces:
func couldBeRelease(ref string) bool— cheap synchronous prefilter: arefs/tags/<semver>OR arefs/heads/<…>/<semver>(any branch whose final path segment is a release semver). Lets ordinarymain/feature pushes short-circuit without spawning a goroutine, without knowing the per-repo prefix.func releasePlan(cfg release.RepoConfig, ref string) (archiveRef, version, skip string)— given the repo config and the full pushed ref, return the ref to archive + the version to publish, or a non-emptyskipreason. Pure; no git, no I/O.
-
Step 1: Write the failing test
Create internal/server/release_plan_test.go:
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)
}
})
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/server/ -run 'TestCouldBeRelease|TestReleasePlan' -v
Expected: FAIL — undefined: couldBeRelease, undefined: releasePlan.
- Step 3: Write minimal implementation
Create internal/server/release_plan.go:
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, ""
}
}
- Step 4: Run test to verify it passes
Run: go test ./internal/server/ -run 'TestCouldBeRelease|TestReleasePlan' -v
Expected: PASS.
- Step 5: Rewire the webhook handler to use them
In internal/server/webhook.go, replace the synchronous tag-skip block (lines 54-79) with a prefilter that passes the full ref to the goroutine:
// 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)
Then rewrite publishRelease (lines 84-124) to take the full ref, read config, and apply the mode plan + the mode's guard:
// 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)
}
- Step 6: Run the whole server + release + gitread suite
Run: go build ./... && go test ./internal/server/ ./internal/release/ ./internal/gitread/ -v
Expected: PASS, build clean.
- Step 7: Commit
git add internal/server/webhook.go internal/server/release_plan.go internal/server/release_plan_test.go
git commit -m "feat(webhook): mode-aware release trigger (tags or bookmarks)"
Task 5: Display — mode-aware version pill + Releases on the refs page
Files:
- Modify:
internal/server/server.go:321-334(meta),:241-249(handleRefs) - Modify:
web/templates/templates.templ(RefsPage type + Refs template) - Test:
internal/server/server.gocovered via a newinternal/server/display_test.go
Interfaces:
-
Consumes:
gitread.LatestRelease,gitread.LatestTag,gitread.ReleaseBookmarks,release.ReadRepoConfig,cfg.ReleaseSource(),cfg.Prefix(). -
Produces:
func (s *Server) latestRelease(name string) (gitread.Ref, bool)— mode-aware: bookmark mode →LatestRelease, elseLatestTag.RefsPage.Releases []gitread.Ref(template type) — release bookmarks to render; empty in tag mode.
-
Step 1: Write the failing test
Create internal/server/display_test.go:
package server
import (
"os"
"os/exec"
"path/filepath"
"testing"
"custard/internal/config"
"custard/internal/gitread"
)
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
c.Env = append(c.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
// bookmarkRepo builds a bare "proj.git" in bookmark mode with one release branch.
func bookmarkRepo(t *testing.T) *Server {
t.Helper()
root := t.TempDir()
work := filepath.Join(root, "work")
gitRun(t, root, "init", "-q", "-b", "main", "work")
if err := os.WriteFile(filepath.Join(work, ".custard.yaml"),
[]byte("brew:\n enabled: true\n source: bookmark\n"), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, work, "add", ".")
gitRun(t, work, "commit", "-q", "-m", "init")
gitRun(t, work, "checkout", "-q", "-b", "release/v1.2.3")
gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "ship v1.2.3")
gitRun(t, work, "checkout", "-q", "main")
gitRun(t, work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))
return &Server{store: gitread.New(root), cfg: config.Config{ReposPath: root}}
}
func TestLatestReleaseBookmarkMode(t *testing.T) {
s := bookmarkRepo(t)
ref, ok := s.latestRelease("proj")
if !ok || ref.Name != "v1.2.3" {
t.Fatalf("latestRelease = %q,%v; want v1.2.3,true", ref.Name, ok)
}
if ref.Message != "ship v1.2.3" {
t.Errorf("label = %q, want tip subject", ref.Message)
}
}
Note: confirm
Server's field names (store,cfg) againstinternal/server/server.goand adjust the struct literal if they differ. IfServerhas unexported required deps that make direct construction awkward, instead testlatestReleasethrough the existing server constructor used elsewhere in the package.
- Step 2: Run test to verify it fails
Run: go test ./internal/server/ -run TestLatestReleaseBookmarkMode -v
Expected: FAIL — s.latestRelease undefined.
- Step 3: Implement
latestReleaseand use it inmeta
In internal/server/server.go, add the helper (near meta):
// latestRelease returns the repo's current version Ref, mode-aware: a release
// bookmark in bookmark mode, otherwise the highest semver tag. The Ref's Message
// is the version label (annotated tag subject, or bookmark tip commit subject).
func (s *Server) latestRelease(name string) (gitread.Ref, bool) {
branch, err := s.store.DefaultBranch(name)
if err == nil {
if cfg, ok := release.ReadRepoConfig(s.store, name, branch); ok && cfg.ReleaseSource() == release.SourceBookmark {
return s.store.LatestRelease(name, cfg.Prefix())
}
}
return s.store.LatestTag(name)
}
Replace the version block in meta (lines 328-332) with:
if name != "" {
if t, ok := s.latestRelease(name); ok {
m.Version, m.VersionMsg = t.Name, t.Message
}
}
(Ensure gitread is imported in server.go; it already is via existing usage of s.store. If the package isn't directly imported, add "custard/internal/gitread".)
- Step 4: Run test to verify it passes
Run: go test ./internal/server/ -run TestLatestReleaseBookmarkMode -v
Expected: PASS.
- Step 5: Populate Releases on the refs page
In web/templates/templates.templ, add to the RefsPage struct (find type RefsPage):
Releases []gitread.Ref
(Confirm the templ file already imports custard/internal/gitread — RefsPage.Refs is *gitread.Refs, so it does.)
In the Refs template, after the Branches <ul> (before <h1>Tags</h1>, line 341), add:
if len(p.Releases) > 0 {
<h1>Releases</h1>
<ul class="refs">
for _, rel := range p.Releases {
<li>
<a class="version-pill" href={ templ.SafeURL("/r/" + p.Meta.Repo + "/log/" + rel.Hash) }>{ rel.Name }</a>
if rel.Message != "" {
<span class="tag-msg">{ rel.Message }</span>
}
<span class="muted"><code>{ ShortHash(rel.Hash) }</code></span>
</li>
}
</ul>
}
In internal/server/server.go handleRefs (lines 241-249), populate it in bookmark mode:
func (s *Server) handleRefs(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
refs, err := s.store.Refs(name)
if err != nil {
s.notFound(w, r)
return
}
page := templates.RefsPage{Meta: s.meta(r, name, "", "refs"), Refs: refs}
if branch, err := s.store.DefaultBranch(name); err == nil {
if cfg, ok := release.ReadRepoConfig(s.store, name, branch); ok && cfg.ReleaseSource() == release.SourceBookmark {
page.Releases, _ = s.store.ReleaseBookmarks(name, cfg.Prefix())
}
}
s.render(w, r, templates.Refs(page))
}
Confirm the exact existing
handleRefsbody (Meta tab string, RefsPage field names) and preserve them; only add theReleasespopulation.
- Step 6: Regenerate templates, build, test
Run:
templ generate
go build ./... && go test ./internal/server/ -v
Expected: templ regenerates templates_templ.go, build clean, tests PASS.
- Step 7: Commit
git add internal/server/server.go internal/server/display_test.go web/templates/templates.templ web/templates/*_templ.go
git commit -m "feat(forge): version pill + refs page recognize release bookmarks"
Task 6: Docs — jj usage guide + migration section + README links
Files:
- Create:
docs/JUJUTSU.md - Modify:
docs/MIGRATION.md(append a switchover section) - Modify:
README.md:176-179(link the new doc)
Interfaces: none (documentation).
- Step 1: Write
docs/JUJUTSU.md
Create docs/JUJUTSU.md with these sections (write real prose, not placeholders):
-
What works unchanged. jj uses git as its backend;
jj git clone/jj git pushspeak plain git to Soft Serve (git.kortum.world:23231). Commits, log, tree, blob, refs all render identically in custard — it never knows jj is in use. -
Concept mapping. bookmark → git branch (custard refs page); commit → git commit; jj change IDs / op log are local-only and never pushed.
-
The tag gap. jj cannot create or move git tags (no annotated tags). So a pure-jj repo publishes releases from bookmarks instead — set
brew.source: bookmarkin.custard.yaml. -
Cutting a release in bookmark mode. Create/move a
release/vX.Y.Zbookmark at the commit to ship, thenjj git push --bookmark release/vX.Y.Z. The webhook fires, the tarball + formula publish, and the version pill showsvX.Y.Zlabeled with that commit's subject. -
How each jj op shows up in custard. Table: everyday
jj commit/jj describe→ new commits in log;jj bookmark set+ push → branch in refs;release/*bookmark push → release (Releases section + pill + tap); history rewrite + force-push → custard shows new state (read-only, safe). -
What's intentionally not supported. No auto-detection — mode is the explicit
brew.sourceflag; one mode at a time. -
Step 2: Append the switchover section to
docs/MIGRATION.md
Add a section "Switching a repo from git tags to jj bookmarks":
-
This is an intentional, one-way flip: set
brew.source: bookmark(optionallybookmark_prefix) in.custard.yaml, commit, push. After the flip the tag path stops triggering; releases come only fromrelease/*bookmarks. -
Past tag-based releases already in the tap are unaffected; the next bookmark release must be
>=the highest already-published version (the downgrade guard compares against release bookmarks now, so cut the first bookmark at or above your last tag). -
Link to
docs/JUJUTSU.mdfor the day-to-day workflow. -
Step 3: Link the new doc from README
In README.md after line 177 (the MIGRATION bullet), add:
- **[docs/JUJUTSU.md](docs/JUJUTSU.md)** — using jujutsu (jj) with Soft + custard: bookmark-mode releases and how each jj operation appears in the forge.
- Step 4: Verify links resolve
Run:
test -f docs/JUJUTSU.md && grep -q "JUJUTSU.md" README.md && grep -qi "bookmark" docs/MIGRATION.md && echo OK
Expected: OK.
- Step 5: Commit
git add docs/JUJUTSU.md docs/MIGRATION.md README.md
git commit -m "docs: jj bookmark-mode release guide + git→jj migration section"
Self-Review
Spec coverage (vs docs/superpowers/specs/2026-06-18-jj-bookmark-releases-design.md):
- Config
source/bookmark_prefix→ Task 1. ✓ - Webhook mode-aware trigger, tags-ignored-in-bookmark-mode → Task 4 (
releasePlan,couldBeRelease). ✓ - Publish archive-ref vs version split → Task 3. ✓
- Mode-aware downgrade guard → Task 2 (
IsHighestRelease) + Task 4 (selection). ✓ - Version pill + refs page recognize bookmarks, tip-subject label → Task 5. ✓
- Tests: extraction, mode-aware highest-version incl. downgrade rejection, tip-subject, webhook bookmark trigger → Tasks 1-5. ✓
- MIGRATION + jj usage docs, linked from README → Task 6. ✓
Known scope limit (deliberate, documented): the repo index list version (gitread.summary → LatestTag, templates.templ:152) stays tag-only. Making it mode-aware would require gitread to read .custard.yaml (a gitread → release import cycle) or per-repo config reads on the index. The mode-aware version pill lives on each repo's own page (meta) and refs page, which is the primary surface. Note this in docs/JUJUTSU.md if it surprises.
Placeholder scan: no TBD/TODO; every code step shows complete code. ✓
Type consistency: Options.Ref/Options.Tag, ReleaseSource()/Prefix(), BookmarkVersion, ReleaseBookmarks/LatestRelease/IsHighestRelease, couldBeRelease/releasePlan, latestRelease, RefsPage.Releases — names used identically across tasks. ✓
Caveat to verify during execution: the Server struct field names (store, cfg) and handleRefs body are assumed from reads of internal/server/server.go; confirm before editing and adjust literals/Meta tab strings to match.