carlos / platform Public

Merge pull request #107 from carlosframework/worktree-release-versioning-spec

Release versioning: targets, labeled iterations, tags with changelogs
Paul Campbell 1c9381d281c15d8178c840cf7955ef25f328eaa2 parent 32dec0bparent c827662
27 files changed, +5246 −62
  • apimode.go +132 −4
  • apimode_test.go +191 −24
  • cli.go +339 −17
  • cli_test.go +245 −0
  • docs/superpowers/plans/2026-08-08-release-versioning.md +1592 −0
  • docs/superpowers/specs/2026-08-08-release-versioning-design.md +130 −0
  • internal/console/apicli.go +70 −8
  • internal/console/apicli_test.go +212 −0
  • internal/console/apiversion.go +230 −0
  • internal/console/apiversion_test.go +339 −0
  • internal/console/authcli.go +9 −0
  • internal/console/server.go +50 −2
  • internal/console/server_test.go +137 −0
  • internal/console/templates/app_releases.html +17 −4
  • internal/console/templates/layout.html +4 −0
  • internal/console/view.go +67 −1
  • internal/release/golden_test.go +201 −0
  • internal/release/ladder_test.go +284 −0
  • internal/release/release.go +72 −0
  • internal/release/release_test.go +51 −0
  • internal/release/semver.go +105 −0
  • internal/release/semver_test.go +57 −0
  • internal/release/tag.go +187 −0
  • internal/release/tag_test.go +202 −0
  • internal/release/target.go +151 −0
  • internal/release/target_test.go +153 −0
  • main.go +19 −2
diff --git a/apimode.go b/apimode.go
index ec697f2f3..10feca0cf 100644
--- a/apimode.go
+++ b/apimode.go
@@ -161,12 +161,18 @@ type apiChannel struct {
// hashes) is deliberately not modelled here — the CLI already knows the
// version/channel it asked for and prints those, never the server's echo of
// them.
+//
+// apiShipResult is the one exception: Version (release versioning spec).
+// When a ship's meta part carries no version, the CONSOLE mints one from the
+// app's target — the caller sent no version to already know, so the
+// server's answer is the only place it exists.
type apiPromoteResult struct {
NoOp bool
}
type apiShipResult struct {
- NoOp bool
+ NoOp bool
+ Version string
}
// apiAcct is one resolved -account/-account-id pair, API-mode: the segment
@@ -430,6 +436,117 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]api
return out, nil
}
+// --- version target, tags, releases (release versioning spec, 2026-08-08)
+//
+// The four read/write doors internal/console/apiversion.go opens, in the
+// same shape as channels/promote above: decode only the fields the CLI
+// prints, and let apiErrorFromBody speak for every non-2xx — the console's
+// own refusals ("already released as tag 0.5.0", "want MAJOR.MINOR.PATCH")
+// are better sentences than anything this side could re-derive.
+
+// apiVersionTarget is GET .../version-target's shape. An UNSET target is
+// Target == "" and a 200, not a 404: unset is a state, exactly as
+// release.GetTarget's own contract has it, and this client keeps that shape
+// rather than inventing a sentinel error for it.
+type apiVersionTarget struct {
+ Target string `json:"target"`
+ SetBy string `json:"set_by"`
+ SetAt string `json:"set_at"`
+}
+
+func (s *apiSession) versionTarget(ctx context.Context, a apiAcct, app string) (apiVersionTarget, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/version-target"))
+ if err != nil {
+ return apiVersionTarget{}, err
+ }
+ if status != http.StatusOK {
+ return apiVersionTarget{}, apiErrorFromBody(status, body)
+ }
+ var out apiVersionTarget
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiVersionTarget{}, fmt.Errorf("version target: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// setVersionTarget writes the target and returns what the console stored —
+// its echo, not the argument sent, so a server that normalised the string is
+// the one the operator is shown.
+func (s *apiSession) setVersionTarget(ctx context.Context, a apiAcct, app, target string) (string, error) {
+ in := struct {
+ Target string `json:"target"`
+ }{target}
+ status, body, err := s.putJSON(ctx, apiPath(a, app, "/version-target"), in)
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Target string `json:"target"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return "", fmt.Errorf("version target: unreadable response: %w", err)
+ }
+ return out.Target, nil
+}
+
+// apiTag is one row of GET .../tags. Release is the ITERATION the tag was cut
+// from ("0.5.0-3"), Version the name it earned ("0.5.0") — the direction
+// matters, because every join in this file goes release -> tag.
+type apiTag struct {
+ Version string `json:"version"`
+ Release string `json:"release"`
+ TaggedAt string `json:"tagged_at"`
+ Actor string `json:"actor"`
+}
+
+func (s *apiSession) tags(ctx context.Context, a apiAcct, app string) ([]apiTag, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/tags"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Tags []apiTag `json:"tags"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("tags: unreadable response: %w", err)
+ }
+ return res.Tags, nil
+}
+
+// apiRelease is one row of GET .../releases: what `carlos releases` prints,
+// already joined against the tags server-side (handleAPIReleasesList), so
+// this client never issues a second call to fill the TAG column.
+type apiRelease struct {
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ BuiltAt string `json:"built_at"`
+ Label string `json:"label"`
+ Tag string `json:"tag"`
+}
+
+func (s *apiSession) releases(ctx context.Context, a apiAcct, app string) ([]apiRelease, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Releases []apiRelease `json:"releases"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("releases: unreadable response: %w", err)
+ }
+ return res.Releases, nil
+}
+
// apiTrashResult is handleAPITrashApp's success shape: the confirmed cooloff
// deadline. The SERVER computes it (it re-reads the marker's own DeletedAt
// after writing — TrashApp is create-only, so an app already in the trash
@@ -943,7 +1060,7 @@ const shipTimeout = 15 * time.Minute
// unknown-inside-a-known ones the strict decoder already reported and
// ignored. nil (no -capabilities) omits the key entirely, so the meta part's
// bytes stay byte-identical to what this verb sent yesterday.
-func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind string, files map[string]string, capsSet *caps.Set) (apiShipResult, error) {
+func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set) (apiShipResult, error) {
ctx, cancel := context.WithTimeout(ctx, shipTimeout)
defer cancel()
@@ -965,8 +1082,10 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind str
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, capsRaw})
+ }{version, kind, label, notes, capsRaw})
if err != nil {
return err
}
@@ -996,7 +1115,16 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind str
if err := checkCapabilitiesEcho(capsSet, body); err != nil {
return apiShipResult{}, err
}
- return apiShipResult{NoOp: status == http.StatusOK}, nil
+ // Decoded from the response, not echoed back from the version this
+ // call sent: a minted ship (empty version in) has no other way to
+ // learn what the console actually shipped it as.
+ var out struct {
+ Version string `json:"version"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiShipResult{}, fmt.Errorf("ship: malformed response: %w", err)
+ }
+ return apiShipResult{NoOp: status == http.StatusOK, Version: out.Version}, nil
default:
return apiShipResult{}, apiErrorFromBody(status, body)
}
diff --git a/apimode_test.go b/apimode_test.go
index 7fbff641b..e9bbd1c66 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -261,7 +261,7 @@ func TestAPISessionShipMultipart(t *testing.T) {
}
s := newTestSession(ts.URL)
- res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{
+ res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{
"zeta-amd64": fileB,
"alpha-amd64": fileA,
}, nil)
@@ -324,7 +324,7 @@ func TestAPISessionShipNoOp(t *testing.T) {
t.Fatal(err)
}
s := newTestSession(ts.URL)
- res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
+ res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil)
if err != nil {
t.Fatalf("ship: %v", err)
}
@@ -348,7 +348,7 @@ func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) {
t.Fatal(err)
}
s := newTestSession(ts.URL)
- _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
+ _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil)
if err == nil || !strings.Contains(err.Error(), "already shipped with different content") {
t.Fatalf("ship error = %v, want the server's conflict message", err)
}
@@ -390,7 +390,7 @@ func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
- _, err := s.ship(ctx, apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
+ _, err := s.ship(ctx, apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil)
if err == nil {
t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail")
}
@@ -414,7 +414,7 @@ func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
// shipHTTPClientTimeout is back at its zero production default here
// (the sibling subtest above restored it) — only the context bounds
// this call.
- res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
+ res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil)
if err != nil {
t.Fatalf("ship against a slow-but-healthy server = %v, want success", err)
}
@@ -897,22 +897,35 @@ func TestPromoteVerbAPIModeNoOp(t *testing.T) {
// TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the
// ladder through the console and print the same tabwriter table direct mode
// prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row
-// ERROR behaviour, per apimode.go's doc comment).
+// ERROR behaviour, per apimode.go's doc comment) — plus the TAG column, which
+// comes from a SECOND read (GET .../tags) joined client-side, so the channels
+// endpoint's own wire shape stays untouched (release versioning spec,
+// 2026-08-08).
func TestChannelsVerbAPIMode(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
checkNoRegistry := requireNoRegistryTouch(t)
+ base := "/api/cli/apps/" + apiTestAccount + "/titogo"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/channels" {
+ switch r.URL.Path {
+ case base + "/channels":
+ json.NewEncoder(w).Encode(map[string]any{
+ "channels": []map[string]any{
+ {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
+ {"channel": "stable", "version": "0.5.0-3", "promoted_at": "2026-08-04T00:00:00Z", "rung": "stable"},
+ {"channel": "broken", "error": "signature mismatch"},
+ },
+ })
+ case base + "/tags":
+ json.NewEncoder(w).Encode(map[string]any{
+ "tags": []map[string]any{
+ {"version": "0.5.0", "release": "0.5.0-3", "tagged_at": "2026-08-04T00:00:00Z", "actor": "vicky@keymail.dev"},
+ },
+ })
+ default:
t.Errorf("path = %s", r.URL.Path)
}
- json.NewEncoder(w).Encode(map[string]any{
- "channels": []map[string]any{
- {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
- {"channel": "broken", "error": "signature mismatch"},
- },
- })
}))
defer ts.Close()
@@ -925,7 +938,7 @@ func TestChannelsVerbAPIMode(t *testing.T) {
if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
t.Fatalf("stdout = %q, want it to contain %q", out, want)
}
- if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") {
+ if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") || !strings.Contains(out, "TAG") {
t.Fatalf("stdout = %q, want the tabwriter header", out)
}
if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") {
@@ -934,6 +947,160 @@ func TestChannelsVerbAPIMode(t *testing.T) {
if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") {
t.Fatalf("stdout = %q, want the broken row's ERROR text", out)
}
+ // The tag lands on the row serving the release it was cut from, and on no
+ // other row: the join is release -> tag, not "the newest tag wins".
+ var stableRow, canaryRow string
+ for _, line := range strings.Split(out, "\n") {
+ switch {
+ case strings.HasPrefix(line, "stable"):
+ stableRow = strings.TrimRight(line, " ")
+ case strings.HasPrefix(line, "canary/a"):
+ canaryRow = strings.TrimRight(line, " ")
+ }
+ }
+ if !strings.HasSuffix(stableRow, "0.5.0") {
+ t.Fatalf("stable row = %q, want it to end in the tag 0.5.0", stableRow)
+ }
+ if strings.Contains(canaryRow, "0.5.0") {
+ t.Fatalf("canary row = %q, want no tag on an untagged version", canaryRow)
+ }
+ checkNoRegistry()
+}
+
+// TestVersionTargetVerbAPIMode pins runVersionTarget's API-mode wiring in all
+// three states it has: unset (a 200 with an empty target, NOT a 404), set,
+// and the write — which must be a PUT carrying {"target"}, the shape
+// handleAPIVersionTargetSet (internal/console/apiversion.go) reads.
+func TestVersionTargetVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ path := "/api/cli/apps/" + apiTestAccount + "/titogo/version-target"
+ var stored string
+ var gotPutBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != path {
+ t.Errorf("path = %s", r.URL.Path)
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ if stored == "" {
+ json.NewEncoder(w).Encode(map[string]string{"target": ""})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "target": stored, "set_by": "vicky@keymail.dev", "set_at": "2026-08-08T00:00:00Z",
+ })
+ case http.MethodPut:
+ b, _ := io.ReadAll(r.Body)
+ gotPutBody = strings.TrimSpace(string(b))
+ var req struct {
+ Target string `json:"target"`
+ }
+ json.Unmarshal(b, &req)
+ stored = req.Target
+ json.NewEncoder(w).Encode(map[string]string{"target": stored})
+ default:
+ t.Errorf("method = %s", r.Method)
+ }
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount})
+ if out := buf.String(); !strings.Contains(out, "no version target set") {
+ t.Fatalf("unset target stdout = %q", out)
+ }
+
+ buf = captureStdout(t)
+ runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount, "0.5.0"})
+ if out := buf.String(); !strings.Contains(out, "version target 0.5.0") {
+ t.Fatalf("set stdout = %q", out)
+ }
+ if want := `{"target":"0.5.0"}`; gotPutBody != want {
+ t.Fatalf("PUT body = %q, want %q", gotPutBody, want)
+ }
+
+ buf = captureStdout(t)
+ runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount})
+ out := buf.String()
+ if !strings.Contains(out, "version target 0.5.0") {
+ t.Fatalf("get-after-set stdout = %q", out)
+ }
+ if !strings.Contains(out, "set by vicky@keymail.dev") {
+ t.Fatalf("get-after-set stdout = %q, want the provenance line", out)
+ }
+ checkNoRegistry()
+}
+
+// TestReleasesVerbAPIMode pins runReleases' API-mode wiring: one GET, the
+// server's row ORDER preserved verbatim (the console already sorts newest
+// first — re-sorting client-side would be a second opinion nobody asked
+// for), and every column rendered.
+func TestReleasesVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/releases" {
+ t.Errorf("path = %s", r.URL.Path)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "releases": []map[string]any{
+ {"version": "0.5.0-4", "kind": "binary", "built_at": "2026-08-08T10:00:00Z", "label": "and the sidebar", "tag": ""},
+ {"version": "0.5.0-3", "kind": "binary", "built_at": "2026-08-07T10:00:00Z", "label": "fixing modals", "tag": "0.5.0"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runReleases([]string{"-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
+ if len(lines) < 4 {
+ t.Fatalf("stdout = %q, want a using line, a header and two rows", out)
+ }
+ if !strings.Contains(lines[1], "VERSION") || !strings.Contains(lines[1], "LABEL") || !strings.Contains(lines[1], "TAG") {
+ t.Fatalf("header = %q", lines[1])
+ }
+ if !strings.Contains(lines[2], "0.5.0-4") || !strings.Contains(lines[2], "and the sidebar") {
+ t.Fatalf("first row = %q, want the server's first row", lines[2])
+ }
+ if !strings.Contains(lines[3], "0.5.0-3") || !strings.HasSuffix(strings.TrimRight(lines[3], " "), "0.5.0") {
+ t.Fatalf("second row = %q, want the tagged row carrying its tag", lines[3])
+ }
+ checkNoRegistry()
+}
+
+// TestReleasesVerbAPIModeEmpty: an app with nothing shipped gets a sentence,
+// not a bare header — the empty table would otherwise read as a failure.
+func TestReleasesVerbAPIModeEmpty(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"releases": []map[string]any{}})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runReleases([]string{"-app", "titogo", "-account", apiTestAccount})
+ if out := buf.String(); !strings.Contains(out, "no releases yet") {
+ t.Fatalf("stdout = %q, want the empty-state line", out)
+ }
checkNoRegistry()
}
@@ -1084,7 +1251,7 @@ func TestShipAPIStaticNamesSiteTarGz(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", nil); err != nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1131,7 +1298,7 @@ func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", nil); err == nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil); err == nil {
t.Fatal("runShipAPI against a failing server must error")
}
@@ -1197,7 +1364,7 @@ func TestShipAPIMultiFileGoodBasenames(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd)
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil); err != nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1239,7 +1406,7 @@ func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad)
- err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil)
+ err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil)
if err == nil {
t.Fatal("runShipAPI with a bad basename must error")
}
@@ -1274,7 +1441,7 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing)
- err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil)
+ err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil)
if err == nil {
t.Fatal("runShipAPI with a missing artifact path must error")
}
@@ -1334,7 +1501,7 @@ func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) {
}
s := newTestSession(ts.URL)
- if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary",
+ if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "",
map[string]string{"alpha-amd64": file}, set); err != nil {
t.Fatalf("ship: %v", err)
}
@@ -1396,7 +1563,7 @@ func TestAPISessionShipAcceptsAMatchingCapabilitiesEcho(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", shipTestArtifact(t), shipTestCaps(t))
+ res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t))
if err != nil {
t.Fatalf("ship with a matching echo: %v", err)
}
@@ -1417,7 +1584,7 @@ func TestAPISessionShipRefusesAConsoleThatDropsCapabilities(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", shipTestArtifact(t), shipTestCaps(t))
+ _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t))
if err == nil {
t.Fatal("a console that echoed no capability block was accepted — that is an old console silently dropping the declaration")
}
@@ -1435,7 +1602,7 @@ func TestAPISessionShipRefusesADifferentCapabilitiesEcho(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", shipTestArtifact(t), shipTestCaps(t))
+ _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t))
if err == nil {
t.Fatal("a console that stored a DIFFERENT capability block was accepted")
}
@@ -1454,7 +1621,7 @@ func TestAPISessionShipWithoutCapabilitiesNeedsNoEcho(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", shipTestArtifact(t), nil); err != nil {
+ if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), nil); err != nil {
t.Fatalf("ship without -capabilities must not require an echo: %v", err)
}
}
diff --git a/cli.go b/cli.go
index 78682b327..c993744c4 100644
--- a/cli.go
+++ b/cli.go
@@ -1440,12 +1440,43 @@ func runShip(args []string) {
acctRaw, acctIDRaw, acctOf := accountFlags(fs)
consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)")
kind := fs.String("kind", "binary", "binary | static")
- ver := fs.String("version", "", "version (git short sha)")
+ ver := fs.String("version", "", "version (git short sha); omit to mint the next iteration under the app's version target")
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
+ 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 holding the changelog fragment (mutually exclusive with -notes)")
fs.Parse(args)
- if *app == "" || *ver == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] <files-or-site-dir>")
+ // -version is no longer required: with a version target set, the next
+ // iteration under it is inferable, and inferring it is the whole point of
+ // the target (release versioning spec, 2026-08-08). Everything else about
+ // this check is unchanged — an app and at least one file are still the
+ // minimum a ship can mean.
+ if *app == "" || fs.NArg() == 0 {
+ log.Fatal("usage: carlos ship -app <a> [-version <sha>] [-label <one line>] [-notes <text> | -notes-file <file>] [-kind binary|static] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] <files-or-site-dir>")
+ }
+
+ // -notes and -notes-file are two spellings of ONE manifest field, so both
+ // together is a refusal rather than a precedence rule nobody remembers.
+ 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)
+ }
+ // Bounded HERE, before the mode split, for the same reason the capability
+ // block is: both doors must refuse the same bytes with the same message
+ // at the operator's terminal. release.ValidateLabelNotes is the one home
+ // for the limits and their wording — direct mode would reach it again
+ // inside release.Ship, API mode again in the console, but neither of
+ // those is where a human is standing.
+ if err := release.ValidateLabelNotes(*label, noteText); err != nil {
+ log.Fatal(err)
}
// Read and decode the declaration BEFORE the mode split, so both doors
@@ -1470,7 +1501,7 @@ func runShip(args []string) {
// branch defers the cleanup of a scratch tarball up to 256MiB).
// Evaluated as an argument, the refusal lands before runShipAPI's
// body has registered a defer at all.
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, capsSet); err != nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, *label, noteText, capsSet); err != nil {
log.Fatal(err)
}
return
@@ -1513,18 +1544,50 @@ func runShip(args []string) {
addFile(filepath.Base(path), data)
}
}
+ // Direct mode opens the store ONCE, here: the mint below reads it (the
+ // release dirs are the iteration counter — there is no side counter to
+ // drift from them), and release.Ship writes through the same handle.
+ st := openStore()
+ acct := acctOf()
+ shipVersion := *ver
+ if shipVersion == "" {
+ vt, _, err := release.GetTarget(context.Background(), st, acct, *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>`, or pass -version")
+ }
+ t, ok := release.ParseSemVer(vt.Target)
+ if !ok {
+ // GetTarget already refuses a stored target that isn't a bare
+ // semver, so this is unreachable through that door — kept because
+ // the alternative to a named refusal here is a silent zero SemVer
+ // minting "0.0.0-1" against an app nobody meant to touch.
+ log.Fatalf("stored version target %q is not a semver — set it again", vt.Target)
+ }
+ shipVersion, err = release.NextIteration(context.Background(), st, acct, *app, t)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
m := release.Manifest{
- Schema: 1, App: *app, Version: *ver, Kind: *kind,
+ Schema: 1, App: *app, Version: shipVersion, Kind: *kind,
BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339),
Artifacts: arts,
+ // Empty when the flags were not given: both are omitempty, so the
+ // manifest bytes stay byte-identical to what this verb wrote before
+ // labels and notes existed.
+ Label: *label,
+ Notes: noteText,
// nil when -capabilities was not given: omitempty then keeps the
// manifest bytes byte-identical to what this verb wrote yesterday.
Capabilities: capsSet,
}
- if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil {
+ if err := release.Ship(context.Background(), st, acct, m, files); err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, *ver, len(files))
+ fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, shipVersion, len(files))
}
// readCapabilities is the ship-time declaration surface: a file holding
@@ -1589,7 +1652,7 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
// function — including the static kind's scratch temp file cleanup below,
// up to 256MiB per invocation. Returning lets that defer actually run on
// every exit path, fatal or not.
-func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target string, capsSet *caps.Set) error {
+func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set) error {
files := map[string]string{}
if kind == "static" {
if fs.NArg() != 1 {
@@ -1645,14 +1708,18 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
}
printUsing(stdout, sess)
- res, err := sess.ship(context.Background(), acct, app, ver, kind, files, capsSet)
+ res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet)
if err != nil {
return err
}
+ // res.Version, never ver: when the caller passed no -version the CONSOLE
+ // minted one from the app's target, and the response is the only place
+ // that name exists. It echoes the sent version verbatim otherwise, so
+ // this is the right thing to print on both paths rather than two.
if res.NoOp {
- mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver)
+ mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, res.Version)
} else {
- fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, ver)
+ fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, res.Version)
}
return nil
}
@@ -1718,6 +1785,22 @@ func runPromote(args []string) {
default:
fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel)
}
+ // The tag a stable promotion cut, printed after EVERY branch above rather
+ // than only the fresh ones: PromoteOnLadder cuts the tag on the no-op
+ // path too (a repeat promote heals a version that reached stable before
+ // tags existed, or whose tag write failed the first time), and a name
+ // that was just minted with nothing said about it is a name the operator
+ // never learns. Empty off stable, so nothing new prints anywhere else.
+ //
+ // TagWarning is a warning, not an error, for the reason release.stableTag
+ // gives: the pointer has already moved, so a promotion that happened must
+ // report success. It is printed verbatim.
+ 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))
+ }
}
// promoteActor names who is promoting, for the pointer's audit trail: the
@@ -1754,14 +1837,28 @@ func runChannels(args []string) {
if err != nil {
log.Fatal(err)
}
+ // A separate read rather than a new field on the channels response:
+ // the channels endpoint is what the box-facing world already parses,
+ // and a display join belongs on the display side. A tag read that
+ // fails degrades to a tag-less table — the question this verb answers
+ // is "what is each channel serving", and no tag has ever been part of
+ // that answer.
+ tags, err := sess.tags(context.Background(), acct, *app)
+ if err != nil {
+ mutedf(stdout, "(tags unreadable: %v)\n", err)
+ }
+ byRelease := make(map[string]string, len(tags))
+ for _, t := range tags {
+ byRelease[t.Release] = t.Version
+ }
w := newTable(stdout)
- fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
+ fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG")
for _, ch := range chans {
if ch.Err != "" {
- fmt.Fprintf(w, "%s\tERROR: %s\t\n", ch.Channel, ch.Err)
+ fmt.Fprintf(w, "%s\tERROR: %s\t\t\n", ch.Channel, ch.Err)
continue
}
- fmt.Fprintf(w, "%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version])
}
w.Flush()
return
@@ -1774,17 +1871,242 @@ func runChannels(args []string) {
if err != nil {
log.Fatal(err)
}
+ // Same join as API mode above, from the store rather than the console,
+ // and degrading the same way.
+ tags, err := release.ListTags(context.Background(), s, acct, *app)
+ if err != nil {
+ mutedf(stdout, "(tags unreadable: %v)\n", err)
+ }
+ byRelease := make(map[string]string, len(tags))
+ for _, t := range tags {
+ byRelease[t.Release] = t.Version
+ }
pub := publicKey()
w := newTable(stdout)
- fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
+ fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG")
for _, k := range keys {
name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
cp, err := release.Channel(context.Background(), s, pub, acct, *app, name)
if err != nil {
- fmt.Fprintf(w, "%s\tERROR: %v\t\n", name, err)
+ fmt.Fprintf(w, "%s\tERROR: %v\t\t\n", name, err)
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339), byRelease[cp.Version])
+ }
+ w.Flush()
+}
+
+// runVersionTarget is `carlos version target`: the per-app semver that
+// `carlos ship` counts iterations under (release versioning spec,
+// 2026-08-08). No positional argument reads it, one sets it — rather than a
+// -set flag, which would make a mistyped read (a stray value, a shell glob)
+// into a write of the one number every future version of this app hangs off.
+//
+// This is a SUB-verb of `carlos version` (main.go), which on its own still
+// prints the binary's build id and nothing else: the oldest output in this
+// program, typed by every install script there is.
+func runVersionTarget(args []string) {
+ fs := flag.NewFlagSet("version target", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to read or set through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() > 1 {
+ log.Fatal("usage: carlos version target -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [<semver>]")
+ }
+
+ // unset is what both modes print when no target has ever been set, and it
+ // names the way to set one: an operator meeting this line is one command
+ // away from the feature, and the command is not guessable from "none".
+ unset := func() {
+ mutedf(stdout, "no version target set\n")
+ mutedf(stdout, " set one with: carlos version target -app %s 0.1.0\n", *app)
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ if fs.NArg() == 0 {
+ vt, err := sess.versionTarget(context.Background(), acct, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if vt.Target == "" {
+ unset()
+ return
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), vt.Target)
+ if vt.SetBy != "" {
+ mutedf(stdout, " set by %s at %s\n", vt.SetBy, vt.SetAt)
+ }
+ return
+ }
+ stored, err := sess.setVersionTarget(context.Background(), acct, *app, fs.Arg(0))
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), stored)
+ return
+ }
+
+ acct := acctOf()
+ s := openStore()
+ if fs.NArg() == 0 {
+ vt, _, err := release.GetTarget(context.Background(), s, acct, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if vt == nil {
+ unset()
+ return
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), vt.Target)
+ if vt.SetBy != "" {
+ mutedf(stdout, " set by %s at %s\n", vt.SetBy, vt.SetAt.Format(time.RFC3339))
+ }
+ return
+ }
+ // SetTarget carries both refusals — a target that isn't a bare semver,
+ // and one that is not above every tag already cut — and its messages name
+ // the offending value, so they pass through untouched.
+ target := fs.Arg(0)
+ if err := release.SetTarget(context.Background(), s, acct, *app, target, promoteActor(), time.Now().UTC()); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), target)
+}
+
+// releaseRow is one row of `carlos releases`, in either mode: the console
+// joins the tags server-side (handleAPIReleasesList) and direct mode joins
+// them here, but both arrive at the same five columns so the table renders
+// once below. Err is not a column — it is the direct-mode "this manifest
+// would not read" row, printed in the LABEL cell the way runChannels prints
+// an unverifiable pointer.
+type releaseRow struct {
+ Version, Label, Kind, BuiltAt, Tag, Err string
+}
+
+// releasesCap bounds the list, matching the console's own versionsCap: a
+// long-lived app has thousands of releases and nobody reads past the first
+// screen of them.
+const releasesCap = 20
+
+// localReleases is the direct-mode half of `carlos releases`: List the
+// release dirs, read each manifest, join the tags, newest first.
+//
+// It is a local reimplementation of console.Versions rather than a call to
+// it, deliberately: the CLI does not import internal/console today — that
+// package carries templates, sessions, a registry and a whole HTTP server —
+// and twenty lines of List + GetManifest is a far smaller thing to own than
+// that dependency. A manifest that will not read is a row with Err set,
+// exactly as it is on the console's app page and in runChannels above, not a
+// failure of the whole listing.
+func localReleases(ctx context.Context, s release.Store, acct, app string) ([]releaseRow, error) {
+ prefix := release.ReleasesPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ tags, err := release.ListTags(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ byRelease := make(map[string]string, len(tags))
+ for _, t := range tags {
+ byRelease[t.Release] = t.Version
+ }
+ seen := map[string]bool{}
+ var out []releaseRow
+ for _, k := range keys {
+ // Every artifact of a release shares its dir, so the version is the
+ // first segment after the prefix and each one is taken once.
+ v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
+ if !ok || v == "" || seen[v] {
+ continue
+ }
+ seen[v] = true
+ m, err := release.GetManifest(ctx, s, acct, app, v)
+ if err != nil {
+ out = append(out, releaseRow{Version: v, Tag: byRelease[v], Err: err.Error()})
+ continue
+ }
+ out = append(out, releaseRow{
+ Version: v, Label: m.Label, Kind: m.Kind, BuiltAt: m.BuiltAt, Tag: byRelease[v],
+ })
+ }
+ // Newest first by build time, version-descending within a tie — the same
+ // order console.Versions sorts by, so the two surfaces agree about what
+ // "the latest release" is. An unparseable BuiltAt sorts as the zero time
+ // (last), which is where a manifest that could not be read belongs.
+ parse := func(s string) time.Time {
+ t, _ := time.Parse(time.RFC3339, s)
+ return t
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt)
+ if !ti.Equal(tj) {
+ return ti.After(tj)
+ }
+ return out[i].Version > out[j].Version
+ })
+ if len(out) > releasesCap {
+ out = out[:releasesCap]
+ }
+ return out, nil
+}
+
+// runReleases is `carlos releases`: what has been shipped, newest first,
+// with the label its author typed and the tag it earned if it reached
+// stable. `carlos channels` answers "what is running"; this answers "what is
+// there to run".
+func runReleases(args []string) {
+ fs := flag.NewFlagSet("releases", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal("usage: carlos releases -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ var rows []releaseRow
+ if sess != nil {
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ rels, err := sess.releases(context.Background(), acct, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, r := range rels {
+ rows = append(rows, releaseRow{Version: r.Version, Label: r.Label, Kind: r.Kind, BuiltAt: r.BuiltAt, Tag: r.Tag})
+ }
+ } else {
+ rows, err = localReleases(context.Background(), openStore(), acctOf(), *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ if len(rows) == 0 {
+ mutedf(stdout, "no releases yet — carlos ship -app %s <files>\n", *app)
+ return
+ }
+ w := newTable(stdout)
+ fmt.Fprintln(w, "VERSION\tLABEL\tKIND\tBUILT\tTAG")
+ for _, r := range rows {
+ if r.Err != "" {
+ fmt.Fprintf(w, "%s\tERROR: %s\t\t\t%s\n", r.Version, r.Err, r.Tag)
continue
}
- fmt.Fprintf(w, "%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339))
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Version, r.Label, r.Kind, r.BuiltAt, r.Tag)
}
w.Flush()
}
diff --git a/cli_test.go b/cli_test.go
index 69fe99115..ee98ebea2 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -9,6 +9,7 @@ import (
"context"
"crypto/ed25519"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
@@ -432,3 +433,247 @@ func TestBackingRailRefusals(t *testing.T) {
t.Fatalf("backingRail(unit-backed instance) = %v, want accepted", err)
}
}
+
+// --- release versioning: the CLI surface -------------------------------
+//
+// (release versioning spec, 2026-08-08.) Driven through the real binary in
+// DIRECT mode, following scopedkeys_e2e_test.go exactly: a DirStore
+// deployment dir under t.TempDir(), a root signing key the binary mints for
+// itself, carlosTestEnv to mask the operator's real login and console, and
+// cmd.Dir at a scratch dir because the binary walks cwd ancestors for
+// .carlos/config.
+
+// versioningCLI builds carlos, mints a root key with it, and returns a
+// runner bound to a fresh deployment dir — returned alongside the runner so
+// a test can assert on what actually landed in the store, not only on what
+// stdout claimed.
+func versioningCLI(t *testing.T) (run func(args ...string) (string, error), bucket string) {
+ t.Helper()
+ bin := buildCarlos(t)
+ bucket = t.TempDir()
+ scratch := t.TempDir()
+ store := []string{"CARLOS_DEPLOYMENT_DIR=" + bucket, "CARLOS_DATA_DIR=" + t.TempDir()}
+ runWith := func(env []string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, append(append([]string{}, store...), env...)...)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+ out, err := runWith(nil, "release-keygen")
+ if err != nil {
+ t.Fatalf("release-keygen: %v\n%s", err, out)
+ }
+ keys := []string{
+ "CARLOS_RELEASE_KEY=" + envValue(t, out, "CARLOS_RELEASE_KEY"),
+ "CARLOS_RELEASE_PUBKEY=" + envValue(t, out, "CARLOS_RELEASE_PUBKEY"),
+ }
+ return func(args ...string) (string, error) { return runWith(keys, args...) }, bucket
+}
+
+// versioningArtifact drops one throwaway binary artifact and returns its path.
+func versioningArtifact(t *testing.T, body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "kass")
+ if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// TestShipMintsFromTargetDirect: with a target set, a -version-less ship
+// mints the next iteration under it, and the label/notes the operator typed
+// reach the manifest — the two halves of the feature an operator sees at
+// their own terminal.
+func TestShipMintsFromTargetDirect(t *testing.T) {
+ run, bucket := versioningCLI(t)
+
+ if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
+ t.Fatalf("version target 0.5.0: %v\n%s", err, out)
+ }
+ out, err := run("ship", "-app", "kass",
+ "-label", "fixing modals",
+ "-notes", "modals close on escape now",
+ versioningArtifact(t, "build one"))
+ if err != nil {
+ t.Fatalf("minted ship: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "0.5.0-1") {
+ t.Fatalf("the first minted ship does not name 0.5.0-1:\n%s", out)
+ }
+
+ st := release.NewDirStore(bucket)
+ m, err := release.GetManifest(context.Background(), st, release.OpsAccount, "kass", "0.5.0-1")
+ if err != nil {
+ t.Fatalf("manifest 0.5.0-1: %v", err)
+ }
+ if m.Label != "fixing modals" {
+ t.Fatalf("manifest label = %q, want %q", m.Label, "fixing modals")
+ }
+ if m.Notes != "modals close on escape now" {
+ t.Fatalf("manifest notes = %q, want the -notes text", m.Notes)
+ }
+
+ // The counter comes off what has been SHIPPED, so a second ship under the
+ // same target is -2 with no state anywhere but the release dirs.
+ out, err = run("ship", "-app", "kass", "-label", "and the sidebar", versioningArtifact(t, "build two"))
+ if err != nil {
+ t.Fatalf("second minted ship: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "0.5.0-2") {
+ t.Fatalf("the second minted ship does not name 0.5.0-2:\n%s", out)
+ }
+}
+
+// TestShipNoTargetNoVersionFatal: -version is optional only where a target
+// makes it inferable. With neither, the refusal has to name the verb that
+// fixes it — an operator who just learned -version is optional must not be
+// left guessing what the alternative was.
+func TestShipNoTargetNoVersionFatal(t *testing.T) {
+ run, _ := versioningCLI(t)
+ out, err := run("ship", "-app", "kass", versioningArtifact(t, "build one"))
+ if err == nil {
+ t.Fatalf("ship with no -version and no target succeeded:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos version target") {
+ t.Fatalf("refusal does not name `carlos version target`:\n%s", out)
+ }
+}
+
+// TestVersionTargetGetSetDirect: get before set, set, get after — and a
+// garbage target refused rather than stored.
+func TestVersionTargetGetSetDirect(t *testing.T) {
+ run, _ := versioningCLI(t)
+
+ out, err := run("version", "target", "-app", "kass")
+ if err != nil {
+ t.Fatalf("version target (unset): %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "no version target set") {
+ t.Fatalf("an unset target does not say so:\n%s", out)
+ }
+
+ if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
+ t.Fatalf("version target 0.5.0: %v\n%s", err, out)
+ }
+ out, err = run("version", "target", "-app", "kass")
+ if err != nil {
+ t.Fatalf("version target (set): %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "0.5.0") {
+ t.Fatalf("version target does not print the stored target:\n%s", out)
+ }
+
+ if out, err := run("version", "target", "-app", "kass", "banana"); err == nil {
+ t.Fatalf("version target accepted a non-semver:\n%s", out)
+ }
+
+ // `carlos version` on its own is still the build id, not a target: the
+ // new sub-verb must not have moved the oldest output in the binary.
+ out, err = run("version")
+ if err != nil {
+ t.Fatalf("version: %v\n%s", err, out)
+ }
+ if !strings.HasPrefix(out, "carlos ") || strings.Contains(out, "version target") {
+ t.Fatalf("`carlos version` is no longer the build id:\n%s", out)
+ }
+}
+
+// TestReleasesTableDirect: the shipped list, newest first, carrying the
+// labels that were typed at ship time.
+func TestReleasesTableDirect(t *testing.T) {
+ run, _ := versioningCLI(t)
+ if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
+ t.Fatalf("version target: %v\n%s", err, out)
+ }
+ if out, err := run("ship", "-app", "kass", "-label", "fixing modals", versioningArtifact(t, "build one")); err != nil {
+ t.Fatalf("ship one: %v\n%s", err, out)
+ }
+ if out, err := run("ship", "-app", "kass", "-label", "and the sidebar", versioningArtifact(t, "build two")); err != nil {
+ t.Fatalf("ship two: %v\n%s", err, out)
+ }
+
+ out, err := run("releases", "-app", "kass")
+ if err != nil {
+ t.Fatalf("releases: %v\n%s", err, out)
+ }
+ lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
+ if len(lines) < 3 {
+ t.Fatalf("releases printed %d lines, want a header and two rows:\n%s", len(lines), out)
+ }
+ if !strings.Contains(lines[0], "VERSION") || !strings.Contains(lines[0], "LABEL") || !strings.Contains(lines[0], "TAG") {
+ t.Fatalf("releases header = %q, want VERSION/LABEL/.../TAG", lines[0])
+ }
+ if !strings.Contains(lines[1], "0.5.0-2") || !strings.Contains(lines[1], "and the sidebar") {
+ t.Fatalf("the newest release is not the first row:\n%s", out)
+ }
+ if !strings.Contains(out, "fixing modals") {
+ t.Fatalf("releases does not carry the older label:\n%s", out)
+ }
+}
+
+// TestPromoteStableTagOutputDirect: the tag a stable promotion cuts is
+// PRINTED — the operator who typed the promote is the one who needs to know
+// what the release is now called. `carlos channels` carries it too, so the
+// name survives past the one line that announced it.
+func TestPromoteStableTagOutputDirect(t *testing.T) {
+ run, _ := versioningCLI(t)
+ if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
+ t.Fatalf("version target: %v\n%s", err, out)
+ }
+ if out, err := run("ship", "-app", "kass", "-label", "fixing modals", versioningArtifact(t, "build one")); err != nil {
+ t.Fatalf("ship: %v\n%s", err, out)
+ }
+ for _, ch := range []string{"edge", "beta"} {
+ if out, err := run("promote", "-app", "kass", "0.5.0-1", ch); err != nil {
+ t.Fatalf("promote to %s: %v\n%s", ch, err, out)
+ }
+ }
+ out, err := run("promote", "-app", "kass", "0.5.0-1", "stable")
+ if err != nil {
+ t.Fatalf("promote to stable: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "tagged 0.5.0") {
+ t.Fatalf("a stable promotion does not print the tag it cut:\n%s", out)
+ }
+
+ chans, err := run("channels", "-app", "kass")
+ if err != nil {
+ t.Fatalf("channels: %v\n%s", err, chans)
+ }
+ if !strings.Contains(chans, "TAG") {
+ t.Fatalf("channels has no TAG column:\n%s", chans)
+ }
+ var stableRow string
+ for _, line := range strings.Split(chans, "\n") {
+ if strings.HasPrefix(line, "stable") {
+ stableRow = strings.TrimRight(line, " ")
+ }
+ }
+ if stableRow == "" {
+ t.Fatalf("channels has no stable row:\n%s", chans)
+ }
+ if !strings.Contains(stableRow, "0.5.0-1") || !strings.HasSuffix(stableRow, "0.5.0") {
+ t.Fatalf("the stable row does not carry the tag of the version it serves: %q\n%s", stableRow, chans)
+ }
+}
+
+// TestNotesFileAndNotesConflict: two spellings of one field, so passing both
+// is a refusal rather than a silent precedence rule nobody can remember.
+func TestNotesFileAndNotesConflict(t *testing.T) {
+ run, _ := versioningCLI(t)
+ notes := filepath.Join(t.TempDir(), "NOTES.md")
+ if err := os.WriteFile(notes, []byte("from a file"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ out, err := run("ship", "-app", "kass", "-version", "9f3c1ad",
+ "-notes", "inline", "-notes-file", notes, versioningArtifact(t, "build one"))
+ if err == nil {
+ t.Fatalf("ship accepted both -notes and -notes-file:\n%s", out)
+ }
+ if !strings.Contains(out, "-notes-file") {
+ t.Fatalf("refusal does not name -notes-file:\n%s", out)
+ }
+}
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.
diff --git a/docs/superpowers/specs/2026-08-08-release-versioning-design.md b/docs/superpowers/specs/2026-08-08-release-versioning-design.md
new file mode 100644
index 000000000..8ebee032e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-08-release-versioning-design.md
@@ -0,0 +1,130 @@
+# Release versioning — design
+
+2026-08-08. Status: approved by Paul (brainstorming session), pending spec review.
+
+## Problem
+
+Every release surface — the Running column, `carlos releases`, pointers,
+promote output — identifies releases by whatever `-version` string was passed
+at ship time, which by convention is a git sha. A 40-character checksum is
+unique but meaningless: it carries no ordering, no intent, and no story. There
+is no human label, no notion of "what we're working toward," and no changelog
+anywhere in the system.
+
+## Decisions (made during brainstorming)
+
+1. **Tag model, not rename.** A build keeps its immutable version id forever.
+ "0.5.0" is a *tag* written at promote time that points at a build. Nothing
+ is ever renamed or re-signed.
+2. **Counter iterations.** Under a target of `0.5.0`, shipped builds are
+ `0.5.0-1`, `0.5.0-2`, … — ordered, scannable, and semver-prerelease-valid.
+ The git sha stays traceable via the manifest's existing `Commit` field, so
+ it does not need to pollute the version string.
+3. **Notes written at ship.** Changelog content is deliberate operator input
+ at ship time (`-notes` / `-notes-file`), not derived from git log. Tagging
+ compiles the notes of all iterations into the tag's changelog.
+4. **Auto-tag at stable promote.** Promoting an iteration to stable writes the
+ tag and advances the target to the next patch version. One command; the
+ ladder flow is unchanged; stable always ends up with a human name.
+
+## Concepts
+
+Three new nouns. Everything is additive; no existing object changes shape.
+
+### Target
+
+The per-app "next version": a small JSON object in the bucket.
+
+- Key: `apps/<app>/version-target.json`
+- Shape: `{schema: 1, target: "0.5.0", set_by, set_at}`
+- Set via `carlos version target 0.5.0` (console API backed).
+- **No target set → ship behaves exactly as today.** The feature is opt-in
+ per app; every existing app, script, and habit keeps working untouched.
+- A new target must compare strictly greater (semver) than any existing tag
+ for the app; lowering or reusing a released version is refused.
+- After a tag is cut the target auto-advances to the next patch version
+ (`0.5.0` → `0.5.1`). The operator can raise it (e.g. to `0.6.0`) at any
+ time.
+
+### Iteration
+
+With a target set, `carlos ship` mints the version itself.
+
+- Version: `<target>-<n>` (e.g. `0.5.0-3`). The counter is allocated
+ server-side inside the existing ship path (`handleAPIShip` already
+ serializes ships through a semaphore), so allocation is race-free. Counters
+ are per app + target and monotonically increasing.
+- New optional ship inputs, stored as new *optional* manifest fields:
+ - `label` — short human label for the iteration ("fixing modals").
+ - `notes` — changelog fragment, free text/markdown (`-notes` inline or
+ `-notes-file`).
+- Old manifests without these fields still parse (additive wire-shape rule).
+- An explicit `-version` still wins — escape hatch and back-compat for
+ direct-bucket shippers.
+
+### Tag
+
+Written automatically when an iteration is promoted to stable.
+
+- Key: `apps/<app>/tags/<version>.json` (e.g. `apps/keymail/tags/0.5.0.json`)
+- Shape: `{schema: 1, version: "0.5.0", release: "0.5.0-3", commit,
+ tagged_at, actor, changelog}`
+- `changelog` is compiled at tag time: the `notes` of every iteration of that
+ target (`0.5.0-1` … `0.5.0-3`), oldest first, each section headed by the
+ iteration's version and label. Iterations without notes contribute a
+ heading only.
+- Tags are immutable once written; a tag key that already exists refuses
+ overwrite (same posture as release immutability).
+
+## What does not change
+
+- Pointers, adopt, bake, grants, laddercheck, channel names: untouched.
+- The Running column mechanics are unchanged — it displays the release id as
+ today; the id is simply `0.5.0-3` now instead of a sha.
+- Promote/adopt still operate on release ids. Tags are read-side sugar plus
+ an audit record; no control-plane flow keys off a tag.
+- Release immutability (`shipImmutableConflict`) already refuses re-shipping
+ a taken version with different artifacts — this is the collision backstop
+ for counter allocation too.
+
+## Edge cases
+
+- **Promoting a non-semver build to stable** (old sha release, or explicit
+ `-version` that isn't `<semver>-<n>`): promote succeeds, no tag is written,
+ and the output warns that no tag was cut. History is never invented.
+- **Target vs. existing iterations:** raising the target mid-cycle (e.g.
+ `0.5.0` → `0.6.0` with `0.5.0-3` un-promoted) is allowed; the abandoned
+ iterations simply never get a tag. Counters restart at 1 per target.
+- **Hotfix flow:** target is already `0.5.1` once `0.5.0` is tagged; ship
+ `0.5.1-1`, fast-track through the ladder as today, promote tags `0.5.1`.
+- **Direct-bucket shippers** (pinfra `scripts/ship-app.sh`): keep sha
+ versions or pass explicit `-version`. The console API is the paved road;
+ flagship apps opt in by setting a target and shipping via `carlos ship`.
+- **Concurrent ships under one target:** serialized by the existing ship
+ semaphore; allocation reads the release list for the target and takes
+ max+1.
+
+## Surfaces
+
+- **CLI:** `carlos ship -label "..." -notes/-notes-file`; `carlos version
+ target [get|set]`; `carlos releases` shows version, label, and tag where
+ one exists; promote output names the tag it cut.
+- **Console:** releases page shows label and notes per iteration; app page
+ shows "stable · 0.5.0" when the stable build is tagged; tag detail view
+ renders the compiled changelog.
+- **API:** additive fields on the existing ship/release endpoints; new
+ endpoints for target get/set and tag list/read. Everything the console
+ shows is API-reachable (operate-via-API rule — no box-local-only).
+
+## Testing
+
+- `internal/release` unit tests: counter allocation (including gap/restart
+ behavior per target), target set/advance/refusal (semver comparison),
+ changelog compilation (with and without notes), tag immutability,
+ no-tag-for-non-semver promote.
+- Console API tests: ship with label/notes, target endpoints, tag endpoints,
+ owner/auth gates matching existing API posture.
+- Wire-shape back-compat: a pre-change manifest and pointer (no new fields)
+ must still parse; golden tests extended for the new objects.
+- Production channel names in `laddercheck_test.go` are untouched — this
+ design adds no channel or ladder rules.
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 101bccba3..eeb1cc9ec 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -640,6 +640,15 @@ const (
apiShipMaxPartsBinary = 8
)
+// apiShipMetaMaxBytes bounds the "meta" part's own JSON — separately from
+// apiShipMaxBytes, which bounds the whole request (meta plus every
+// artifact). release.MaxNotesLen is the biggest single field meta carries;
+// the extra 8KiB is headroom for everything else in the same object
+// (version, kind, label, and a capability block). Still tiny next to
+// apiShipMaxBytes, and still read inside the same semaphore-bounded
+// handler, so this is a decode-shape bound, not a new resource exposure.
+const apiShipMetaMaxBytes = release.MaxNotesLen + 8<<10
+
// targetRe validates a binary artifact's part NAME before it is used to
// build a bucket key: release.ArtifactKey joins it straight into the object
// path, so this check is path-traversal defense, not tidiness — a part
@@ -848,6 +857,13 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
var meta struct {
Version string `json:"version"`
Kind string `json:"kind"`
+ // Label and Notes are the human half of a manifest (release
+ // versioning spec): a table-cell summary and a changelog fragment,
+ // both optional and both metadata rather than content — a re-ship of
+ // the same bytes tolerates a different value for either, the same
+ // way it already tolerates a different BuiltAt.
+ Label string `json:"label,omitempty"`
+ Notes string `json:"notes,omitempty"`
// Capabilities is the app's declaration (internal/caps), ADDITIVE:
// absent from every request a CLI shipped before this field existed,
// and absent from every request that does not pass -capabilities. It
@@ -856,21 +872,59 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// specific, actionable thing to be told.
Capabilities json.RawMessage `json:"capabilities,omitempty"`
}
- // The 8KiB limit stands unchanged: a capability block is a few hundred
- // bytes, and the CLI sends the canonical re-marshal, not a file verbatim.
- if err := json.NewDecoder(io.LimitReader(first, 8<<10)).Decode(&meta); err != nil {
+ // The budget is release.MaxNotesLen — the biggest single field the meta
+ // part now carries — plus 8KiB of headroom for everything else in it:
+ // version, kind, label, and a capability block (a few hundred bytes, the
+ // CLI's canonical re-marshal rather than a file verbatim). Before Notes
+ // existed this reader was a flat 8KiB; capping it at that today would
+ // truncate notes anywhere above ~8KiB mid-JSON, so a caller within
+ // release.MaxNotesLen would meet "malformed meta part" instead of the
+ // notes-specific refusal below.
+ if err := json.NewDecoder(io.LimitReader(first, apiShipMetaMaxBytes)).Decode(&meta); err != nil {
jsonError(w, http.StatusBadRequest, "malformed meta part")
return
}
- ver := strings.TrimSpace(meta.Version)
- if !release.ValidVersion(ver) || ver == "dev" {
- jsonError(w, http.StatusBadRequest, "invalid version")
- return
- }
if meta.Kind != "binary" && meta.Kind != "static" {
jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
return
}
+ // Checked before the version is minted or validated: NextIteration below
+ // lists the app's whole releases prefix, and a label/notes refusal should
+ // not pay for that bucket round trip first.
+ if err := release.ValidateLabelNotes(meta.Label, meta.Notes); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ 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. This runs inside
+ // the ship semaphore acquired above, which is what makes allocation
+ // race-free on one console.
+ 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
+ }
// Decoded and validated HERE — before a single artifact part is read, let
// alone buffered — so a bad declaration costs the caller nothing but the
// meta part. release.Ship would reach the same verdict through
@@ -970,6 +1024,8 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
BuiltAt: time.Now().UTC().Format(time.RFC3339),
Artifacts: artifacts,
Capabilities: declared, // nil when the meta part carried no key
+ Label: meta.Label,
+ Notes: meta.Notes,
}
// Longer than the other CLI doors' 15-20s: this one moves real bytes —
// up to apiShipMaxBytes of them — into the bucket, not a handful of
@@ -988,6 +1044,12 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// ARTIFACTS here first — content, not metadata — is what makes
// re-shipping the exact same bytes twice actually succeed instead of
// permanently wedging on its own BuiltAt.
+ // Label and Notes are checked NOWHERE below this point — deliberately.
+ // They are metadata about a version, not content, the same tolerance
+ // BuiltAt already gets: a re-ship of an existing version with a
+ // DIFFERENT label or notes still succeeds through the short-circuit
+ // below. The STORED manifest keeps its ORIGINAL label/notes — nothing
+ // is written on this path, so a later ship can never rewrite them.
if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil {
if !shipArtifactsMatch(existing.Artifacts, artifacts) {
jsonError(w, http.StatusConflict,
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 1f0407a8b..1cbc49faa 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -761,6 +761,20 @@ func metaPart(t *testing.T, version, kind string) shipPart {
return shipPart{name: "meta", body: raw}
}
+// metaPartFull is metaPart with label and notes attached, and a version that
+// may be empty — the shape a target-minting ship (release versioning spec)
+// puts on the wire, alongside the label a human types at ship time.
+func metaPartFull(t *testing.T, version, kind, label, notes string) shipPart {
+ t.Helper()
+ raw, err := json.Marshal(map[string]string{
+ "version": version, "kind": kind, "label": label, "notes": notes,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return shipPart{name: "meta", body: raw}
+}
+
// randomBytes is a stand-in artifact payload: real bytes, not a fixed
// string, so a hash mismatch would actually be caught rather than a lucky
// coincidence.
@@ -1573,6 +1587,204 @@ func TestAPIShipOmitsTheEchoWhenNothingIsDeclared(t *testing.T) {
}
}
+// TestAPIShipMintsFromTarget: an empty "version" in the meta part is a
+// refusal naming `carlos version target` when no target is set, and mints
+// successive iteration versions off the target once one is (release
+// versioning spec, task 4/1's GetTarget/NextIteration). The label and notes
+// sent alongside a minted ship land in the stored manifest.
+func TestAPIShipMintsFromTarget(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-mint")
+
+ // 1. No target set: an empty version is refused, naming the fix.
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token,
+ []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("empty version, no target = %d %v, want 400", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "carlos version target") {
+ t.Fatalf("refusal = %q, want it to name `carlos version target`", str(t, out, "error"))
+ }
+
+ // 2. Set a target.
+ ctx := context.Background()
+ if err := release.SetTarget(ctx, fx.rel, release.OpsAccount, "km", "0.5.0", "t", time.Now().UTC()); err != nil {
+ t.Fatalf("set target: %v", err)
+ }
+
+ // 3. Ship with an empty version, a label and notes: mints 0.5.0-1.
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token,
+ []shipPart{metaPartFull(t, "", "binary", "fixing modals", "- x"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusCreated {
+ t.Fatalf("minted ship = %d %v, want 201", status, out)
+ }
+ if v := str(t, out, "version"); v != "0.5.0-1" {
+ t.Fatalf("minted version = %q, want 0.5.0-1", v)
+ }
+
+ // 4. The stored manifest carries the label and notes.
+ m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Label != "fixing modals" || m.Notes != "- x" {
+ t.Fatalf("manifest label/notes = %q/%q, want %q/%q", m.Label, m.Notes, "fixing modals", "- x")
+ }
+
+ // 5. A second minted ship advances the iteration.
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token,
+ []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusCreated {
+ t.Fatalf("second minted ship = %d %v, want 201", status, out)
+ }
+ if v := str(t, out, "version"); v != "0.5.0-2" {
+ t.Fatalf("second minted version = %q, want 0.5.0-2", v)
+ }
+}
+
+// TestAPIShipLabelLimits: a label or notes outside release.MaxLabelLen /
+// MaxNotesLen, or a label spanning more than one line, is refused 400 before
+// any artifact bytes are stored — the same "refused before the wire cost"
+// property the capability block already has.
+func TestAPIShipLabelLimits(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-labellimits")
+
+ cases := []struct {
+ name string
+ version string
+ label string
+ notes string
+ wantErr string // substring the refusal must contain
+ }{
+ {"label too long", "lbl0001", strings.Repeat("x", release.MaxLabelLen+1), "", "label: one line"},
+ {"label multi-line", "lbl0002", "line one\nline two", "", "label: one line"},
+ // One byte over MaxNotesLen — well inside apiShipMetaMaxBytes, so this
+ // must reach release.ValidateLabelNotes's own refusal rather than
+ // tripping the meta part's outer size limit first (the bug the
+ // apiShipMetaMaxBytes budget exists to avoid: a truncated-JSON
+ // "malformed meta part" instead of the notes-specific message).
+ {"notes too big", "lbl0003", "", strings.Repeat("x", release.MaxNotesLen+1), "notes: at most"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPartFull(t, c.version, "binary", c.label, c.notes), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("%s = %d %v, want 400", c.name, status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), c.wantErr) {
+ t.Fatalf("%s: error = %q, want it to contain %q (proves the real check was reached, not a truncated meta part)",
+ c.name, str(t, out, "error"), c.wantErr)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
+ t.Fatalf("%s: a manifest was stored despite the refusal", c.name)
+ }
+ })
+ }
+}
+
+// TestAPIShipLargeNotesSucceed: notes comfortably within release.MaxNotesLen
+// but well past the meta part's OLD flat 8KiB reader limit must still ship —
+// apiShipMetaMaxBytes exists precisely so a caller inside the documented
+// ceiling never meets "malformed meta part" for a value the spec calls
+// valid.
+func TestAPIShipLargeNotesSucceed(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-bignotes")
+
+ notes := strings.Repeat("- fixed a thing\n", 640) // ~10KiB, well past the old 8KiB cap
+ if len(notes) <= 8<<10 || len(notes) > release.MaxNotesLen {
+ t.Fatalf("test fixture notes = %d bytes, want >8KiB and <= MaxNotesLen(%d)", len(notes), release.MaxNotesLen)
+ }
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPartFull(t, "big0001", "binary", "", notes), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusCreated {
+ t.Fatalf("~10KiB notes ship = %d %v, want 201", status, out)
+ }
+ m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "big0001")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Notes != notes {
+ t.Fatalf("stored notes length = %d, want %d (bytes must survive intact)", len(m.Notes), len(notes))
+ }
+}
+
+// TestAPIShipExplicitVersionStillWorks: a meta part naming an explicit
+// version and no label/notes ships exactly as it did before this change —
+// the mint-or-validate branch must not disturb the existing explicit-version
+// path.
+func TestAPIShipExplicitVersionStillWorks(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-explicit")
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "de2766f8", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusCreated {
+ t.Fatalf("explicit-version ship = %d %v, want 201", status, out)
+ }
+ if v := str(t, out, "version"); v != "de2766f8" {
+ t.Fatalf("version = %q, want de2766f8", v)
+ }
+ m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "de2766f8")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Label != "" || m.Notes != "" {
+ t.Fatalf("manifest label/notes = %q/%q, want both empty", m.Label, m.Notes)
+ }
+}
+
+// TestAPIShipReshipToleratesADifferentLabel: labels and notes are metadata,
+// not content — a re-ship of an already-shipped version's exact bytes still
+// succeeds when the label differs, the same tolerance BuiltAt already gets.
+// The STORED manifest keeps its ORIGINAL label: the short-circuit answers
+// success without writing anything (see the comment above
+// shipArtifactsMatch/shipCapabilitiesMatch in handleAPIShip), so a second
+// ship can never rewrite a version's label after the fact.
+func TestAPIShipReshipToleratesADifferentLabel(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-reshiplabel")
+ payload := randomBytes(t, 128)
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPartFull(t, "abc9999", "binary", "label A", ""), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("first ship = %d %v, want 201", status, out)
+ }
+
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPartFull(t, "abc9999", "binary", "label B", ""), {name: "linux-arm64", body: payload}})
+ if status != http.StatusOK {
+ t.Fatalf("re-ship with a different label = %d %v, want 200 (metadata, not content)", status, out)
+ }
+
+ m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc9999")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Label != "label A" {
+ t.Fatalf("stored label = %q, want the ORIGINAL %q (immutable — a re-ship never rewrites it)", m.Label, "label A")
+ }
+}
+
// ---- trash and restore ------------------------------------------------------
// trashFixture is a console with titogo claimed in the ops account and a
diff --git a/internal/console/apiversion.go b/internal/console/apiversion.go
new file mode 100644
index 000000000..32038b9ae
--- /dev/null
+++ b/internal/console/apiversion.go
@@ -0,0 +1,230 @@
+// The CLI's version-target, tags and releases API (release versioning spec,
+// 2026-08-08): `carlos version target` and the releases list behind `carlos
+// releases`. The tags routes have no CLI verb of their own yet — their rows
+// feed `carlos channels`' tag annotations and the console's releases page,
+// and a future `carlos tags` verb may read them directly. Every handler
+// opens with s.apiAccountApp — the SAME account/app 404 apicli.go's other
+// doors give, so a non-member cannot tell an app they don't belong to apart
+// from one that does not exist by probing any of these five routes either.
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// handleAPIVersionTargetGet answers `carlos version target` with no argument:
+// the app's current target, or an empty one when none has ever been set —
+// unset is a state, not an error, matching release.GetTarget's own contract.
+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 {
+ log.Printf("console: api version-target get %s/%s: %v", acct.Sqid, app, err)
+ 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})
+}
+
+// handleAPIVersionTargetSet answers `carlos version target <version>`: the
+// one write door in this file, gated the same way handleAPIPromote's write
+// is — apiAccountApp's 404 first, then the shared write budget, keyed on the
+// caller's address like every other mutation here.
+//
+// release.SetTarget carries its own two refusals — not a bare semver, and
+// not above every existing tag — and this handler surfaces both rather than
+// re-deriving them: a 400 for the shape (this caller's mistake, fixable by
+// retyping, checked here before SetTarget is even called) and a 409 for the
+// ordering (a fact about the app's history that SetTarget's own message
+// names, recognised below via errors.Is(release.ErrTargetNotAboveTags)
+// rather than a string match). Anything else SetTarget can return — a
+// store outage, a lost CAS race against a concurrent writer — is the
+// STORE's fault, not the caller's: surfacing it as a 409 with the raw
+// error text would tell a caller retrying a good request that their INPUT
+// was the problem, and would put storage-layer wording (key names, request
+// ids) in front of a terminal. Those get logged and answered with the same
+// fixed 502 every other bucket failure in this file gets.
+func (s *Server) handleAPIVersionTargetSet(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ // Keyed on the ADDRESS, not the token: the principal a write budget
+ // belongs to is the PERSON, not the terminal they happen to be typing on
+ // — the same key handleAPIPromote's budget uses.
+ 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 {
+ if errors.Is(err, release.ErrTargetNotAboveTags) || errors.Is(err, release.ErrCASConflict) {
+ // SetTarget's own message names the offending tag when the
+ // target is not above every release already cut — as useful
+ // over the API as it is on the command line, so it passes
+ // through verbatim. A lost CAS race gets the same status: a
+ // concurrent write beat this one, which is the caller's cue to
+ // re-read and retry, exactly like the 409 above.
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ log.Printf("console: api version-target set %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not write the version target")
+ return
+ }
+ s.audit(acct, app, addr, "version-target", "version target set to "+target+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]string{"target": target})
+}
+
+// tagListRow is one row of the tags list — deliberately WITHOUT Changelog,
+// which can run to several KB per tag and is not what a list view needs;
+// GetTag (below) is the door onto a single tag's full changelog.
+type tagListRow struct {
+ Version string `json:"version"`
+ Release string `json:"release"`
+ TaggedAt time.Time `json:"tagged_at"`
+ Actor string `json:"actor,omitempty"`
+}
+
+// handleAPITagsList answers the tags list: every tag this app has cut,
+// semver-descending — release.ListTags' own order. `carlos channels` reads
+// this to annotate its TAG column and the console's releases page reads it
+// for the same join; there is no standalone `carlos tags` verb yet.
+func (s *Server) handleAPITagsList(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()
+ tags, err := release.ListTags(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ // ListTags propagates anything beyond a list/delete race (a
+ // corrupted tag object, an unreadable store) rather than swallowing
+ // it into a shorter list — the same "an unreadable row is worth
+ // refusing over, not hiding" call handleAPIChannels' per-row error
+ // makes differently, because there a partial answer is still useful
+ // and here list order already depends on parsing every row.
+ log.Printf("console: api tags list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the tags")
+ return
+ }
+ out := make([]tagListRow, 0, len(tags))
+ for _, t := range tags {
+ out = append(out, tagListRow{Version: t.Version, Release: t.Release, TaggedAt: t.TaggedAt, Actor: t.Actor})
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tags": out})
+}
+
+// handleAPITagGet answers a single tag, in full — including the changelog
+// the list route deliberately omits; the console's releases page renders it.
+// A version this app never tagged is a 404 naming the version, distinct from
+// apiAccountApp's byte-identical membership 404 above it: a member is told
+// exactly what is missing, because by the time this line runs membership is
+// already proved and there is nothing left to protect by staying vague.
+func (s *Server) handleAPITagGet(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ version := r.PathValue("version")
+ // Shape-checked before the key builder, not after — the same discipline
+ // apiinstances.go's handleAPIInstanceGet applies to a {host} segment.
+ // A {version} segment arrives percent-decoded, so "..%2Fversion-target"
+ // reaches this handler as a path with a separator in it, and TagKey
+ // would happily join it into a key naming a DIFFERENT object under this
+ // app's prefix. ListTags already only ever produces semver-parsable tag
+ // names (tag.go), so a version that fails ParseSemVer can never name a
+ // real tag regardless of what the store holds — this 404 is correct on
+ // every backend, not just the ones where the traversal itself would fail.
+ if _, ok := release.ParseSemVer(version); !ok {
+ jsonError(w, http.StatusNotFound, "no such tag "+version)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ tag, err := release.GetTag(ctx, s.cfg.Releases, acct.Sqid, app, version)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ jsonError(w, http.StatusNotFound, "no such tag "+version)
+ return
+ }
+ log.Printf("console: api tag get %s/%s %s: %v", acct.Sqid, app, version, err)
+ jsonError(w, http.StatusBadGateway, "could not read tag "+version)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "version": tag.Version, "release": tag.Release, "commit": tag.Commit,
+ "tagged_at": tag.TaggedAt, "actor": tag.Actor, "changelog": tag.Changelog,
+ })
+}
+
+// handleAPIReleasesList answers `carlos releases`: every shipped release
+// (Versions, view.go — the same read the console's own app page uses),
+// joined against ListTags so a row that a tag points at carries the tag's
+// name. The join key is Release, not Version-as-tag: a tag names the
+// ITERATION it was cut from (Tag.Release), so this is the inverse lookup —
+// "which tag, if any, points at this release".
+func (s *Server) handleAPIReleasesList(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()
+ versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api releases list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the releases")
+ return
+ }
+ // Unlike the console's own releases page (handleAppReleases, server.go),
+ // which degrades a broken tags read to a tag-less page, an API caller
+ // that asked for tags and got a wrong 200 back with every tag silently
+ // missing would have no way to tell "no tags exist" from "the read
+ // failed" — so this propagates the error to the same 502 every other
+ // store failure in this file gets.
+ byRelease, err := tagByRelease(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api releases list %s/%s: tags: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the tags")
+ return
+ }
+ out := make([]map[string]any, 0, len(versions))
+ for _, v := range versions {
+ out = append(out, map[string]any{
+ "version": v.Version, "kind": v.Kind, "built_at": v.BuiltAt,
+ "label": v.Label, "tag": byRelease[v.Version],
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"releases": out})
+}
diff --git a/internal/console/apiversion_test.go b/internal/console/apiversion_test.go
new file mode 100644
index 000000000..4436884be
--- /dev/null
+++ b/internal/console/apiversion_test.go
@@ -0,0 +1,339 @@
+package console
+
+// The version-target, tags and releases CLI API (apiversion.go, release
+// versioning spec 2026-08-08). The property that matters most, shared with
+// every other apicli.go door: a non-member meets the byte-identical 404 an
+// unknown app gets, on every one of these five routes, before any of the
+// version/tag machinery is ever consulted.
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestAPIVersionTargetGetSet: the get/set door for `carlos version target`
+// — unset reads as an empty target rather than an error, a set round-trips
+// with the token's own address as set_by, a non-semver is refused 400, a
+// target that is not above an existing tag is refused 409 (SetTarget's own
+// message), and a non-member meets the identical 404 an unknown app gets.
+func TestAPIVersionTargetGetSet(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-version-target")
+ u := fx.ts.URL + "/api/cli/apps/bab/km/version-target"
+
+ // Unauthenticated: the same 401 every /api/cli door gives with no bearer.
+ if status, out := getJSON(t, u, ""); status != http.StatusUnauthorized {
+ t.Fatalf("no token = %d %v, want 401", status, out)
+ }
+
+ // Nothing set yet.
+ status, out := getJSON(t, u, token)
+ if status != http.StatusOK {
+ t.Fatalf("get unset = %d %v, want 200", status, out)
+ }
+ if tgt := str(t, out, "target"); tgt != "" {
+ t.Fatalf("unset target = %q, want empty", tgt)
+ }
+
+ // Set it.
+ status, out = putJSON(t, u, map[string]string{"target": "0.5.0"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("put 0.5.0 = %d %v, want 200", status, out)
+ }
+ if tgt := str(t, out, "target"); tgt != "0.5.0" {
+ t.Fatalf("put response target = %q, want 0.5.0", tgt)
+ }
+
+ // Reads back, with set_by naming the token's own address.
+ status, out = getJSON(t, u, token)
+ if status != http.StatusOK {
+ t.Fatalf("get after set = %d %v, want 200", status, out)
+ }
+ if tgt := str(t, out, "target"); tgt != "0.5.0" {
+ t.Fatalf("get target = %q, want 0.5.0", tgt)
+ }
+ if by := str(t, out, "set_by"); by != "paul@keymail.dev" {
+ t.Fatalf("set_by = %q, want paul@keymail.dev", by)
+ }
+ if at := str(t, out, "set_at"); at == "" {
+ t.Fatal("set_at is empty")
+ }
+
+ // A non-semver target is refused 400.
+ status, out = putJSON(t, u, map[string]string{"target": "nope"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("put non-semver = %d %v, want 400", status, out)
+ }
+
+ // Cut a tag at 0.5.0, then a re-attempt to (re-)set the target to 0.5.0
+ // is refused 409 — SetTarget's own "already released" message.
+ ctx := context.Background()
+ if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
+ Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ status, out = putJSON(t, u, map[string]string{"target": "0.5.0"}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("put not-above-tags = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "already released as tag") {
+ t.Fatalf("409 body = %q, want it to contain %q", msg, "already released as tag")
+ }
+
+ // Non-member: byte-identical 404 to an unknown app.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-version-target-eve")
+ statusMember, bodyMember := getRaw(t, u, eve)
+ statusUnknown, bodyUnknown := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/no-such-app/version-target", token)
+ if statusMember != http.StatusNotFound || statusUnknown != http.StatusNotFound {
+ t.Fatalf("non-member/unknown status = %d/%d, want both 404", statusMember, statusUnknown)
+ }
+ if bodyMember != bodyUnknown {
+ t.Fatalf("non-member body %q != unknown-app body %q — the 404 is not byte-identical", bodyMember, bodyUnknown)
+ }
+}
+
+// brokenTargetStore fails every write to the version-target object — a
+// bucket outage, not a bad request. Everything else passes through
+// unchanged, so a GET (which reads a different key) is unaffected.
+type brokenTargetStore struct{ release.Store }
+
+const brokenTargetStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/version-target request id ABC123"
+
+func (b brokenTargetStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if strings.HasSuffix(key, "version-target.json") {
+ return "", errors.New(brokenTargetStoreErr)
+ }
+ return b.Store.Put(ctx, key, body, ifMatch)
+}
+
+// TestAPIVersionTargetSetStoreFailure: a write that fails because the STORE
+// is broken — not because the caller sent anything wrong — must not be
+// reported as a 409 (that status says "your input conflicts with reality",
+// which is false here), and must not echo the store's own error text (bucket
+// names, request ids) to the caller.
+func TestAPIVersionTargetSetStoreFailure(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-version-target-storefail")
+ fx.srv.cfg.Releases = brokenTargetStore{fx.rel}
+
+ status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/version-target",
+ map[string]string{"target": "0.5.0"}, token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("put on a broken store = %d %v, want 502", status, out)
+ }
+ if msg := str(t, out, "error"); strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "carlos-prod-secret-bucket") {
+ t.Fatalf("502 body leaks the store's own error text: %q", msg)
+ }
+}
+
+// TestAPIVersionTargetSetNonMemberByteIdentical404: the PUT door is the one
+// handler in this file with a second gate after apiAccountApp — the write
+// budget (handleAPIVersionTargetSet's s.cli.write.allow) — and that gate must
+// never run, let alone answer, before membership is settled: a non-member's
+// PUT has to meet the exact byte-identical 404 an unknown app's PUT gets,
+// the same property TestAPIVersionTargetGetSet already pins for the GET door.
+//
+// Mutation-mindedness: move the write-budget check ahead of apiAccountApp (or
+// give it its own error body) and this is the test that notices — a
+// non-member would get a 429 or a differently-worded 404 instead of the
+// account-existence-blind refusal every other apicli.go door gives.
+func TestAPIVersionTargetSetNonMemberByteIdentical404(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-version-target-put-eve")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-version-target-put-member")
+
+ body := map[string]string{"target": "0.5.0"}
+ statusMember, bodyMember := putRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/version-target", body, eve)
+ statusUnknown, bodyUnknown := putRaw(t, fx.ts.URL+"/api/cli/apps/bab/no-such-app/version-target", body, token)
+ if statusMember != http.StatusNotFound || statusUnknown != http.StatusNotFound {
+ t.Fatalf("non-member/unknown status = %d/%d, want both 404", statusMember, statusUnknown)
+ }
+ if bodyMember != bodyUnknown {
+ t.Fatalf("non-member body %q != unknown-app body %q — the 404 is not byte-identical", bodyMember, bodyUnknown)
+ }
+}
+
+// TestAPITagsListAndGet: the tags list omits the changelog, semver-descending
+// (0.10.0 above 0.6.0, which string order gets wrong); the single-tag door
+// carries the full changelog; a version never tagged is a 404.
+func TestAPITagsListAndGet(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-tags-list")
+ ctx := context.Background()
+
+ ship := func(version, label string) {
+ t.Helper()
+ if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
+ Schema: 1, App: "km", Version: version, Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
+ Label: label,
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin " + version)}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ ship("0.6.0-1", "six")
+ if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.6.0-1", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ ship("0.10.0-1", "ten")
+ if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.10.0-1", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+
+ status, listOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags", token)
+ if status != http.StatusOK {
+ t.Fatalf("list tags = %d, want 200", status)
+ }
+ tags, _ := listOut["tags"].([]any)
+ if len(tags) != 2 {
+ t.Fatalf("list tags = %d entries, want 2: %v", len(tags), listOut)
+ }
+ first, _ := tags[0].(map[string]any)
+ second, _ := tags[1].(map[string]any)
+ if first["version"] != "0.10.0" || second["version"] != "0.6.0" {
+ t.Fatalf("order = %v then %v, want 0.10.0 then 0.6.0 (semver-desc, not string order)", first["version"], second["version"])
+ }
+ if _, has := first["changelog"]; has {
+ t.Fatalf("list row carries a changelog key: %v", first)
+ }
+
+ status, oneOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/0.6.0", token)
+ if status != http.StatusOK {
+ t.Fatalf("get tag = %d %v, want 200", status, oneOut)
+ }
+ if cl := str(t, oneOut, "changelog"); cl == "" {
+ t.Fatalf("get tag changelog is empty: %v", oneOut)
+ }
+
+ status, missOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/9.9.9", token)
+ if status != http.StatusNotFound {
+ t.Fatalf("get missing tag = %d %v, want 404", status, missOut)
+ }
+
+ // A version that isn't a bare semver at all — including a path-traversal
+ // attempt — is refused before the key builder ever runs: ListTags only
+ // ever produces semver-parsable names, so neither string could ever name
+ // a real tag, on any backend.
+ status, traversalOut := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/..%2Fversion-target", token)
+ if status != http.StatusNotFound {
+ t.Fatalf("get tags/..%%2Fversion-target = %d %v, want 404", status, traversalOut)
+ }
+ status, nonSemverOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/not-semver", token)
+ if status != http.StatusNotFound {
+ t.Fatalf("get tags/not-semver = %d %v, want 404", status, nonSemverOut)
+ }
+}
+
+// TestAPIReleasesList: Versions() joined against ListTags — the release a
+// tag was cut from carries that tag's name, and every row carries its
+// manifest's label.
+func TestAPIReleasesList(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-releases-list")
+ ctx := context.Background()
+
+ if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
+ Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
+ Label: "fixing modals",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token)
+ if status != http.StatusOK {
+ t.Fatalf("list releases = %d, want 200", status)
+ }
+ rows, _ := out["releases"].([]any)
+ if len(rows) != 1 {
+ t.Fatalf("releases = %d rows, want 1: %v", len(rows), out)
+ }
+ row, _ := rows[0].(map[string]any)
+ if row["version"] != "0.5.0-1" {
+ t.Fatalf("row version = %v, want 0.5.0-1", row["version"])
+ }
+ if row["label"] != "fixing modals" {
+ t.Fatalf("row label = %v, want %q", row["label"], "fixing modals")
+ }
+ if row["tag"] != "0.5.0" {
+ t.Fatalf("row tag = %v, want 0.5.0", row["tag"])
+ }
+}
+
+// brokenTagsListStore fails every List against a tags/ prefix — a bucket
+// outage on the tag read specifically, not on the releases read ListTags's
+// caller already succeeded at.
+type brokenTagsListStore struct{ release.Store }
+
+const brokenTagsListStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/tags request id DEF456"
+
+func (b brokenTagsListStore) List(ctx context.Context, prefix string) ([]string, error) {
+ if strings.HasSuffix(prefix, "/tags/") {
+ return nil, errors.New(brokenTagsListStoreErr)
+ }
+ return b.Store.List(ctx, prefix)
+}
+
+// TestAPIReleasesListTagsStoreFailure: view.go's tagByRelease (the fetch
+// form, used here rather than the pure tagIndex fold since this handler has
+// no tag slice of its own) propagates a ListTags failure as an error rather
+// than an empty map — this pins that handleAPIReleasesList turns that error
+// into the same 502 every other broken-store read in this file gets,
+// rather than a 200 whose every row silently claims no tag exists.
+func TestAPIReleasesListTagsStoreFailure(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-releases-list-tagsfail")
+ if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{
+ Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.Releases = brokenTagsListStore{fx.rel}
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("releases list with a broken tags read = %d %v, want 502", status, out)
+ }
+ if msg := str(t, out, "error"); strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "carlos-prod-secret-bucket") {
+ t.Fatalf("502 body leaks the store's own error text: %q", msg)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index bc1f4e2f4..0f3351d02 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -225,6 +225,15 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip)
+ // The version-target, tags and releases-list API (apiversion.go,
+ // release versioning spec 2026-08-08). GET .../releases coexists with
+ // the POST above on the SAME path — a distinct method route, not a
+ // collision — for `carlos releases` to read what `carlos ship` writes.
+ 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)
// Trash and restore (apicli.go). Delete resolves through the LIVE-apps
// door and restore through the trashed-apps one, so each verb answers
// only for an app in the state it can act on — and both give a stranger
diff --git a/internal/console/server.go b/internal/console/server.go
index 568ff349f..27fec5ef8 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -444,6 +444,7 @@ type page struct {
Apps []appRow // account home
Detail *AppView // /a/{sqid}/apps/{app}
Versions []VersionView // app page: shipped releases, newest first
+ Tags []release.Tag // releases tab: every tag this app has cut, changelog and all
Instances *InstanceReport // app page: the app's self-report, nil when unpolled
Events []Event // app page: recent console actions, newest first
CanPromote bool // a signing key is configured on this host
@@ -1497,6 +1498,34 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
log.Printf("console: versions %s: %v", p.AppName, err)
p.FlashErr = "Could not list this app's releases."
}
+ // Versions itself never reads ListTags (view.go), and neither does
+ // assembleChannels any more — a full tag sweep thrown away by every
+ // OTHER app-page tab and the account home page's whole app list would
+ // be a needless read on every one of those renders (release versioning
+ // spec, 2026-08-08; view.go's tagByRelease doc comment has the full
+ // story). This tab is the one place that sweep pays for itself: ONE
+ // ListTags read serves the Tags section below (raw records, changelog
+ // and all, capped at tagsCap — ListTags' own semver-descending order),
+ // the Releases table's pill (view.go's tagIndex, the pure fold with no
+ // read of its own), and the Channels table's pill, backfilled onto
+ // Assemble's already-built ChannelView rows since assembleChannels
+ // itself leaves Tag "". A failed read degrades this tab's tag
+ // decoration only; the page still renders.
+ if tags, terr := release.ListTags(context.Background(), s.cfg.Releases, acct.Sqid, p.AppName); terr != nil {
+ log.Printf("console: tags %s: %v", p.AppName, terr)
+ } else {
+ byRelease := tagIndex(tags)
+ for i := range versions {
+ versions[i].Tag = byRelease[versions[i].Version]
+ }
+ for i := range p.Detail.Channels {
+ p.Detail.Channels[i].Tag = byRelease[p.Detail.Channels[i].Version]
+ }
+ if len(tags) > tagsCap {
+ tags = tags[:tagsCap]
+ }
+ p.Tags = tags
+ }
p.Versions = versions
// The canary form's "how do I give this a URL" copy — this tab's data, not
// shared chrome: the canary form lives here and nowhere else. Bounded and
@@ -1737,6 +1766,25 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
}
}
+// tagSuffix names what a stable promotion's tag ended up as, or why it
+// carries none (release versioning spec, 2026-08-08) — shared between the
+// fresh-promote and no-op flashes in handlePromote below it, because
+// PromoteOnLadder's stableTag runs on EVERY landing on stable, no-op
+// included: a repeat promotion can heal a release that ended up nameless
+// from a previous attempt's failed write, and the flash must say so exactly
+// as loudly as the promotion that cut the tag the first time would have.
+// "" off stable and for a version that carries no tag either way.
+func tagSuffix(res release.PromoteResult) string {
+ suffix := ""
+ if res.Tag != "" {
+ suffix += " — tagged " + res.Tag
+ }
+ if res.TagWarning != "" {
+ suffix += " (" + res.TagWarning + ")"
+ }
+ return suffix
+}
+
// handlePromote points a channel at a shipped version. It is the app's ONE
// promote door — the releases table posts to it, and so does every step of the
// ladder section — which is what makes the ladder and the passkey gate below
@@ -1817,11 +1865,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
return
}
if res.NoOp {
- back("ok", channel+" already serves "+version+" — nothing to do.")
+ back("ok", channel+" already serves "+version+tagSuffix(res)+" — nothing to do.")
return
}
s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel)
- msg := "Promoted " + version + " to " + channel + "."
+ msg := "Promoted " + version + " to " + channel + tagSuffix(res) + "."
// Whether this app bakes at all is the production flag's business (bake
// controls spec §2): a non-production app's boxes adopt a held channel
// immediately, so the window note below would be a lie for them. Read the
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index b069892d4..226e4a1ee 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -1993,6 +1993,143 @@ func TestFleetMetricsRendersFiringAlertsWithUnits(t *testing.T) {
}
}
+// TestReleasesPageShowsLabelsAndTags: the Releases tab's own read joins
+// Versions against ListTags (view.go's tagByRelease, release versioning
+// spec 2026-08-08) — a shipped iteration's label and notes are visible, and
+// once a tag has been cut for it, the tag pill and its changelog show too.
+func TestReleasesPageShowsLabelsAndTags(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ ctx := context.Background()
+
+ if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
+ Schema: 1, App: "kass", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
+ Label: "fixing modals", Notes: "- x",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "kass", "0.5.0-1", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != http.StatusOK {
+ t.Fatalf("/apps/kass/releases = %d", status)
+ }
+ for _, want := range []string{"fixing modals", "0.5.0-1", "- x"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("releases page missing %q in:\n%s", want, body)
+ }
+ }
+ // The tag pill sits on the Releases table's Version cell as a chip, not
+ // merely somewhere on the page.
+ if !strings.Contains(body, `<span class="chip chip-env">0.5.0</span>`) {
+ t.Fatalf("no tag pill for 0.5.0 in:\n%s", body)
+ }
+ // The Tags section below the Releases table carries the compiled
+ // changelog — the same notes text, reached a different way.
+ if !strings.Contains(body, "<h2>Tags</h2>") {
+ t.Fatalf("no Tags section in:\n%s", body)
+ }
+}
+
+// TestChannelRowShowsStableTag: a channel whose current version carries a
+// tag shows it next to the version in the Channels table.
+func TestChannelRowShowsStableTag(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ shipVersion(t, fx, "kass", "0.6.0-1")
+ seedChannel(t, fx, "kass", "stable", "0.6.0-1")
+ if _, _, err := release.EnsureTag(context.Background(), fx.rel, release.OpsAccount, "kass", "0.6.0-1", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if !strings.Contains(body, `<td><code>0.6.0-1</code> <span class="chip chip-env">0.6.0</span></td>`) {
+ t.Fatalf("stable channel row missing tag pill for 0.6.0:\n%s", body)
+ }
+}
+
+// TestPromoteFlashNamesTag drives the browser promote-to-stable path (the
+// same ladder walk TestPromoteFlow uses) with an iteration-shaped version,
+// and checks the flash names the tag PromoteOnLadder cut. The repeat
+// promotion is the CARRY-FORWARD binding from the task-10 brief: a no-op
+// still names the tag, because PromoteOnLadder's stableTag re-cuts (or
+// heals) it on every stable landing, no-op included, and a flash that went
+// quiet on a repeat would hide a healed tag from the operator who just
+// asked for one.
+func TestPromoteFlashNamesTag(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ shipVersion(t, fx, "kass", "0.7.0-1")
+ postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"edge"}, "confirmed": {"1"}})
+ postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"beta"}, "confirmed": {"1"}})
+ postGated(t, fx, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}})
+
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "tagged 0.7.0") {
+ t.Fatalf("promote-to-stable flash missing tag name:\n%s", body)
+ }
+
+ postGated(t, fx, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}})
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "already serves 0.7.0-1") || !strings.Contains(body, "tagged 0.7.0") {
+ t.Fatalf("no-op stable promote flash missing tag name:\n%s", body)
+ }
+}
+
+// TestPromoteFlashNamesTagWarning is the other half of the binding
+// constraint: a version that reaches stable but is NOT a "<target>-<n>"
+// iteration (a sha build, an explicit -version ship) carries no tag —
+// stableTag sets res.TagWarning instead of res.Tag (internal/release's
+// release.go), and the flash must say why, in parentheses, exactly as
+// tagSuffix builds it, not go silent about it.
+func TestPromoteFlashNamesTagWarning(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ shipVersion(t, fx, "kass", "v9")
+ postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"v9"}, "new_channel": {"edge"}, "confirmed": {"1"}})
+ postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"v9"}, "new_channel": {"beta"}, "confirmed": {"1"}})
+ postGated(t, fx, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}, "confirmed": {"1"}})
+
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ // html/template escapes the flash text, so the literal angle brackets in
+ // TagWarning's "<target>-<n>" arrive as entities.
+ if !strings.Contains(body, "(no tag cut: v9 is not a &lt;target&gt;-&lt;n&gt; iteration)") {
+ t.Fatalf("promote-to-stable flash missing tag warning:\n%s", body)
+ }
+ if strings.Contains(body, " — tagged ") {
+ t.Fatalf("flash claims a tag was cut for a non-iteration version:\n%s", body)
+ }
+}
+
+// TestReleasesPageNoTagsSection: an app with no tags cut renders 200 and
+// omits the Tags section entirely — {{if .Tags}} must not print an empty
+// heading, and a nil/empty p.Tags must not be confused with a read failure.
+func TestReleasesPageNoTagsSection(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != http.StatusOK {
+ t.Fatalf("/apps/kass/releases = %d, want 200", status)
+ }
+ if strings.Contains(body, "<h2>Tags</h2>") {
+ t.Fatalf("Tags section rendered with no tags ever cut:\n%s", body)
+ }
+}
+
// newRedirectFixture builds the minimal Server the CONSOLE_REDIRECT_HOSTS
// tests need — same trimmed shape as newMetricsFixture (metricsview_test.go),
// with none of consoleFixture's registry/keymail/HTTP-listener scaffolding,
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 7c1c5a8c8..1d4d9e65f 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -25,7 +25,7 @@
{{if .Err}}
<td colspan="3" class="error">{{.Err}}</td>
{{else}}
- <td><code>{{.Version}}</code></td>
+ <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
<td><span title="{{fmtTime .PromotedAt}}">{{ago .PromotedAt}}</span></td>
<td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td>
{{end}}
@@ -98,13 +98,16 @@ never bakes.</p>
{{if .Versions}}
<div class="tablewrap">
<table>
- <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
+ <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
{{range .Versions}}
<tr>
- <td><code>{{.Version}}</code></td>
+ <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
{{if .Err}}
- <td colspan="2" class="error">{{.Err}}</td>
+ <td colspan="3" class="error">{{.Err}}</td>
{{else}}
+ <td>{{if .Label}}{{.Label}}{{end}}{{if .Notes}}
+ <details><summary>notes</summary><pre class="changelog">{{.Notes}}</pre></details>
+ {{end}}{{if and (not .Label) (not .Notes)}}—{{end}}</td>
<td>{{.Kind}}</td>
<td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
{{end}}
@@ -130,5 +133,15 @@ never bakes.</p>
carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code></pre>
<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
{{end}}
+
+{{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}}
{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 362bdd63b..55235fdab 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -39,6 +39,10 @@
overflow-x: auto; font-family: var(--mono); font-size: 0.85rem; line-height: 1.6;
}
pre code { background: none; padding: 0; }
+ /* A tag's changelog and a release's notes are prose, not code — long lines
+ should wrap inside the box rather than force the horizontal scrollbar
+ every other <pre> on this page gets for a shell command. */
+ .changelog { white-space: pre-wrap; }
select, input[type=text] {
font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg);
border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
diff --git a/internal/console/view.go b/internal/console/view.go
index b9de2b31e..3bc79e6ca 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -173,6 +173,16 @@ type ChannelView struct {
// FastTrack is the pointer's bake waiver (release.ChannelPointer) — the
// badge is permanent, like the hotfix badge.
FastTrack bool
+ // Tag is the semver tag pointing at this channel's current release, if
+ // any (release versioning spec, 2026-08-08). assembleChannels — the one
+ // builder every app-page tab AND the account home page's app list share
+ // — deliberately leaves this "": a full ListTags sweep is one List plus
+ // one GetTag per tag, and every render except the releases tab throws
+ // that read away unread. handleAppReleases fills it in, per row, after
+ // Assemble returns, from the one tag read that render already needs for
+ // its own Tags section. "" for a release nobody tagged, for every
+ // channel below stable (no tag ever names one), and on every OTHER tab.
+ Tag string
// Err degrades a missing or invalid channel pointer to one row's error
// text instead of a failed page.
Err string
@@ -205,6 +215,20 @@ type VersionView struct {
Version, Kind, BuiltAt string
// Err degrades an unreadable manifest to one row's error text.
Err string
+ // Label is the manifest's own one-line summary (release versioning
+ // spec, 2026-08-08) — "" for every release shipped before the field
+ // existed, and for one whose manifest failed to read (Err is set
+ // instead).
+ Label string
+ // Notes is the manifest's longer freeform changelog entry, alongside
+ // Label — shown on the releases page inside a <details> so the table
+ // stays scannable. Same "" rules as Label.
+ Notes string
+ // Tag is the tag name pointing at this release, if any. Versions itself
+ // never reads ListTags — every caller that wants Tag populated joins
+ // against tagByRelease's map itself (the CLI API's releases handler in
+ // apiversion.go, and the console releases page in handleAppReleases).
+ Tag string
}
// TrashedAppView is one row of the Trash page: the app, who put it there,
@@ -426,6 +450,42 @@ func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView {
return out
}
+// tagIndex folds an already-read tag list into a release version -> tag
+// version map (release versioning spec, 2026-08-08) — the pure join, with
+// no store read of its own. This is the ONE place the fold lives: a caller
+// that already has a []release.Tag in hand (handleAppReleases, which reads
+// tags once for both its Tags section and this map) calls this directly
+// rather than re-listing just to build the same map tagByRelease below
+// would.
+func tagIndex(tags []release.Tag) map[string]string {
+ m := make(map[string]string, len(tags))
+ for _, t := range tags {
+ m[t.Release] = t.Version
+ }
+ return m
+}
+
+// tagByRelease is tagIndex plus the read, for a caller with no tag slice of
+// its own yet (apiversion.go's handleAPIReleasesList). Unlike a page
+// render's usual degrade-and-continue posture, a listing failure here is
+// RETURNED rather than swallowed into an empty map: an API caller that
+// asked for tags and silently got none back would see a wrong 200 instead
+// of the 502 a broken store deserves.
+//
+// Deliberately NOT called from assembleChannels: that builder is shared by
+// every app-page tab and the account home page's whole app list, and a full
+// ListTags sweep (one List, one GetTag per tag) thrown away by every
+// caller except the releases tab would be a full tag-history read on every
+// page view. handleAppReleases fills ChannelView.Tag itself, after Assemble
+// returns, from the one read that render already needs.
+func tagByRelease(ctx context.Context, s release.Store, acct, app string) (map[string]string, error) {
+ tags, err := release.ListTags(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ return tagIndex(tags), nil
+}
+
// assembleChannels lists the app's account-qualified channel pointers and
// resolves each one. An app whose objects are still at the legacy unqualified
// keys lists as empty here, exactly as the fleet sees it — the console showing
@@ -635,6 +695,12 @@ func humanAge(d time.Duration) string {
// versionsCap bounds the app page's release list.
const versionsCap = 20
+// tagsCap bounds the releases tab's Tags section the same way versionsCap
+// bounds the table above it. release.ListTags is already semver-descending,
+// so this is a plain slice truncate — the newest tagsCap tags, not an
+// arbitrary subset.
+const tagsCap = 20
+
// Versions lists an app's shipped releases — the distinct version dirs under
// apps/<app>/releases/ with each manifest's kind and build stamp — newest
// first by built_at (best effort: RFC3339 parses win, then version string),
@@ -659,7 +725,7 @@ func Versions(s release.Store, acct, app string) ([]VersionView, error) {
out = append(out, VersionView{Version: v, Err: err.Error()})
continue
}
- out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt})
+ out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt, Label: m.Label, Notes: m.Notes})
}
parse := func(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
diff --git a/internal/release/golden_test.go b/internal/release/golden_test.go
index 4b4a0df76..b9ef53820 100644
--- a/internal/release/golden_test.go
+++ b/internal/release/golden_test.go
@@ -213,3 +213,204 @@ func TestAManifestWithFutureTopLevelKeysStillParses(t *testing.T) {
t.Fatalf("Capabilities = %+v, want the daemon block decoded", m.Capabilities)
}
}
+
+// A manifest from BEFORE the release versioning spec (2026-08-08): no
+// "label" or "notes" keys at all, same as every manifest already sitting in
+// a production bucket today. It has to keep parsing, and Label/Notes have to
+// come back empty rather than erroring — that is the wire back-compat lock
+// for the new fields, the same guarantee TestYesterdaysManifestParsesWithNoCapabilities
+// gives Capabilities.
+//
+// Mutation-mindedness: make Label or Notes required, or reject a manifest
+// missing either key, and this test goes red — which would mean every
+// manifest shipped before today's change stopped parsing.
+func TestYesterdaysManifestParsesWithNoLabelOrNotes(t *testing.T) {
+ yesterday := `{
+ "schema": 1,
+ "app": "kass",
+ "version": "9f3c1ad",
+ "kind": "binary",
+ "commit": "9f3c1ad",
+ "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }
+}`
+ var m Manifest
+ if err := json.Unmarshal([]byte(yesterday), &m); err != nil {
+ t.Fatalf("a manifest already in every production bucket stopped parsing: %v", err)
+ }
+ if m.Label != "" || m.Notes != "" {
+ t.Fatalf("Label=%q Notes=%q, want both empty — absent means none", m.Label, m.Notes)
+ }
+ if err := validateManifest(&m); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+}
+
+// goldenLabeledManifest is a manifest carrying the release-versioning spec's
+// two new fields: Label (a short one-line intent) and Notes (a changelog
+// fragment), both written at ship time. Captured from json.MarshalIndent
+// on schema 1 with every field the ship path populates set.
+//
+// Mutation-mindedness: reorder the Manifest struct's fields, drop
+// `,omitempty` from Label or Notes, or rename either json tag, and the
+// marshal half of this test fails; corrupt the decoder and the unmarshal
+// half does.
+//
+// A var, not a const: strings.Repeat is not a constant expression (same
+// reason TestYesterdaysManifestParsesWithNoCapabilities's "yesterday" is a
+// var too).
+var goldenLabeledManifest = `{
+ "schema": 1,
+ "app": "kass",
+ "version": "9f3c1ad",
+ "kind": "binary",
+ "commit": "9f3c1ad",
+ "built_by": "ci@carlosframework.com",
+ "built_at": "2026-08-08T09:15:00Z",
+ "label": "fixing modals",
+ "notes": "- focus trap fixed\n- escape closes on nested dialogs",
+ "artifacts": {
+ "linux-arm64": {
+ "file": "linux-arm64",
+ "sha256": "` + strings.Repeat("ab", 32) + `",
+ "size": 12
+ }
+ }
+}`
+
+func goldenLabeledManifestValue() *Manifest {
+ return &Manifest{
+ Schema: 1,
+ App: "kass",
+ Version: "9f3c1ad",
+ Kind: "binary",
+ Commit: "9f3c1ad",
+ BuiltBy: "ci@carlosframework.com",
+ BuiltAt: "2026-08-08T09:15:00Z",
+ Label: "fixing modals",
+ Notes: "- focus trap fixed\n- escape closes on nested dialogs",
+ Artifacts: map[string]Artifact{
+ "linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 12},
+ },
+ }
+}
+
+// TestGoldenLabeledManifestRoundTrips is the labeled-manifest half of the
+// wire-shape lock: the Go type marshals to exactly the golden bytes, and the
+// golden bytes decode to exactly the Go value — both directions, so a
+// regression in either the encoder or the decoder is caught here rather than
+// downstream in Ship or console rendering.
+func TestGoldenLabeledManifestRoundTrips(t *testing.T) {
+ want := goldenLabeledManifestValue()
+ raw, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenLabeledManifest {
+ t.Fatalf("marshaled manifest =\n%s\nwant golden\n%s", raw, goldenLabeledManifest)
+ }
+ var got Manifest
+ if err := json.Unmarshal([]byte(goldenLabeledManifest), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, want) {
+ t.Fatalf("golden manifest decoded as\n%+v\nwant\n%+v", got, want)
+ }
+ if err := validateManifest(&got); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+}
+
+// goldenVersionTarget is the version-target.json bucket object (release
+// versioning spec, 2026-08-08; see target.go's TargetKey) — the per-app
+// "what we're building toward" that SetTarget writes and GetTarget reads.
+// Schema 1, every field set.
+//
+// Mutation-mindedness: reorder VersionTarget's fields, rename a json tag, or
+// drop `,omitempty` from SetBy, and the marshal half fails; break the
+// decoder's handling of set_at as RFC3339 and the unmarshal half does.
+const goldenVersionTarget = `{
+ "schema": 1,
+ "target": "0.5.0",
+ "set_by": "paul@example.com",
+ "set_at": "2026-08-08T09:15:00Z"
+}`
+
+func goldenVersionTargetValue() *VersionTarget {
+ return &VersionTarget{
+ Schema: 1,
+ Target: "0.5.0",
+ SetBy: "paul@example.com",
+ SetAt: time.Date(2026, 8, 8, 9, 15, 0, 0, time.UTC),
+ }
+}
+
+// TestGoldenVersionTargetRoundTrips locks the version-target.json wire shape
+// down in both directions, matching the pattern every other object in this
+// file follows.
+func TestGoldenVersionTargetRoundTrips(t *testing.T) {
+ want := goldenVersionTargetValue()
+ raw, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenVersionTarget {
+ t.Fatalf("marshaled version target =\n%s\nwant golden\n%s", raw, goldenVersionTarget)
+ }
+ var got VersionTarget
+ if err := json.Unmarshal([]byte(goldenVersionTarget), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, want) {
+ t.Fatalf("golden version target decoded as\n%+v\nwant\n%+v", got, want)
+ }
+}
+
+// goldenTagWithChangelog is a Tag as written by EnsureTag at promote-to-stable
+// time: every field populated, including the compiled changelog spanning
+// more than one iteration. Tags are unsigned read-side sugar (tag.go's
+// package doc), so unlike a channel pointer this shape has no signature to
+// pin — just the JSON.
+//
+// Mutation-mindedness: reorder Tag's fields, rename a json tag, or drop
+// `,omitempty` from Commit/Actor/Changelog, and the marshal half fails.
+const goldenTagWithChangelog = `{
+ "schema": 1,
+ "version": "0.5.0",
+ "release": "0.5.0-3",
+ "commit": "9f3c1ad",
+ "tagged_at": "2026-08-08T09:15:00Z",
+ "actor": "paul@example.com",
+ "changelog": "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-3 — polish\n\n- rounded corners\n"
+}`
+
+func goldenTagWithChangelogValue() *Tag {
+ return &Tag{
+ Schema: 1,
+ Version: "0.5.0",
+ Release: "0.5.0-3",
+ Commit: "9f3c1ad",
+ TaggedAt: time.Date(2026, 8, 8, 9, 15, 0, 0, time.UTC),
+ Actor: "paul@example.com",
+ Changelog: "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-3 — polish\n\n- rounded corners\n",
+ }
+}
+
+// TestGoldenTagWithChangelogRoundTrips locks the tag wire shape down in both
+// directions.
+func TestGoldenTagWithChangelogRoundTrips(t *testing.T) {
+ want := goldenTagWithChangelogValue()
+ raw, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenTagWithChangelog {
+ t.Fatalf("marshaled tag =\n%s\nwant golden\n%s", raw, goldenTagWithChangelog)
+ }
+ var got Tag
+ if err := json.Unmarshal([]byte(goldenTagWithChangelog), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, want) {
+ t.Fatalf("golden tag decoded as\n%+v\nwant\n%+v", got, want)
+ }
+}
diff --git a/internal/release/ladder_test.go b/internal/release/ladder_test.go
index 496b38dfa..54f5d4028 100644
--- a/internal/release/ladder_test.go
+++ b/internal/release/ladder_test.go
@@ -1,6 +1,11 @@
package release
import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "os"
"strings"
"testing"
)
@@ -98,3 +103,282 @@ func TestPromotionAllowedRefusesSkipsAndNamesHotfix(t *testing.T) {
}
}
}
+
+// --- Stable promotion cuts the version tag (release versioning spec) ---
+
+// shipLabelledIteration ships a real one-artifact release carrying the human
+// half of a manifest, so a tag cut from it has a non-empty changelog. It goes
+// through Ship (not a hand-written manifest) so the artifact sha256s are the
+// real thing and validateManifest is exercised.
+func shipLabelledIteration(t *testing.T, s Store, acct, app, version, label, notes string) {
+ t.Helper()
+ m := Manifest{
+ App: app, Version: version, Kind: "binary",
+ Commit: "c-" + version, Label: label, Notes: notes,
+ Artifacts: map[string]Artifact{"linux-arm64": {}},
+ }
+ files := map[string][]byte{"linux-arm64": []byte("build " + version)}
+ if err := Ship(context.Background(), s, acct, m, files); err != nil {
+ t.Fatalf("Ship %s/%s: %v", app, version, err)
+ }
+}
+
+// climbToBeta walks a version up the ladder the way an operator does, so the
+// stable promote under test meets a real edge->beta->stable history.
+func climbToBeta(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, version string) {
+ t.Helper()
+ for _, ch := range []string{"edge", "beta"} {
+ if _, err := PromoteOnLadder(ctx, s, priv, acct, app, ch, version, PromoteOptions{}); err != nil {
+ t.Fatalf("promote %s to %s: %v", version, ch, err)
+ }
+ }
+}
+
+// A promotion to stable cuts the iteration's tag, reports it, and stays
+// re-runnable: the repeat is a no-op that still names the tag.
+func TestStablePromoteCutsTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+
+ shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "fixing modals", "- focus trap fixed")
+ shipLabelledIteration(t, s, "bab", "km", "0.5.0-2", "polish", "- rounded corners")
+ climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-2")
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "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, "bab", "km", "0.5.0")
+ if err != nil || tag.Release != "0.5.0-2" {
+ t.Fatalf("tag after promote: %+v, %v", tag, err)
+ }
+ if tag.Actor != "paul@example.com" || tag.Commit != "c-0.5.0-2" {
+ t.Fatalf("tag fields: %+v", tag)
+ }
+ // The changelog is compiled from BOTH iterations, oldest first — proof the
+ // hook ran with the whole line in view, not just the promoted build.
+ if !strings.Contains(tag.Changelog, "0.5.0-1") || !strings.Contains(tag.Changelog, "rounded corners") {
+ t.Fatalf("changelog does not carry the line: %q", tag.Changelog)
+ }
+
+ // Repeat promote: NoOp, and the tag is still reported.
+ res2, err := PromoteOnLadder(ctx, s, priv, "bab", "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)
+ }
+ if res2.TagWarning != "" {
+ t.Fatalf("repeat warned: %q", res2.TagWarning)
+ }
+}
+
+// The no-op path is a HEAL, not just a report: a promotion whose tag write
+// failed the first time is repaired by re-running the same command.
+//
+// Mutation-mindedness: drop the stableTag call from the NoOp early return and
+// this is the only test that notices — and the operator would have no way back
+// to a tag, since the pointer never moves again.
+func TestRepeatStablePromoteHealsAMissingTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+
+ shipLabelledIteration(t, s, "bab", "km", "0.6.0-1", "first", "- one")
+ climbToBeta(t, ctx, s, priv, "bab", "km", "0.6.0-1")
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.6.0-1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ // Simulate the tag write having failed on the first attempt.
+ if err := s.Delete(ctx, TagKey("bab", "km", "0.6.0")); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.6.0-1",
+ PromoteOptions{Actor: "paul@example.com"})
+ if err != nil || !res.NoOp {
+ t.Fatalf("repeat: %+v, %v", res, err)
+ }
+ if res.Tag != "0.6.0" || res.TagWarning != "" {
+ t.Fatalf("no-op did not heal the tag: Tag=%q warning=%q", res.Tag, res.TagWarning)
+ }
+ if tag, err := GetTag(ctx, s, "bab", "km", "0.6.0"); err != nil || tag.Release != "0.6.0-1" {
+ t.Fatalf("tag after heal: %+v, %v", tag, err)
+ }
+}
+
+// A sha-named build reaching stable cuts nothing — no invented history — and
+// says so, so the operator knows why the release has no name.
+func TestStablePromoteShaVersionNoTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+
+ shipRelease(t, s, "bab", "km", "de2766f8", "linux-arm64", []byte("build"))
+ climbToBeta(t, ctx, s, priv, "bab", "km", "de2766f8")
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "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")
+ }
+ if !strings.Contains(res.TagWarning, "de2766f8") {
+ t.Fatalf("warning does not name the version: %q", res.TagWarning)
+ }
+ // The promotion itself is real, warning or not.
+ p, err := Channel(ctx, s, pub, "bab", "km", "stable")
+ if err != nil || p.Version != "de2766f8" {
+ t.Fatalf("stable pointer = %+v, %v", p, err)
+ }
+}
+
+// Nothing below stable touches tags: edge and beta are staging rungs, and a
+// build that never reaches stable never earns a name.
+func TestBetaPromoteNoTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+
+ shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one")
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "edge", "0.5.0-1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "beta", "0.5.0-1", PromoteOptions{})
+ if err != nil || res.Tag != "" || res.TagWarning != "" {
+ t.Fatalf("beta promote touched tags: %+v, %v", res, err)
+ }
+ if _, err := GetTag(ctx, s, "bab", "km", "0.5.0"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("beta promote wrote a tag: %v", err)
+ }
+ // A canary promotion of the same iteration is equally tag-free.
+ res, err = PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "0.5.0-1", PromoteOptions{})
+ if err != nil || res.Tag != "" || res.TagWarning != "" {
+ t.Fatalf("canary promote touched tags: %+v, %v", res, err)
+ }
+}
+
+// tagFailStore fails every read under an app's tags/ prefix with an error that
+// is NOT os.ErrNotExist — the shape of a bucket that is up but unhappy.
+type tagFailStore struct {
+ Store
+ fail error
+}
+
+func (s tagFailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.Contains(key, "/tags/") {
+ return nil, "", s.fail
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// The promotion has already happened when the tag is cut, so a tag failure can
+// only ever be a warning. Returning an error here would tell the operator the
+// deploy failed when the fleet is already moving to the new build.
+//
+// Mutation-mindedness: make stableTag return its error and have PromoteOnLadder
+// propagate it, and this is the test that fails — with a stable pointer that
+// moved under a "promote failed" message.
+func TestStablePromoteTagFailureIsAWarningNotAnError(t *testing.T) {
+ dir := NewDirStore(t.TempDir())
+ s := tagFailStore{Store: dir, fail: fmt.Errorf("bucket said no")}
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+
+ shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one")
+ climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-1")
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-1",
+ PromoteOptions{Actor: "paul@example.com"})
+ if err != nil {
+ t.Fatalf("a failed tag write failed the promotion: %v", err)
+ }
+ if res.Tag != "" {
+ t.Fatalf("res.Tag = %q after a failed tag read", res.Tag)
+ }
+ if !strings.Contains(res.TagWarning, "bucket said no") {
+ t.Fatalf("warning does not carry the cause: %q", res.TagWarning)
+ }
+ // And the pointer really moved.
+ p, err := Channel(ctx, s, pub, "bab", "km", "stable")
+ if err != nil || p.Version != "0.5.0-1" {
+ t.Fatalf("stable pointer = %+v, %v", p, err)
+ }
+}
+
+// A second iteration under a target already tagged by an earlier one must not
+// claim the earlier tag as its own. 0.5.0 got tagged at 0.5.0-1; when 0.5.0-2
+// later reaches stable, EnsureTag finds that existing tag (it names the same
+// target) and hands it back unchanged — the promotion is real, but it did not
+// cut or move the tag, so res.Tag must stay empty and the operator must be
+// told which iteration the tag actually names.
+//
+// Mutation-mindedness: report res.Tag = tag.Version regardless of tag.Release
+// and this is the test that catches it — a false "tagged 0.5.0" for a
+// promotion the tag does not describe.
+func TestStablePromoteAfterEarlierTagWarnsInsteadOfClaimingIt(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+
+ shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one")
+ climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-1")
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+
+ shipLabelledIteration(t, s, "bab", "km", "0.5.0-2", "second", "- two")
+ climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-2")
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-2", PromoteOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Tag != "" {
+ t.Fatalf("res.Tag = %q; a tag belonging to a different iteration must not be claimed", res.Tag)
+ }
+ if !strings.Contains(res.TagWarning, "already tagged at 0.5.0-1") {
+ t.Fatalf("TagWarning = %q; want it to name the iteration the tag actually carries", res.TagWarning)
+ }
+ // The promotion itself is real regardless of the tag warning.
+ p, err := Channel(ctx, s, pub, "bab", "km", "stable")
+ if err != nil || p.Version != "0.5.0-2" {
+ t.Fatalf("stable pointer = %+v, %v", p, err)
+ }
+}
+
+// A hotfix straight to stable — no rungs climbed — still cuts the version's
+// tag: the ladder gate is bypassed, but stable promotion's tag-cutting is not
+// gated on how the version got there.
+func TestHotfixToStableCutsTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+
+ shipLabelledIteration(t, s, "bab", "km", "0.7.0-1", "urgent fix", "- patched the leak")
+
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.7.0-1",
+ PromoteOptions{Hotfix: true, Actor: "paul@example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !res.Hotfix {
+ t.Fatalf("res.Hotfix = false for a hotfix promotion")
+ }
+ if res.Tag != "0.7.0" {
+ t.Fatalf("res.Tag = %q, TagWarning = %q; want 0.7.0 cut even via hotfix", res.Tag, res.TagWarning)
+ }
+ tag, err := GetTag(ctx, s, "bab", "km", "0.7.0")
+ if err != nil || tag.Release != "0.7.0-1" {
+ t.Fatalf("tag after hotfix promote: %+v, %v", tag, err)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index c9f689deb..e76ec9743 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -51,6 +51,14 @@ type Manifest struct {
Commit string `json:"commit,omitempty"`
BuiltBy string `json:"built_by,omitempty"`
BuiltAt string `json:"built_at,omitempty"`
+ // 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"`
// Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a
// static tree tar.
Artifacts map[string]Artifact `json:"artifacts"`
@@ -149,6 +157,30 @@ const (
auditMax = 20
)
+// 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
+)
+
+// ValidateLabelNotes is the one home for the label/notes bounds and their
+// refusal text — called from validateManifest (Ship and every GetManifest
+// read) AND from the console API's handleAPIShip, which must reach the same
+// verdict before a single artifact byte crosses the wire rather than only
+// after release.Ship re-derives it. Keeping the messages here means a future
+// change to either limit, or its wording, has one call site to edit rather
+// than two copies that can drift.
+func ValidateLabelNotes(label, notes string) error {
+ if len(label) > MaxLabelLen || strings.ContainsAny(label, "\r\n") {
+ return fmt.Errorf("label: one line, at most %d characters", MaxLabelLen)
+ }
+ if len(notes) > MaxNotesLen {
+ return fmt.Errorf("notes: at most %d bytes", MaxNotesLen)
+ }
+ return nil
+}
+
// Bucket keys, account-scoped: app names are unique WITHIN an account, so the
// account sqid is part of every key and the key path itself carries the
// app→account linkage (there is no separate linkage manifest). Channel names
@@ -265,6 +297,9 @@ func validateManifest(m *Manifest) error {
return fmt.Errorf("artifact %s: negative size", k)
}
}
+ if err := ValidateLabelNotes(m.Label, m.Notes); err != nil {
+ return err
+ }
// LAST, and deliberately the same call on both sides of the trust chain:
// Ship runs validateManifest before writing a byte (a human is present to
// read the refusal), and GetManifest runs it on every read (so a box
@@ -708,6 +743,38 @@ type PromoteResult struct {
NoOp bool
// Hotfix is whether the ordering check was bypassed.
Hotfix bool
+ // 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
+}
+
+// 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, and a caller that turned this into an error
+// would tell the operator a deploy failed while the fleet moved to the build.
+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"
+ case tag.Release != version:
+ // EnsureTag found an existing tag, but it names a DIFFERENT iteration
+ // than the one just promoted (tag 0.5.0 -> 0.5.0-1, stable now serves
+ // 0.5.0-2): the tag does not describe this promotion, so it is not
+ // this promotion's tag.
+ res.TagWarning = fmt.Sprintf("%s is already tagged at %s — this promotion carries no tag", tag.Version, tag.Release)
+ default:
+ res.Tag = tag.Version
+ }
}
// PromoteOnLadder is Promote with the channel ladder enforced: it works out
@@ -761,6 +828,10 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
if p, verr := verifyPointer(pub, cur, app, channel); verr == nil && p.Version == version {
res.NoOp = true
res.From, _ = Rung(channel)
+ // A repeat still reports the tag — and cuts it if a previous
+ // attempt's write failed, so re-running the same command heals a
+ // stable release that ended up nameless.
+ stableTag(ctx, s, acct, app, version, opt.Actor, &res)
return res, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
@@ -805,6 +876,7 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
if err := sg.Promote(ctx, s, acct, app, channel, version, rec); err != nil {
return res, err
}
+ stableTag(ctx, s, acct, app, version, opt.Actor, &res)
return res, nil
}
diff --git a/internal/release/release_test.go b/internal/release/release_test.go
index 727a9fcd6..fc9b380f2 100644
--- a/internal/release/release_test.go
+++ b/internal/release/release_test.go
@@ -1,6 +1,7 @@
package release
import (
+ "bytes"
"context"
"crypto/ed25519"
"encoding/base64"
@@ -776,3 +777,53 @@ func TestFastTrackIsAdditiveOnTheWire(t *testing.T) {
t.Fatalf("old shape read version %q", old.Version)
}
}
+
+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":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","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: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 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")
+ }
+}
diff --git a/internal/release/semver.go b/internal/release/semver.go
new file mode 100644
index 000000000..ddb70366e
--- /dev/null
+++ b/internal/release/semver.go
@@ -0,0 +1,105 @@
+// 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)
+}
diff --git a/internal/release/semver_test.go b/internal/release/semver_test.go
new file mode 100644
index 000000000..665ec154c
--- /dev/null
+++ b/internal/release/semver_test.go
@@ -0,0 +1,57 @@
+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)
+ }
+ }
+}
diff --git a/internal/release/tag.go b/internal/release/tag.go
new file mode 100644
index 000000000..1616e99f5
--- /dev/null
+++ b/internal/release/tag.go
@@ -0,0 +1,187 @@
+// 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"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "time"
+)
+
+// 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. Skips key names
+// that don't parse as semver and os.ErrNotExist misses (list/delete race).
+// Returns other GetTag errors wrapped with the key.
+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 {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return nil, fmt.Errorf("tag at %s: %w", k, err)
+ }
+ 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)
+}
+
+// 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 {
+ // Only skip genuine gaps (not-found); propagate other errors.
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ // For the promoted release itself, any error prevents tag creation.
+ if ver == releaseVersion {
+ return nil, false, err
+ }
+ // For other iterations, a transient error also aborts.
+ return nil, false, err
+ }
+ 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, etag, 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 {
+ // Advance with the etag from our check. The CAS window now spans
+ // the full check-then-write, so a lost race is truly advisory
+ // (operator's raise wins, concurrent advance is idempotent).
+ if cur == nil {
+ etag = "" // first write: create-only
+ }
+ _ = setTargetUnchecked(ctx, s, acct, app, next, actor, now, etag)
+ }
+ }
+ }
+ return tag, true, nil
+}
diff --git a/internal/release/tag_test.go b/internal/release/tag_test.go
new file mode 100644
index 000000000..af9aa02ef
--- /dev/null
+++ b/internal/release/tag_test.go
@@ -0,0 +1,202 @@
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+ "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")
+ }
+}
+
+func TestTagListErrors(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ // Write a valid tag and a garbage tag (malformed JSON).
+ good := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-1", TaggedAt: time.Now().UTC()}
+ if err := writeTag(ctx, s, "acct1", "km", &good); err != nil {
+ t.Fatal(err)
+ }
+ // Write garbage bytes directly to a valid tag key.
+ if _, err := s.Put(ctx, TagKey("acct1", "km", "0.7.0"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ // ListTags should return an error naming the key, not silently skip it.
+ _, err := ListTags(ctx, s, "acct1", "km")
+ if err == nil {
+ t.Fatal("ListTags accepted malformed JSON")
+ }
+ if !strings.Contains(err.Error(), "0.7.0") {
+ t.Fatalf("error does not name the key: %v", err)
+ }
+}
+
+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: "0000000000000000000000000000000000000000000000000000000000000000", 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)
+ }
+}
+
+func TestEnsureTagBlockedByHigherTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "first", "- first iteration")
+ // Operator or a fleet already past has a 0.6.0 tag; next patch would collide.
+ higherTag := &Tag{Schema: 1, Version: "0.6.0", Release: "0.6.0-1", TaggedAt: now, Actor: "admin"}
+ if err := writeTag(ctx, s, "acct1", "km", higherTag); err != nil {
+ t.Fatal(err)
+ }
+ // Cutting 0.5.0 from -1 must succeed, but advance is blocked by the collision.
+ tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-1", "paul@example.com", now)
+ if err != nil || !created || tag == nil {
+ t.Fatalf("EnsureTag should succeed: %+v, %v, %v", tag, created, err)
+ }
+ if tag.Version != "0.5.0" {
+ t.Fatalf("tag version: %s; want 0.5.0", tag.Version)
+ }
+ // Target must not have been set (advance blocked by 0.6.0).
+ vt, _, _ := GetTarget(ctx, s, "acct1", "km")
+ if vt != nil {
+ t.Fatalf("target was set: %+v; want nil (blocked by higher tag 0.6.0)", vt)
+ }
+}
+
+func TestEnsureTagMidRangeGap(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "first", "- first")
+ // Skip 0.5.0-2 intentionally.
+ shipIteration(t, s, "0.5.0-3", "third", "- third")
+ 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)
+ }
+ // Changelog should have sections for -1 and -3 only (gap at -2 skipped).
+ want := "## 0.5.0-1 — first\n\n- first\n\n## 0.5.0-3 — third\n\n- third\n"
+ if tag.Changelog != want {
+ t.Fatalf("changelog with gap:\n%q\nwant:\n%q", tag.Changelog, want)
+ }
+ // Target should auto-advance to 0.5.1 (no collision).
+ vt, _, _ := GetTarget(ctx, s, "acct1", "km")
+ if vt == nil || vt.Target != "0.5.1" {
+ t.Fatalf("target: %+v; want 0.5.1", vt)
+ }
+}
diff --git a/internal/release/target.go b/internal/release/target.go
new file mode 100644
index 000000000..3ab77cfe8
--- /dev/null
+++ b/internal/release/target.go
@@ -0,0 +1,151 @@
+// 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"
+)
+
+// ErrTargetNotAboveTags is SetTarget's ordering refusal, wrapped (not
+// returned bare) so a caller can tell it apart from every other way
+// SetTarget can fail — a store outage, say — with errors.Is, while the
+// human-facing message (which names the colliding tag) still comes through
+// on Error(). The console API is exactly that caller: SetTarget's shape
+// refusal (400, the caller mistyped) and this ordering refusal (409, a fact
+// about the app's own history) are a caller's fault either way, but
+// anything else SetTarget can return is the STORE's fault and must not
+// share a status code with either.
+var ErrTargetNotAboveTags = errors.New("target must be greater than every existing tag")
+
+// 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. Like
+// validateProductionFlag and GetTag, a hit is validated before it is
+// trusted: an unsupported schema or a target that no longer parses as a
+// bare semver is refused rather than handed to a caller that assumes both.
+func GetTarget(ctx context.Context, s Store, acct, app string) (*VersionTarget, string, error) {
+ key := TargetKey(acct, app)
+ raw, etag, err := s.Get(ctx, key)
+ 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", key, err)
+ }
+ if vt.Schema != 1 {
+ return nil, "", fmt.Errorf("version target at %s: unsupported schema %d", key, vt.Schema)
+ }
+ if _, ok := ParseSemVer(vt.Target); !ok {
+ return nil, "", fmt.Errorf("version target at %s: target %q is not a bare semver", key, vt.Target)
+ }
+ 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 — %w", target, tag.Version, ErrTargetNotAboveTags)
+ }
+ }
+ cur, etag, err := GetTarget(ctx, s, acct, app)
+ if err != nil {
+ return err
+ }
+ if cur == nil {
+ etag = "" // first write: create-only
+ }
+ return setTargetUnchecked(ctx, s, acct, app, t, actor, now, etag)
+}
+
+// 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. The caller provides the
+// ifMatch etag from their own GetTarget read, so the CAS window spans
+// the entire check-then-write: a lost race returns ErrCASConflict and the
+// auto-advance caller treats that conflict as ADVISORY and does not retry.
+// Both outcomes are correct: an operator's raise between the caller's check
+// and this write wins (operator's ordering was higher), and a concurrent
+// advance landing the same value is idempotent. Neither should be
+// retried by a stale check.
+//
+// Put mechanism: this codebase's Store.Put treats ifMatch "" as create-only
+// (If-None-Match: *) and non-empty ifMatch as replace-only against that
+// exact etag (store.go Put on both DirStore and S3Store) — there is no "*"
+// sentinel meaning "overwrite unconditionally". Every existing mutable
+// object in this package (SetProduction in production.go, Promote's
+// pointer swap in release.go) follows the same shape to update-or-create:
+// Get the key first, treat os.ErrNotExist as "" (first write, create-only),
+// otherwise use the etag just read, then Put once with that ifMatch and let
+// a lost race surface as ErrCASConflict to the caller — re-read and retry,
+// never overwrite blind. The target follows that same convention.
+func setTargetUnchecked(ctx context.Context, s Store, acct, app string, t SemVer, actor string, now time.Time, ifMatch string) error {
+ raw, err := json.Marshal(VersionTarget{Schema: 1, Target: t.String(), SetBy: actor, SetAt: now})
+ if err != nil {
+ return err
+ }
+ key := TargetKey(acct, app)
+ if _, err := s.Put(ctx, key, raw, ifMatch); 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
+}
diff --git a/internal/release/target_test.go b/internal/release/target_test.go
new file mode 100644
index 000000000..fbfea3546
--- /dev/null
+++ b/internal/release/target_test.go
@@ -0,0 +1,153 @@
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestTargetSetGet(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if vt, etag, err := GetTarget(ctx, s, "acct1", "km"); err != nil || vt != nil || etag != "" {
+ t.Fatalf("unset target: got %v, %q, %v; want nil, \"\", nil", vt, etag, 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, etag, 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)
+ }
+ if etag == "" {
+ t.Fatal("etag on a hit must not be empty — CAS on update depends on it")
+ }
+ // 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)
+ }
+}
+
+// TestGetTargetValidates matches GetTag/validateProductionFlag's discipline:
+// a hit is checked before it is trusted, not handed back as-is.
+func TestGetTargetValidates(t *testing.T) {
+ ctx := context.Background()
+ cases := []struct {
+ name string
+ raw []byte
+ }{
+ {"garbage json", []byte("not json")},
+ {"unsupported schema", mustMarshal(t, VersionTarget{Schema: 2, Target: "0.5.0"})},
+ {"target not a bare semver", mustMarshal(t, VersionTarget{Schema: 1, Target: "not-semver"})},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ if _, err := s.Put(ctx, TargetKey("acct1", "km"), c.raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if vt, etag, err := GetTarget(ctx, s, "acct1", "km"); err == nil {
+ t.Fatalf("want error, got vt=%+v etag=%q", vt, etag)
+ }
+ })
+ }
+}
+
+func mustMarshal(t *testing.T, v any) []byte {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+// racingStore wraps a Store and, the first time its key of interest is Get,
+// sneaks in a competing write directly against the underlying store before
+// returning — simulating a second operator's write landing between the
+// caller's Get and its own Put. Everything else passes through unchanged.
+type racingStore struct {
+ Store
+ key string
+ raced bool
+ race func()
+}
+
+func (r *racingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ raw, etag, err := r.Store.Get(ctx, key)
+ if key == r.key && !r.raced {
+ r.raced = true
+ r.race()
+ }
+ return raw, etag, err
+}
+
+// TestSetTargetCASConflict is the insurance for setTargetUnchecked's CAS
+// rewrite: a competing write landing between SetTarget's read and its own
+// Put must surface as ErrCASConflict, not a silent overwrite.
+func TestSetTargetCASConflict(t *testing.T) {
+ base := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(1000, 0).UTC()
+ key := TargetKey("acct1", "km")
+ rs := &racingStore{Store: base, key: key}
+ rs.race = func() {
+ // A second writer creates the target first, directly against the
+ // underlying store — bypassing rs so this doesn't re-trigger itself.
+ if err := SetTarget(ctx, base, "acct1", "km", "0.4.0", "other", now); err != nil {
+ t.Fatalf("competing write: %v", err)
+ }
+ }
+ err := SetTarget(ctx, rs, "acct1", "km", "0.5.0", "actor", now)
+ if !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("want ErrCASConflict, got %v", err)
+ }
+}
diff --git a/main.go b/main.go
index a068aa33a..816a8cd72 100644
--- a/main.go
+++ b/main.go
@@ -32,7 +32,17 @@ func main() {
case "help", "-h", "--help":
usage(os.Stdout)
case "version":
- fmt.Printf("carlos %s\n", version.Build)
+ // `carlos version` on its own is the build id and nothing else —
+ // unchanged, because every install script and support request in
+ // existence types it. `carlos version target ...` is the only string
+ // that dispatches anywhere new (release versioning spec, 2026-08-08):
+ // a sub-verb rather than a top-level `carlos target`, because what it
+ // sets IS the app's version, and it reads that way at the terminal.
+ if len(args) > 0 && args[0] == "target" {
+ runVersionTarget(args[1:])
+ } else {
+ fmt.Printf("carlos %s\n", version.Build)
+ }
case "edge":
edge.Run(args)
case "add":
@@ -49,6 +59,8 @@ func main() {
runPromote(args)
case "channels":
runChannels(args)
+ case "releases":
+ runReleases(args)
case "rollback":
runRollback(args)
case "ledger":
@@ -100,10 +112,15 @@ One binary, three roles. "carlos <command> -h" prints a command's own flags.
Your apps — ship and run software on a CARLOS deployment
auth log in, see who this terminal is, log out
apps create | delete | restore an app name
- ship publish a release of an app
+ ship publish a release of an app — -label and -notes name it,
+ and -version is optional once a version target is set
promote move a version up the ladder: canary → edge → beta → stable
+ (reaching stable cuts the release's semver tag)
rollback point a channel back at an earlier version
channels show what each of an app's channels is serving
+ releases every version shipped, newest first, with labels and tags
+ version target the semver ship counts iterations under: print it, or set
+ it — "carlos version target -app <a> 0.5.0"
env plain per-app config vars: set | unset | list
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete