cli: ship labels/notes, version target, releases table, tag output
The CLI half of release versioning: `carlos ship` takes -label and -notes/-notes-file and mints its version from the app's target when -version is omitted, `carlos version target` reads and sets that target, `carlos releases` lists what has been shipped, and the tag a stable promotion cuts is printed by promote and carried in channels' new TAG column. Both modes throughout — API mode through the console doors apiversion.go opens, direct mode through internal/release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+895
−33
apimode.go+111 −0apimode_test.go+181 −14cli.go+339 −17cli_test.go+245 −0main.go+19 −2
diff --git a/apimode.go b/apimode.go| index 935b5e223..10feca0cf 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -436,6 +436,117 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]api |
| return out, nil |
| } |
| +// --- version target, tags, releases (release versioning spec, 2026-08-08) |
| +// |
| +// The four read/write doors internal/console/apiversion.go opens, in the |
| +// same shape as channels/promote above: decode only the fields the CLI |
| +// prints, and let apiErrorFromBody speak for every non-2xx — the console's |
| +// own refusals ("already released as tag 0.5.0", "want MAJOR.MINOR.PATCH") |
| +// are better sentences than anything this side could re-derive. |
| + |
| +// apiVersionTarget is GET .../version-target's shape. An UNSET target is |
| +// Target == "" and a 200, not a 404: unset is a state, exactly as |
| +// release.GetTarget's own contract has it, and this client keeps that shape |
| +// rather than inventing a sentinel error for it. |
| +type apiVersionTarget struct { |
| + Target string `json:"target"` |
| + SetBy string `json:"set_by"` |
| + SetAt string `json:"set_at"` |
| +} |
| + |
| +func (s *apiSession) versionTarget(ctx context.Context, a apiAcct, app string) (apiVersionTarget, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/version-target")) |
| + if err != nil { |
| + return apiVersionTarget{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiVersionTarget{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiVersionTarget |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiVersionTarget{}, fmt.Errorf("version target: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// setVersionTarget writes the target and returns what the console stored — |
| +// its echo, not the argument sent, so a server that normalised the string is |
| +// the one the operator is shown. |
| +func (s *apiSession) setVersionTarget(ctx context.Context, a apiAcct, app, target string) (string, error) { |
| + in := struct { |
| + Target string `json:"target"` |
| + }{target} |
| + status, body, err := s.putJSON(ctx, apiPath(a, app, "/version-target"), in) |
| + if err != nil { |
| + return "", err |
| + } |
| + if status != http.StatusOK { |
| + return "", apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Target string `json:"target"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return "", fmt.Errorf("version target: unreadable response: %w", err) |
| + } |
| + return out.Target, nil |
| +} |
| + |
| +// apiTag is one row of GET .../tags. Release is the ITERATION the tag was cut |
| +// from ("0.5.0-3"), Version the name it earned ("0.5.0") — the direction |
| +// matters, because every join in this file goes release -> tag. |
| +type apiTag struct { |
| + Version string `json:"version"` |
| + Release string `json:"release"` |
| + TaggedAt string `json:"tagged_at"` |
| + Actor string `json:"actor"` |
| +} |
| + |
| +func (s *apiSession) tags(ctx context.Context, a apiAcct, app string) ([]apiTag, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/tags")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Tags []apiTag `json:"tags"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("tags: unreadable response: %w", err) |
| + } |
| + return res.Tags, nil |
| +} |
| + |
| +// apiRelease is one row of GET .../releases: what `carlos releases` prints, |
| +// already joined against the tags server-side (handleAPIReleasesList), so |
| +// this client never issues a second call to fill the TAG column. |
| +type apiRelease struct { |
| + Version string `json:"version"` |
| + Kind string `json:"kind"` |
| + BuiltAt string `json:"built_at"` |
| + Label string `json:"label"` |
| + Tag string `json:"tag"` |
| +} |
| + |
| +func (s *apiSession) releases(ctx context.Context, a apiAcct, app string) ([]apiRelease, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Releases []apiRelease `json:"releases"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("releases: unreadable response: %w", err) |
| + } |
| + return res.Releases, nil |
| +} |
| + |
| // apiTrashResult is handleAPITrashApp's success shape: the confirmed cooloff |
| // deadline. The SERVER computes it (it re-reads the marker's own DeletedAt |
| // after writing — TrashApp is create-only, so an app already in the trash |
diff --git a/apimode_test.go b/apimode_test.go| index cf5c9b895..e9bbd1c66 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -897,22 +897,35 @@ func TestPromoteVerbAPIModeNoOp(t *testing.T) { |
| // TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the |
| // ladder through the console and print the same tabwriter table direct mode |
| // prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row |
| -// ERROR behaviour, per apimode.go's doc comment). |
| +// ERROR behaviour, per apimode.go's doc comment) — plus the TAG column, which |
| +// comes from a SECOND read (GET .../tags) joined client-side, so the channels |
| +// endpoint's own wire shape stays untouched (release versioning spec, |
| +// 2026-08-08). |
| func TestChannelsVerbAPIMode(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| checkNoRegistry := requireNoRegistryTouch(t) |
| + base := "/api/cli/apps/" + apiTestAccount + "/titogo" |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/channels" { |
| + switch r.URL.Path { |
| + case base + "/channels": |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "channels": []map[string]any{ |
| + {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, |
| + {"channel": "stable", "version": "0.5.0-3", "promoted_at": "2026-08-04T00:00:00Z", "rung": "stable"}, |
| + {"channel": "broken", "error": "signature mismatch"}, |
| + }, |
| + }) |
| + case base + "/tags": |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "tags": []map[string]any{ |
| + {"version": "0.5.0", "release": "0.5.0-3", "tagged_at": "2026-08-04T00:00:00Z", "actor": "vicky@keymail.dev"}, |
| + }, |
| + }) |
| + default: |
| t.Errorf("path = %s", r.URL.Path) |
| } |
| - json.NewEncoder(w).Encode(map[string]any{ |
| - "channels": []map[string]any{ |
| - {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, |
| - {"channel": "broken", "error": "signature mismatch"}, |
| - }, |
| - }) |
| })) |
| defer ts.Close() |
| @@ -925,7 +938,7 @@ func TestChannelsVerbAPIMode(t *testing.T) { |
| if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| } |
| - if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") { |
| + if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") || !strings.Contains(out, "TAG") { |
| t.Fatalf("stdout = %q, want the tabwriter header", out) |
| } |
| if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") { |
| @@ -934,6 +947,160 @@ func TestChannelsVerbAPIMode(t *testing.T) { |
| if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") { |
| t.Fatalf("stdout = %q, want the broken row's ERROR text", out) |
| } |
| + // The tag lands on the row serving the release it was cut from, and on no |
| + // other row: the join is release -> tag, not "the newest tag wins". |
| + var stableRow, canaryRow string |
| + for _, line := range strings.Split(out, "\n") { |
| + switch { |
| + case strings.HasPrefix(line, "stable"): |
| + stableRow = strings.TrimRight(line, " ") |
| + case strings.HasPrefix(line, "canary/a"): |
| + canaryRow = strings.TrimRight(line, " ") |
| + } |
| + } |
| + if !strings.HasSuffix(stableRow, "0.5.0") { |
| + t.Fatalf("stable row = %q, want it to end in the tag 0.5.0", stableRow) |
| + } |
| + if strings.Contains(canaryRow, "0.5.0") { |
| + t.Fatalf("canary row = %q, want no tag on an untagged version", canaryRow) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestVersionTargetVerbAPIMode pins runVersionTarget's API-mode wiring in all |
| +// three states it has: unset (a 200 with an empty target, NOT a 404), set, |
| +// and the write — which must be a PUT carrying {"target"}, the shape |
| +// handleAPIVersionTargetSet (internal/console/apiversion.go) reads. |
| +func TestVersionTargetVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + path := "/api/cli/apps/" + apiTestAccount + "/titogo/version-target" |
| + var stored string |
| + var gotPutBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != path { |
| + t.Errorf("path = %s", r.URL.Path) |
| + return |
| + } |
| + switch r.Method { |
| + case http.MethodGet: |
| + if stored == "" { |
| + json.NewEncoder(w).Encode(map[string]string{"target": ""}) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "target": stored, "set_by": "vicky@keymail.dev", "set_at": "2026-08-08T00:00:00Z", |
| + }) |
| + case http.MethodPut: |
| + b, _ := io.ReadAll(r.Body) |
| + gotPutBody = strings.TrimSpace(string(b)) |
| + var req struct { |
| + Target string `json:"target"` |
| + } |
| + json.Unmarshal(b, &req) |
| + stored = req.Target |
| + json.NewEncoder(w).Encode(map[string]string{"target": stored}) |
| + default: |
| + t.Errorf("method = %s", r.Method) |
| + } |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + if out := buf.String(); !strings.Contains(out, "no version target set") { |
| + t.Fatalf("unset target stdout = %q", out) |
| + } |
| + |
| + buf = captureStdout(t) |
| + runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount, "0.5.0"}) |
| + if out := buf.String(); !strings.Contains(out, "version target 0.5.0") { |
| + t.Fatalf("set stdout = %q", out) |
| + } |
| + if want := `{"target":"0.5.0"}`; gotPutBody != want { |
| + t.Fatalf("PUT body = %q, want %q", gotPutBody, want) |
| + } |
| + |
| + buf = captureStdout(t) |
| + runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + out := buf.String() |
| + if !strings.Contains(out, "version target 0.5.0") { |
| + t.Fatalf("get-after-set stdout = %q", out) |
| + } |
| + if !strings.Contains(out, "set by vicky@keymail.dev") { |
| + t.Fatalf("get-after-set stdout = %q, want the provenance line", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestReleasesVerbAPIMode pins runReleases' API-mode wiring: one GET, the |
| +// server's row ORDER preserved verbatim (the console already sorts newest |
| +// first — re-sorting client-side would be a second opinion nobody asked |
| +// for), and every column rendered. |
| +func TestReleasesVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/releases" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "releases": []map[string]any{ |
| + {"version": "0.5.0-4", "kind": "binary", "built_at": "2026-08-08T10:00:00Z", "label": "and the sidebar", "tag": ""}, |
| + {"version": "0.5.0-3", "kind": "binary", "built_at": "2026-08-07T10:00:00Z", "label": "fixing modals", "tag": "0.5.0"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runReleases([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") |
| + if len(lines) < 4 { |
| + t.Fatalf("stdout = %q, want a using line, a header and two rows", out) |
| + } |
| + if !strings.Contains(lines[1], "VERSION") || !strings.Contains(lines[1], "LABEL") || !strings.Contains(lines[1], "TAG") { |
| + t.Fatalf("header = %q", lines[1]) |
| + } |
| + if !strings.Contains(lines[2], "0.5.0-4") || !strings.Contains(lines[2], "and the sidebar") { |
| + t.Fatalf("first row = %q, want the server's first row", lines[2]) |
| + } |
| + if !strings.Contains(lines[3], "0.5.0-3") || !strings.HasSuffix(strings.TrimRight(lines[3], " "), "0.5.0") { |
| + t.Fatalf("second row = %q, want the tagged row carrying its tag", lines[3]) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestReleasesVerbAPIModeEmpty: an app with nothing shipped gets a sentence, |
| +// not a bare header — the empty table would otherwise read as a failure. |
| +func TestReleasesVerbAPIModeEmpty(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"releases": []map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runReleases([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + if out := buf.String(); !strings.Contains(out, "no releases yet") { |
| + t.Fatalf("stdout = %q, want the empty-state line", out) |
| + } |
| checkNoRegistry() |
| } |
| @@ -1084,7 +1251,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); err != nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1131,7 +1298,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); err == nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil); err == nil { |
| t.Fatal("runShipAPI against a failing server must error") |
| } |
| @@ -1197,7 +1364,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); err != nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1239,7 +1406,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) |
| + err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil) |
| if err == nil { |
| t.Fatal("runShipAPI with a bad basename must error") |
| } |
| @@ -1274,7 +1441,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) |
| + err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil) |
| if err == nil { |
| t.Fatal("runShipAPI with a missing artifact path must error") |
| } |
diff --git a/cli.go b/cli.go| index f94a56193..c993744c4 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1440,12 +1440,43 @@ func runShip(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| - ver := fs.String("version", "", "version (git short sha)") |
| + ver := fs.String("version", "", "version (git short sha); omit to mint the next iteration under the app's version target") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest") |
| + 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)") |
| fs.Parse(args) |
| - if *app == "" || *ver == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] <files-or-site-dir>") |
| + // -version is no longer required: with a version target set, the next |
| + // iteration under it is inferable, and inferring it is the whole point of |
| + // 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. |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("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>") |
| + } |
| + |
| + // -notes and -notes-file are two spellings of ONE manifest field, so both |
| + // together is a refusal rather than a precedence rule nobody remembers. |
| + if *notes != "" && *notesFile != "" { |
| + log.Fatal("pass -notes or -notes-file, not both") |
| + } |
| + noteText := *notes |
| + if *notesFile != "" { |
| + b, err := os.ReadFile(*notesFile) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + noteText = string(b) |
| + } |
| + // Bounded HERE, before the mode split, for the same reason the capability |
| + // block is: both doors must refuse the same bytes with the same message |
| + // at the operator's terminal. release.ValidateLabelNotes is the one home |
| + // for the limits and their wording — direct mode would reach it again |
| + // inside release.Ship, API mode again in the console, but neither of |
| + // those is where a human is standing. |
| + if err := release.ValidateLabelNotes(*label, noteText); err != nil { |
| + log.Fatal(err) |
| } |
| // Read and decode the declaration BEFORE the mode split, so both doors |
| @@ -1470,7 +1501,7 @@ func runShip(args []string) { |
| // branch defers the cleanup of a scratch tarball up to 256MiB). |
| // Evaluated as an argument, the refusal lands before runShipAPI's |
| // body has registered a defer at all. |
| - if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, capsSet); err != nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, *label, noteText, capsSet); err != nil { |
| log.Fatal(err) |
| } |
| return |
| @@ -1513,18 +1544,50 @@ func runShip(args []string) { |
| addFile(filepath.Base(path), data) |
| } |
| } |
| + // Direct mode opens the store ONCE, here: the mint below reads it (the |
| + // release dirs are the iteration counter — there is no side counter to |
| + // drift from them), and release.Ship writes through the same handle. |
| + st := openStore() |
| + acct := acctOf() |
| + shipVersion := *ver |
| + if shipVersion == "" { |
| + vt, _, err := release.GetTarget(context.Background(), st, acct, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if vt == nil { |
| + log.Fatal("no -version and no version target set — set one with `carlos version target -app " + *app + " <semver>`, or pass -version") |
| + } |
| + t, ok := release.ParseSemVer(vt.Target) |
| + if !ok { |
| + // GetTarget already refuses a stored target that isn't a bare |
| + // semver, so this is unreachable through that door — kept because |
| + // the alternative to a named refusal here is a silent zero SemVer |
| + // minting "0.0.0-1" against an app nobody meant to touch. |
| + log.Fatalf("stored version target %q is not a semver — set it again", vt.Target) |
| + } |
| + shipVersion, err = release.NextIteration(context.Background(), st, acct, *app, t) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| m := release.Manifest{ |
| - Schema: 1, App: *app, Version: *ver, Kind: *kind, |
| + Schema: 1, App: *app, Version: shipVersion, Kind: *kind, |
| BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().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 |
| + // labels and notes existed. |
| + Label: *label, |
| + Notes: noteText, |
| // nil when -capabilities was not given: omitempty then keeps the |
| // manifest bytes byte-identical to what this verb wrote yesterday. |
| Capabilities: capsSet, |
| } |
| - if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil { |
| + if err := release.Ship(context.Background(), st, acct, m, files); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, *ver, len(files)) |
| + fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, shipVersion, len(files)) |
| } |
| // readCapabilities is the ship-time declaration surface: a file holding |
| @@ -1589,7 +1652,7 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| // function — including the static kind's scratch temp file cleanup below, |
| // up to 256MiB per invocation. Returning lets that defer actually run on |
| // every exit path, fatal or not. |
| -func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target string, capsSet *caps.Set) error { |
| +func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set) error { |
| files := map[string]string{} |
| if kind == "static" { |
| if fs.NArg() != 1 { |
| @@ -1645,14 +1708,18 @@ 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, label, notes, files, capsSet) |
| if err != nil { |
| return err |
| } |
| + // res.Version, never ver: when the caller passed no -version the CONSOLE |
| + // minted one from the app's target, and the response is the only place |
| + // that name exists. It echoes the sent version verbatim otherwise, so |
| + // this is the right thing to print on both paths rather than two. |
| if res.NoOp { |
| - mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver) |
| + mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, res.Version) |
| } else { |
| - fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, ver) |
| + fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, res.Version) |
| } |
| return nil |
| } |
| @@ -1718,6 +1785,22 @@ func runPromote(args []string) { |
| default: |
| fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel) |
| } |
| + // The tag a stable promotion cut, printed after EVERY branch above rather |
| + // than only the fresh ones: PromoteOnLadder cuts the tag on the no-op |
| + // path too (a repeat promote heals a version that reached stable before |
| + // tags existed, or whose tag write failed the first time), and a name |
| + // that was just minted with nothing said about it is a name the operator |
| + // never learns. Empty off stable, so nothing new prints anywhere else. |
| + // |
| + // TagWarning is a warning, not an error, for the reason release.stableTag |
| + // gives: the pointer has already moved, so a promotion that happened must |
| + // report success. It is printed verbatim. |
| + if res.Tag != "" { |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("tagged"), res.Tag) |
| + } |
| + if res.TagWarning != "" { |
| + fmt.Fprintf(stdout, "%s\n", colorFor(stdout).warn(res.TagWarning)) |
| + } |
| } |
| // promoteActor names who is promoting, for the pointer's audit trail: the |
| @@ -1754,14 +1837,28 @@ func runChannels(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // A separate read rather than a new field on the channels response: |
| + // the channels endpoint is what the box-facing world already parses, |
| + // and a display join belongs on the display side. A tag read that |
| + // fails degrades to a tag-less table — the question this verb answers |
| + // is "what is each channel serving", and no tag has ever been part of |
| + // that answer. |
| + tags, err := sess.tags(context.Background(), acct, *app) |
| + if err != nil { |
| + mutedf(stdout, "(tags unreadable: %v)\n", err) |
| + } |
| + byRelease := make(map[string]string, len(tags)) |
| + for _, t := range tags { |
| + byRelease[t.Release] = t.Version |
| + } |
| w := newTable(stdout) |
| - fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| + fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG") |
| for _, ch := range chans { |
| if ch.Err != "" { |
| - fmt.Fprintf(w, "%s\tERROR: %s\t\n", ch.Channel, ch.Err) |
| + fmt.Fprintf(w, "%s\tERROR: %s\t\t\n", ch.Channel, ch.Err) |
| continue |
| } |
| - fmt.Fprintf(w, "%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt) |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version]) |
| } |
| w.Flush() |
| return |
| @@ -1774,17 +1871,242 @@ func runChannels(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // Same join as API mode above, from the store rather than the console, |
| + // and degrading the same way. |
| + tags, err := release.ListTags(context.Background(), s, acct, *app) |
| + if err != nil { |
| + mutedf(stdout, "(tags unreadable: %v)\n", err) |
| + } |
| + byRelease := make(map[string]string, len(tags)) |
| + for _, t := range tags { |
| + byRelease[t.Release] = t.Version |
| + } |
| pub := publicKey() |
| w := newTable(stdout) |
| - fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| + fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG") |
| for _, k := range keys { |
| name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| cp, err := release.Channel(context.Background(), s, pub, acct, *app, name) |
| if err != nil { |
| - fmt.Fprintf(w, "%s\tERROR: %v\t\n", name, err) |
| + fmt.Fprintf(w, "%s\tERROR: %v\t\t\n", name, err) |
| + continue |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339), byRelease[cp.Version]) |
| + } |
| + w.Flush() |
| +} |
| + |
| +// runVersionTarget is `carlos version target`: the per-app semver that |
| +// `carlos ship` counts iterations under (release versioning spec, |
| +// 2026-08-08). No positional argument reads it, one sets it — rather than a |
| +// -set flag, which would make a mistyped read (a stray value, a shell glob) |
| +// into a write of the one number every future version of this app hangs off. |
| +// |
| +// This is a SUB-verb of `carlos version` (main.go), which on its own still |
| +// prints the binary's build id and nothing else: the oldest output in this |
| +// program, typed by every install script there is. |
| +func runVersionTarget(args []string) { |
| + fs := flag.NewFlagSet("version target", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to read or set through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() > 1 { |
| + log.Fatal("usage: carlos version target -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [<semver>]") |
| + } |
| + |
| + // unset is what both modes print when no target has ever been set, and it |
| + // names the way to set one: an operator meeting this line is one command |
| + // away from the feature, and the command is not guessable from "none". |
| + unset := func() { |
| + mutedf(stdout, "no version target set\n") |
| + mutedf(stdout, " set one with: carlos version target -app %s 0.1.0\n", *app) |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess != nil { |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + if fs.NArg() == 0 { |
| + vt, err := sess.versionTarget(context.Background(), acct, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if vt.Target == "" { |
| + unset() |
| + return |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), vt.Target) |
| + if vt.SetBy != "" { |
| + mutedf(stdout, " set by %s at %s\n", vt.SetBy, vt.SetAt) |
| + } |
| + return |
| + } |
| + stored, err := sess.setVersionTarget(context.Background(), acct, *app, fs.Arg(0)) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), stored) |
| + return |
| + } |
| + |
| + acct := acctOf() |
| + s := openStore() |
| + if fs.NArg() == 0 { |
| + vt, _, err := release.GetTarget(context.Background(), s, acct, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if vt == nil { |
| + unset() |
| + return |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), vt.Target) |
| + if vt.SetBy != "" { |
| + mutedf(stdout, " set by %s at %s\n", vt.SetBy, vt.SetAt.Format(time.RFC3339)) |
| + } |
| + return |
| + } |
| + // SetTarget carries both refusals — a target that isn't a bare semver, |
| + // and one that is not above every tag already cut — and its messages name |
| + // the offending value, so they pass through untouched. |
| + target := fs.Arg(0) |
| + if err := release.SetTarget(context.Background(), s, acct, *app, target, promoteActor(), time.Now().UTC()); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), target) |
| +} |
| + |
| +// releaseRow is one row of `carlos releases`, in either mode: the console |
| +// joins the tags server-side (handleAPIReleasesList) and direct mode joins |
| +// them here, but both arrive at the same five columns so the table renders |
| +// once below. Err is not a column — it is the direct-mode "this manifest |
| +// would not read" row, printed in the LABEL cell the way runChannels prints |
| +// an unverifiable pointer. |
| +type releaseRow struct { |
| + Version, Label, Kind, BuiltAt, Tag, Err string |
| +} |
| + |
| +// releasesCap bounds the list, matching the console's own versionsCap: a |
| +// long-lived app has thousands of releases and nobody reads past the first |
| +// screen of them. |
| +const releasesCap = 20 |
| + |
| +// localReleases is the direct-mode half of `carlos releases`: List the |
| +// release dirs, read each manifest, join the tags, newest first. |
| +// |
| +// It is a local reimplementation of console.Versions rather than a call to |
| +// it, deliberately: the CLI does not import internal/console today — that |
| +// package carries templates, sessions, a registry and a whole HTTP server — |
| +// and twenty lines of List + GetManifest is a far smaller thing to own than |
| +// that dependency. A manifest that will not read is a row with Err set, |
| +// exactly as it is on the console's app page and in runChannels above, not a |
| +// failure of the whole listing. |
| +func localReleases(ctx context.Context, s release.Store, acct, app string) ([]releaseRow, error) { |
| + prefix := release.ReleasesPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + tags, err := release.ListTags(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + byRelease := make(map[string]string, len(tags)) |
| + for _, t := range tags { |
| + byRelease[t.Release] = t.Version |
| + } |
| + seen := map[string]bool{} |
| + var out []releaseRow |
| + for _, k := range keys { |
| + // Every artifact of a release shares its dir, so the version is the |
| + // first segment after the prefix and each one is taken once. |
| + v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/") |
| + if !ok || v == "" || seen[v] { |
| + continue |
| + } |
| + seen[v] = true |
| + m, err := release.GetManifest(ctx, s, acct, app, v) |
| + if err != nil { |
| + out = append(out, releaseRow{Version: v, Tag: byRelease[v], Err: err.Error()}) |
| + continue |
| + } |
| + out = append(out, releaseRow{ |
| + Version: v, Label: m.Label, Kind: m.Kind, BuiltAt: m.BuiltAt, Tag: byRelease[v], |
| + }) |
| + } |
| + // Newest first by build time, version-descending within a tie — the same |
| + // order console.Versions sorts by, so the two surfaces agree about what |
| + // "the latest release" is. An unparseable BuiltAt sorts as the zero time |
| + // (last), which is where a manifest that could not be read belongs. |
| + parse := func(s string) time.Time { |
| + t, _ := time.Parse(time.RFC3339, s) |
| + return t |
| + } |
| + sort.SliceStable(out, func(i, j int) bool { |
| + ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt) |
| + if !ti.Equal(tj) { |
| + return ti.After(tj) |
| + } |
| + return out[i].Version > out[j].Version |
| + }) |
| + if len(out) > releasesCap { |
| + out = out[:releasesCap] |
| + } |
| + return out, nil |
| +} |
| + |
| +// runReleases is `carlos releases`: what has been shipped, newest first, |
| +// with the label its author typed and the tag it earned if it reached |
| +// stable. `carlos channels` answers "what is running"; this answers "what is |
| +// there to run". |
| +func runReleases(args []string) { |
| + fs := flag.NewFlagSet("releases", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos releases -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var rows []releaseRow |
| + if sess != nil { |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + rels, err := sess.releases(context.Background(), acct, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + for _, r := range rels { |
| + rows = append(rows, releaseRow{Version: r.Version, Label: r.Label, Kind: r.Kind, BuiltAt: r.BuiltAt, Tag: r.Tag}) |
| + } |
| + } else { |
| + rows, err = localReleases(context.Background(), openStore(), acctOf(), *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + |
| + if len(rows) == 0 { |
| + mutedf(stdout, "no releases yet — carlos ship -app %s <files>\n", *app) |
| + return |
| + } |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "VERSION\tLABEL\tKIND\tBUILT\tTAG") |
| + for _, r := range rows { |
| + if r.Err != "" { |
| + fmt.Fprintf(w, "%s\tERROR: %s\t\t\t%s\n", r.Version, r.Err, r.Tag) |
| continue |
| } |
| - fmt.Fprintf(w, "%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339)) |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Version, r.Label, r.Kind, r.BuiltAt, r.Tag) |
| } |
| w.Flush() |
| } |
diff --git a/cli_test.go b/cli_test.go| index 69fe99115..ee98ebea2 100644 |
| --- a/cli_test.go |
| +++ b/cli_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "context" |
| "crypto/ed25519" |
| "os" |
| + "os/exec" |
| "path/filepath" |
| "strings" |
| "testing" |
| @@ -432,3 +433,247 @@ func TestBackingRailRefusals(t *testing.T) { |
| t.Fatalf("backingRail(unit-backed instance) = %v, want accepted", err) |
| } |
| } |
| + |
| +// --- release versioning: the CLI surface ------------------------------- |
| +// |
| +// (release versioning spec, 2026-08-08.) Driven through the real binary in |
| +// DIRECT mode, following scopedkeys_e2e_test.go exactly: a DirStore |
| +// deployment dir under t.TempDir(), a root signing key the binary mints for |
| +// itself, carlosTestEnv to mask the operator's real login and console, and |
| +// cmd.Dir at a scratch dir because the binary walks cwd ancestors for |
| +// .carlos/config. |
| + |
| +// versioningCLI builds carlos, mints a root key with it, and returns a |
| +// runner bound to a fresh deployment dir — returned alongside the runner so |
| +// a test can assert on what actually landed in the store, not only on what |
| +// stdout claimed. |
| +func versioningCLI(t *testing.T) (run func(args ...string) (string, error), bucket string) { |
| + t.Helper() |
| + bin := buildCarlos(t) |
| + bucket = t.TempDir() |
| + scratch := t.TempDir() |
| + store := []string{"CARLOS_DEPLOYMENT_DIR=" + bucket, "CARLOS_DATA_DIR=" + t.TempDir()} |
| + runWith := func(env []string, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t, append(append([]string{}, store...), env...)...) |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + out, err := runWith(nil, "release-keygen") |
| + if err != nil { |
| + t.Fatalf("release-keygen: %v\n%s", err, out) |
| + } |
| + keys := []string{ |
| + "CARLOS_RELEASE_KEY=" + envValue(t, out, "CARLOS_RELEASE_KEY"), |
| + "CARLOS_RELEASE_PUBKEY=" + envValue(t, out, "CARLOS_RELEASE_PUBKEY"), |
| + } |
| + return func(args ...string) (string, error) { return runWith(keys, args...) }, bucket |
| +} |
| + |
| +// versioningArtifact drops one throwaway binary artifact and returns its path. |
| +func versioningArtifact(t *testing.T, body string) string { |
| + t.Helper() |
| + p := filepath.Join(t.TempDir(), "kass") |
| + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +// TestShipMintsFromTargetDirect: with a target set, a -version-less ship |
| +// mints the next iteration under it, and the label/notes the operator typed |
| +// reach the manifest — the two halves of the feature an operator sees at |
| +// their own terminal. |
| +func TestShipMintsFromTargetDirect(t *testing.T) { |
| + run, bucket := versioningCLI(t) |
| + |
| + if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil { |
| + t.Fatalf("version target 0.5.0: %v\n%s", err, out) |
| + } |
| + out, err := run("ship", "-app", "kass", |
| + "-label", "fixing modals", |
| + "-notes", "modals close on escape now", |
| + versioningArtifact(t, "build one")) |
| + if err != nil { |
| + t.Fatalf("minted ship: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "0.5.0-1") { |
| + t.Fatalf("the first minted ship does not name 0.5.0-1:\n%s", out) |
| + } |
| + |
| + st := release.NewDirStore(bucket) |
| + m, err := release.GetManifest(context.Background(), st, release.OpsAccount, "kass", "0.5.0-1") |
| + if err != nil { |
| + t.Fatalf("manifest 0.5.0-1: %v", err) |
| + } |
| + if m.Label != "fixing modals" { |
| + t.Fatalf("manifest label = %q, want %q", m.Label, "fixing modals") |
| + } |
| + if m.Notes != "modals close on escape now" { |
| + t.Fatalf("manifest notes = %q, want the -notes text", m.Notes) |
| + } |
| + |
| + // The counter comes off what has been SHIPPED, so a second ship under the |
| + // same target is -2 with no state anywhere but the release dirs. |
| + out, err = run("ship", "-app", "kass", "-label", "and the sidebar", versioningArtifact(t, "build two")) |
| + if err != nil { |
| + t.Fatalf("second minted ship: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "0.5.0-2") { |
| + t.Fatalf("the second minted ship does not name 0.5.0-2:\n%s", out) |
| + } |
| +} |
| + |
| +// TestShipNoTargetNoVersionFatal: -version is optional only where a target |
| +// makes it inferable. With neither, the refusal has to name the verb that |
| +// fixes it — an operator who just learned -version is optional must not be |
| +// left guessing what the alternative was. |
| +func TestShipNoTargetNoVersionFatal(t *testing.T) { |
| + run, _ := versioningCLI(t) |
| + out, err := run("ship", "-app", "kass", versioningArtifact(t, "build one")) |
| + if err == nil { |
| + t.Fatalf("ship with no -version and no target succeeded:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos version target") { |
| + t.Fatalf("refusal does not name `carlos version target`:\n%s", out) |
| + } |
| +} |
| + |
| +// TestVersionTargetGetSetDirect: get before set, set, get after — and a |
| +// garbage target refused rather than stored. |
| +func TestVersionTargetGetSetDirect(t *testing.T) { |
| + run, _ := versioningCLI(t) |
| + |
| + out, err := run("version", "target", "-app", "kass") |
| + if err != nil { |
| + t.Fatalf("version target (unset): %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "no version target set") { |
| + t.Fatalf("an unset target does not say so:\n%s", out) |
| + } |
| + |
| + if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil { |
| + t.Fatalf("version target 0.5.0: %v\n%s", err, out) |
| + } |
| + out, err = run("version", "target", "-app", "kass") |
| + if err != nil { |
| + t.Fatalf("version target (set): %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "0.5.0") { |
| + t.Fatalf("version target does not print the stored target:\n%s", out) |
| + } |
| + |
| + if out, err := run("version", "target", "-app", "kass", "banana"); err == nil { |
| + t.Fatalf("version target accepted a non-semver:\n%s", out) |
| + } |
| + |
| + // `carlos version` on its own is still the build id, not a target: the |
| + // new sub-verb must not have moved the oldest output in the binary. |
| + out, err = run("version") |
| + if err != nil { |
| + t.Fatalf("version: %v\n%s", err, out) |
| + } |
| + if !strings.HasPrefix(out, "carlos ") || strings.Contains(out, "version target") { |
| + t.Fatalf("`carlos version` is no longer the build id:\n%s", out) |
| + } |
| +} |
| + |
| +// TestReleasesTableDirect: the shipped list, newest first, carrying the |
| +// labels that were typed at ship time. |
| +func TestReleasesTableDirect(t *testing.T) { |
| + run, _ := versioningCLI(t) |
| + if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil { |
| + t.Fatalf("version target: %v\n%s", err, out) |
| + } |
| + if out, err := run("ship", "-app", "kass", "-label", "fixing modals", versioningArtifact(t, "build one")); err != nil { |
| + t.Fatalf("ship one: %v\n%s", err, out) |
| + } |
| + if out, err := run("ship", "-app", "kass", "-label", "and the sidebar", versioningArtifact(t, "build two")); err != nil { |
| + t.Fatalf("ship two: %v\n%s", err, out) |
| + } |
| + |
| + out, err := run("releases", "-app", "kass") |
| + if err != nil { |
| + t.Fatalf("releases: %v\n%s", err, out) |
| + } |
| + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") |
| + if len(lines) < 3 { |
| + t.Fatalf("releases printed %d lines, want a header and two rows:\n%s", len(lines), out) |
| + } |
| + if !strings.Contains(lines[0], "VERSION") || !strings.Contains(lines[0], "LABEL") || !strings.Contains(lines[0], "TAG") { |
| + t.Fatalf("releases header = %q, want VERSION/LABEL/.../TAG", lines[0]) |
| + } |
| + if !strings.Contains(lines[1], "0.5.0-2") || !strings.Contains(lines[1], "and the sidebar") { |
| + t.Fatalf("the newest release is not the first row:\n%s", out) |
| + } |
| + if !strings.Contains(out, "fixing modals") { |
| + t.Fatalf("releases does not carry the older label:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPromoteStableTagOutputDirect: the tag a stable promotion cuts is |
| +// PRINTED — the operator who typed the promote is the one who needs to know |
| +// what the release is now called. `carlos channels` carries it too, so the |
| +// name survives past the one line that announced it. |
| +func TestPromoteStableTagOutputDirect(t *testing.T) { |
| + run, _ := versioningCLI(t) |
| + if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil { |
| + t.Fatalf("version target: %v\n%s", err, out) |
| + } |
| + if out, err := run("ship", "-app", "kass", "-label", "fixing modals", versioningArtifact(t, "build one")); err != nil { |
| + t.Fatalf("ship: %v\n%s", err, out) |
| + } |
| + for _, ch := range []string{"edge", "beta"} { |
| + if out, err := run("promote", "-app", "kass", "0.5.0-1", ch); err != nil { |
| + t.Fatalf("promote to %s: %v\n%s", ch, err, out) |
| + } |
| + } |
| + out, err := run("promote", "-app", "kass", "0.5.0-1", "stable") |
| + if err != nil { |
| + t.Fatalf("promote to stable: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "tagged 0.5.0") { |
| + t.Fatalf("a stable promotion does not print the tag it cut:\n%s", out) |
| + } |
| + |
| + chans, err := run("channels", "-app", "kass") |
| + if err != nil { |
| + t.Fatalf("channels: %v\n%s", err, chans) |
| + } |
| + if !strings.Contains(chans, "TAG") { |
| + t.Fatalf("channels has no TAG column:\n%s", chans) |
| + } |
| + var stableRow string |
| + for _, line := range strings.Split(chans, "\n") { |
| + if strings.HasPrefix(line, "stable") { |
| + stableRow = strings.TrimRight(line, " ") |
| + } |
| + } |
| + if stableRow == "" { |
| + t.Fatalf("channels has no stable row:\n%s", chans) |
| + } |
| + if !strings.Contains(stableRow, "0.5.0-1") || !strings.HasSuffix(stableRow, "0.5.0") { |
| + t.Fatalf("the stable row does not carry the tag of the version it serves: %q\n%s", stableRow, chans) |
| + } |
| +} |
| + |
| +// TestNotesFileAndNotesConflict: two spellings of one field, so passing both |
| +// is a refusal rather than a silent precedence rule nobody can remember. |
| +func TestNotesFileAndNotesConflict(t *testing.T) { |
| + run, _ := versioningCLI(t) |
| + notes := filepath.Join(t.TempDir(), "NOTES.md") |
| + if err := os.WriteFile(notes, []byte("from a file"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + out, err := run("ship", "-app", "kass", "-version", "9f3c1ad", |
| + "-notes", "inline", "-notes-file", notes, versioningArtifact(t, "build one")) |
| + if err == nil { |
| + t.Fatalf("ship accepted both -notes and -notes-file:\n%s", out) |
| + } |
| + if !strings.Contains(out, "-notes-file") { |
| + t.Fatalf("refusal does not name -notes-file:\n%s", out) |
| + } |
| +} |
diff --git a/main.go b/main.go| index a068aa33a..816a8cd72 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -32,7 +32,17 @@ func main() { |
| case "help", "-h", "--help": |
| usage(os.Stdout) |
| case "version": |
| - fmt.Printf("carlos %s\n", version.Build) |
| + // `carlos version` on its own is the build id and nothing else — |
| + // unchanged, because every install script and support request in |
| + // existence types it. `carlos version target ...` is the only string |
| + // that dispatches anywhere new (release versioning spec, 2026-08-08): |
| + // a sub-verb rather than a top-level `carlos target`, because what it |
| + // sets IS the app's version, and it reads that way at the terminal. |
| + if len(args) > 0 && args[0] == "target" { |
| + runVersionTarget(args[1:]) |
| + } else { |
| + fmt.Printf("carlos %s\n", version.Build) |
| + } |
| case "edge": |
| edge.Run(args) |
| case "add": |
| @@ -49,6 +59,8 @@ func main() { |
| runPromote(args) |
| case "channels": |
| runChannels(args) |
| + case "releases": |
| + runReleases(args) |
| case "rollback": |
| runRollback(args) |
| case "ledger": |
| @@ -100,10 +112,15 @@ One binary, three roles. "carlos <command> -h" prints a command's own flags. |
| Your apps — ship and run software on a CARLOS deployment |
| auth log in, see who this terminal is, log out |
| apps create | delete | restore an app name |
| - ship publish a release of an app |
| + ship publish a release of an app — -label and -notes name it, |
| + and -version is optional once a version target is set |
| promote move a version up the ladder: canary → edge → beta → stable |
| + (reaching stable cuts the release's semver tag) |
| rollback point a channel back at an earlier version |
| channels show what each of an app's channels is serving |
| + releases every version shipped, newest first, with labels and tags |
| + version target the semver ship counts iterations under: print it, or set |
| + it — "carlos version target -app <a> 0.5.0" |
| env plain per-app config vars: set | unset | list |
| secrets sealed per-app secrets: set | unset | list | genkey |
| instances an app's instances: enable | create | list | delete |