# 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/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`; Go `1.26.4`. Verbatim from `go.mod`. - `internal/gitread` is the **only** package that touches go-git, and it must **not** import `internal/release` or `internal/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 — same `semverTag` regex (`^v\d+\.\d+\.\d+$`) as today. - Default `brew.source` is `tag`: every existing repo must behave exactly as before with no config edit. - Default `brew.bookmark_prefix` is `release/`. - `templ generate` MUST run after editing `web/templates/templates.templ`; the build uses the generated `_templ.go`. - New tests shell out to the real `git` CLI for fixtures (no go-git write API is used in this codebase); follow the table-driven style of `internal/backlog/backlog_test.go`. --- ## File Structure - `internal/release/release.go` — **Modify.** Add `Source`/`BookmarkPrefix` config fields + `ReleaseSource()`/`Prefix()` normalizers + `SourceTag`/`SourceBookmark` consts + `BookmarkVersion()` parser; add `Options.Ref` (archive ref) and use it in `Publish`/`gitArchive`/`detectLicense`. - `internal/release/release_test.go` — **Create.** Unit tests for config defaults, `ReleaseSource`/`Prefix`, `BookmarkVersion`, and `Publish` archiving a branch ref. - `internal/gitread/gitread.go` — **Modify.** Add `ReleaseBookmarks`, `LatestRelease`, `IsHighestRelease`. - `internal/gitread/gitread_test.go` — **Create.** Git-fixture helper + tests for the three new functions. - `internal/server/webhook.go` — **Modify.** Synchronous `couldBeRelease` prefilter; rewrite `publishRelease` to read config, pick mode, and call a new pure `releasePlan` decision function. - `internal/server/release_plan.go` — **Create.** Pure `releasePlan(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 for `couldBeRelease` and `releasePlan`. - `internal/server/server.go` — **Modify.** `meta()` uses a mode-aware `latestRelease` helper for the version pill; `handleRefs` populates `RefsPage.Releases` in bookmark mode. - `web/templates/templates.templ` — **Modify.** `RefsPage` gains `Releases []gitread.Ref`; `Refs` template 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 + `BookmarkVersion` near `IsReleaseTag` (`:68-72`) - Test: `internal/release/release_test.go` (create) **Interfaces:** - Produces: - `const SourceTag = "tag"`, `const SourceBookmark = "bookmark"` - `RepoConfig.Brew.Source string` (yaml `source`), `RepoConfig.Brew.BookmarkPrefix string` (yaml `bookmark_prefix`) - `func (c RepoConfig) ReleaseSource() string` — normalized, `""`→`tag` - `func (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`: ```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): ```go 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): ```go // 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 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** ```bash 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 after `IsHighestVersion`, 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 under `prefix` whose 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-semver `version` → false. - [ ] **Step 1: Write the failing test** Create `internal/gitread/gitread_test.go`: ```go package gitread import ( "os/exec" "path/filepath" "testing" ) // newBareWithReleases builds a bare repo named "/proj.git" containing one // commit plus the given release branches (each pointing at its own commit whose // subject is "ship "). 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): ```go // 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** ```bash 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 ref `git archive` runs against (a tag or a release branch). When empty, falls back to `Options.Tag` (preserves the tag-mode call). `Options.Tag` remains the `vX.Y.Z` **version** 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: ```go 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`): ```go 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: ```go 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`: ```go 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** ```bash 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: a `refs/tags/` OR a `refs/heads/<…>/` (any branch whose final path segment is a release semver). Lets ordinary `main`/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-empty `skip` reason. Pure; no git, no I/O. - [ ] **Step 1: Write the failing test** Create `internal/server/release_plan_test.go`: ```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`: ```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: ```go // 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: ```go // 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** ```bash 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.go` covered via a new `internal/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`, else `LatestTag`. - `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`: ```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`) against `internal/server/server.go` and adjust the struct literal if they differ. If `Server` has unexported required deps that make direct construction awkward, instead test `latestRelease` through 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 `latestRelease` and use it in `meta`** In `internal/server/server.go`, add the helper (near `meta`): ```go // 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: ```go 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`): ```go 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` `
    ` (before `

    Tags

    `, line 341), add: ```templ if len(p.Releases) > 0 {

    Releases

      for _, rel := range p.Releases {
    • { rel.Name } if rel.Message != "" { { rel.Message } } { ShortHash(rel.Hash) }
    • }
    } ``` In `internal/server/server.go` `handleRefs` (lines 241-249), populate it in bookmark mode: ```go 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 `handleRefs` body (Meta tab string, RefsPage field names) and preserve them; only add the `Releases` population. - [ ] **Step 6: Regenerate templates, build, test** Run: ```bash templ generate go build ./... && go test ./internal/server/ -v ``` Expected: templ regenerates `templates_templ.go`, build clean, tests PASS. - [ ] **Step 7: Commit** ```bash 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 push` speak 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: bookmark` in `.custard.yaml`. - **Cutting a release in bookmark mode.** Create/move a `release/vX.Y.Z` bookmark at the commit to ship, then `jj git push --bookmark release/vX.Y.Z`. The webhook fires, the tarball + formula publish, and the version pill shows `vX.Y.Z` labeled 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.source` flag; 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` (optionally `bookmark_prefix`) in `.custard.yaml`, commit, push. After the flip the tag path stops triggering; releases come only from `release/*` 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.md` for the day-to-day workflow. - [ ] **Step 3: Link the new doc from README** In `README.md` after line 177 (the MIGRATION bullet), add: ```markdown - **[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: ```bash test -f docs/JUJUTSU.md && grep -q "JUJUTSU.md" README.md && grep -qi "bookmark" docs/MIGRATION.md && echo OK ``` Expected: `OK`. - [ ] **Step 5: Commit** ```bash 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.