carlos / platform Public

docs: release versioning implementation plan

Eleven TDD tasks: semver/iteration parsing, additive manifest
label+notes, tag and target storage, EnsureTag changelog compile,
the PromoteOnLadder stable hook, console ship minting, new CLI API
endpoints, CLI verbs, console UI, and wire-shape goldens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b46a70c1841c96949d1eb9c842fbce088f3f9b5e parent 72ba65b
1 file changed, +1592 −0
  • docs/superpowers/plans/2026-08-08-release-versioning.md +1592 −0
diff --git a/docs/superpowers/plans/2026-08-08-release-versioning.md b/docs/superpowers/plans/2026-08-08-release-versioning.md
new file mode 100644
index 000000000..1289ae0ec
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-release-versioning.md
@@ -0,0 +1,1592 @@
+# Release Versioning 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:** Human-meaningful release versions: per-app version targets mint counter iterations (`0.5.0-3`) with labels and notes at ship, and stable promotion auto-cuts an immutable tag (`0.5.0`) with a compiled changelog.
+
+**Architecture:** All new state is plain additive JSON in the release bucket (`version-target.json`, `tags/<v>.json`); the tag hook lives inside `release.PromoteOnLadder` so every promote door (console browser, direct-bucket CLI) gets it; the console API mints iteration numbers inside the existing ship semaphore. No existing object changes shape — new manifest fields are optional, old objects keep parsing.
+
+**Tech Stack:** Go stdlib only (no semver dependency — a ~40-line parser is written in-repo). Spec: `docs/superpowers/specs/2026-08-08-release-versioning-design.md`.
+
+## Global Constraints
+
+- Build gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` — all clean, all packages.
+- Sandbox env for go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; disable the sandbox if the toolchain reports "read-only file system".
+- `gofmt -l` clean for every file you touch.
+- **Wire shapes are additive**: every new JSON field is `omitempty`; anything written before this change must still parse. Never edit an existing field or its meaning.
+- Production channel names pinned in `internal/release/laddercheck_test.go` are untouched — this feature adds no channels and no ladder rules.
+- Any test that shells out to the carlos binary must build its env through `carlosTestEnv` and set `cmd.Dir` to a scratch dir (see existing `e2e_test.go`).
+- Limits (used in several tasks): label ≤ 120 chars, single line; notes ≤ 16 KiB. Targets and tags are unsigned JSON (same trust posture as manifests: reached only through authenticated doors, never consumed by adopt).
+- Version-target key: `apps/<acct>/<app>/version-target.json`. Tag key: `apps/<acct>/<app>/tags/<version>.json`.
+
+---
+
+### Task 1: Semver and iteration parsing
+
+**Files:**
+- Create: `internal/release/semver.go`
+- Test: `internal/release/semver_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `type SemVer struct{ Major, Minor, Patch int }`; `ParseSemVer(s string) (SemVer, bool)`; `(SemVer) Compare(o SemVer) int`; `(SemVer) NextPatch() SemVer`; `(SemVer) String() string`; `SplitIteration(version string) (SemVer, int, bool)`; `IterationVersion(t SemVer, n int) string`. Later tasks rely on these exact names.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import "testing"
+
+func TestParseSemVer(t *testing.T) {
+ good := map[string]SemVer{
+ "0.5.0": {0, 5, 0},
+ "1.0.0": {1, 0, 0},
+ "10.2.33": {10, 2, 33},
+ }
+ for s, want := range good {
+ got, ok := ParseSemVer(s)
+ if !ok || got != want {
+ t.Errorf("ParseSemVer(%q) = %v, %v; want %v, true", s, got, ok, want)
+ }
+ }
+ bad := []string{"", "0.5", "0.5.0.1", "v0.5.0", "0.05.0", "01.0.0", "0.5.0-3", "0.5.x", "-1.0.0", "0.5.0 "}
+ for _, s := range bad {
+ if _, ok := ParseSemVer(s); ok {
+ t.Errorf("ParseSemVer(%q) accepted; want refused", s)
+ }
+ }
+}
+
+func TestSemVerCompareAndNext(t *testing.T) {
+ if c := (SemVer{0, 5, 0}).Compare(SemVer{0, 5, 1}); c >= 0 {
+ t.Errorf("0.5.0 vs 0.5.1 = %d; want < 0", c)
+ }
+ if c := (SemVer{1, 0, 0}).Compare(SemVer{0, 9, 9}); c <= 0 {
+ t.Errorf("1.0.0 vs 0.9.9 = %d; want > 0", c)
+ }
+ if c := (SemVer{0, 5, 0}).Compare(SemVer{0, 5, 0}); c != 0 {
+ t.Errorf("equal compare = %d; want 0", c)
+ }
+ if got := (SemVer{0, 5, 0}).NextPatch(); got != (SemVer{0, 5, 1}) {
+ t.Errorf("NextPatch = %v; want 0.5.1", got)
+ }
+ if got := (SemVer{0, 5, 0}).String(); got != "0.5.0" {
+ t.Errorf("String = %q; want 0.5.0", got)
+ }
+}
+
+func TestSplitIteration(t *testing.T) {
+ tgt, n, ok := SplitIteration("0.5.0-3")
+ if !ok || tgt != (SemVer{0, 5, 0}) || n != 3 {
+ t.Fatalf("SplitIteration(0.5.0-3) = %v, %d, %v", tgt, n, ok)
+ }
+ if got := IterationVersion(SemVer{0, 5, 0}, 4); got != "0.5.0-4" {
+ t.Errorf("IterationVersion = %q; want 0.5.0-4", got)
+ }
+ bad := []string{"de2766f8", "0.5.0", "0.5.0-0", "0.5.0-03", "0.5.0-x", "0.5.0-3-4", "0.5.0-", ""}
+ for _, s := range bad {
+ if _, _, ok := SplitIteration(s); ok {
+ t.Errorf("SplitIteration(%q) accepted; want refused", s)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run 'TestParseSemVer|TestSemVerCompareAndNext|TestSplitIteration' -count=1`
+Expected: FAIL — `undefined: ParseSemVer` etc.
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+// Semver targets and counter iterations (release versioning spec,
+// 2026-08-08). A TARGET is a bare MAJOR.MINOR.PATCH ("0.5.0"); an ITERATION
+// is a shipped build under a target ("0.5.0-3"). Both are ordinary version
+// strings to everything else in this package — ValidVersion accepts them,
+// pointers and manifests carry them unchanged. This file only teaches the
+// package to read the structure back OUT of them.
+package release
+
+import (
+ "strconv"
+ "strings"
+)
+
+// SemVer is a parsed MAJOR.MINOR.PATCH. No prerelease/build fields: the
+// iteration counter is this package's only prerelease shape, and
+// SplitIteration owns it.
+type SemVer struct{ Major, Minor, Patch int }
+
+// ParseSemVer parses a strict bare semver: three dot-separated decimal
+// numbers, no leading zeros, nothing else. Refusals are (zero, false) —
+// version strings outside this shape are legal everywhere, they just are
+// not targets.
+func ParseSemVer(s string) (SemVer, bool) {
+ parts := strings.Split(s, ".")
+ if len(parts) != 3 {
+ return SemVer{}, false
+ }
+ var n [3]int
+ for i, p := range parts {
+ v, ok := parseCounter(p, true)
+ if !ok {
+ return SemVer{}, false
+ }
+ n[i] = v
+ }
+ return SemVer{n[0], n[1], n[2]}, true
+}
+
+// parseCounter parses a plain decimal with no sign and no leading zeros.
+// zeroOK distinguishes semver components (0 is fine) from iteration
+// counters (they start at 1).
+func parseCounter(s string, zeroOK bool) (int, bool) {
+ if s == "" || len(s) > 9 {
+ return 0, false
+ }
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return 0, false
+ }
+ }
+ if len(s) > 1 && s[0] == '0' {
+ return 0, false
+ }
+ v, err := strconv.Atoi(s)
+ if err != nil || (!zeroOK && v == 0) {
+ return 0, false
+ }
+ return v, true
+}
+
+func (v SemVer) String() string {
+ return strconv.Itoa(v.Major) + "." + strconv.Itoa(v.Minor) + "." + strconv.Itoa(v.Patch)
+}
+
+// Compare is < 0 / 0 / > 0, semver precedence (major, then minor, then patch).
+func (v SemVer) Compare(o SemVer) int {
+ switch {
+ case v.Major != o.Major:
+ return v.Major - o.Major
+ case v.Minor != o.Minor:
+ return v.Minor - o.Minor
+ default:
+ return v.Patch - o.Patch
+ }
+}
+
+// NextPatch is where the target auto-advances after a tag is cut.
+func (v SemVer) NextPatch() SemVer { return SemVer{v.Major, v.Minor, v.Patch + 1} }
+
+// SplitIteration reads "<target>-<n>" back out of an iteration version:
+// "0.5.0-3" -> (0.5.0, 3, true). Counters start at 1 and carry no leading
+// zeros, so every iteration version has exactly one spelling. Anything
+// else — a sha, a bare target, "dev" — is (zero, 0, false), which callers
+// read as "not an iteration; no tag business here".
+func SplitIteration(version string) (SemVer, int, bool) {
+ base, cnt, found := strings.Cut(version, "-")
+ if !found || strings.Contains(cnt, "-") {
+ return SemVer{}, 0, false
+ }
+ t, ok := ParseSemVer(base)
+ if !ok {
+ return SemVer{}, 0, false
+ }
+ n, ok := parseCounter(cnt, false)
+ if !ok {
+ return SemVer{}, 0, false
+ }
+ return t, n, true
+}
+
+// IterationVersion is SplitIteration's inverse: the version string minted
+// for iteration n under a target.
+func IterationVersion(t SemVer, n int) string {
+ return t.String() + "-" + strconv.Itoa(n)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/release/ -run 'TestParseSemVer|TestSemVerCompareAndNext|TestSplitIteration' -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/release/
+git add internal/release/semver.go internal/release/semver_test.go
+git commit -m "release: semver targets and iteration version parsing"
+```
+
+---
+
+### Task 2: Manifest gains Label and Notes (additive)
+
+**Files:**
+- Modify: `internal/release/release.go` (Manifest struct ~line 46; `validateManifest` ~line 241)
+- Test: `internal/release/release_test.go` (append)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `Manifest.Label string`, `Manifest.Notes string` (both `omitempty`); exported limits `MaxLabelLen = 120`, `MaxNotesLen = 16 << 10`. Ship paths in Tasks 7 and 9 set these; Task 5 reads them.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/release/release_test.go`:
+
+```go
+func TestManifestLabelNotesAdditive(t *testing.T) {
+ // A manifest written before label/notes existed must still parse (the
+ // production buckets are full of them) …
+ old := []byte(`{"schema":1,"app":"km","version":"de2766f8","kind":"binary","artifacts":{"linux-arm64":{"file":"linux-arm64","sha256":"x","size":1}}}`)
+ var m Manifest
+ if err := json.Unmarshal(old, &m); err != nil {
+ t.Fatalf("old manifest no longer parses: %v", err)
+ }
+ if m.Label != "" || m.Notes != "" {
+ t.Fatalf("absent fields decoded non-empty: %q %q", m.Label, m.Notes)
+ }
+ // … and a label-free manifest must MARSHAL byte-identically to before,
+ // or idempotent re-ship of an old release breaks on its own metadata.
+ out, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(out, []byte("label")) || bytes.Contains(out, []byte("notes")) {
+ t.Fatalf("empty label/notes leaked into marshal: %s", out)
+ }
+}
+
+func TestValidateManifestLabelNotesLimits(t *testing.T) {
+ base := func() *Manifest {
+ return &Manifest{Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary",
+ Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: "x", Size: 1}}}
+ }
+ m := base()
+ m.Label = "fixing modals"
+ m.Notes = "- rounded the corners\n- fixed the focus trap"
+ if err := validateManifest(m); err != nil {
+ t.Fatalf("valid label/notes refused: %v", err)
+ }
+ m = base()
+ m.Label = strings.Repeat("x", MaxLabelLen+1)
+ if err := validateManifest(m); err == nil {
+ t.Fatal("overlong label accepted")
+ }
+ m = base()
+ m.Label = "two\nlines"
+ if err := validateManifest(m); err == nil {
+ t.Fatal("multi-line label accepted")
+ }
+ m = base()
+ m.Notes = strings.Repeat("x", MaxNotesLen+1)
+ if err := validateManifest(m); err == nil {
+ t.Fatal("overlong notes accepted")
+ }
+}
+```
+
+Add `"bytes"`, `"strings"`, `"encoding/json"` to the test file's imports if not present.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run 'TestManifestLabelNotes|TestValidateManifestLabelNotes' -count=1`
+Expected: FAIL — `m.Label undefined`.
+
+- [ ] **Step 3: Implement**
+
+In the `Manifest` struct, after `BuiltAt`:
+
+```go
+ // Label and Notes are the human half of a release (release versioning
+ // spec, 2026-08-08): a short one-line intent ("fixing modals") and a
+ // changelog fragment, both written at ship time. ADDITIVE to schema 1
+ // exactly as Capabilities was — absent from every manifest written
+ // before the fields existed, omitted from marshal when empty, ignored
+ // by old binaries. Nothing on the adopt path reads either.
+ Label string `json:"label,omitempty"`
+ Notes string `json:"notes,omitempty"`
+```
+
+Near the top of release.go (beside other consts) add:
+
+```go
+// MaxLabelLen / MaxNotesLen bound the human half of a manifest. The label
+// is a table cell; the notes are a changelog fragment, not a novel.
+const (
+ MaxLabelLen = 120
+ MaxNotesLen = 16 << 10
+)
+```
+
+In `validateManifest`, after the existing field checks:
+
+```go
+ if len(m.Label) > MaxLabelLen || strings.ContainsAny(m.Label, "\r\n") {
+ return fmt.Errorf("label: one line, at most %d characters", MaxLabelLen)
+ }
+ if len(m.Notes) > MaxNotesLen {
+ return fmt.Errorf("notes: at most %d bytes", MaxNotesLen)
+ }
+```
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/release/ -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/release/
+git add internal/release/release.go internal/release/release_test.go
+git commit -m "release: manifests carry an optional label and notes"
+```
+
+---
+
+### Task 3: Tag storage — type, keys, read/list/write
+
+**Files:**
+- Create: `internal/release/tag.go`
+- Test: `internal/release/tag_test.go`
+
+**Interfaces:**
+- Consumes: `putImmutable`, `GetQualified`, store `List` (existing); `ParseSemVer` (Task 1).
+- Produces: `type Tag struct{...}`; `TagKey(acct, app, version string) string`; `TagsPrefix(acct, app string) string`; `GetTag(ctx, s, acct, app, version) (*Tag, error)` (os.ErrNotExist-wrapped miss); `ListTags(ctx, s, acct, app) ([]Tag, error)` (semver-descending); `writeTag(ctx, s Store, acct, app string, tag *Tag) error` (unexported, immutable). Tasks 4, 5, 8, 9, 10 rely on these names.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestTagWriteGetList(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := GetTag(ctx, s, "acct1", "km", "0.5.0"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("missing tag: got %v; want os.ErrNotExist", err)
+ }
+ tags := []Tag{
+ {Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: time.Now().UTC(), Actor: "paul@example.com", Changelog: "## 0.5.0-1 — fixing modals\n"},
+ {Schema: 1, Version: "0.10.0", Release: "0.10.0-1", TaggedAt: time.Now().UTC()},
+ {Schema: 1, Version: "0.6.0", Release: "0.6.0-2", TaggedAt: time.Now().UTC()},
+ }
+ for i := range tags {
+ if err := writeTag(ctx, s, "acct1", "km", &tags[i]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := GetTag(ctx, s, "acct1", "km", "0.5.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Release != "0.5.0-3" || got.Changelog == "" {
+ t.Fatalf("round trip lost fields: %+v", got)
+ }
+ list, err := ListTags(ctx, s, "acct1", "km")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Semver-descending, which is NOT string order (0.10.0 > 0.6.0).
+ if len(list) != 3 || list[0].Version != "0.10.0" || list[1].Version != "0.6.0" || list[2].Version != "0.5.0" {
+ t.Fatalf("wrong order: %+v", list)
+ }
+}
+
+func TestTagImmutable(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ a := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: time.Unix(100, 0).UTC()}
+ if err := writeTag(ctx, s, "acct1", "km", &a); err != nil {
+ t.Fatal(err)
+ }
+ // Same bytes: idempotent. Different bytes: refused.
+ if err := writeTag(ctx, s, "acct1", "km", &a); err != nil {
+ t.Fatalf("identical rewrite refused: %v", err)
+ }
+ b := a
+ b.Release = "0.5.0-4"
+ if err := writeTag(ctx, s, "acct1", "km", &b); err == nil {
+ t.Fatal("conflicting rewrite accepted")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run TestTag -count=1`
+Expected: FAIL — `undefined: GetTag`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// Tags — the human names releases earn when they reach stable (release
+// versioning spec, 2026-08-08). A tag is written once, points at exactly
+// one shipped release, and carries the compiled changelog of every
+// iteration that led there. Tags are read-side sugar plus an audit record:
+// no control-plane flow keys off one, adopt never reads them, and they are
+// unsigned for the same reason manifests are — every path that writes one
+// sits behind an authenticated door, and nothing that runs code trusts
+// them.
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// Tag records one cut version: which build it points at, who cut it, and
+// the story of how it got there.
+type Tag struct {
+ Schema int `json:"schema"` // 1
+ Version string `json:"version"`
+ Release string `json:"release"`
+ Commit string `json:"commit,omitempty"`
+ TaggedAt time.Time `json:"tagged_at"`
+ Actor string `json:"actor,omitempty"`
+ Changelog string `json:"changelog,omitempty"`
+}
+
+func TagsPrefix(acct, app string) string { return appPrefix(acct, app) + "tags/" }
+
+func TagKey(acct, app, version string) string {
+ return TagsPrefix(acct, app) + version + ".json"
+}
+
+// GetTag reads one tag; a miss is os.ErrNotExist-wrapped, matching
+// GetManifest's contract.
+func GetTag(ctx context.Context, s Store, acct, app, version string) (*Tag, error) {
+ raw, _, err := s.Get(ctx, TagKey(acct, app, version))
+ if err != nil {
+ return nil, err
+ }
+ var t Tag
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("tag at %s: %w", TagKey(acct, app, version), err)
+ }
+ if t.Version != version {
+ return nil, fmt.Errorf("tag at %s names %s", TagKey(acct, app, version), t.Version)
+ }
+ return &t, nil
+}
+
+// ListTags lists an app's tags, semver-descending — newest line first,
+// which string order gets wrong the day 0.10.0 exists. A key that does not
+// parse as a semver tag is skipped, not fatal.
+func ListTags(ctx context.Context, s Store, acct, app string) ([]Tag, error) {
+ prefix := TagsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []Tag
+ for _, k := range keys {
+ name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
+ if _, ok := ParseSemVer(name); !ok {
+ continue
+ }
+ t, err := GetTag(ctx, s, acct, app, name)
+ if err != nil {
+ continue
+ }
+ out = append(out, *t)
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ a, _ := ParseSemVer(out[i].Version)
+ b, _ := ParseSemVer(out[j].Version)
+ return a.Compare(b) > 0
+ })
+ return out, nil
+}
+
+// writeTag stores a tag under the same immutability rule as every release
+// object: create, or confirm byte-identical content already there.
+func writeTag(ctx context.Context, s Store, acct, app string, t *Tag) error {
+ raw, err := json.Marshal(t)
+ if err != nil {
+ return err
+ }
+ return putImmutable(ctx, s, TagKey(acct, app, t.Version), raw)
+}
+```
+
+Add `"time"` to imports.
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/release/ -run TestTag -count=1` — PASS, then the full gate:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/release/
+git add internal/release/tag.go internal/release/tag_test.go
+git commit -m "release: tag objects — immutable cut records under tags/"
+```
+
+---
+
+### Task 4: Version target — get/set and iteration allocation
+
+**Files:**
+- Create: `internal/release/target.go`
+- Test: `internal/release/target_test.go`
+
+**Interfaces:**
+- Consumes: `ParseSemVer`, `SplitIteration`, `IterationVersion` (Task 1); `ListTags` (Task 3); store `Get`/`Put`/`List`, `ReleasesPrefix` (existing).
+- Produces: `type VersionTarget struct{Schema int; Target string; SetBy string; SetAt time.Time}` (json: `schema`, `target`, `set_by` omitempty, `set_at`); `TargetKey(acct, app) string`; `GetTarget(ctx, s, acct, app) (*VersionTarget, string, error)` — `(nil, "", nil)` when unset; `SetTarget(ctx, s, acct, app, target, actor string, now time.Time) error`; `setTargetUnchecked(...)` (unexported, used by auto-advance); `NextIteration(ctx, s, acct, app string, t SemVer) (string, error)`. Tasks 5, 7, 8, 9 rely on these.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestTargetSetGet(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if vt, _, err := GetTarget(ctx, s, "acct1", "km"); err != nil || vt != nil {
+ t.Fatalf("unset target: got %v, %v; want nil, nil", vt, err)
+ }
+ now := time.Unix(1000, 0).UTC()
+ if err := SetTarget(ctx, s, "acct1", "km", "0.5.0", "paul@example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ vt, _, err := GetTarget(ctx, s, "acct1", "km")
+ if err != nil || vt == nil || vt.Target != "0.5.0" || vt.SetBy != "paul@example.com" || !vt.SetAt.Equal(now) {
+ t.Fatalf("round trip: %+v, %v", vt, err)
+ }
+ // Raising is fine; garbage and iterations are not.
+ if err := SetTarget(ctx, s, "acct1", "km", "0.6.0", "paul@example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"", "0.5", "v1.0.0", "0.5.0-3", "de2766f8"} {
+ if err := SetTarget(ctx, s, "acct1", "km", bad, "x", now); err == nil {
+ t.Errorf("SetTarget(%q) accepted", bad)
+ }
+ }
+}
+
+func TestTargetMustExceedTags(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(1000, 0).UTC()
+ tag := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: now}
+ if err := writeTag(ctx, s, "acct1", "km", &tag); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetTarget(ctx, s, "acct1", "km", "0.5.0", "x", now); err == nil {
+ t.Fatal("target equal to an existing tag accepted")
+ }
+ if err := SetTarget(ctx, s, "acct1", "km", "0.4.9", "x", now); err == nil {
+ t.Fatal("target below an existing tag accepted")
+ }
+ if err := SetTarget(ctx, s, "acct1", "km", "0.5.1", "x", now); err != nil {
+ t.Fatalf("next patch refused: %v", err)
+ }
+}
+
+func TestNextIteration(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ tgt := SemVer{0, 5, 0}
+ v, err := NextIteration(ctx, s, "acct1", "km", tgt)
+ if err != nil || v != "0.5.0-1" {
+ t.Fatalf("first iteration: %q, %v", v, err)
+ }
+ // Existing releases under this target and noise from other schemes both
+ // sit in the same prefix; only this target's iterations count.
+ for _, ver := range []string{"0.5.0-1", "0.5.0-3", "0.4.0-9", "de2766f8"} {
+ if _, err := s.Put(ctx, ReleasesPrefix("acct1", "km")+ver+"/manifest.json", []byte("{}"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ v, err = NextIteration(ctx, s, "acct1", "km", tgt)
+ if err != nil || v != "0.5.0-4" {
+ t.Fatalf("after -1 and -3 exist: %q, %v; want 0.5.0-4", v, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run 'TestTarget|TestNextIteration' -count=1`
+Expected: FAIL — `undefined: GetTarget`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// The version target — the per-app "what we're building toward" (release
+// versioning spec, 2026-08-08). With a target set, ship mints iteration
+// versions under it ("0.5.0-3"); with none set, ship behaves exactly as it
+// always has. The whole feature hangs off this one optional object, which
+// is what keeps it opt-in per app.
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+)
+
+// VersionTarget is the bucket object at TargetKey.
+type VersionTarget struct {
+ Schema int `json:"schema"` // 1
+ Target string `json:"target"`
+ SetBy string `json:"set_by,omitempty"`
+ SetAt time.Time `json:"set_at"`
+}
+
+func TargetKey(acct, app string) string { return appPrefix(acct, app) + "version-target.json" }
+
+// GetTarget reads the target; (nil, "", nil) when none is set — unset is a
+// state, not an error. The etag is returned for CAS on update.
+func GetTarget(ctx context.Context, s Store, acct, app string) (*VersionTarget, string, error) {
+ raw, etag, err := s.Get(ctx, TargetKey(acct, app))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ var vt VersionTarget
+ if err := json.Unmarshal(raw, &vt); err != nil {
+ return nil, "", fmt.Errorf("version target at %s: %w", TargetKey(acct, app), err)
+ }
+ return &vt, etag, nil
+}
+
+// SetTarget validates and writes the target. A target must be a bare
+// semver strictly greater than every existing tag — a released version is
+// history, and history is not reopened.
+func SetTarget(ctx context.Context, s Store, acct, app, target, actor string, now time.Time) error {
+ t, ok := ParseSemVer(target)
+ if !ok {
+ return fmt.Errorf("version target %q: want MAJOR.MINOR.PATCH, like 0.5.0", target)
+ }
+ tags, err := ListTags(ctx, s, acct, app)
+ if err != nil {
+ return fmt.Errorf("version target: list tags: %w", err)
+ }
+ for _, tag := range tags {
+ if tv, ok := ParseSemVer(tag.Version); ok && t.Compare(tv) <= 0 {
+ return fmt.Errorf("version target %s: already released as tag %s — the target must be greater", target, tag.Version)
+ }
+ }
+ return setTargetUnchecked(ctx, s, acct, app, t, actor, now)
+}
+
+// setTargetUnchecked writes the target with no tag comparison — the
+// auto-advance after a tag cut has already proved its ordering, and
+// re-checking would race the tag it just wrote. Last write wins: the
+// target is an intention, not a ledger.
+func setTargetUnchecked(ctx context.Context, s Store, acct, app string, t SemVer, actor string, now time.Time) error {
+ raw, err := json.Marshal(VersionTarget{Schema: 1, Target: t.String(), SetBy: actor, SetAt: now})
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, TargetKey(acct, app), raw, "*"); err != nil {
+ return fmt.Errorf("version target: write: %w", err)
+ }
+ return nil
+}
+
+// NextIteration allocates the next counter under a target by reading what
+// has actually been shipped — the release dirs are the ledger, so there is
+// no side counter to drift from it. Racing allocators can collide, and the
+// immutable-release refusal is the backstop: the loser's ship fails
+// cleanly and is re-run.
+func NextIteration(ctx context.Context, s Store, acct, app string, t SemVer) (string, error) {
+ prefix := ReleasesPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return "", fmt.Errorf("next iteration: list %s: %w", prefix, err)
+ }
+ max := 0
+ seen := map[string]bool{}
+ for _, k := range keys {
+ v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
+ if !ok || seen[v] {
+ continue
+ }
+ seen[v] = true
+ vt, n, ok := SplitIteration(v)
+ if ok && vt == t && n > max {
+ max = n
+ }
+ }
+ return IterationVersion(t, max+1), nil
+}
+```
+
+Note: check how `Put` with ifMatch is used for overwrite in this codebase before using `"*"` — read `store.go` `Put` semantics (`internal/release/store.go:107` and `:307`). If unconditional overwrite is expressed differently (e.g. empty ifMatch means create-only and there is a sentinel for overwrite), match the existing convention; the DirStore test will tell you immediately.
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/release/ -run 'TestTarget|TestNextIteration' -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/release/
+git add internal/release/target.go internal/release/target_test.go
+git commit -m "release: per-app version target and iteration allocation"
+```
+
+---
+
+### Task 5: EnsureTag — changelog compilation and target auto-advance
+
+**Files:**
+- Modify: `internal/release/tag.go`
+- Test: `internal/release/tag_test.go` (append)
+
+**Interfaces:**
+- Consumes: `SplitIteration`, `GetTag`, `writeTag`, `GetManifest`, `GetTarget`, `setTargetUnchecked`, `ListTags` (Tasks 1–4).
+- Produces: `EnsureTag(ctx, s Store, acct, app, releaseVersion, actor string, now time.Time) (tag *Tag, created bool, err error)`. Contract: non-iteration version → `(nil, false, nil)`; existing tag → `(tag, false, nil)`; else compiles + writes the tag, best-effort advances the target, returns `(tag, true, nil)`. Task 6 calls exactly this.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tag_test.go` (a `shipIteration` helper keeps them readable):
+
+```go
+func shipIteration(t *testing.T, s Store, ver, label, notes string) {
+ t.Helper()
+ m := Manifest{Schema: 1, App: "km", Version: ver, Kind: "binary",
+ Commit: "c-" + ver, Label: label, Notes: notes,
+ Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: "x", Size: 1}}}
+ raw, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := putImmutable(context.Background(), s, ManifestKey("acct1", "km", ver), raw); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestEnsureTagCompilesChangelog(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "fixing modals", "- focus trap fixed")
+ shipIteration(t, s, "0.5.0-2", "", "")
+ shipIteration(t, s, "0.5.0-3", "polish", "- rounded corners")
+ tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "paul@example.com", now)
+ if err != nil || !created || tag == nil {
+ t.Fatalf("EnsureTag: %+v, %v, %v", tag, created, err)
+ }
+ if tag.Version != "0.5.0" || tag.Release != "0.5.0-3" || tag.Commit != "c-0.5.0-3" || tag.Actor != "paul@example.com" {
+ t.Fatalf("tag fields: %+v", tag)
+ }
+ want := "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-2\n\n## 0.5.0-3 — polish\n\n- rounded corners\n"
+ if tag.Changelog != want {
+ t.Fatalf("changelog:\n%q\nwant:\n%q", tag.Changelog, want)
+ }
+ // Auto-advance: the target moved to the next patch.
+ vt, _, err := GetTarget(ctx, s, "acct1", "km")
+ if err != nil || vt == nil || vt.Target != "0.5.1" {
+ t.Fatalf("target after cut: %+v, %v; want 0.5.1", vt, err)
+ }
+ // Idempotent: a second call finds the tag, creates nothing.
+ tag2, created2, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "someone@else.com", now.Add(time.Hour))
+ if err != nil || created2 || tag2.Actor != "paul@example.com" {
+ t.Fatalf("repeat: %+v, %v, %v", tag2, created2, err)
+ }
+}
+
+func TestEnsureTagSkipsNonIterations(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ tag, created, err := EnsureTag(context.Background(), s, "acct1", "km", "de2766f8", "x", time.Unix(0, 0).UTC())
+ if tag != nil || created || err != nil {
+ t.Fatalf("sha version: %+v, %v, %v; want nil, false, nil", tag, created, err)
+ }
+}
+
+func TestEnsureTagDoesNotLowerRaisedTarget(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "", "")
+ // Operator already aimed at 0.6.0; cutting 0.5.0 must not drag it back.
+ if err := SetTarget(ctx, s, "acct1", "km", "0.6.0", "paul@example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-1", "x", now); err != nil {
+ t.Fatal(err)
+ }
+ vt, _, _ := GetTarget(ctx, s, "acct1", "km")
+ if vt == nil || vt.Target != "0.6.0" {
+ t.Fatalf("target was moved: %+v; want 0.6.0 kept", vt)
+ }
+}
+```
+
+Add `"encoding/json"` to imports if missing.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run TestEnsureTag -count=1`
+Expected: FAIL — `undefined: EnsureTag`.
+
+- [ ] **Step 3: Implement**
+
+Append to `tag.go`:
+
+```go
+// EnsureTag is the promote-to-stable hook: given the release version that
+// just landed on stable, make sure its tag exists. Three outcomes, none of
+// which may fail a promotion that already happened:
+//
+// not an iteration -> (nil, false, nil): sha builds and explicit
+// -version ships never grow invented history.
+// tag exists -> (tag, false, nil): promote is re-runnable, so
+// this must be too.
+// tag cut -> (tag, true, nil), changelog compiled from every
+// iteration of the target, oldest first; then the
+// target auto-advances to the next patch —
+// best-effort, and never past a target the
+// operator already raised.
+func EnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor string, now time.Time) (*Tag, bool, error) {
+ target, n, ok := SplitIteration(releaseVersion)
+ if !ok {
+ return nil, false, nil
+ }
+ if existing, err := GetTag(ctx, s, acct, app, target.String()); err == nil {
+ return existing, false, nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, false, err
+ }
+
+ tag := &Tag{Schema: 1, Version: target.String(), Release: releaseVersion, TaggedAt: now, Actor: actor}
+ var b strings.Builder
+ for i := 1; i <= n; i++ {
+ ver := IterationVersion(target, i)
+ m, err := GetManifest(ctx, s, acct, app, ver)
+ if err != nil {
+ continue // an unshipped or unreadable iteration is a gap, not a failure
+ }
+ if ver == releaseVersion {
+ tag.Commit = m.Commit
+ }
+ if b.Len() > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString("## " + ver)
+ if m.Label != "" {
+ b.WriteString(" — " + m.Label)
+ }
+ b.WriteString("\n")
+ if m.Notes != "" {
+ b.WriteString("\n" + strings.TrimRight(m.Notes, "\n") + "\n")
+ }
+ }
+ tag.Changelog = b.String()
+ if err := writeTag(ctx, s, acct, app, tag); err != nil {
+ return nil, false, err
+ }
+
+ // Advance the target — best-effort, and only forward: an unset target
+ // stays unset only if the next patch would collide with a HIGHER
+ // existing tag (a hotfix line under a fleet already past it).
+ next := target.NextPatch()
+ cur, _, err := GetTarget(ctx, s, acct, app)
+ if err == nil && (cur == nil || func() bool { c, ok := ParseSemVer(cur.Target); return ok && c.Compare(target) <= 0 }()) {
+ if higher, lerr := ListTags(ctx, s, acct, app); lerr == nil {
+ blocked := false
+ for _, ht := range higher {
+ if hv, ok := ParseSemVer(ht.Version); ok && next.Compare(hv) <= 0 {
+ blocked = true
+ break
+ }
+ }
+ if !blocked {
+ _ = setTargetUnchecked(ctx, s, acct, app, next, actor, now) // advisory; the tag is already cut
+ }
+ }
+ }
+ return tag, true, nil
+}
+```
+
+Add `"errors"`, `"os"` to tag.go imports.
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/release/ -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/release/
+git add internal/release/tag.go internal/release/tag_test.go
+git commit -m "release: EnsureTag compiles the changelog and advances the target"
+```
+
+---
+
+### Task 6: Auto-tag hook in PromoteOnLadder
+
+**Files:**
+- Modify: `internal/release/release.go` (`PromoteResult` ~line 695; `Signer.PromoteOnLadder` ~line 736)
+- Test: `internal/release/ladder_test.go` (append; follow the file's existing promote-test setup — it already builds stores, keys, and shipped manifests for ladder tests)
+
+**Interfaces:**
+- Consumes: `EnsureTag` (Task 5), `RungStable` (existing).
+- Produces: `PromoteResult.Tag string` and `PromoteResult.TagWarning string`. Consoles and CLI (Tasks 9, 10) read both.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `ladder_test.go`, reusing its existing helpers for a signed store setup (read the file first; the pattern below names what must be true, adapt the setup lines to the file's local helpers):
+
+```go
+func TestStablePromoteCutsTag(t *testing.T) {
+ // Setup: a DirStore, a root key, app "km" under acct "acct1", with
+ // iterations 0.5.0-1 and 0.5.0-2 shipped (use the file's existing
+ // ship/manifest helper; the manifests should carry labels/notes so the
+ // changelog is non-empty), 0.5.0-2 promoted to edge then beta.
+ // … existing-helper setup here …
+
+ res, err := PromoteOnLadder(ctx, s, priv, "acct1", "km", "stable", "0.5.0-2", PromoteOptions{Actor: "paul@example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Tag != "0.5.0" || res.TagWarning != "" {
+ t.Fatalf("res.Tag=%q TagWarning=%q; want 0.5.0, empty", res.Tag, res.TagWarning)
+ }
+ tag, err := GetTag(ctx, s, "acct1", "km", "0.5.0")
+ if err != nil || tag.Release != "0.5.0-2" {
+ t.Fatalf("tag after promote: %+v, %v", tag, err)
+ }
+ // Repeat promote: NoOp, and the tag is still reported.
+ res2, err := PromoteOnLadder(ctx, s, priv, "acct1", "km", "stable", "0.5.0-2", PromoteOptions{Actor: "paul@example.com"})
+ if err != nil || !res2.NoOp || res2.Tag != "0.5.0" {
+ t.Fatalf("repeat: %+v, %v", res2, err)
+ }
+}
+
+func TestStablePromoteShaVersionNoTag(t *testing.T) {
+ // Setup: same shape, version "de2766f8" climbed to beta.
+ res, err := PromoteOnLadder(ctx, s, priv, "acct1", "km", "stable", "de2766f8", PromoteOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Tag != "" {
+ t.Fatalf("sha promote cut tag %q", res.Tag)
+ }
+ if res.TagWarning == "" {
+ t.Fatal("sha promote to stable should warn that no tag was cut")
+ }
+}
+
+func TestBetaPromoteNoTag(t *testing.T) {
+ // Setup: 0.5.0-1 on edge; promote to beta.
+ res, err := PromoteOnLadder(ctx, s, priv, "acct1", "km", "beta", "0.5.0-1", PromoteOptions{})
+ if err != nil || res.Tag != "" || res.TagWarning != "" {
+ t.Fatalf("beta promote touched tags: %+v, %v", res, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run 'TestStablePromote|TestBetaPromoteNoTag' -count=1`
+Expected: FAIL — `res.Tag undefined`.
+
+- [ ] **Step 3: Implement**
+
+Add to `PromoteResult` (after `Hotfix`):
+
+```go
+ // Tag is the semver tag stable promotion cut (or found already cut) for
+ // this version — "" off stable, and "" for versions that are not
+ // iterations. TagWarning says why a stable promotion carries no tag, or
+ // that cutting one failed; it is never an error, because the pointer
+ // has already moved and a promotion that happened must report success.
+ Tag string
+ TagWarning string
+```
+
+In `Signer.PromoteOnLadder`, add one unexported helper and two call sites:
+
+```go
+// stableTag fills res.Tag/TagWarning after version landed on (or was
+// already on) stable. Failures degrade to the warning: the promotion is
+// real whether or not its name-cutting worked.
+func stableTag(ctx context.Context, s Store, acct, app, version, actor string, res *PromoteResult) {
+ if res.To != RungStable {
+ return
+ }
+ tag, _, err := EnsureTag(ctx, s, acct, app, version, actor, time.Now().UTC())
+ switch {
+ case err != nil:
+ res.TagWarning = "promoted, but cutting the tag failed: " + err.Error()
+ case tag == nil:
+ res.TagWarning = "no tag cut: " + version + " is not a <target>-<n> iteration"
+ default:
+ res.Tag = tag.Version
+ }
+}
+```
+
+Call it (1) on the NoOp early-return path, just before `return res, nil` — so a promote that succeeded but whose tag write failed is healed by re-running the same command; and (2) at the end of the function, after `sg.Promote` succeeds, before `return res, nil`. Both calls:
+
+```go
+ stableTag(ctx, s, acct, app, version, opt.Actor, &res)
+```
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/release/ -count=1` — PASS (including the untouched `laddercheck_test.go` and `scopedpromote_test.go`), then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/release/
+git add internal/release/release.go internal/release/ladder_test.go
+git commit -m "release: stable promotion cuts the version tag"
+```
+
+---
+
+### Task 7: Console API ship — label, notes, and minted versions
+
+**Files:**
+- Modify: `internal/console/apicli.go` (`handleAPIShip` meta handling ~line 846–905; response write at the end of the handler)
+- Modify: `apimode.go` (`apiShipResult` ~line 168; `(s *apiSession) ship` meta marshal)
+- Test: `internal/console/apicli_test.go` (append; follow the existing ship API tests' server/fixture setup in that file)
+
+**Interfaces:**
+- Consumes: `release.GetTarget`, `release.NextIteration`, `release.ParseSemVer` (Task 4/1), `release.MaxLabelLen`/`MaxNotesLen` (Task 2).
+- Produces: ship meta accepts `label`, `notes`, and empty `version` (mint-from-target); ship response JSON gains `"version"`; client `apiShipResult` gains `Version string` and `ship()` takes `label, notes string` params (signature: `ship(ctx, a, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set)`). Task 9 uses the new signature.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `apicli_test.go`, using the file's existing helpers for an authed CLI token + multipart ship body (read a neighboring ship test and copy its scaffolding):
+
+```go
+func TestAPIShipMintsFromTarget(t *testing.T) {
+ // Setup per the file's existing ship tests: server, account, app "km",
+ // authed token. Then:
+ // 1. With no target set, a meta part with "version":"" is refused 400,
+ // and the error names `carlos version target`.
+ // 2. Set a target: release.SetTarget(ctx, store, acct, "km", "0.5.0", "t", time.Now().UTC()).
+ // 3. Ship with "version":"", "label":"fixing modals", "notes":"- x".
+ // Expect 201, response body "version" == "0.5.0-1".
+ // 4. Manifest at 0.5.0-1 has Label "fixing modals", Notes "- x".
+ // 5. Ship again with "version":"" -> 201, "version" == "0.5.0-2".
+}
+
+func TestAPIShipLabelLimits(t *testing.T) {
+ // Explicit version, label of MaxLabelLen+1 -> 400 before any artifact
+ // concerns; multi-line label -> 400; notes over MaxNotesLen -> 400.
+}
+
+func TestAPIShipExplicitVersionStillWorks(t *testing.T) {
+ // A meta part with "version":"de2766f8" and no label/notes ships
+ // exactly as before this change — 201, manifest has empty Label/Notes.
+}
+```
+
+Write these as real tests against the scaffolding you find; the numbered behaviors are the assertions.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run TestAPIShip -count=1`
+Expected: FAIL — empty version currently 400s as "invalid version"; meta has no label field.
+
+- [ ] **Step 3: Implement the server side**
+
+In `handleAPIShip`, extend the meta struct:
+
+```go
+ var meta struct {
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ Label string `json:"label,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
+ }
+```
+
+Replace the version validation block with mint-or-validate (this runs inside the ship semaphore, which is what makes allocation race-free on one console):
+
+```go
+ ver := strings.TrimSpace(meta.Version)
+ if ver == "" {
+ // Mint from the app's version target (release versioning spec):
+ // no target, no mint — and the refusal names the fix.
+ vt, _, err := release.GetTarget(r.Context(), s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ jsonError(w, http.StatusBadGateway, "could not read the version target")
+ return
+ }
+ if vt == nil {
+ jsonError(w, http.StatusBadRequest,
+ "no version given and no version target set — pass -version, or set a target with `carlos version target`")
+ return
+ }
+ t, ok := release.ParseSemVer(vt.Target)
+ if !ok {
+ jsonError(w, http.StatusConflict, "the stored version target "+vt.Target+" is not a semver — set it again")
+ return
+ }
+ ver, err = release.NextIteration(r.Context(), s.cfg.Releases, acct.Sqid, app, t)
+ if err != nil {
+ jsonError(w, http.StatusBadGateway, "could not allocate the next iteration")
+ return
+ }
+ } else if !release.ValidVersion(ver) || ver == "dev" {
+ jsonError(w, http.StatusBadRequest, "invalid version")
+ return
+ }
+ if len(meta.Label) > release.MaxLabelLen || strings.ContainsAny(meta.Label, "\r\n") {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf("label: one line, at most %d characters", release.MaxLabelLen))
+ return
+ }
+ if len(meta.Notes) > release.MaxNotesLen {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf("notes: at most %d bytes", release.MaxNotesLen))
+ return
+ }
+```
+
+In the `release.Manifest{...}` literal later in the handler, add `Label: meta.Label, Notes: meta.Notes,`. In both success `writeJSON` calls at the end of the handler (the 201 shipped and the already-shipped path), add `"version": ver` to the response map.
+
+Also check the already-shipped short-circuit: a re-ship of an existing version whose stored manifest has a different Label/Notes should be tolerated the same way BuiltAt is (compare artifacts and capabilities, as today — labels are metadata, not content). No code change needed if the comparison is `shipArtifactsMatch` + `shipCapabilitiesMatch`; confirm and leave a test asserting a re-ship with a different label still succeeds.
+
+- [ ] **Step 4: Implement the client side**
+
+In `apimode.go`: add `Version string \`json:"version"\`` to `apiShipResult`; change `ship`'s signature to `ship(ctx context.Context, a apiAcct, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set) (apiShipResult, error)` and extend the meta marshal:
+
+```go
+ metaJSON, err := json.Marshal(struct {
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ Label string `json:"label,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
+ }{version, kind, label, notes, capsRaw})
+```
+
+Update `runShipAPI` in `cli.go` to pass `"", ""` for now (Task 9 wires the real flags) and keep the build green.
+
+- [ ] **Step 5: Run tests, gate, commit**
+
+Run: `go test ./internal/console/ -count=1 && go test . -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/console/apicli.go apimode.go cli.go
+git add internal/console/apicli.go internal/console/apicli_test.go apimode.go cli.go
+git commit -m "console: ship API mints iteration versions and carries label/notes"
+```
+
+---
+
+### Task 8: Console API — version-target, tags, and releases endpoints
+
+**Files:**
+- Create: `internal/console/apiversion.go`
+- Modify: `internal/console/authcli.go` (`registerCLIRoutes`, after the promote/rollback lines ~line 227)
+- Test: `internal/console/apiversion_test.go`
+
+**Interfaces:**
+- Consumes: `release.GetTarget/SetTarget/ListTags/GetTag` (Tasks 3–4), `console.Versions` (existing, `view.go:642`), `s.apiAccountApp`, `s.cli.write`, `jsonError`, `writeJSON`, `readJSON` (existing patterns — copy the gate order from `handleAPIPromote`: account/app 404 first, then rate limit on writes).
+- Produces routes:
+ - `GET /api/cli/apps/{account}/{app}/version-target` → `{"target":"0.5.0","set_by":"...","set_at":"..."}`, or `{"target":""}` when unset.
+ - `PUT /api/cli/apps/{account}/{app}/version-target` body `{"target":"0.6.0"}` → 200 `{"target":"0.6.0"}`; 400 non-semver; 409 not-above-tags (surface `SetTarget`'s message).
+ - `GET /api/cli/apps/{account}/{app}/tags` → `{"tags":[{"version","release","tagged_at","actor"}...]}` (no changelog in the list).
+ - `GET /api/cli/apps/{account}/{app}/tags/{version}` → full tag including changelog; 404 unknown.
+ - `GET /api/cli/apps/{account}/{app}/releases` → `{"releases":[{"version","kind","built_at","label","tag"}...]}` — `Versions()` output joined against `ListTags` (`tag` set on the release a tag points at). Extend `VersionView` with `Label string` and `Tag string` fields and populate `Label` in `Versions()` from the manifest (one line: `Label: m.Label`); populate `Tag` in the handler (and Task 10 reuses it).
+- Task 9's CLI calls all five.
+
+- [ ] **Step 1: Write the failing tests**
+
+`apiversion_test.go`, following the auth/fixture scaffolding of `apicli_test.go`:
+
+```go
+func TestAPIVersionTargetGetSet(t *testing.T) {
+ // unauth -> 401/404 per the file's convention for /api/cli routes.
+ // GET with none set -> 200 {"target":""}.
+ // PUT {"target":"0.5.0"} -> 200; GET -> "0.5.0", set_by = the token's address.
+ // PUT {"target":"nope"} -> 400.
+ // After writeTag 0.5.0: PUT {"target":"0.5.0"} -> 409.
+ // Non-member -> byte-identical 404 (assert same body as an unknown app).
+}
+
+func TestAPITagsListAndGet(t *testing.T) {
+ // Seed two tags via release test helpers; GET list -> semver-desc, no
+ // changelog key in list items; GET one -> changelog present; GET
+ // missing -> 404.
+}
+
+func TestAPIReleasesList(t *testing.T) {
+ // Ship 0.5.0-1 (label "fixing modals") and tag it 0.5.0 via EnsureTag;
+ // GET releases -> row has label and tag "0.5.0".
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run 'TestAPIVersion|TestAPITags|TestAPIReleases' -count=1`
+Expected: FAIL — 404s from unregistered routes.
+
+- [ ] **Step 3: Implement**
+
+`apiversion.go` — five handlers in one file, each opening with `s.apiAccountApp(w, r)` and the PUT also passing `s.cli.write.allow(addr, time.Now())` (copy the comment discipline of `handleAPIPromote`). Representative shapes:
+
+```go
+func (s *Server) handleAPIVersionTargetGet(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ vt, _, err := release.GetTarget(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ jsonError(w, http.StatusBadGateway, "could not read the version target")
+ return
+ }
+ if vt == nil {
+ writeJSON(w, http.StatusOK, map[string]string{"target": ""})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"target": vt.Target, "set_by": vt.SetBy, "set_at": vt.SetAt})
+}
+
+func (s *Server) handleAPIVersionTargetSet(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ var req struct {
+ Target string `json:"target"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ target := strings.TrimSpace(req.Target)
+ if _, ok := release.ParseSemVer(target); !ok {
+ jsonError(w, http.StatusBadRequest, "version target: want MAJOR.MINOR.PATCH, like 0.5.0")
+ return
+ }
+ if err := release.SetTarget(ctx, s.cfg.Releases, acct.Sqid, app, target, addr, time.Now().UTC()); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ s.audit(acct, app, addr, "version-target", "version target set to "+target+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]string{"target": target})
+}
+```
+
+Tags and releases handlers follow the same read pattern (`ListTags`, `GetTag` — map its `os.ErrNotExist` to a 404 with the file's standard body — and `Versions` + tag join). Routes in `registerCLIRoutes`:
+
+```go
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/version-target", s.handleAPIVersionTargetGet)
+ s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/version-target", s.handleAPIVersionTargetSet)
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/tags", s.handleAPITagsList)
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/tags/{version}", s.handleAPITagGet)
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/releases", s.handleAPIReleasesList)
+```
+
+(`POST .../releases` for ship already exists; the `GET` on the same path is a distinct method route.)
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/console/ -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/console/
+git add internal/console/apiversion.go internal/console/apiversion_test.go internal/console/authcli.go internal/console/view.go
+git commit -m "console: version-target, tags and releases CLI API"
+```
+
+---
+
+### Task 9: CLI — ship flags, version target, releases, tag display
+
+**Files:**
+- Modify: `cli.go` (`runShip` ~line 1437, `runShipAPI` ~line 1592, `runPromote` output ~line 1700, `runChannels` ~line 1736)
+- Modify: `main.go` (dispatch ~line 34 `case "version"`, new `case "releases"`)
+- Modify: `apimode.go` (new session methods)
+- Test: `cli_test.go` / `e2e_test.go` (append, following the files' existing carlosTestEnv patterns)
+
+**Interfaces:**
+- Consumes: Task 7's `ship(...)` signature and `apiShipResult.Version`; Task 8's endpoints; `release.GetTarget/SetTarget/NextIteration/ListTags/ParseSemVer` for direct mode; `openStore()`, `resolveMode`, `accountFlags`, `printUsing` (existing).
+- Produces user-facing commands:
+ - `carlos ship -app X [-version V] [-label "..."] [-notes "..." | -notes-file F] <files>` — `-version` now optional; when omitted, API mode lets the console mint (prints the returned version), direct mode mints via `GetTarget`+`NextIteration`; no target → fatal naming `carlos version target`. `-notes` and `-notes-file` are mutually exclusive.
+ - `carlos version` — unchanged (CLI's own version). `carlos version target -app X` prints the target (or "no version target set"); `carlos version target -app X 0.5.0` sets it. Both modes.
+ - `carlos releases -app X` — table: VERSION / LABEL / KIND / BUILT / TAG. API mode via `GET .../releases`; direct mode lists the store the same way `console.Versions` does (local reimplementation, ~20 lines — the console package is not imported by the CLI today; keep it that way).
+ - `carlos channels -app X` — stable rows gain the tag when one points at the running version (join via `ListTags` direct, or the `tag` field Task 8 adds to the channels API response if you extend `handleAPIChannels`; extending it is optional — if you skip it, annotate API mode from `GET .../tags`).
+ - `carlos promote` direct-mode output: append `— tagged 0.5.0` when `res.Tag != ""`, and print `res.TagWarning` via the warn color when set.
+
+- [ ] **Step 1: Write the failing tests**
+
+Follow `cli_test.go`/`e2e_test.go` conventions exactly (carlosTestEnv, scratch `cmd.Dir`). Cover, as separate tests:
+
+```go
+// TestShipMintsFromTargetDirect: direct mode (CARLOS_DEPLOYMENT_BUCKET at a
+// temp DirStore path per existing direct-mode tests): set target 0.5.0 via
+// `carlos version target`, ship with no -version and -label/-notes, expect
+// output naming 0.5.0-1 and a manifest carrying label+notes; second ship
+// mints 0.5.0-2.
+// TestShipNoTargetNoVersionFatal: no target, no -version -> non-zero exit,
+// stderr names `carlos version target`.
+// TestVersionTargetGetSetDirect: set then get; garbage refused.
+// TestReleasesTableDirect: after the ships above, `carlos releases -app X`
+// lists 0.5.0-2 first with its label.
+// TestPromoteStableTagOutputDirect: climb 0.5.0-2 edge->beta->stable
+// (direct mode, root key), assert stdout contains "tagged 0.5.0".
+// TestNotesFileAndNotesConflict: both flags -> fatal.
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test . -run 'TestShipMints|TestShipNoTarget|TestVersionTarget|TestReleasesTable|TestPromoteStableTag|TestNotesFile' -count=1`
+Expected: FAIL — unknown flags / unknown subcommand.
+
+- [ ] **Step 3: Implement**
+
+`runShip` flag additions and the mint path:
+
+```go
+ label := fs.String("label", "", "one-line human label for this release (\"fixing modals\")")
+ notes := fs.String("notes", "", "changelog fragment for this release")
+ notesFile := fs.String("notes-file", "", "file containing the changelog fragment")
+```
+
+After `fs.Parse`: drop `*ver == ""` from the usage fatal (files are still required); resolve notes:
+
+```go
+ if *notes != "" && *notesFile != "" {
+ log.Fatal("pass -notes or -notes-file, not both")
+ }
+ noteText := *notes
+ if *notesFile != "" {
+ b, err := os.ReadFile(*notesFile)
+ if err != nil {
+ log.Fatal(err)
+ }
+ noteText = string(b)
+ }
+ if len(*label) > release.MaxLabelLen || strings.ContainsAny(*label, "\r\n") {
+ log.Fatalf("label: one line, at most %d characters", release.MaxLabelLen)
+ }
+ if len(noteText) > release.MaxNotesLen {
+ log.Fatalf("notes: at most %d bytes", release.MaxNotesLen)
+ }
+```
+
+API mode passes `*ver` (possibly empty), `*label`, `noteText` through the new `sess.ship` signature and prints `res.Version` instead of `ver` in both output lines. Direct mode, before building the manifest:
+
+```go
+ version := *ver
+ if version == "" {
+ st := openStore()
+ vt, _, err := release.GetTarget(context.Background(), st, acctOf(), *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if vt == nil {
+ log.Fatal("no -version and no version target set — set one with `carlos version target -app " + *app + " <semver>`")
+ }
+ t, ok := release.ParseSemVer(vt.Target)
+ if !ok {
+ log.Fatalf("stored version target %q is not a semver — set it again", vt.Target)
+ }
+ version, err = release.NextIteration(context.Background(), st, acctOf(), *app, t)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+```
+
+and set `Label: *label, Notes: noteText` in the direct-mode `release.Manifest` literal (find it just after the artifact loop; it mirrors the console's). Print the resolved `version` in the shipped line.
+
+`main.go`: inside `case "version"`, dispatch `os.Args[2] == "target"` (when present) to `runVersionTarget(os.Args[3:])`, else current behavior; add `case "releases": runReleases(os.Args[2:])`. Add both to the help text beside ship/promote/channels.
+
+`runVersionTarget` (in `cli.go`): flags `-app`, account flags, `-console`; 0 positional args = get, 1 = set. API mode: new `sess.versionTarget(ctx, acct, app)` / `sess.setVersionTarget(ctx, acct, app, target)` (GET/PUT per Task 8; add to `apimode.go` following `channels`/`promote` shapes). Direct mode: `release.GetTarget` / `release.SetTarget` with `promoteActor()` as actor.
+
+`runReleases`: API mode `sess.releases(...)` → `GET .../releases`; direct mode: `List(ReleasesPrefix)` + `GetManifest` + `ListTags`, sort as `console.Versions` does (BuiltAt desc, then version desc), print with the CLI's existing table style (see `runChannels` for the pattern).
+
+`runPromote` direct-mode success prints:
+
+```go
+ if res.Tag != "" {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("tagged"), res.Tag)
+ }
+ if res.TagWarning != "" {
+ fmt.Fprintf(stdout, "%s\n", colorFor(stdout).warn(res.TagWarning))
+ }
+```
+
+(both after the promoted line, in the default and Hotfix cases).
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test . -count=1 && go test ./... -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l cli.go main.go apimode.go
+git add cli.go cli_test.go e2e_test.go main.go apimode.go
+git commit -m "cli: ship labels/notes, version target, releases table, tag output"
+```
+
+---
+
+### Task 10: Console UI — labels, tags, and the changelog
+
+**Files:**
+- Modify: `internal/console/view.go` (`VersionView` ~line 204 — Label/Tag fields exist since Task 8; `ChannelView` ~line 154; `Versions` ~line 642; the builder that fills `Detail.Channels` — find it via `grep -n "Detail.Channels\|ChannelView{" internal/console/*.go`)
+- Modify: `internal/console/templates/app_releases.html`
+- Modify: `internal/console/server.go` (`handlePromote` flash ~line 1724)
+- Test: `internal/console/view_test.go` and/or `server_test.go` (append, following existing page-render tests)
+
+**Interfaces:**
+- Consumes: `release.ListTags`, `PromoteResult.Tag/TagWarning` (Task 6), `VersionView.Label/Tag` (Task 8).
+- Produces: `ChannelView.Tag string`; rendered pages. No new routes — the changelog renders on the releases page.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// TestReleasesPageShowsLabelsAndTags: fixture app with 0.5.0-1 shipped
+// (label "fixing modals", notes "- x") and EnsureTag cut 0.5.0; render the
+// releases page; body contains "fixing modals", "0.5.0-1", a tag marker
+// "0.5.0", and the changelog text "- x".
+// TestChannelRowShowsStableTag: stable pointer at 0.5.0-1 with tag cut;
+// rendered channels table row contains "0.5.0" next to the version.
+// TestPromoteFlashNamesTag: drive the browser promote-to-stable path the
+// way existing handlePromote tests do; follow-up page shows a flash
+// containing "tagged 0.5.0".
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run 'TestReleasesPage|TestChannelRow|TestPromoteFlash' -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+- `ChannelView`: add `Tag string`. In the code that builds channel views, after the pointer read, join against a `map[string]string` built once from `release.ListTags` (`tag.Release -> tag.Version`); set `Tag` when the channel's version is in the map. Same map serves `Versions()` callers if Task 8 left the join to callers — do the join in ONE place, a small helper in view.go:
+
+```go
+// tagByRelease maps release version -> tag version, one bucket list.
+func tagByRelease(s release.Store, acct, app string) map[string]string {
+ tags, err := release.ListTags(context.Background(), s, acct, app)
+ if err != nil {
+ return nil
+ }
+ m := make(map[string]string, len(tags))
+ for _, t := range tags {
+ m[t.Release] = t.Version
+ }
+ return m
+}
+```
+
+- `app_releases.html`, Channels table Version cell:
+
+```html
+ <td><code>{{.Version}}</code>{{if .Tag}} <span class="pill">{{.Tag}}</span>{{end}}</td>
+```
+
+(use the template's existing badge/pill class — grep the templates for how `fast-tracked` or similar chips are styled and reuse that class; icons are vendored Lucide, emoji is content — no icon needed here.)
+
+- Releases list (the `.Versions` table further down the same template): add a Label column showing `{{.Label}}`, and the tag pill after the version as above. Per-iteration notes must be visible too (spec: "label and notes per iteration"): extend `VersionView` with `Notes string` (filled in `Versions()` from `m.Notes` alongside Label), and render non-empty notes as a `<details><summary>notes</summary><pre>{{.Notes}}</pre></details>` inside the label cell, so the table stays scannable.
+- Changelog: below the Channels section, add a Tags section rendered only when tags exist. Pass `Tags []release.Tag` through the page struct the same way `Versions` flows (server.go ~line 1385 area):
+
+```html
+{{if .Tags}}
+<h2>Tags</h2>
+{{range .Tags}}
+<details>
+ <summary><code>{{.Version}}</code> — <code>{{.Release}}</code>{{if .Actor}} · {{.Actor}}{{end}}</summary>
+ <pre class="changelog">{{.Changelog}}</pre>
+</details>
+{{end}}
+{{end}}
+```
+
+- `handlePromote` (server.go): where the success flash is composed after `release.PromoteOnLadder`, append the tag:
+
+```go
+ msg := "promoted " + version + " to " + channel
+ if res.Tag != "" {
+ msg += " — tagged " + res.Tag
+ }
+ if res.TagWarning != "" {
+ msg += " (" + res.TagWarning + ")"
+ }
+```
+
+(adapt to the handler's actual flash mechanism — read the surrounding lines and keep its exact style.)
+
+- [ ] **Step 4: Run tests, gate, commit**
+
+Run: `go test ./internal/console/ -count=1` — PASS, then:
+
+```bash
+go build ./... && go vet ./... && gofmt -l internal/console/
+git add internal/console/view.go internal/console/server.go internal/console/templates/app_releases.html internal/console/view_test.go internal/console/server_test.go
+git commit -m "console: releases page shows labels, tags and the changelog"
+```
+
+---
+
+### Task 11: Wire-shape goldens and the full-suite gate
+
+**Files:**
+- Modify: `internal/release/golden_test.go` (read it first; extend with the new objects following its exact conventions)
+- Test: full suite.
+
+**Interfaces:** none new — this task locks the shapes down.
+
+- [ ] **Step 1: Add goldens**
+
+Following `golden_test.go`'s existing pattern, add golden fixtures for: a manifest WITH label+notes; `version-target.json`; a tag with changelog. Each asserts both directions: the Go type marshals to the golden bytes, and the golden bytes unmarshal to the type. Also add (if the file's pattern supports it) a decode-only golden of a PRE-change manifest — no label/notes keys — asserting it parses clean.
+
+- [ ] **Step 2: Full gate**
+
+Run, sandbox-disabled if needed:
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... &&
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... &&
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1
+```
+
+Expected: every package PASS — including `laddercheck_test.go` (production channels untouched) and the pre-existing suites.
+
+- [ ] **Step 3: gofmt sweep and commit**
+
+```bash
+gofmt -l $(git diff --name-only main -- '*.go')
+git add internal/release/golden_test.go
+git commit -m "release: golden fixtures for target, tag and labeled manifests"
+```
+
+---
+
+## Deploy notes (for the PR description, not tasks)
+
+- Deploy order: platform binaries (console) first, then start using targets — old consoles 400 an empty-version ship, which is safe.
+- Console restart required after adopt (long-running unit rule: promote → adopt → restart).
+- pinfra `scripts/ship-app.sh` and every existing sha-based flow are untouched; flagship apps opt in per-app with `carlos version target`.
+- Website/tap batch: `carlos` CLI release + docs after merge, per the standing website-feature-sync rule.