console: version-target, tags and releases CLI API
Five new CLI API doors (apiversion.go): GET/PUT version-target,
GET tags, GET tags/{version}, GET releases. Same apiAccountApp gate
discipline as handleAPIPromote — account/app 404 first, write budget
on the PUT only. VersionView grows Label and Tag; Versions()
populates Label from the manifest, the releases handler joins
ListTags to fill in Tag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+420
−1
internal/console/apiversion.go+189 −0internal/console/apiversion_test.go+211 −0internal/console/authcli.go+9 −0internal/console/view.go+11 −1
diff --git a/internal/console/apiversion.go b/internal/console/apiversion.go| new file mode 100644 |
| index 000000000..8a335dcb1 |
| --- /dev/null |
| +++ b/internal/console/apiversion.go |
| @@ -0,0 +1,189 @@ |
| +// The CLI's version-target, tags and releases API (release versioning spec, |
| +// 2026-08-08): `carlos version target`, `carlos tags`, and the releases list |
| +// behind `carlos releases`. 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" |
| + "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 { |
| + 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) and a 409 for the ordering (a fact about the app's history that |
| +// SetTarget's own message names). |
| +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 { |
| + // 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. |
| + 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}) |
| +} |
| + |
| +// tagListRow is one row of `carlos tags` — 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 `carlos tags`: every tag this app has cut, |
| +// semver-descending — release.ListTags' own order. |
| +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. |
| + 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 `carlos tags <version>`: one tag, in full — |
| +// including the changelog the list route deliberately omits. 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") |
| + 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 |
| + } |
| + 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 { |
| + jsonError(w, http.StatusBadGateway, "could not read the releases") |
| + return |
| + } |
| + tags, err := release.ListTags(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + jsonError(w, http.StatusBadGateway, "could not read the tags") |
| + return |
| + } |
| + byRelease := make(map[string]string, len(tags)) |
| + for _, t := range tags { |
| + byRelease[t.Release] = t.Version |
| + } |
| + 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..0eb4dbbd1 |
| --- /dev/null |
| +++ b/internal/console/apiversion_test.go |
| @@ -0,0 +1,211 @@ |
| +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" |
| + "net/http" |
| + "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"); msg == "" { |
| + t.Fatal("409 body carries no error message") |
| + } |
| + |
| + // 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) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +// 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"]) |
| + } |
| +} |
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/view.go b/internal/console/view.go| index b9de2b31e..9bdce2c86 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -205,6 +205,16 @@ 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 |
| + // Tag is the tag name pointing at this release, if any — populated by |
| + // the CLI API's releases handler (apiversion.go), not here: computing |
| + // it needs ListTags, which Versions has no reason to read for the app |
| + // page's own callers. |
| + Tag string |
| } |
| // TrashedAppView is one row of the Trash page: the app, who put it there, |
| @@ -659,7 +669,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}) |
| } |
| parse := func(s string) time.Time { |
| t, _ := time.Parse(time.RFC3339, s) |