ship: a direct re-ship is stamped like its first ship
Ruling G2. Direct-mode carlos ship skipped CheckShipProgram for a version already on record, which also skipped the stamp, so the re-ship's manifest had no Program or ProgramChange and release.Ship's byte comparison refused it as different content. StampProgram (program.go) is now the stamp half of CheckShipProgram, and the direct door calls it on a re-ship, skipping only the comparison with the previous release. The pre-existing BuiltAt drift on direct re-ships is out of scope: shipNow is a test seam so the re-ship test can hold BuiltAt still. Also the Task 2 triage tests: the flag reaches the manifest through the direct door, and apiSession.ship's meta part carries program_change true, and omits it when false.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+178
−12
apimode_test.go+45 −0cli.go+14 −4cli_test.go+92 −0internal/release/program.go+27 −8
diff --git a/apimode_test.go b/apimode_test.go| index 4090bb6ab..157887e7d 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -1881,6 +1881,51 @@ func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) { |
| } |
| } |
| +// TestAPISessionShipCarriesProgramChangeInMeta: --program-change rides in the |
| +// meta part as "program_change":true, and is omitted when false, so a ship |
| +// without the flag sends the bytes this verb sent before the flag existed. |
| +func TestAPISessionShipCarriesProgramChangeInMeta(t *testing.T) { |
| + for _, tc := range []struct { |
| + change bool |
| + want string |
| + }{ |
| + {true, `{"version":"1.2.3","kind":"binary","program_change":true}`}, |
| + {false, `{"version":"1.2.3","kind":"binary"}`}, |
| + } { |
| + var metaBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Errorf("request was not multipart: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + for { |
| + p, err := mr.NextPart() |
| + if err != nil { |
| + break |
| + } |
| + b, _ := io.ReadAll(p) |
| + if p.FormName() == "meta" { |
| + metaBody = string(b) |
| + } |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + w.Write([]byte(`{"result":"shipped"}`)) |
| + })) |
| + s := newTestSession(ts.URL) |
| + _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", |
| + shipTestArtifact(t), nil, tc.change) |
| + ts.Close() |
| + if err != nil { |
| + t.Fatalf("ship (program_change %v): %v", tc.change, err) |
| + } |
| + if metaBody != tc.want { |
| + t.Fatalf("meta part (program_change %v) = %q, want %q", tc.change, metaBody, tc.want) |
| + } |
| + } |
| +} |
| + |
| // shipEchoServer is a console stand-in for the capability-echo tests: it |
| // accepts any multipart ship and answers 201 with the body given. |
| func shipEchoServer(t *testing.T, body string) *httptest.Server { |
diff --git a/cli.go b/cli.go| index 3aa6cb5a7..ffeeab224 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2005,6 +2005,11 @@ func publicKey() ed25519.PublicKey { |
| return k |
| } |
| +// shipNow is the clock direct-mode `carlos ship` stamps BuiltAt with. A test |
| +// seam only: a re-ship of an existing version is byte-compared with the |
| +// stored manifest, BuiltAt included, so a test of the re-ship path pins it. |
| +var shipNow = time.Now |
| + |
| func runShip(args []string) { |
| fs := flag.NewFlagSet("ship", flag.ExitOnError) |
| messageFlags := addMutationMessageFlags(fs) |
| @@ -2179,7 +2184,7 @@ func runShip(args []string) { |
| } |
| m := release.Manifest{ |
| Schema: 1, App: *app, Version: shipVersion, Kind: *kind, |
| - BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339), |
| + BuiltBy: "carlos " + version.Build, BuiltAt: shipNow().UTC().Format(time.RFC3339), |
| Artifacts: arts, |
| // Empty when the flags were not given: both are omitempty, so the |
| // manifest bytes stay byte-identical to what this verb wrote before |
| @@ -2192,13 +2197,18 @@ func runShip(args []string) { |
| } |
| // 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. Only a NEW version meets it: |
| - // re-shipping a version already on record stays idempotent, and |
| - // release.Ship below decides between identical and conflict as before. |
| + // unless --program-change says it is meant. Only a NEW version meets the |
| + // comparison: re-shipping a version already on record stays idempotent. |
| + // A re-ship is still STAMPED the way its first ship was (Program from the |
| + // bytes, ProgramChange from the flag), because release.Ship below decides |
| + // between identical and conflict by comparing manifest bytes, and an |
| + // unstamped manifest never matches a stamped one. |
| if _, gerr := release.GetManifest(context.Background(), st, acct, *app, shipVersion); gerr != nil { |
| if err := release.CheckShipProgram(context.Background(), st, acct, &m, files, *programChange); err != nil { |
| log.Fatal(err) |
| } |
| + } else if err := release.StampProgram(&m, files, *programChange); err != nil { |
| + log.Fatal(err) |
| } |
| if err := release.Ship(context.Background(), st, acct, m, files); err != nil { |
| log.Fatal(err) |
diff --git a/cli_test.go b/cli_test.go| index a98f75b85..05b0fafd6 100644 |
| --- a/cli_test.go |
| +++ b/cli_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -157,6 +158,97 @@ func TestShipDirectModeEmbedsTheCapabilityFile(t *testing.T) { |
| } |
| } |
| +// tinyProgramForCLI 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 at test time and written to a file |
| +// `carlos ship` can read. Nothing is checked in. |
| +func tinyProgramForCLI(t *testing.T, modPath string) string { |
| + 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) |
| + } |
| + return out |
| +} |
| + |
| +// pinShipClock fixes direct-mode ship's BuiltAt so a re-ship of the same |
| +// version can be byte-identical to its first ship. |
| +func pinShipClock(t *testing.T) { |
| + t.Helper() |
| + at := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| + orig := shipNow |
| + shipNow = func() time.Time { return at } |
| + t.Cleanup(func() { shipNow = orig }) |
| +} |
| + |
| +// TestShipDirectModeRecordsTheProgramAndTheFlag: the direct-bucket door |
| +// stamps the program from the artifact bytes, and --program-change reaches |
| +// the manifest. |
| +func TestShipDirectModeRecordsTheProgramAndTheFlag(t *testing.T) { |
| + clearDeploymentEnv(t) |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + console, cli := tinyProgramForCLI(t, "example.com/console"), tinyProgramForCLI(t, "example.com/carlos") |
| + |
| + captureStdout(t) |
| + runShip([]string{"-app", "console", "-version", "va", "-target", "linux-arm64", console}) |
| + runShip([]string{"-app", "console", "-version", "vb", "-target", "linux-arm64", "-program-change", cli}) |
| + |
| + store := release.NewDirStore(dir) |
| + for ver, want := range map[string]struct { |
| + prog string |
| + change bool |
| + }{"va": {"example.com/console", false}, "vb": {"example.com/carlos", true}} { |
| + m, err := release.GetManifest(context.Background(), store, release.OpsAccount, "console", ver) |
| + if err != nil || m.Program != want.prog || m.ProgramChange != want.change { |
| + t.Fatalf("%s manifest = %+v, %v; want program %s, program_change %v", ver, m, err, want.prog, want.change) |
| + } |
| + } |
| +} |
| + |
| +// TestShipDirectModeReshipIsStampedLikeTheFirstShip (ruling G2): re-shipping |
| +// a version that is already on record skips the program comparison but still |
| +// stamps Program and ProgramChange, so release.Ship's byte comparison finds |
| +// the stored manifest identical. Before G2 the re-ship's manifest had neither |
| +// field and was refused as different content. vb is re-shipped after va2 |
| +// took the app back to the console program, which the comparison would |
| +// refuse without the flag; the flag is given again, as it was the first time. |
| +func TestShipDirectModeReshipIsStampedLikeTheFirstShip(t *testing.T) { |
| + clearDeploymentEnv(t) |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + pinShipClock(t) |
| + console, cli := tinyProgramForCLI(t, "example.com/console"), tinyProgramForCLI(t, "example.com/carlos") |
| + |
| + captureStdout(t) |
| + runShip([]string{"-app", "console", "-version", "va", "-target", "linux-arm64", console}) |
| + runShip([]string{"-app", "console", "-version", "vb", "-target", "linux-arm64", "-program-change", cli}) |
| + runShip([]string{"-app", "console", "-version", "va2", "-target", "linux-arm64", "-program-change", console}) |
| + // The re-ships: log.Fatal (a conflict) would end the test binary here. |
| + runShip([]string{"-app", "console", "-version", "va", "-target", "linux-arm64", console}) |
| + runShip([]string{"-app", "console", "-version", "vb", "-target", "linux-arm64", "-program-change", cli}) |
| + |
| + m, err := release.GetManifest(context.Background(), release.NewDirStore(dir), release.OpsAccount, "console", "vb") |
| + if err != nil || m.Program != "example.com/carlos" || !m.ProgramChange { |
| + t.Fatalf("vb manifest = %+v, %v; want the first ship's stamp kept", m, err) |
| + } |
| +} |
| + |
| // --- the grant door (Task 4) ------------------------------------------- |
| // |
| // `carlos route -grant/-revoke` is the operator's door to a transfer of box |
diff --git a/internal/release/program.go b/internal/release/program.go| index 45121f3b6..56ce653d3 100644 |
| --- a/internal/release/program.go |
| +++ b/internal/release/program.go |
| @@ -126,15 +126,21 @@ func (e *ProgramMismatchError) Error() string { |
| e.Got, e.App, e.Want, e.WantVersion) |
| } |
| -// CheckShipProgram is the one ship-side rule both ship doors call before |
| -// release.Ship. It stamps m.Program, and m.ProgramChange from allowChange. |
| +// StampProgram records a binary release's Go program on m, and the operator's |
| +// --program-change as m.ProgramChange. A static release, or one with no Go |
| +// artifact, is left unstamped. It errors only when the artifacts are |
| +// different programs. |
| // |
| -// The flag is recorded whenever it was given, not only when this ship differs |
| -// from the newest release: a box decides by its own route's history, and a box |
| -// that missed the release which changed the program (ship A, ship B1 with the |
| -// flag, a box still on A) must find the consent on B2 too. The comparison with |
| -// the previous release only decides whether a ship WITHOUT the flag refuses. |
| -func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, files map[string][]byte, allowChange bool) error { |
| +// The flag is recorded whenever it was given, not only when the release |
| +// differs from the newest one: a box decides by its own route's history, and a |
| +// box that missed the release which changed the program (ship A, ship B1 with |
| +// the flag, a box still on A) must find the consent on B2 too. |
| +// |
| +// It is the half of CheckShipProgram that does not read the store, so the |
| +// direct-bucket door can stamp a re-ship of an existing version exactly as |
| +// its first ship was stamped (the version's manifest is compared byte for |
| +// byte by release.Ship) without judging it against the history again. |
| +func StampProgram(m *Manifest, files map[string][]byte, allowChange bool) error { |
| if m.Kind != "binary" { |
| return nil |
| } |
| @@ -144,6 +150,19 @@ func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, fi |
| } |
| m.Program = prog |
| m.ProgramChange = allowChange |
| + return nil |
| +} |
| + |
| +// CheckShipProgram is the one ship-side rule both ship doors call before |
| +// release.Ship for a new version. It stamps m (StampProgram), then refuses a |
| +// release whose program differs from the app's previous release unless |
| +// allowChange: the comparison only decides whether a ship WITHOUT the flag |
| +// refuses. |
| +func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, files map[string][]byte, allowChange bool) error { |
| + if err := StampProgram(m, files, allowChange); err != nil || m.Program == "" { |
| + return err |
| + } |
| + prog := m.Program |
| prev, prevVer, err := PreviousProgram(ctx, s, acct, m.App) |
| if err != nil { |
| return fmt.Errorf("reading %s's previous releases: %w", m.App, err) |