carlos / platform Public

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>
Paul Campbell 22120dab54607bc8566f55402504cd4dfefafe89 parent 6ab3290
5 files changed, +895 −33
  • apimode.go +111 −0
  • apimode_test.go +181 −14
  • cli.go +339 −17
  • cli_test.go +245 −0
  • main.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