# 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/`). 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 = ``, 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/`; 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 (`-.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).