ship: both doors refuse a change of program without --program-change
carlos ship (direct bucket) and the console's API ship door both call release.CheckShipProgram before release.Ship. --program-change rides the meta part as program_change. runShipAPI and apiSession.ship gain a programChange parameter; deploy and canary pass false so they never change program silently.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
8 files changed,
+143
−31
apimode.go+13 −8apimode_test.go+16 −16canary.go+1 −1cli.go+11 −4deploy.go+1 −1internal/console/apicli.go+21 −0internal/console/apicli_test.go+79 −0mutation_message_test.go+1 −1
diff --git a/apimode.go b/apimode.go| index 409353694..7e4351ead 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -3950,7 +3950,11 @@ 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, label, notes string, files map[string]string, capsSet *caps.Set, messages ...string) (apiShipResult, error) { |
| +// |
| +// programChange rides in the meta part too (`program_change`, omitted when |
| +// false so the bytes match what this verb sent before the flag): it is the |
| +// operator's --program-change, and only `carlos ship` ever passes true. |
| +func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set, programChange bool, messages ...string) (apiShipResult, error) { |
| message := firstMutationMessage(messages) |
| ctx, cancel := context.WithTimeout(ctx, shipTimeout) |
| defer cancel() |
| @@ -3971,13 +3975,14 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la |
| capsRaw = b |
| } |
| metaJSON, err := json.Marshal(struct { |
| - Version string `json:"version"` |
| - Kind string `json:"kind"` |
| - Label string `json:"label,omitempty"` |
| - Notes string `json:"notes,omitempty"` |
| - Message string `json:"message,omitempty"` |
| - Capabilities json.RawMessage `json:"capabilities,omitempty"` |
| - }{version, kind, label, notes, message, capsRaw}) |
| + Version string `json:"version"` |
| + Kind string `json:"kind"` |
| + Label string `json:"label,omitempty"` |
| + Notes string `json:"notes,omitempty"` |
| + Message string `json:"message,omitempty"` |
| + Capabilities json.RawMessage `json:"capabilities,omitempty"` |
| + ProgramChange bool `json:"program_change,omitempty"` |
| + }{version, kind, label, notes, message, capsRaw, programChange}) |
| if err != nil { |
| return err |
| } |
diff --git a/apimode_test.go b/apimode_test.go| index 0c7ab101b..4090bb6ab 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -271,7 +271,7 @@ func TestAPISessionShipMultipart(t *testing.T) { |
| res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{ |
| "zeta-amd64": fileB, |
| "alpha-amd64": fileA, |
| - }, nil) |
| + }, nil, false) |
| if err != nil { |
| t.Fatalf("ship: %v", err) |
| } |
| @@ -331,7 +331,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, false) |
| if err != nil { |
| t.Fatalf("ship: %v", err) |
| } |
| @@ -355,7 +355,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, false) |
| if err == nil || !strings.Contains(err.Error(), "already shipped with different content") { |
| t.Fatalf("ship error = %v, want the server's conflict message", err) |
| } |
| @@ -397,7 +397,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, false) |
| if err == nil { |
| t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail") |
| } |
| @@ -421,7 +421,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, false) |
| if err != nil { |
| t.Fatalf("ship against a slow-but-healthy server = %v, want success", err) |
| } |
| @@ -1621,7 +1621,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, true); err != nil { |
| + if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true, false); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1668,7 +1668,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, true); err == nil { |
| + if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true, false); err == nil { |
| t.Fatal("runShipAPI against a failing server must error") |
| } |
| @@ -1734,7 +1734,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, true); err != nil { |
| + if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1776,7 +1776,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, true) |
| + _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false) |
| if err == nil { |
| t.Fatal("runShipAPI with a bad basename must error") |
| } |
| @@ -1811,7 +1811,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, true) |
| + _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false) |
| if err == nil { |
| t.Fatal("runShipAPI with a missing artifact path must error") |
| } |
| @@ -1872,7 +1872,7 @@ func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) { |
| s := newTestSession(ts.URL) |
| if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", |
| - map[string]string{"alpha-amd64": file}, set); err != nil { |
| + map[string]string{"alpha-amd64": file}, set, false); err != nil { |
| t.Fatalf("ship: %v", err) |
| } |
| want := `{"version":"1.2.3","kind":"binary","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}` |
| @@ -1933,7 +1933,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), false) |
| if err != nil { |
| t.Fatalf("ship with a matching echo: %v", err) |
| } |
| @@ -1954,7 +1954,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), false) |
| if err == nil { |
| t.Fatal("a console that echoed no capability block was accepted — that is an old console silently dropping the declaration") |
| } |
| @@ -1972,7 +1972,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), false) |
| if err == nil { |
| t.Fatal("a console that stored a DIFFERENT capability block was accepted") |
| } |
| @@ -1991,7 +1991,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, false); err != nil { |
| t.Fatalf("ship without --capabilities must not require an echo: %v", err) |
| } |
| } |
| @@ -4025,7 +4025,7 @@ func TestCarlosDeployRoutinePathUsesOnlyDeployDoors(t *testing.T) { |
| run func() error |
| }{ |
| {"ship", func() error { |
| - _, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil) |
| + _, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false) |
| return err |
| }}, |
| {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false, false); return err }}, |
diff --git a/canary.go b/canary.go| index 02f9fd1d1..9fe29e3ee 100644 |
| --- a/canary.go |
| +++ b/canary.go |
| @@ -638,7 +638,7 @@ func runCanaryDeploy(args []string) { |
| } |
| // printBanner false: printUsing above already said it once. |
| - if _, err = runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, "", nil, false); err != nil { |
| + if _, err = runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, "", nil, false, false); err != nil { |
| log.Fatalf("canary: ship: %v", err) |
| } |
diff --git a/cli.go b/cli.go| index f843ef76c..eaa178390 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2018,6 +2018,7 @@ func runShip(args []string) { |
| 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)") |
| + programChange := fs.Bool("program-change", false, "accept that this release's Go main package differs from the app's previous release (recorded in the manifest)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| // --version is no longer required: with a version target set, the next |
| @@ -2025,7 +2026,7 @@ func runShip(args []string) { |
| // 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. |
| - usage := "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>" |
| + usage := "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>] [--program-change] <files-or-site-dir>" |
| if fs.NArg() == 0 { |
| log.Fatal(usage) |
| } |
| @@ -2103,7 +2104,7 @@ func runShip(args []string) { |
| log.Fatal(err) |
| } |
| } |
| - if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false, message); err != nil { |
| + if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false, *programChange, message); err != nil { |
| log.Fatal(err) |
| } |
| return |
| @@ -2189,6 +2190,12 @@ func runShip(args []string) { |
| // manifest bytes byte-identical to what this verb wrote yesterday. |
| Capabilities: capsSet, |
| } |
| + // The wrong-program rule, shared with the console's API door: refuse a |
| + // release whose Go main package differs from the app's previous release |
| + // unless --program-change says it is meant. |
| + if err := release.CheckShipProgram(context.Background(), st, acct, &m, files, *programChange); err != nil { |
| + log.Fatal(err) |
| + } |
| if err := release.Ship(context.Background(), st, acct, m, files); err != nil { |
| log.Fatal(err) |
| } |
| @@ -2265,7 +2272,7 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| // would otherwise print it twice. The "shipped"/"already shipped" line |
| // below is never suppressed; it is the one true account of what ship did, |
| // and deploy relies on that rather than printing its own. |
| -func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set, printBanner bool, messages ...string) (apiShipResult, error) { |
| +func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set, printBanner, programChange bool, messages ...string) (apiShipResult, error) { |
| message := firstMutationMessage(messages) |
| files := map[string]string{} |
| if kind == "static" { |
| @@ -2324,7 +2331,7 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind |
| if printBanner { |
| printUsing(stdout, sess) |
| } |
| - res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet, message) |
| + res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet, programChange, message) |
| if err != nil { |
| return apiShipResult{}, err |
| } |
diff --git a/deploy.go b/deploy.go| index 3d71ff985..cf7973496 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -297,7 +297,7 @@ func runDeploy(args []string) { |
| // printBanner=false: the "using ..." line already printed above, before |
| // the instance list — so a listing failure still names the console — |
| // and runShipAPI would otherwise print it a second time. |
| - shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, *notes, capsSet, false, message) |
| + shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, *notes, capsSet, false, false, message) |
| if err != nil { |
| log.Fatalf("deploy: ship: %v", err) |
| } |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 6fd6d2040..825f204b5 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -1750,6 +1750,11 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| // than "malformed meta part" — an unknown capability name is a |
| // specific, actionable thing to be told. |
| Capabilities json.RawMessage `json:"capabilities,omitempty"` |
| + // ProgramChange is `carlos ship --program-change`: the operator's |
| + // statement that this release's Go main package really differs from |
| + // the app's previous release. Absent from every request a CLI shipped |
| + // before the flag existed, which then meets the refusal below. |
| + ProgramChange bool `json:"program_change,omitempty"` |
| } |
| // The budget is release.MaxNotesLen — the biggest single field the meta |
| // part now carries — plus 8KiB of headroom for everything else in it: |
| @@ -1996,6 +2001,22 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| Label: meta.Label, |
| Notes: meta.Notes, |
| } |
| + // The wrong-program rule, the same one the direct-bucket `carlos ship` |
| + // calls: a release whose Go main package differs from the app's previous |
| + // release is refused unless the caller said --program-change. It reads |
| + // the artifact bytes that arrived, so it runs here, after m exists and |
| + // before anything is written. A mismatch is a 409 (the request is well |
| + // formed, the app's history disagrees); anything else is a 400, the |
| + // artifact or the app's releases being unreadable. |
| + if err := release.CheckShipProgram(r.Context(), s.cfg.Releases, acct.Sqid, &m, files, meta.ProgramChange); err != nil { |
| + var pm *release.ProgramMismatchError |
| + if errors.As(err, &pm) { |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| // 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 |
| // short strings. |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index b592e9c78..c652091b7 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -21,6 +21,9 @@ import ( |
| "mime/multipart" |
| "net/http" |
| "net/url" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| "reflect" |
| "strings" |
| "sync" |
| @@ -1347,6 +1350,82 @@ func TestAPIShipImmutableConflict(t *testing.T) { |
| } |
| } |
| +// tinyProgramForConsole is internal/release's tinyProgram, copied because a |
| +// test helper cannot be imported across packages: a stripped linux/arm64 Go |
| +// binary whose main package is modPath, built as the console is built, at |
| +// test time. Nothing is checked in. |
| +func tinyProgramForConsole(t *testing.T, modPath string) []byte { |
| + t.Helper() |
| + if testing.Short() { |
| + t.Skip("builds a binary") |
| + } |
| + dir := t.TempDir() |
| + must := func(err error) { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644)) |
| + must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644)) |
| + out := filepath.Join(dir, "bin") |
| + cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".") |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false") |
| + if b, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("build %s: %v\n%s", modPath, err, b) |
| + } |
| + b, err := os.ReadFile(out) |
| + must(err) |
| + return b |
| +} |
| + |
| +// TestAPIShipRefusesAProgramChange: the wrong-program rule at the API door. |
| +// A second release whose Go main package differs from the app's previous |
| +// release is a 409 naming --program-change, and the same ship with the |
| +// meta part's program_change set goes through and is recorded. |
| +func TestAPIShipRefusesAProgramChange(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-program") |
| + liftShipBudget(fx) |
| + url := fx.ts.URL + "/api/cli/apps/bab/titogo/releases" |
| + console := tinyProgramForConsole(t, "example.com/console") |
| + carlos := tinyProgramForConsole(t, "example.com/carlos") |
| + |
| + status, out := postShip(t, url, token, |
| + []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("first ship = %d %v", status, out) |
| + } |
| + |
| + status, out = postShip(t, url, token, |
| + []shipPart{metaPart(t, "v2", "binary"), {name: "linux-arm64", body: carlos}}) |
| + if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "--program-change") { |
| + t.Fatalf("second ship = %d %v, want 409 naming --program-change", status, out) |
| + } |
| + |
| + raw, err := json.Marshal(map[string]any{"version": "v3", "kind": "binary", "program_change": true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out = postShip(t, url, token, |
| + []shipPart{{name: "meta", body: raw}, {name: "linux-arm64", body: carlos}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship with program_change = %d %v, want 201", status, out) |
| + } |
| + m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "v3") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Program != "example.com/carlos" || !m.ProgramChange { |
| + t.Fatalf("manifest program = %q change = %v, want example.com/carlos recorded as a change", m.Program, m.ProgramChange) |
| + } |
| +} |
| + |
| // TestAPIShipConcurrencyLimit: the rate limiter bounds requests over TIME, |
| // but says nothing about how many run AT ONCE. Two ships already in flight |
| // (each holding the server's ship semaphore while its body trickles in, |
diff --git a/mutation_message_test.go b/mutation_message_test.go| index bf1349533..b8783aa14 100644 |
| --- a/mutation_message_test.go |
| +++ b/mutation_message_test.go |
| @@ -179,7 +179,7 @@ func TestMutationMessageTravelsInShipMetadata(t *testing.T) { |
| t.Fatal(err) |
| } |
| _, err := newTestSession(ts.URL).ship(context.Background(), apiAcct{Seg: "bab"}, "titogo", |
| - "abc1234", "binary", "", "", map[string]string{"linux-arm64": artifact}, nil, |
| + "abc1234", "binary", "", "", map[string]string{"linux-arm64": artifact}, nil, false, |
| "ship the checkout fix") |
| if err != nil { |
| t.Fatal(err) |