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