carlos / platform Public

release: wrong-program guidance asks for a new version shipped with carlos ship --program-change

Ruling G4. The box told the operator to ship the refused version with --program-change, but a version's manifest is immutable: the API door answers an identical re-ship 200 before reading the flag, so the advice could succeed and change nothing. The gate line, the first-pass refusal line and the alert body now say to ship a new version with carlos ship --program-change and promote it, if the change is intended. ProgramMismatchError names carlos ship, since carlos deploy and canary meet it and do not take the flag. Wording to be copy-reviewed by Paul.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0e3d55846bee6ba30c67c901434a8604f3fafa33 parent 58e1eef
7 files changed, +28 −17
  • internal/console/apicli_test.go +3 −3
  • internal/host/alertsink.go +3 −2
  • internal/host/refusalwatch_test.go +3 −2
  • internal/release/adopt.go +5 −3
  • internal/release/program.go +5 −1
  • internal/release/program_test.go +1 −1
  • internal/release/programhistory_test.go +8 −5
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 1860b22d0..969d668d3 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -1382,7 +1382,7 @@ func tinyProgramForConsole(t *testing.T, modPath string) []byte {
// 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
+// release is a 409 naming carlos ship --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)
@@ -1404,8 +1404,8 @@ func TestAPIShipRefusesAProgramChange(t *testing.T) {
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)
+ if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos ship --program-change") {
+ t.Fatalf("second ship = %d %v, want 409 naming carlos ship --program-change", status, out)
}
raw, err := json.Marshal(map[string]any{"version": "v3", "kind": "binary", "program_change": true})
diff --git a/internal/host/alertsink.go b/internal/host/alertsink.go
index 14c02f95e..f3fde47ad 100644
--- a/internal/host/alertsink.go
+++ b/internal/host/alertsink.go
@@ -164,12 +164,13 @@ func (a *alertSinks) deliverRelease(ctx context.Context, r release.Refusal) {
func formatReleaseAlert(r release.Refusal) (subject, msg string) {
if r.Kind == release.RefusalWrongProgram {
// Nothing started and nothing was rolled back: the box never left its
- // old build, and "--retry" would be refused the same way.
+ // old build, and "--retry" would be refused the same way. A version's
+ // manifest is immutable, so the way out is a NEW version.
subject = fmt.Sprintf("%s: %s refused, wrong program (still on %s)", r.Host, r.Version, r.RestoredVersion)
if len(subject) > 100 { // SNS subject limit
subject = subject[:100]
}
- msg = fmt.Sprintf("%s refused %s at %s: %s\n\nShip it with --program-change if the change is intended, or promote a different build.",
+ msg = fmt.Sprintf("%s refused %s at %s: %s\n\nShip a new version with carlos ship --program-change and promote it, if the change is intended, or promote a different build.",
r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), r.Reason)
return subject, msg
}
diff --git a/internal/host/refusalwatch_test.go b/internal/host/refusalwatch_test.go
index 3df1d8c97..419712022 100644
--- a/internal/host/refusalwatch_test.go
+++ b/internal/host/refusalwatch_test.go
@@ -94,7 +94,8 @@ func TestRefusalWatchReportsTheRecoveryOutcome(t *testing.T) {
}
// D2's refusal is not a rollback: the subject says so and the message points
-// at --program-change, never --retry. The webhook keeps its envelope kind and
+// at a NEW version shipped with carlos ship --program-change (the refused
+// version's manifest is immutable), never --retry. The webhook keeps its envelope kind and
// carries the record's as refusal_kind; the status row carries it too.
func TestRefusalWatchReportsAWrongProgram(t *testing.T) {
bake := t.TempDir()
@@ -108,7 +109,7 @@ func TestRefusalWatchReportsAWrongProgram(t *testing.T) {
if subject != "console.example.com: v2 refused, wrong program (still on v1)" {
t.Fatalf("subject = %q", subject)
}
- if !strings.Contains(msg, "--program-change") || strings.Contains(msg, "--retry") {
+ if !strings.Contains(msg, "Ship a new version with carlos ship --program-change and promote it, if the change is intended") || strings.Contains(msg, "--retry") {
t.Fatalf("message = %q", msg)
}
var posts []string
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index eed2f3fb7..17f858825 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -995,8 +995,10 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
fmt.Printf(" %s: reading refusal record: %v\n", rt.Host, err)
} else if r.Blocks(ptr) {
if r.Kind == RefusalWrongProgram {
- // --retry would re-stage the same bytes and be refused again.
- fmt.Printf(" %s: refused: %s is the wrong program for this route (%s); still on %s; ship it with --program-change if the change is intended\n",
+ // --retry would re-stage the same bytes and be refused again,
+ // and re-shipping this version cannot help: its manifest is
+ // immutable, so the consent has to ride on a new version.
+ fmt.Printf(" %s: refused: %s is the wrong program for this route (%s); still on %s; ship a new version with carlos ship --program-change and promote it, if the change is intended\n",
rt.Host, ver, r.Reason, r.RestoredVersion)
} else {
fmt.Printf(" %s: refused: %s did not come up on this box at %s (%s); retry with: carlos promote --retry %s %s\n",
@@ -1045,7 +1047,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
return
}
if len(hist) > 0 && !slices.Contains(hist, newProg) && !m.ProgramChange {
- fmt.Printf(" %s: %s is %s, but this route has only run %s — not pointed live (ship with --program-change if this is intended)\n",
+ fmt.Printf(" %s: %s is %s, but this route has only run %s — not pointed live (ship a new version with carlos ship --program-change and promote it, if the change is intended)\n",
rt.Host, ver, newProg, strings.Join(hist, ", "))
sha := m.Artifacts[adoptPlatform].SHA256
// Nothing was restarted, so the box is still on its old build.
diff --git a/internal/release/program.go b/internal/release/program.go
index 56ce653d3..da2c1280a 100644
--- a/internal/release/program.go
+++ b/internal/release/program.go
@@ -119,10 +119,14 @@ func PreviousProgram(ctx context.Context, s Store, acct, app string) (string, st
// ProgramMismatchError is returned when a release attempts to ship a different program
// than the app's previous releases without the --program-change override.
+//
+// The message names `carlos ship` because `carlos deploy` and canary meet
+// this refusal too and do not take the flag. Nothing was written, so the
+// same version can be shipped again.
type ProgramMismatchError struct{ App, Got, Want, WantVersion string }
func (e *ProgramMismatchError) Error() string {
- return fmt.Sprintf("this artifact is %s; %s's releases are %s (%s). If the app really is changing program, ship again with --program-change",
+ return fmt.Sprintf("this artifact is %s; %s's releases are %s (%s). If the app really is changing program, ship again with carlos ship --program-change",
e.Got, e.App, e.Want, e.WantVersion)
}
diff --git a/internal/release/program_test.go b/internal/release/program_test.go
index 23f688095..5ee889a40 100644
--- a/internal/release/program_test.go
+++ b/internal/release/program_test.go
@@ -83,7 +83,7 @@ func TestCheckShipProgram(t *testing.T) {
if !errors.As(err, &pm) || pm.Got != "example.com/carlos" || pm.Want != "example.com/console" {
t.Fatalf("wrong program ship = %v, want ProgramMismatchError", err)
}
- if !strings.Contains(err.Error(), "--program-change") {
+ if !strings.Contains(err.Error(), "carlos ship --program-change") {
t.Fatalf("refusal %q does not name the override", err)
}
if err := shipWithProgram(t, s, "v3", cli, true); err != nil {
diff --git a/internal/release/programhistory_test.go b/internal/release/programhistory_test.go
index 3b3b1dc57..812a40f07 100644
--- a/internal/release/programhistory_test.go
+++ b/internal/release/programhistory_test.go
@@ -41,8 +41,9 @@ func TestBoxRefusesWrongProgramWithNoManifestField(t *testing.T) {
if v, _ := LiveVersion(opt, host); v != "old" {
t.Fatalf("live = %q, want old", v)
}
- if !strings.Contains(out.String(), "example.com/carlos") || !strings.Contains(out.String(), "not pointed live") {
- t.Fatalf("printed %q, want the program refusal", out.String())
+ if !strings.Contains(out.String(), "example.com/carlos") || !strings.Contains(out.String(), "not pointed live") ||
+ !strings.Contains(out.String(), "ship a new version with carlos ship --program-change and promote it, if the change is intended") {
+ t.Fatalf("printed %q, want the program refusal and its way out", out.String())
}
r, _ := ReadRefusal(bake, host)
if r == nil || r.Version != "new" {
@@ -52,15 +53,17 @@ func TestBoxRefusesWrongProgramWithNoManifestField(t *testing.T) {
t.Fatalf("refusal = %+v, want a recovered wrong-program record still on old", r)
}
// The next pass reads the record at the gate: no --retry (it would loop),
- // and the way out is named.
+ // and the way out is named: a NEW version, since this one's manifest is
+ // immutable.
out.Reset()
withCapturedStdout(t, &out, func() {
if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
t.Fatalf("second Pass: %v", err)
}
})
- if !strings.Contains(out.String(), "--program-change") || strings.Contains(out.String(), "--retry") || !strings.Contains(out.String(), "still on old") {
- t.Fatalf("gate line = %q, want --program-change, still on old, and no --retry", out.String())
+ if !strings.Contains(out.String(), "ship a new version with carlos ship --program-change and promote it, if the change is intended") ||
+ strings.Contains(out.String(), "--retry") || !strings.Contains(out.String(), "still on old") {
+ t.Fatalf("gate line = %q, want a new version with carlos ship --program-change, still on old, and no --retry", out.String())
}
}