Merge pull request #107 from carlosframework/worktree-release-versioning-spec
Release versioning: targets, labeled iterations, tags with changelogs
27 files changed,
+5246
−62
apimode.go+132 −4apimode_test.go+191 −24cli.go+339 −17cli_test.go+245 −0docs/superpowers/plans/2026-08-08-release-versioning.md+1592 −0docs/superpowers/specs/2026-08-08-release-versioning-design.md+130 −0internal/console/apicli.go+70 −8internal/console/apicli_test.go+212 −0internal/console/apiversion.go+230 −0internal/console/apiversion_test.go+339 −0internal/console/authcli.go+9 −0internal/console/server.go+50 −2internal/console/server_test.go+137 −0internal/console/templates/app_releases.html+17 −4internal/console/templates/layout.html+4 −0internal/console/view.go+67 −1internal/release/golden_test.go+201 −0internal/release/ladder_test.go+284 −0internal/release/release.go+72 −0internal/release/release_test.go+51 −0internal/release/semver.go+105 −0internal/release/semver_test.go+57 −0internal/release/tag.go+187 −0internal/release/tag_test.go+202 −0internal/release/target.go+151 −0internal/release/target_test.go+153 −0main.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 <target>-<n> 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 <site-dir></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 |