jj planning docs
78dd7c3c60e4747bba6cbdde718950bacb188078
humdrum <me@humdrum.me> · 2026-06-24 08:02
parent c0a9f6ba
4 files changed
- → Release-webhook-downgrade-guard.md +25 −0
@@ -0,0 +1,25 @@
+---
+id: TASK-009
+title: Release webhook downgrade guard
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-18 20:07'
+updated_date: '2026-06-18 20:08'
+labels:
+ - bug
+dependencies: []
+priority: high
+ordinal: 9000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Webhook published whatever tag fired (chronological), so an out-of-order lower tag (v0.1.0 pushed after v0.2.0) clobbered the tap formula to a lower version — brew reads that as a downgrade and never upgrades. Root cause was leftover dogfood test tags. Fixed: gitread.IsHighestVersion guards publishRelease; only the highest semver tag publishes. Deleted stray v0.1.0, deployed, republished v0.2.0. Commit 43ac61a.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Out-of-order/lower tag never downgrades the tap formula
+- [ ] #2 Highest semver tag still publishes normally
+<!-- AC:END -->
- → jj-jujutsu-compatibility-bookmark-mode-releases-migration-guide.md +36 −0
@@ -0,0 +1,36 @@
+---
+id: TASK-010
+title: 'jj (jujutsu) compatibility: bookmark-mode releases + migration guide'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-19 06:04'
+updated_date: '2026-06-19 19:48'
+labels:
+ - feature
+dependencies: []
+priority: high
+ordinal: 10000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Make custard's release/tap pipeline and version display work for repos using jj (jujutsu) instead of git. jj can't create git tags, so releases must trigger off bookmarks (git branches) instead.
+
+Per-repo .custard.yaml gains brew.source: tag (default, git) | bookmark (jj), plus brew.bookmark_prefix (default 'release/'). A repo is in ONE mode at a time; switching is an explicit config flip. Tag mode = current behavior, untouched.
+
+Bookmark mode: webhook triggers on refs/heads/<prefix>vX.Y.Z, version = stripped name; downgrade guard compares against highest release bookmark; version pill + refs page read the bookmark's tip commit subject as the label (bookmarks have no annotated message).
+
+Also ship: MIGRATION guide (git tags -> jj bookmarks, intentional switchover) and a jj usage doc covering how to use jj against soft-serve + how each jj operation shows up in custard. Design spec drafted under docs/superpowers/specs/.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 brew.source config field (tag|bookmark) parsed from .custard.yaml, default tag, existing repos unchanged
+- [ ] #2 Bookmark-mode webhook triggers only on configured prefix + semver; tags ignored in bookmark mode; main/feature pushes never trigger
+- [ ] #3 Publish splits archive-ref from version; git archive runs against the bookmark branch in bookmark mode
+- [ ] #4 Downgrade guard is mode-aware: compares against highest semver of the active kind only
+- [ ] #5 Version pill + refs page recognize release bookmarks, using tip commit subject as the label
+- [ ] #6 Unit tests: prefix+semver extraction, mode-aware highest-version incl. downgrade rejection, tip-subject label, bookmark-mode webhook
+- [ ] #7 MIGRATION guide + jj usage/custard-effect doc written and linked from README
+<!-- AC:END -->
docs/superpowers/plans/2026-06-19-jj-bookmark-releases.md +1020 −0
@@ -0,0 +1,1020 @@
+# 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`; 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 <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**
+
+```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 "<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):
+
+```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/<semver>` OR a `refs/heads/<…>/<semver>` (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` `<ul>` (before `<h1>Tags</h1>`, line 341), add:
+
+```templ
+ 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:
+
+```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.
docs/superpowers/specs/2026-06-18-jj-bookmark-releases-design.md +150 −0
@@ -0,0 +1,150 @@
+# Design: jj (jujutsu) compatibility — bookmark-mode releases
+
+**Date:** 2026-06-18
+**Task:** TASK-010
+**Status:** approved design, pre-implementation
+
+## Problem
+
+custard's release pipeline turns a pushed git **tag** (`vX.Y.Z`) into a tarball +
+sha256 in `/dl` and a Homebrew formula pushed to the `homebrew-tap` repo. The forge
+header version pill and refs page read **annotated tag messages** as version labels.
+
+jujutsu (jj) uses git as its storage backend, so soft-serve and custard's read views
+(`tree`/`blob`/`log`/`commit`/`refs`) work unchanged — jj pushes real git commits and
+branch refs. The one gap: **jj cannot create or move git tags** (no native tag
+creation; annotated tags impossible). A pure-jj repo therefore has no way to fire the
+tag-driven release pipeline.
+
+jj's release primitive is the **bookmark** — which lands on the wire as a normal git
+branch (`refs/heads/<name>`). soft-serve already emits `branch_tag_create` webhook
+events for branches as well as tags. So the fix is entirely custard-side: accept a
+release **bookmark** as an alternative trigger.
+
+## Mode model (the core decision)
+
+A repo is in **one release mode at a time**:
+
+- **git → tag mode** (default): releases come from `vX.Y.Z` tags. Current behavior,
+ untouched.
+- **jj → bookmark mode**: releases come from bookmarks under a configured prefix
+ (`release/vX.Y.Z`). Tags are ignored.
+
+Only one method is active. Switching modes is an **explicit config flip**, after which
+the previously-active method stops triggering. No simultaneous dual-mode, no
+cross-mode version comparison.
+
+## Config
+
+Per-repo `.custard.yaml` (parsed by `RepoConfig` in `internal/release/release.go`):
+
+```yaml
+brew:
+ enabled: true
+ package: "."
+ source: tag # "tag" (default, git) | "bookmark" (jj)
+ bookmark_prefix: "release/" # only used in bookmark mode; default "release/"
+```
+
+- `source` default `tag` → every existing repo keeps current behavior with no edit.
+- `bookmark_prefix` default `release/`. Release bookmark = `<prefix><semver>`, e.g.
+ `release/v1.2.3` → version `v1.2.3`.
+
+New `RepoConfig.Brew` fields: `Source string` (yaml `source`), `BookmarkPrefix string`
+(yaml `bookmark_prefix`). Empty `Source` normalizes to `tag`; empty `BookmarkPrefix`
+normalizes to `release/`.
+
+## Components
+
+### 1. Webhook trigger — `internal/server/webhook.go`
+
+Read `RepoConfig` early (already loaded for `brew.enabled`), branch on `source`:
+
+- **tag mode** (unchanged): require ref `refs/tags/`; version = tag short name; run the
+ existing `IsHighestVersion` + publish path.
+- **bookmark mode**: require ref `refs/heads/<prefix>`; the remainder must parse as
+ semver (`vX.Y.Z`) → version. **Tags are ignored** (a `refs/tags/` event in bookmark
+ mode is dropped). Normal `main`/feature branch pushes don't match the prefix → no
+ trigger, same as today.
+
+`created=true && deleted=false` semantics still apply (first push of the bookmark).
+Moving a bookmark later (`created=false`) does not re-trigger — matches the tag-create
+contract.
+
+### 2. Publish — `internal/release/release.go`
+
+Currently `Options.Tag` is used both as the `git archive` ref **and** as the version
+source. Split these:
+
+- **archive ref** — what `git archive` runs against: the tag (tag mode) or the
+ `release/v1.2.3` branch (bookmark mode).
+- **version** — the `vX.Y.Z` semver used for tarball name (`<repo>-<ver>.tar.gz`),
+ formula `url`, and tap commit message.
+
+Everything downstream (`sha256File`, `detectLicense`, `formulaTmpl`, `commitToTap`) is
+already ref-agnostic and unchanged.
+
+### 3. Downgrade guard — `internal/gitread/gitread.go`
+
+Generalize the existing tag-only guard to be **mode-aware**, comparing against the
+highest semver **of the active kind only**:
+
+- tag mode: highest semver across tags (current `LatestTag` / `IsHighestVersion`).
+- bookmark mode: highest semver across **release bookmarks** (branches under prefix).
+
+Add bookmark listing: filter `repo.Branches()` by prefix, strip prefix, `parseSemver`
+the remainder, reuse existing `semverLess` ordering. Because modes never mix, a tag and
+a bookmark can never downgrade each other — the guard only ever sees one kind.
+
+Likely shape: a mode+prefix-parameterized `LatestRelease(name, mode, prefix)` and
+`IsHighestRelease(name, mode, prefix, version)`, with the existing tag functions kept as
+the tag-mode path (or refactored to delegate).
+
+### 4. Display — forge header pill + refs page
+
+Bookmarks carry **no annotated message**, so the version label comes from the
+**tip commit subject** (first line of the bookmark's tip commit).
+
+- **Version pill** (forge header): mode-aware. Bookmark mode reads the highest release
+ bookmark and uses its tip commit subject as the label, in place of the annotated-tag
+ message. Pure-jj repos (no tags) still show a version pill.
+- **Refs page**: release-prefixed bookmarks render with the version-label treatment
+ (version + tip subject), not as plain branches.
+
+Both surfaces read `RepoConfig` to learn the mode (a cheap extra read on repo pages,
+which already open the repo).
+
+## Documentation deliverables
+
+Shipped with the feature (linked from README, alongside the existing MANUAL/MIGRATION
+guides):
+
+1. **MIGRATION guide — git tags → jj bookmarks.** How to flip a repo to bookmark mode:
+ set `brew.source: bookmark` in `.custard.yaml`, choose a prefix, cut the first
+ release bookmark, and the intentional-switchover semantics (old tag method stops
+ triggering once switched).
+
+2. **jj usage + custard-effect doc.** How to drive jj against soft-serve
+ (`jj git clone`/`push`, bookmarks vs tags) and how each jj operation surfaces in
+ custard (commits/log/tree unaffected; bookmarks → branches; release bookmark →
+ tap publish + version pill). Includes the "what doesn't work" note: no native git
+ tags from jj, hence bookmark mode.
+
+## Testing
+
+Unit tests:
+
+- prefix + semver extraction from a bookmark ref (valid, wrong prefix, non-semver
+ suffix all handled).
+- mode-aware highest-version incl. **downgrade rejection** (older bookmark after newer
+ one is skipped).
+- tip-commit-subject label resolution for a release bookmark.
+- webhook bookmark-mode trigger: fires on `refs/heads/release/v1.2.3`; **ignores a tag
+ event in bookmark mode**; ignores `main`/feature pushes.
+
+## Scope held out (YAGNI)
+
+- No auto-detection of jj vs git — mode is the explicit config flip.
+- No simultaneous dual-mode and no cross-mode guard.
+- No automated migration of existing tags → bookmarks (the guide is manual steps).
+- No soft-serve changes (it already emits branch create events).