carlos / platform Public

console: ship API mints iteration versions and carries label/notes

handleAPIShip now mints a version from the app's version target
(release.GetTarget + NextIteration) when the meta part's "version" is
empty, and accepts optional label/notes bounded by MaxLabelLen/
MaxNotesLen. The already-shipped short-circuit keeps comparing only
artifacts and capabilities, so a re-ship with a different label still
succeeds and the stored manifest keeps its original label. Client side
(apimode.go/cli.go) gains a matching label/notes ship() signature and
reads the minted version back out of the response.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7fcff053cf0d63ae377ab651687dc630d934686c parent 44cc445
5 files changed, +253 −16
  • apimode.go +21 −4
  • apimode_test.go +10 −10
  • cli.go +1 −1
  • internal/console/apicli.go +49 −1
  • internal/console/apicli_test.go +172 −0
diff --git a/apimode.go b/apimode.go
index ec697f2f3..935b5e223 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
@@ -943,7 +949,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 +971,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 +1004,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..cf5c9b895 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)
}
@@ -1334,7 +1334,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 +1396,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 +1417,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 +1435,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 +1454,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..f94a56193 100644
--- a/cli.go
+++ b/cli.go
@@ -1645,7 +1645,7 @@ 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, "", "", files, capsSet)
if err != nil {
return err
}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 101bccba3..d2da14044 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -848,6 +848,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
@@ -863,7 +870,32 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
return
}
ver := strings.TrimSpace(meta.Version)
- if !release.ValidVersion(ver) || ver == "dev" {
+ 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
}
@@ -871,6 +903,14 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
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
+ }
// 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 +1010,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 +1030,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..f73dab91a 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,164 @@ 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
+ }{
+ {"label too long", "lbl0001", strings.Repeat("x", release.MaxLabelLen+1), ""},
+ {"label multi-line", "lbl0002", "line one\nline two", ""},
+ {"notes too big", "lbl0003", "", strings.Repeat("x", release.MaxNotesLen+1)},
+ }
+ 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 _, 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)
+ }
+ })
+ }
+}
+
+// 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