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>
5 files changed,
+253
−16
apimode.go+21 −4apimode_test.go+10 −10cli.go+1 −1internal/console/apicli.go+49 −1internal/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 |