carlos / platform Public

settings-1 task 12: carlos channels resign and digests, and the DIGESTS column

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ed3be370a1499b6442ab46e24da2776d0ad915c2 parent 44912c4
7 files changed, +641 −9
  • apimode.go +94 −0
  • apimode_test.go +3 −2
  • channels_digests.go +289 −0
  • channels_digests_test.go +236 −0
  • cli.go +8 −6
  • cli_test.go +1 −1
  • main.go +10 −0
diff --git a/apimode.go b/apimode.go
index c4ce9029d..e321da09c 100644
--- a/apimode.go
+++ b/apimode.go
@@ -355,6 +355,11 @@ type apiChannel struct {
PromotedAt string
Rung string
Err string
+ // Digests is whether the pointer carries signed manifest digests;
+ // Straggler is a pointer without them written after digests went on
+ // (#253). An older console omits both, which reads as false.
+ Digests bool
+ Straggler bool
}
// apiChannelsResult is what `channels` returns: the channel rows plus the
@@ -1510,6 +1515,8 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiCh
PromotedAt string `json:"promoted_at"`
Rung string `json:"rung"`
Error string `json:"error"`
+ Digests bool `json:"digests"`
+ Straggler bool `json:"straggler"`
} `json:"channels"`
// PipelineEntry/PipelineChannels are two flat, top-level keys — NOT
// nested under a "pipeline" object — deliberately distinct from the
@@ -1536,11 +1543,98 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiCh
out.Channels[i] = apiChannel{
Channel: ch.Channel, Version: ch.Version,
PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error,
+ Digests: ch.Digests, Straggler: ch.Straggler,
}
}
return out, nil
}
+type apiResignPlan struct {
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ Digest string `json:"digest"`
+ History []string `json:"history"`
+ HistoryDigests []string `json:"history_digests"`
+ Computed []bool `json:"computed"`
+}
+
+// resignPlan asks the console what `channels resign` would sign. Nothing is
+// written.
+func (s *apiSession) resignPlan(ctx context.Context, a apiAcct, app, channel string) (apiResignPlan, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), map[string]string{"channel": channel})
+ if err != nil {
+ return apiResignPlan{}, err
+ }
+ if status != http.StatusOK {
+ return apiResignPlan{}, apiErrorFromBody(status, body)
+ }
+ var p apiResignPlan
+ if err := json.Unmarshal(body, &p); err != nil {
+ return apiResignPlan{}, fmt.Errorf("resign: unreadable response: %w", err)
+ }
+ if len(p.Computed) != len(p.History)+1 || len(p.HistoryDigests) != len(p.History) {
+ return apiResignPlan{}, fmt.Errorf("resign: the console's plan is malformed")
+ }
+ return p, nil
+}
+
+// resignSign sends back the digests the operator confirmed; the console
+// signs them only if a plan made now is exactly that plan.
+func (s *apiSession) resignSign(ctx context.Context, a apiAcct, app, channel, digest string, hd []string) error {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"),
+ map[string]any{"channel": channel, "digest": digest, "history_digests": hd})
+ if err != nil {
+ return err
+ }
+ if status == http.StatusOK {
+ return nil
+ }
+ return apiErrorFromBody(status, body)
+}
+
+type apiDigestAccount struct {
+ Account string `json:"account"`
+ On bool `json:"on"`
+ Since string `json:"since"`
+ Error string `json:"error"`
+}
+type apiDigestChannel struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ Straggler bool `json:"straggler"`
+}
+type apiPointerDigests struct {
+ Accounts []apiDigestAccount `json:"accounts"`
+ Channels []apiDigestChannel `json:"channels"`
+}
+
+// pointerDigests reads the pointer-digest report (verb ""), or turns the
+// marker on or off for the named accounts (every account when none) and
+// reads the report back.
+func (s *apiSession) pointerDigests(ctx context.Context, verb string, accounts []string) (apiPointerDigests, error) {
+ var status int
+ var body []byte
+ var err error
+ if verb == "" {
+ status, body, err = s.getJSON(ctx, "/api/cli/system/pointer-digests")
+ } else {
+ status, body, err = s.postJSON(ctx, "/api/cli/system/pointer-digests", map[string]any{"on": verb == "on", "accounts": accounts})
+ }
+ if err != nil {
+ return apiPointerDigests{}, err
+ }
+ if status != http.StatusOK {
+ return apiPointerDigests{}, apiErrorFromBody(status, body)
+ }
+ var rep apiPointerDigests
+ if err := json.Unmarshal(body, &rep); err != nil {
+ return apiPointerDigests{}, fmt.Errorf("pointer digests: unreadable response: %w", err)
+ }
+ return rep, nil
+}
+
// --- version target, tags, releases (release versioning spec, 2026-08-08)
//
// The four read/write doors internal/console/apiversion.go opens, in the
diff --git a/apimode_test.go b/apimode_test.go
index 157887e7d..33abb4f9d 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -1328,8 +1328,9 @@ func TestChannelsVerbAPIMode(t *testing.T) {
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)
+ // The DIGESTS cell closes every row now; the tag is the cell before it.
+ if !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) {
+ t.Fatalf("stable row = %q, want the tag 0.5.0 then the DIGESTS cell", stableRow)
}
if strings.Contains(canaryRow, "0.5.0") {
t.Fatalf("canary row = %q, want no tag on an untagged version", canaryRow)
diff --git a/channels_digests.go b/channels_digests.go
new file mode 100644
index 000000000..5a65d28ef
--- /dev/null
+++ b/channels_digests.go
@@ -0,0 +1,289 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The strings below are DRAFTS from copy-review/strings.json until the copy
+// review (plan Task 1) approves them. Each carries a TODO-COPY marker.
+const (
+ // TODO-COPY digests.resign-intro
+ textResignIntro = "Re-sign %s %s with these manifest digests:"
+ // TODO-COPY digests.resign-row
+ textResignRow = " %s %s %s"
+ // TODO-COPY digests.resign-computed
+ textResignComputed = "from the bucket now: check it"
+ // TODO-COPY digests.resign-kept
+ textResignKept = "already signed"
+ // TODO-COPY digests.resign-missing
+ textResignMissing = "no manifest: left without a digest"
+ // TODO-COPY digests.resign-prompt
+ textResignPrompt = "Sign this pointer? Type yes to continue: "
+ // TODO-COPY digests.resign-done
+ textResignDone = "re-signed %s %s with digests"
+ // TODO-COPY digests.resign-aborted
+ textResignAborted = "nothing signed"
+ // TODO-COPY digests.channels-header
+ textChannelsHeader = "CHANNEL\tVERSION\tPROMOTED\tTAG\tDIGESTS"
+ // TODO-COPY digests.state-yes
+ textStateYes = "yes"
+ // TODO-COPY digests.state-no
+ textStateNo = "no"
+ // TODO-COPY digests.state-straggler
+ textStateStraggler = "no (written after digests were turned on: an old writer may still be in use)"
+ // TODO-COPY digests.status-on
+ textStatusOn = "pointer digests: on for %s since %s"
+ // TODO-COPY digests.status-off
+ textStatusOff = "pointer digests: off for %s"
+ // TODO-COPY digests.status-header
+ textStatusHeader = "ACCOUNT\tAPP\tCHANNEL\tVERSION\tDIGESTS"
+ // TODO-COPY digests.status-none-left
+ textStatusNoneLeft = "every channel's pointer carries digests: CARLOS_REQUIRE_POINTER_DIGEST can be turned on"
+ // TODO-COPY digests.turned-on
+ textTurnedOn = "pointer digests on for %s. Upgraded writers sign digests from now on"
+ // TODO-COPY digests.turned-off
+ textTurnedOff = "pointer digests off for %s. Boxes that have already seen digests keep refusing pointers without them"
+ // TODO-COPY digests.all-accounts
+ textAllAccounts = "every account"
+ // TODO-COPY digests.unknown-account (NEW: not in strings.json)
+ textUnknownAccount = "no account %s on this console, so nothing was changed for it"
+)
+
+// resignInput is where resign reads its confirmation; tests swap it.
+var resignInput io.Reader = os.Stdin
+
+func digestCell(head, straggler bool) string {
+ switch {
+ case head:
+ return textStateYes
+ case straggler:
+ return textStateStraggler
+ }
+ return textStateNo
+}
+
+// runChannelsResign is `carlos channels resign [--app <a>] <channel>` (also
+// `<app> <channel>`, as the spec writes it). It shows every digest it would
+// sign and signs only on "yes" (or --yes).
+func runChannelsResign(args []string) {
+ fs := flag.NewFlagSet("channels resign", flag.ExitOnError)
+ app := appFlag(fs, "")
+ acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ yes := fs.Bool("yes", false, "sign without asking")
+ consoleFlag := consoleOption(fs, "console origin to resign through (API mode only; default: the only logged-in one)")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ usage := "usage: carlos channels resign [--app <a>] [--account <sqid-or-name>] [--yes] <channel>"
+ pos := fs.Args()
+ if len(pos) == 2 && *app == "" {
+ *app, pos = pos[0], pos[1:]
+ }
+ if len(pos) != 1 {
+ log.Fatal(usage)
+ }
+ channel := pos[0]
+ confirm := func(version, digest string, history, hd []string, computed []bool) bool {
+ fmt.Fprintf(stdout, textResignIntro+"\n", *app, channel)
+ row := func(v, d string, c bool) {
+ src := textResignKept
+ switch {
+ case d == "":
+ src = textResignMissing
+ case c:
+ src = textResignComputed
+ }
+ fmt.Fprintf(stdout, textResignRow+"\n", v, d, src)
+ }
+ row(version, digest, computed[0])
+ for i, v := range history {
+ row(v, hd[i], computed[i+1])
+ }
+ if *yes {
+ return true
+ }
+ fmt.Fprint(stdout, textResignPrompt)
+ line, _ := bufio.NewReader(resignInput).ReadString('\n')
+ if strings.TrimSpace(line) != "yes" {
+ fmt.Fprintln(stdout, textResignAborted)
+ return false
+ }
+ return true
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx := context.Background()
+ if sess != nil {
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ *app = resolvedApp(sess, acct, *app, usage)
+ plan, err := sess.resignPlan(ctx, acct, *app, channel)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if !confirm(plan.Version, plan.Digest, plan.History, plan.HistoryDigests, plan.Computed) {
+ return
+ }
+ if err := sess.resignSign(ctx, acct, *app, channel, plan.Digest, plan.HistoryDigests); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, textResignDone+"\n", *app, channel)
+ return
+ }
+ if *app == "" {
+ log.Fatal(usage)
+ }
+ sg := releaseSigner()
+ s, acct := openStore(), acctOf()
+ plan, err := sg.PlanResign(ctx, s, acct, *app, channel)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if !confirm(plan.Version, plan.Digest, plan.History, plan.HistoryDigests, plan.Computed) {
+ return
+ }
+ if err := sg.Resign(ctx, s, plan, promoteActor()); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, textResignDone+"\n", *app, channel)
+}
+
+// runChannelsDigests is `carlos channels digests [on|off] [--account-id <sqid>]...`.
+// Bare, it reports; on and off write or remove the emission marker. Through a
+// console these are platform-operator doors; direct-store they need the root
+// key in CARLOS_RELEASE_KEY and name each account.
+func runChannelsDigests(args []string) {
+ fs := flag.NewFlagSet("channels digests", flag.ExitOnError)
+ var ids multiFlag
+ fs.Var(&ids, "account-id", "an account sqid; repeat for several (direct-store: required)")
+ consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
+ fs.Usage = flagUsage(fs, "")
+ usage := "usage: carlos channels digests [on|off] [--account-id <sqid>]..."
+ // The verb comes first (`digests on --account-id bab`, the form the
+ // refusal texts print), and the flag package stops at the first
+ // non-flag, so it is peeled off before the flags are parsed.
+ verb := ""
+ if len(args) > 0 && (args[0] == "on" || args[0] == "off") {
+ verb, args = args[0], args[1:]
+ }
+ fs.Parse(args)
+ if fs.NArg() != 0 {
+ log.Fatal(usage)
+ }
+ ctx := context.Background()
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ var rep apiPointerDigests
+ if sess != nil {
+ printUsing(stdout, sess)
+ rep, err = sess.pointerDigests(ctx, verb, ids)
+ } else {
+ if len(ids) == 0 {
+ log.Fatal(usage)
+ }
+ rep, err = localPointerDigests(ctx, openStore(), verb, ids)
+ }
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil && verb != "" {
+ // The console's system door skips an account id it does not know and
+ // still answers 200; an operator who named one must not read that as
+ // success.
+ for _, id := range ids {
+ known := false
+ for _, a := range rep.Accounts {
+ known = known || a.Account == id
+ }
+ if !known {
+ log.Fatalf(textUnknownAccount, id)
+ }
+ }
+ }
+ names := strings.Join(ids, ", ")
+ if names == "" {
+ names = textAllAccounts
+ }
+ switch verb {
+ case "on":
+ fmt.Fprintf(stdout, textTurnedOn+"\n", names)
+ case "off":
+ fmt.Fprintf(stdout, textTurnedOff+"\n", names)
+ }
+ for _, a := range rep.Accounts {
+ if a.On {
+ fmt.Fprintf(stdout, textStatusOn+"\n", a.Account, a.Since)
+ } else {
+ fmt.Fprintf(stdout, textStatusOff+"\n", a.Account)
+ }
+ }
+ if len(rep.Channels) == 0 {
+ fmt.Fprintln(stdout, textStatusNoneLeft)
+ return
+ }
+ w := newTable(stdout)
+ fmt.Fprintln(w, textStatusHeader)
+ for _, c := range rep.Channels {
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", c.Account, c.App, c.Channel, c.Version, digestCell(false, c.Straggler))
+ }
+ w.Flush()
+}
+
+// localPointerDigests is the direct-store form: the root key writes or
+// removes each named account's marker, and the bucket is listed for the
+// report.
+func localPointerDigests(ctx context.Context, s release.Store, verb string, ids []string) (apiPointerDigests, error) {
+ pub := publicKey()
+ var rep apiPointerDigests
+ for _, acct := range ids {
+ var err error
+ switch verb {
+ case "on":
+ err = release.WriteDigestMarker(ctx, s, releaseSigner(), acct, promoteActor())
+ case "off":
+ err = release.RemoveDigestMarker(ctx, s, acct)
+ }
+ if err != nil {
+ return rep, err
+ }
+ dirs, err := s.ListDirs(ctx, "apps/"+acct+"/")
+ if err != nil {
+ return rep, err
+ }
+ row := apiDigestAccount{Account: acct}
+ for _, d := range dirs {
+ app := strings.TrimSuffix(strings.TrimPrefix(d, "apps/"+acct+"/"), "/")
+ if !release.ValidApp(app) {
+ continue
+ }
+ states, marker, err := release.DigestReport(ctx, s, pub, acct, app)
+ if err != nil {
+ return rep, err
+ }
+ if marker != nil {
+ row.On, row.Since = true, marker.At.UTC().Format(time.RFC3339)
+ }
+ for _, st := range states {
+ if !st.HeadDigest {
+ rep.Channels = append(rep.Channels, apiDigestChannel{Account: acct, App: app, Channel: st.Channel, Version: st.Version, Straggler: st.Straggler})
+ }
+ }
+ }
+ rep.Accounts = append(rep.Accounts, row)
+ }
+ return rep, nil
+}
diff --git a/channels_digests_test.go b/channels_digests_test.go
new file mode 100644
index 000000000..f8e19f634
--- /dev/null
+++ b/channels_digests_test.go
@@ -0,0 +1,236 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestDigestCell(t *testing.T) {
+ if digestCell(true, false) != textStateYes || digestCell(false, false) != textStateNo || digestCell(false, true) != textStateStraggler {
+ t.Fatal("digestCell does not map the three states to their approved texts")
+ }
+}
+
+// directDigestStore is a deployment dir with the root key in the env: the
+// shape `carlos` takes with no console. CARLOS_DEPLOYMENT_DIR selects
+// direct-store mode (resolveMode, apimode.go:104); HOME and
+// CARLOS_CREDENTIALS are masked so no real login is ever consulted.
+func directDigestStore(t *testing.T) (release.Store, ed25519.PublicKey) {
+ t.Helper()
+ dir := t.TempDir()
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOME", t.TempDir())
+ t.Setenv("CARLOS_CREDENTIALS", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
+ t.Setenv("CARLOS_RELEASE_KEY", seed)
+ t.Setenv("CARLOS_RELEASE_PUBKEY", pubB64)
+ priv, _ := release.ParseSigningKey(seed, "test")
+ pub, _ := release.ParsePublicKey(pubB64, "test")
+ s := release.NewDirStore(dir)
+ m := release.Manifest{Schema: 1, App: "km", Version: "v1", Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {}}}
+ if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("one")}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(context.Background(), s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ // Resign needs the verified marker (Astra I3 #4); written after the
+ // promotion, so the pointer still has no digests to resign.
+ if err := release.WriteDigestMarker(context.Background(), s, release.RootSigner(priv), "bab", "test"); err != nil {
+ t.Fatal(err)
+ }
+ return s, pub
+}
+
+func runResign(t *testing.T, answer string, args ...string) string {
+ t.Helper()
+ var out bytes.Buffer
+ prevOut, prevIn := stdout, resignInput
+ stdout, resignInput = &out, strings.NewReader(answer)
+ t.Cleanup(func() { stdout, resignInput = prevOut, prevIn })
+ runChannelsResign(args)
+ return out.String()
+}
+
+// Direct-store resign: the plan is printed, "no" signs nothing, "yes" signs.
+func TestResignDirectStoreAsksBeforeSigning(t *testing.T) {
+ s, pub := directDigestStore(t)
+ ctx := context.Background()
+ out := runResign(t, "no\n", "--account-id", "bab", "--app", "km", "stable")
+ if !strings.Contains(out, "v1") || !strings.Contains(out, textResignAborted) {
+ t.Fatalf("answering no:\n%s", out)
+ }
+ if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest != "" {
+ t.Fatal("answering no signed the pointer")
+ }
+ out = runResign(t, "yes\n", "--account-id", "bab", "km", "stable") // the spec's positional form
+ if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest == "" {
+ t.Fatalf("answering yes did not sign:\n%s", out)
+ }
+}
+
+// The DIGESTS column through a console: yes, plain no, and the straggler
+// text, one row each; an older console's rows (no keys) read as no.
+func TestChannelsVerbAPIModeDigestsColumn(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ base := "/api/cli/apps/" + apiTestAccount + "/titogo"
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case base + "/channels":
+ json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{
+ {"channel": "stable", "version": "1", "promoted_at": "2026-08-04T00:00:00Z", "digests": true},
+ {"channel": "beta", "version": "2", "promoted_at": "2026-08-04T00:00:00Z", "straggler": true},
+ {"channel": "edge", "version": "3", "promoted_at": "2026-08-04T00:00:00Z"},
+ {"channel": "broken", "error": "signature mismatch"},
+ }})
+ case base + "/tags":
+ json.NewEncoder(w).Encode(map[string]any{"tags": []map[string]any{}})
+ default:
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runChannels([]string{"-app", "titogo", "-account", apiTestAccount})
+
+ rows := map[string]string{}
+ for _, line := range strings.Split(buf.String(), "\n") {
+ if f := strings.Fields(line); len(f) > 0 {
+ rows[f[0]] = strings.TrimSpace(line)
+ }
+ }
+ if !strings.Contains(rows["CHANNEL"], "DIGESTS") {
+ t.Fatalf("header = %q", rows["CHANNEL"])
+ }
+ for ch, want := range map[string]string{"stable": textStateYes, "beta": textStateStraggler, "edge": textStateNo} {
+ if !strings.HasSuffix(rows[ch], want) {
+ t.Fatalf("%s row = %q, want it to end in %q", ch, rows[ch], want)
+ }
+ }
+ if !strings.Contains(rows["broken"], "ERROR: signature mismatch") {
+ t.Fatalf("broken row = %q", rows["broken"])
+ }
+}
+
+// `carlos channels digests` through a console: the report is read with a GET,
+// and on posts the named accounts.
+func TestChannelsDigestsAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ var posted map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/system/pointer-digests" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ if r.Method == http.MethodPost {
+ json.NewDecoder(r.Body).Decode(&posted)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{
+ {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"},
+ {"account": "bah", "on": false},
+ },
+ "channels": []map[string]any{
+ {"account": "bab", "app": "km", "channel": "stable", "version": "v1", "straggler": true},
+ {"account": "bab", "app": "km", "channel": "beta", "version": "v2"},
+ },
+ })
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runChannelsDigests(nil)
+ out := buf.String()
+ for _, want := range []string{
+ "pointer digests: on for bab since 2026-10-07T00:00:00Z",
+ "pointer digests: off for bah",
+ strings.Split(textStatusHeader, "\t")[0], textStateStraggler, "beta",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("listing lacks %q:\n%s", want, out)
+ }
+ }
+ if posted != nil {
+ t.Fatalf("a bare listing posted %v", posted)
+ }
+
+ buf.Reset()
+ runChannelsDigests([]string{"on", "--account-id", "bab"})
+ if posted["on"] != true || len(posted["accounts"].([]any)) != 1 {
+ t.Fatalf("posted = %v", posted)
+ }
+ if want := "pointer digests on for bab."; !strings.Contains(buf.String(), want) {
+ t.Fatalf("on output lacks %q:\n%s", want, buf.String())
+ }
+}
+
+// Resign through a console: the plan is posted with only the channel, then
+// "yes" posts the same digests back to be signed; "no" posts nothing more.
+func TestResignAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ path := "/api/cli/apps/" + apiTestAccount + "/titogo/channels/resign"
+ var bodies []map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != path || r.Method != http.MethodPost {
+ t.Errorf("request = %s %s", r.Method, r.URL.Path)
+ }
+ var b map[string]any
+ json.NewDecoder(r.Body).Decode(&b)
+ bodies = append(bodies, b)
+ if _, signing := b["digest"]; signing {
+ json.NewEncoder(w).Encode(map[string]string{"result": "resigned"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "channel": "stable", "version": "v2", "digest": strings.Repeat("a", 64),
+ "history": []string{"v1"}, "history_digests": []string{""}, "computed": []bool{true, false},
+ })
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ var out bytes.Buffer
+ prevIn := resignInput
+ t.Cleanup(func() { resignInput = prevIn })
+ captured := captureStdout(t)
+
+ resignInput = strings.NewReader("no\n")
+ runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"})
+ if len(bodies) != 1 || !strings.Contains(captured.String(), textResignAborted) {
+ t.Fatalf("answering no: %d requests\n%s", len(bodies), captured.String())
+ }
+
+ captured.Reset()
+ bodies = nil
+ resignInput = strings.NewReader("yes\n")
+ runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"})
+ out.WriteString(captured.String())
+ if len(bodies) != 2 {
+ t.Fatalf("answering yes: %d requests, want a plan and a signing\n%s", len(bodies), out.String())
+ }
+ if bodies[1]["digest"] != strings.Repeat("a", 64) || bodies[1]["channel"] != "stable" {
+ t.Fatalf("signing body = %v", bodies[1])
+ }
+ for _, want := range []string{textResignComputed, textResignMissing, "re-signed titogo stable with digests"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("output lacks %q:\n%s", want, out.String())
+ }
+ }
+}
diff --git a/cli.go b/cli.go
index a4020850d..bfb6d23fc 100644
--- a/cli.go
+++ b/cli.go
@@ -2688,13 +2688,13 @@ func runChannels(args []string) {
byRelease[t.Release] = t.Version
}
w := newTable(stdout)
- fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG")
+ fmt.Fprintln(w, textChannelsHeader)
for _, ch := range chans {
if ch.Err != "" {
- fmt.Fprintf(w, "%s\tERROR: %s\t\t\n", ch.Channel, ch.Err)
+ fmt.Fprintf(w, "%s\tERROR: %s\t\t\t\n", ch.Channel, ch.Err)
continue
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version])
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version], digestCell(ch.Digests, ch.Straggler))
}
w.Flush()
return
@@ -2721,16 +2721,18 @@ func runChannels(args []string) {
byRelease[t.Release] = t.Version
}
pub := publicKey()
+ marker, _ := release.ReadDigestMarker(context.Background(), s, pub, acct)
w := newTable(stdout)
- fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG")
+ fmt.Fprintln(w, textChannelsHeader)
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\t\n", name, err)
+ fmt.Fprintf(w, "%s\tERROR: %v\t\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])
+ st := release.DigestStateOf(cp, marker)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339), byRelease[cp.Version], digestCell(st.HeadDigest, st.Straggler))
}
w.Flush()
}
diff --git a/cli_test.go b/cli_test.go
index 40acb8877..3c13a84d2 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -1142,7 +1142,7 @@ func TestPromoteStableTagOutputDirect(t *testing.T) {
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") {
+ if !strings.Contains(stableRow, "0.5.0-1") || !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) {
t.Fatalf("the stable row does not carry the tag of the version it serves: %q\n%s", stableRow, chans)
}
}
diff --git a/main.go b/main.go
index 15078b40f..97b4fa892 100644
--- a/main.go
+++ b/main.go
@@ -96,6 +96,16 @@ func main() {
case "analytics":
runAnalytics(args)
case "channels":
+ // Two sub-verbs, resign and digests (#253); bare `carlos channels`
+ // still lists, the `carlos releases retention` precedent.
+ if len(args) > 0 && args[0] == "resign" {
+ runChannelsResign(args[1:])
+ return
+ }
+ if len(args) > 0 && args[0] == "digests" {
+ runChannelsDigests(args[1:])
+ return
+ }
runChannels(args)
case "pipeline":
runPipeline(args)