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.Ztags. 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):
brew:
enabled: true
package: "."
source: tag # "tag" (default, git) | "bookmark" (jj)
bookmark_prefix: "release/" # only used in bookmark mode; default "release/"
sourcedefaulttag→ every existing repo keeps current behavior with no edit.bookmark_prefixdefaultrelease/. Release bookmark =<prefix><semver>, e.g.release/v1.2.3→ versionv1.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 existingIsHighestVersion+ publish path. - bookmark mode: require ref
refs/heads/<prefix>; the remainder must parse as semver (vX.Y.Z) → version. Tags are ignored (arefs/tags/event in bookmark mode is dropped). Normalmain/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 archiveruns against: the tag (tag mode) or therelease/v1.2.3branch (bookmark mode). - version — the
vX.Y.Zsemver used for tarball name (<repo>-<ver>.tar.gz), formulaurl, 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):
-
MIGRATION guide — git tags → jj bookmarks. How to flip a repo to bookmark mode: set
brew.source: bookmarkin.custard.yaml, choose a prefix, cut the first release bookmark, and the intentional-switchover semantics (old tag method stops triggering once switched). -
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; ignoresmain/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).