▍ humdrum codex / custard v0.3.0
license AGPL-3.0
6.5 KB raw

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:

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/"

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:

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:

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:

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).

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:

Scope held out (YAGNI)