docs: release versioning design spec
Tag model, counter iterations under a per-app version target, notes at ship compiled into the tag changelog, auto-tag at stable promote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+130
−0
docs/superpowers/specs/2026-08-08-release-versioning-design.md+130 −0
diff --git a/docs/superpowers/specs/2026-08-08-release-versioning-design.md b/docs/superpowers/specs/2026-08-08-release-versioning-design.md| new file mode 100644 |
| index 000000000..8ebee032e |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-08-release-versioning-design.md |
| @@ -0,0 +1,130 @@ |
| +# Release versioning — design |
| + |
| +2026-08-08. Status: approved by Paul (brainstorming session), pending spec review. |
| + |
| +## Problem |
| + |
| +Every release surface — the Running column, `carlos releases`, pointers, |
| +promote output — identifies releases by whatever `-version` string was passed |
| +at ship time, which by convention is a git sha. A 40-character checksum is |
| +unique but meaningless: it carries no ordering, no intent, and no story. There |
| +is no human label, no notion of "what we're working toward," and no changelog |
| +anywhere in the system. |
| + |
| +## Decisions (made during brainstorming) |
| + |
| +1. **Tag model, not rename.** A build keeps its immutable version id forever. |
| + "0.5.0" is a *tag* written at promote time that points at a build. Nothing |
| + is ever renamed or re-signed. |
| +2. **Counter iterations.** Under a target of `0.5.0`, shipped builds are |
| + `0.5.0-1`, `0.5.0-2`, … — ordered, scannable, and semver-prerelease-valid. |
| + The git sha stays traceable via the manifest's existing `Commit` field, so |
| + it does not need to pollute the version string. |
| +3. **Notes written at ship.** Changelog content is deliberate operator input |
| + at ship time (`-notes` / `-notes-file`), not derived from git log. Tagging |
| + compiles the notes of all iterations into the tag's changelog. |
| +4. **Auto-tag at stable promote.** Promoting an iteration to stable writes the |
| + tag and advances the target to the next patch version. One command; the |
| + ladder flow is unchanged; stable always ends up with a human name. |
| + |
| +## Concepts |
| + |
| +Three new nouns. Everything is additive; no existing object changes shape. |
| + |
| +### Target |
| + |
| +The per-app "next version": a small JSON object in the bucket. |
| + |
| +- Key: `apps/<app>/version-target.json` |
| +- Shape: `{schema: 1, target: "0.5.0", set_by, set_at}` |
| +- Set via `carlos version target 0.5.0` (console API backed). |
| +- **No target set → ship behaves exactly as today.** The feature is opt-in |
| + per app; every existing app, script, and habit keeps working untouched. |
| +- A new target must compare strictly greater (semver) than any existing tag |
| + for the app; lowering or reusing a released version is refused. |
| +- After a tag is cut the target auto-advances to the next patch version |
| + (`0.5.0` → `0.5.1`). The operator can raise it (e.g. to `0.6.0`) at any |
| + time. |
| + |
| +### Iteration |
| + |
| +With a target set, `carlos ship` mints the version itself. |
| + |
| +- Version: `<target>-<n>` (e.g. `0.5.0-3`). The counter is allocated |
| + server-side inside the existing ship path (`handleAPIShip` already |
| + serializes ships through a semaphore), so allocation is race-free. Counters |
| + are per app + target and monotonically increasing. |
| +- New optional ship inputs, stored as new *optional* manifest fields: |
| + - `label` — short human label for the iteration ("fixing modals"). |
| + - `notes` — changelog fragment, free text/markdown (`-notes` inline or |
| + `-notes-file`). |
| +- Old manifests without these fields still parse (additive wire-shape rule). |
| +- An explicit `-version` still wins — escape hatch and back-compat for |
| + direct-bucket shippers. |
| + |
| +### Tag |
| + |
| +Written automatically when an iteration is promoted to stable. |
| + |
| +- Key: `apps/<app>/tags/<version>.json` (e.g. `apps/keymail/tags/0.5.0.json`) |
| +- Shape: `{schema: 1, version: "0.5.0", release: "0.5.0-3", commit, |
| + tagged_at, actor, changelog}` |
| +- `changelog` is compiled at tag time: the `notes` of every iteration of that |
| + target (`0.5.0-1` … `0.5.0-3`), oldest first, each section headed by the |
| + iteration's version and label. Iterations without notes contribute a |
| + heading only. |
| +- Tags are immutable once written; a tag key that already exists refuses |
| + overwrite (same posture as release immutability). |
| + |
| +## What does not change |
| + |
| +- Pointers, adopt, bake, grants, laddercheck, channel names: untouched. |
| +- The Running column mechanics are unchanged — it displays the release id as |
| + today; the id is simply `0.5.0-3` now instead of a sha. |
| +- Promote/adopt still operate on release ids. Tags are read-side sugar plus |
| + an audit record; no control-plane flow keys off a tag. |
| +- Release immutability (`shipImmutableConflict`) already refuses re-shipping |
| + a taken version with different artifacts — this is the collision backstop |
| + for counter allocation too. |
| + |
| +## Edge cases |
| + |
| +- **Promoting a non-semver build to stable** (old sha release, or explicit |
| + `-version` that isn't `<semver>-<n>`): promote succeeds, no tag is written, |
| + and the output warns that no tag was cut. History is never invented. |
| +- **Target vs. existing iterations:** raising the target mid-cycle (e.g. |
| + `0.5.0` → `0.6.0` with `0.5.0-3` un-promoted) is allowed; the abandoned |
| + iterations simply never get a tag. Counters restart at 1 per target. |
| +- **Hotfix flow:** target is already `0.5.1` once `0.5.0` is tagged; ship |
| + `0.5.1-1`, fast-track through the ladder as today, promote tags `0.5.1`. |
| +- **Direct-bucket shippers** (pinfra `scripts/ship-app.sh`): keep sha |
| + versions or pass explicit `-version`. The console API is the paved road; |
| + flagship apps opt in by setting a target and shipping via `carlos ship`. |
| +- **Concurrent ships under one target:** serialized by the existing ship |
| + semaphore; allocation reads the release list for the target and takes |
| + max+1. |
| + |
| +## Surfaces |
| + |
| +- **CLI:** `carlos ship -label "..." -notes/-notes-file`; `carlos version |
| + target [get|set]`; `carlos releases` shows version, label, and tag where |
| + one exists; promote output names the tag it cut. |
| +- **Console:** releases page shows label and notes per iteration; app page |
| + shows "stable · 0.5.0" when the stable build is tagged; tag detail view |
| + renders the compiled changelog. |
| +- **API:** additive fields on the existing ship/release endpoints; new |
| + endpoints for target get/set and tag list/read. Everything the console |
| + shows is API-reachable (operate-via-API rule — no box-local-only). |
| + |
| +## Testing |
| + |
| +- `internal/release` unit tests: counter allocation (including gap/restart |
| + behavior per target), target set/advance/refusal (semver comparison), |
| + changelog compilation (with and without notes), tag immutability, |
| + no-tag-for-non-semver promote. |
| +- Console API tests: ship with label/notes, target endpoints, tag endpoints, |
| + owner/auth gates matching existing API posture. |
| +- Wire-shape back-compat: a pre-change manifest and pointer (no new fields) |
| + must still parse; golden tests extended for the new objects. |
| +- Production channel names in `laddercheck_test.go` are untouched — this |
| + design adds no channel or ladder rules. |