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>
7 files changed,
+28
−17
internal/console/apicli_test.go+3 −3internal/host/alertsink.go+3 −2internal/host/refusalwatch_test.go+3 −2internal/release/adopt.go+5 −3internal/release/program.go+5 −1internal/release/program_test.go+1 −1internal/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()) |
| } |
| } |