carlos / platform Public

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>
Paul Campbell 48154f4f30c746643f94f2397eee800c0a20066a parent 5b000ee
4 files changed, +420 −1
  • internal/console/apiversion.go +189 −0
  • internal/console/apiversion_test.go +211 −0
  • internal/console/authcli.go +9 −0
  • internal/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)