carlos / platform Public

console/CLI releases: rank versions by manifest stamp before reading manifests

The app page (and carlos releases) listed the whole releases prefix and
read EVERY version's manifest — one sequential bucket GET each — before
sorting by built_at and keeping 20. O(every version ever shipped): tito's
titogo, shipped once per merge, reached 318 versions and ~8s per render.

Sort-then-fetch instead. release.VersionCandidates ranks the distinct
versions by each manifest.json object's own last-modified stamp (written
at ship time, so a best-effort stand-in for built_at) and caps BEFORE any
manifest is read; console.Versions and the CLI's localReleases both call
it, so the two surfaces keep agreeing about what 'the latest release' is
and both pages are ~21 bucket reads regardless of history.

The stamp comes from a new optional store extension, release.ModLister:
S3Store keeps the LastModified that ListObjectsV2 already sends; DirStore
uses file mtimes. Optional rather than a Store method so the many test
fakes stay valid — a plain Store falls back to the old fetch-everything
path, byte for byte (no production caller does: cmd/console and the CLI
both hand over a bare *S3Store or *DirStore, pinned by compile-time
assertions). Both stores' List is now a projection of their ListMod, so
the twin walks cannot drift, and a file vanishing between readdir and
stat skips the entry instead of failing the listing.

A version with no manifest.json ranks by its newest object: an aborted
ship stays visible as an error row instead of silently vanishing forever
once an app has 20 readable versions — deliberate, documented on
VersionCandidates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ac37ecbceb777b4a9eb0d0a20236f67548b317ec parent a86de88
8 files changed, +529 −34
  • cli.go +12 −18
  • internal/console/view.go +9 −9
  • internal/console/view_test.go +119 −0
  • internal/release/candidates.go +101 −0
  • internal/release/store.go +71 −7
  • internal/release/store_test.go +45 −0
  • internal/release/versions_test.go +95 −0
  • releases_local_test.go +77 −0
diff --git a/cli.go b/cli.go
index aeca97f16..64b460d6a 100644
--- a/cli.go
+++ b/cli.go
@@ -2198,19 +2198,21 @@ type releaseRow struct {
// 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.
+// localReleases is the direct-mode half of `carlos releases`: pick the
+// releases worth reading, read those manifests, 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,
+// It is a local sibling of console.Versions rather than a call to it,
+// deliberately: the CLI does not import internal/console — that package
+// carries templates, sessions, a registry and a whole HTTP server. What the
+// two DO share is release.VersionCandidates, so both surfaces pick the same
+// releasesCap versions (by manifest mod time, when the store carries stamps
+// — a bare *S3Store or *DirStore, which is all openStore hands out) and read
+// nothing else; the two modes of this one command agree about what "the
+// latest release" is. 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)
+ cands, _, err := release.VersionCandidates(ctx, s, acct, app, releasesCap)
if err != nil {
return nil, err
}
@@ -2222,16 +2224,8 @@ func localReleases(ctx context.Context, s release.Store, acct, app string) ([]re
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
+ for _, v := range cands {
m, err := release.GetManifest(ctx, s, acct, app, v)
if err != nil {
out = append(out, releaseRow{Version: v, Tag: byRelease[v], Err: err.Error()})
diff --git a/internal/console/view.go b/internal/console/view.go
index 58075ce25..6b57bddcb 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -714,21 +714,21 @@ const tagsCap = 20
// apps/<app>/releases/ with each manifest's kind and build stamp — newest
// first by built_at (best effort: RFC3339 parses win, then version string),
// capped at versionsCap. Unreadable manifests degrade to a row error.
+//
+// Manifests are read only for the versionsCap versions that will render:
+// release.VersionCandidates ranks the listing by manifest mod time when the
+// store carries stamps (both real stores do), and this fetches just the
+// survivors — fetch-then-sort made this page O(every version ever shipped)
+// in sequential bucket GETs, ~8s at tito's 318. A plain Store (test fakes
+// only) still yields every version, fetched and sorted exactly as before.
func Versions(s release.Store, acct, app string) ([]VersionView, error) {
ctx := context.Background()
- prefix := release.ReleasesPrefix(acct, app)
- keys, err := s.List(ctx, prefix)
+ cands, _, err := release.VersionCandidates(ctx, s, acct, app, versionsCap)
if err != nil {
return nil, err
}
- seen := map[string]bool{}
var out []VersionView
- for _, k := range keys {
- v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
- if !ok || v == "" || seen[v] {
- continue
- }
- seen[v] = true
+ for _, v := range cands {
m, err := release.GetManifest(ctx, s, acct, app, v)
if err != nil {
out = append(out, VersionView{Version: v, Err: err.Error()})
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 76b22f46a..97242954a 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -288,6 +288,125 @@ func TestVersionsDegradesBadManifest(t *testing.T) {
}
}
+// countingModStore is the production store shape in miniature — a bare
+// DirStore, ListMod and all (struct embedding, so the method survives where
+// deletableStore's interface embedding would drop it) — counting manifest
+// reads.
+type countingModStore struct {
+ *release.DirStore
+ gets int
+}
+
+func (c *countingModStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.gets++
+ return c.DirStore.Get(ctx, key)
+}
+
+// shipAt ships a real release (manifest and all — GetManifest's validation
+// gate must accept these rows) and pins every object's mod time so the
+// ranking key is under the test's control.
+func shipAt(t *testing.T, store release.Store, dir, v string, mod time.Time) {
+ t.Helper()
+ ctx := context.Background()
+ m := release.Manifest{
+ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }
+ if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{
+ release.ManifestKey(release.OpsAccount, "kass", v),
+ release.ArtifactKey(release.OpsAccount, "kass", v, "kass"),
+ } {
+ p := filepath.Join(dir, filepath.FromSlash(key))
+ if _, err := os.Stat(p); err != nil {
+ continue // artifact filename differs per Ship's layout; manifest is the one that must rank
+ }
+ if err := os.Chtimes(p, mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// TestVersionsBoundsManifestReads: when the store can rank keys by recency
+// (ModLister — both real stores), Versions reads only the manifests it will
+// render, and picks them by manifest MOD TIME, not by name — version names
+// here are numbered AGAINST recency (v01 shipped last), so a selection that
+// quietly ranked by name would return the wrong rows. Fetch-then-sort made
+// the app page O(every version ever shipped) in sequential bucket GETs —
+// tito's titogo reached 318 versions and ~8s per page render before this
+// was bounded.
+func TestVersionsBoundsManifestReads(t *testing.T) {
+ dir := t.TempDir()
+ store := &countingModStore{DirStore: release.NewDirStore(dir)}
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ const shipped = versionsCap + 5
+ for i := 1; i <= shipped; i++ {
+ // v01 gets the NEWEST stamp, v25 the oldest.
+ shipAt(t, store, dir, fmt.Sprintf("v%02d", i), base.Add(time.Duration(shipped-i)*time.Minute))
+ }
+ store.gets = 0 // Ship reads during setup don't count
+ vs, err := Versions(store, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs) != versionsCap {
+ t.Fatalf("len = %d, want versionsCap %d", len(vs), versionsCap)
+ }
+ // The newest versionsCap ships, newest first: v01..v20. v21..v25 (the
+ // oldest ships, despite their bigger numbers) are the ones cut.
+ for i := 0; i < versionsCap; i++ {
+ want := fmt.Sprintf("v%02d", i+1)
+ if vs[i].Version != want {
+ t.Fatalf("vs[%d] = %s, want %s (rank by mod time, not name)", i, vs[i].Version, want)
+ }
+ }
+ // Real rendered rows, not error rows: the fixture manifests must survive
+ // GetManifest's validation.
+ if vs[0].Kind != "binary" || vs[0].BuiltAt == "" || vs[0].Err != "" {
+ t.Fatalf("vs[0] = %+v, want a rendered release row", vs[0])
+ }
+ if store.gets != versionsCap {
+ t.Fatalf("read %d manifests for %d shipped versions, want %d — rank by mod time BEFORE reading", store.gets, shipped, versionsCap)
+ }
+}
+
+// TestVersionsRankedPathDegrades: the ranked path keeps the fallback path's
+// degradation contract — a junk manifest and a ship that died before its
+// manifest each cost one row (Err set, sorted last by their unparseable
+// built_at), never the listing.
+func TestVersionsRankedPathDegrades(t *testing.T) {
+ dir := t.TempDir()
+ store := &countingModStore{DirStore: release.NewDirStore(dir)}
+ ctx := context.Background()
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ shipAt(t, store, dir, "good1", base)
+ shipAt(t, store, dir, "good2", base.Add(time.Hour))
+ if _, err := store.Put(ctx, release.ManifestKey(release.OpsAccount, "kass", "junk1"), []byte("junk"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(ctx, release.ArtifactKey(release.OpsAccount, "kass", "dead1", "kass"), []byte("bin"), ""); err != nil {
+ t.Fatal(err)
+ }
+ vs, err := Versions(store, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatalf("Versions must not fail on bad rows: %v", err)
+ }
+ if len(vs) != 4 {
+ t.Fatalf("versions = %+v, want all 4 rows", vs)
+ }
+ // Healthy rows first (newest built_at first), degraded rows last.
+ if vs[0].Version != "good2" || vs[1].Version != "good1" {
+ t.Fatalf("healthy rows = %s, %s; want good2, good1", vs[0].Version, vs[1].Version)
+ }
+ for _, i := range []int{2, 3} {
+ if vs[i].Err == "" {
+ t.Fatalf("vs[%d] = %+v, want a degraded row", i, vs[i])
+ }
+ }
+}
+
func TestVersionsEmptyApp(t *testing.T) {
_, store, _, _ := buildFixtures(t)
vs, err := Versions(store, release.OpsAccount, "ghost")
diff --git a/internal/release/candidates.go b/internal/release/candidates.go
new file mode 100644
index 000000000..d95fb5ecf
--- /dev/null
+++ b/internal/release/candidates.go
@@ -0,0 +1,101 @@
+// Version-candidate selection: which of an app's shipped versions are worth
+// reading manifests for, decided from the listing alone. Shared by the
+// console's app page and `carlos releases` direct mode, so the two surfaces
+// agree about what "the latest releases" are.
+package release
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "time"
+)
+
+// VersionCandidates lists the distinct versions under an app's releases
+// prefix, in the order manifests should be read.
+//
+// With a ModLister (both real stores), the answer is RANKED: the newest max
+// versions by each manifest.json object's own last-modified stamp, newest
+// first. The stamp is written at ship time, so it is a best-effort stand-in
+// for the manifest's built_at — best-effort meaning: S3 stamps are
+// second-granularity (same-second ships tie, broken deterministically by
+// version name descending, the final-sort tie-break every caller already
+// uses), and `accounts migrate` re-stamps the whole pre-migration backlog at
+// migration time. A caller then reads only the manifests it will render —
+// fetch-then-sort read every manifest ever shipped, and a CI-shipped app
+// (tito's titogo, 318 versions) took ~8s of sequential bucket GETs per page
+// render that way.
+//
+// A version with no manifest.json ranks by its newest object instead — an
+// in-flight ship (artifacts land before the manifest, Ship's write order)
+// briefly occupies a slot as the error row its fetch produces, and a ship
+// that died mid-write stays visible as one. Deliberate: the old sort-last
+// behavior silently hid an aborted ship forever once an app had max readable
+// versions, and an operator whose ship died wants the wreckage on the page,
+// not vanished.
+//
+// With a plain Store (test fakes; no production caller — cmd/console and the
+// CLI both hand over a bare *S3Store or *DirStore), the answer is every
+// version, unranked and uncapped, in listing order: the caller keeps its
+// fetch-everything path, byte-identical to the pre-ModLister behavior.
+func VersionCandidates(ctx context.Context, s Store, acct, app string, max int) (versions []string, ranked bool, err error) {
+ prefix := ReleasesPrefix(acct, app)
+ type cand struct {
+ version string
+ mod time.Time // manifest.json's stamp once seen; else newest object's
+ manifest bool
+ }
+ var cands []cand
+ index := map[string]int{}
+ note := func(key string, mod time.Time) {
+ v, rest, ok := strings.Cut(strings.TrimPrefix(key, prefix), "/")
+ if !ok || v == "" {
+ return
+ }
+ isManifest := rest == "manifest.json"
+ i, seen := index[v]
+ if !seen {
+ index[v] = len(cands)
+ cands = append(cands, cand{version: v, mod: mod, manifest: isManifest})
+ return
+ }
+ switch {
+ case isManifest:
+ cands[i].mod, cands[i].manifest = mod, true
+ case !cands[i].manifest && mod.After(cands[i].mod):
+ cands[i].mod = mod
+ }
+ }
+ ml, ranked := s.(ModLister)
+ if ranked {
+ keys, err := ml.ListMod(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, km := range keys {
+ note(km.Key, km.Mod)
+ }
+ sort.SliceStable(cands, func(i, j int) bool {
+ if !cands[i].mod.Equal(cands[j].mod) {
+ return cands[i].mod.After(cands[j].mod)
+ }
+ return cands[i].version > cands[j].version
+ })
+ if len(cands) > max {
+ cands = cands[:max]
+ }
+ } else {
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, k := range keys {
+ note(k, time.Time{})
+ }
+ }
+ versions = make([]string, len(cands))
+ for i, c := range cands {
+ versions[i] = c.version
+ }
+ return versions, ranked, nil
+}
diff --git a/internal/release/store.go b/internal/release/store.go
index f672d74e4..ca6166cc1 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -19,6 +19,7 @@ import (
"sort"
"strings"
"sync"
+ "time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -53,6 +54,30 @@ type Store interface {
Delete(ctx context.Context, key string) error
}
+// KeyMod pairs a listed key with the object's last-modified stamp.
+type KeyMod struct {
+ Key string
+ Mod time.Time
+}
+
+// ModLister is the optional Store extension for listings that carry each
+// object's last-modified time — information both real stores already have in
+// hand (S3 returns it with every ListObjectsV2 entry; DirStore has the file
+// mtime) and List throws away. It exists so a caller ranking objects by
+// recency can decide WHICH bodies to read before reading any: the app page's
+// release list was O(every version ever shipped) in bucket GETs without it.
+// Optional rather than a Store method so the many test fakes that implement
+// Store stay valid; callers type-assert and fall back to List.
+type ModLister interface {
+ ListMod(ctx context.Context, prefix string) ([]KeyMod, error)
+}
+
+// Both real stores answer ListMod; only fakes fall back.
+var (
+ _ ModLister = (*DirStore)(nil)
+ _ ModLister = (*S3Store)(nil)
+)
+
// ---- DirStore ---------------------------------------------------------------
// DirStore keeps objects as files under a root directory — the test double
@@ -140,10 +165,22 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin
return etagOf(body), nil
}
-func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) {
+// List is ListMod minus the mtimes — one walk owns the prefix filter and the
+// .tmp exclusion, so the two listings cannot drift.
+func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) {
+ kms, err := d.ListMod(ctx, prefix)
+ out := make([]string, len(kms))
+ for i, km := range kms {
+ out[i] = km.Key
+ }
+ return out, err
+}
+
+// ListMod is the listing with each file's mtime alongside, key-sorted.
+func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) {
d.mu.Lock()
defer d.mu.Unlock()
- var out []string
+ var out []KeyMod
err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -159,12 +196,24 @@ func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) {
return err
}
key := filepath.ToSlash(rel)
- if strings.HasPrefix(key, prefix) {
- out = append(out, key)
+ if !strings.HasPrefix(key, prefix) {
+ return nil
+ }
+ info, err := de.Info()
+ if err != nil {
+ // Vanished between readdir and stat — another process's delete
+ // racing this walk (in-process writers all hold d.mu). A file
+ // that is gone is not listed; it does not break the listing.
+ // Untestable deterministically, so pinned by comment alone.
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return err
}
+ out = append(out, KeyMod{Key: key, Mod: info.ModTime()})
return nil
})
- sort.Strings(out)
+ sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out, err
}
@@ -326,8 +375,23 @@ func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch stri
return aws.ToString(out.ETag), nil
}
+// List is ListMod minus the stamps — one paginator loop serves both.
func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
- var out []string
+ kms, err := s.ListMod(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]string, len(kms))
+ for i, km := range kms {
+ out[i] = km.Key
+ }
+ return out, nil
+}
+
+// ListMod is the listing keeping the LastModified S3 already sends with
+// every entry.
+func (s *S3Store) ListMod(ctx context.Context, prefix string) ([]KeyMod, error) {
+ var out []KeyMod
p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
Bucket: &s.Bucket,
Prefix: &prefix,
@@ -338,7 +402,7 @@ func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
return nil, err
}
for _, o := range page.Contents {
- out = append(out, aws.ToString(o.Key))
+ out = append(out, KeyMod{Key: aws.ToString(o.Key), Mod: aws.ToTime(o.LastModified)})
}
}
return out, nil
diff --git a/internal/release/store_test.go b/internal/release/store_test.go
index a7a5f7726..417e6efeb 100644
--- a/internal/release/store_test.go
+++ b/internal/release/store_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -172,6 +173,50 @@ func TestDirStoreListDirs(t *testing.T) {
}
}
+// TestDirStoreListMod: the mod-time-aware listing answers exactly like List
+// — same keys, same order, same prefix filter — with each key's mtime
+// alongside, so a caller can rank by recency without reading a single body.
+func TestDirStoreListMod(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ ctx := context.Background()
+ older := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
+ newer := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
+ for k, mod := range map[string]time.Time{
+ "apps/bab/kass/releases/v1/manifest.json": older,
+ "apps/bab/kass/releases/v2/manifest.json": newer,
+ "apps/bab/kass/channels/stable.json": newer, // outside the prefix
+ } {
+ if _, err := s.Put(ctx, k, []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(k)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := s.ListMod(ctx, "apps/bab/kass/releases/")
+ if err != nil {
+ t.Fatalf("ListMod: %v", err)
+ }
+ want := []KeyMod{
+ {Key: "apps/bab/kass/releases/v1/manifest.json", Mod: older},
+ {Key: "apps/bab/kass/releases/v2/manifest.json", Mod: newer},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("ListMod = %+v, want %+v", got, want)
+ }
+ for i := range want {
+ if got[i].Key != want[i].Key || !got[i].Mod.Equal(want[i].Mod) {
+ t.Fatalf("ListMod[%d] = %+v, want %+v", i, got[i], want[i])
+ }
+ }
+ // A missing prefix lists empty like an empty bucket, not an error.
+ empty, err := s.ListMod(ctx, "apps/nosuch/")
+ if err != nil || len(empty) != 0 {
+ t.Fatalf("ListMod of a missing prefix = %v, %v; want empty and no error", empty, err)
+ }
+}
+
// TestS3StoreLive exercises the production store against a real bucket —
// only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles.
func TestS3StoreLive(t *testing.T) {
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go
index ceb3c3437..267b6e41a 100644
--- a/internal/release/versions_test.go
+++ b/internal/release/versions_test.go
@@ -1,6 +1,7 @@
package release
import (
+ "context"
"encoding/json"
"errors"
"net"
@@ -344,3 +345,97 @@ func TestReadStagedManifestRefusesAnIdentityMismatch(t *testing.T) {
t.Fatal("a sibling naming a different version than its path was accepted")
}
}
+
+// modStamp writes key and pins its mtime — candidate ranking reads nothing
+// else.
+func modStamp(t *testing.T, s *DirStore, dir, key string, mod time.Time) {
+ t.Helper()
+ if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestVersionCandidatesRanked: with a mod-aware store the candidates are the
+// newest max versions by each manifest object's own stamp — the mod order
+// here deliberately DISAGREES with version-name order, so a selection that
+// quietly ranked by name would fail — and a version whose ship never wrote a
+// manifest ranks by its newest artifact, so an aborted ship surfaces (as the
+// caller's error row) rather than vanishing.
+func TestVersionCandidatesRanked(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ for v, mod := range map[string]time.Time{
+ "aa": base.Add(3 * time.Hour), // newest ship, first lexically too
+ "zz": base.Add(2 * time.Hour), // second-newest despite sorting last by name
+ "cc": base.Add(1 * time.Hour),
+ "bb": base, // oldest — the one the cap must cut
+ } {
+ modStamp(t, s, dir, ManifestKey("bab", "kass", v), mod)
+ // An artifact NEWER than its own manifest must not outrank the
+ // manifest's stamp.
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod.Add(30*time.Minute))
+ }
+ // bb's ARTIFACT ends up newer than cc's MANIFEST: a ranking that took
+ // the max over all of a version's objects instead of letting the
+ // manifest's own stamp win would rank bb (100m) over cc (60m) and evict
+ // cc from the want below — this line is what pins the rule.
+ bbArtifact := filepath.Join(dir, filepath.FromSlash(ArtifactKey("bab", "kass", "bb", "linux-arm64")))
+ if err := os.Chtimes(bbArtifact, base.Add(100*time.Minute), base.Add(100*time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ // A ship that died before manifest.json: artifact only, newest of all.
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), base.Add(4*time.Hour))
+
+ got, ranked, err := VersionCandidates(context.Background(), s, "bab", "kass", 4)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ranked {
+ t.Fatal("DirStore is a ModLister — candidates must come back ranked")
+ }
+ want := []string{"dead1", "aa", "zz", "cc"}
+ if len(got) != len(want) {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ }
+}
+
+// plainStore hides ListMod behind the bare interface, the shape every test
+// fake has.
+type plainStore struct{ Store }
+
+// TestVersionCandidatesFallback: a store without ListMod answers every
+// version, unranked and uncapped, in listing order — the caller's
+// fetch-everything path, byte-identical to the pre-ModLister behavior.
+func TestVersionCandidatesFallback(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ for i, v := range []string{"v1", "v2", "v3"} {
+ modStamp(t, s, dir, ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour))
+ }
+ got, ranked, err := VersionCandidates(context.Background(), plainStore{s}, "bab", "kass", 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ranked {
+ t.Fatal("a bare Store cannot rank")
+ }
+ want := []string{"v1", "v2", "v3"} // listing order, all of them — no cap
+ if len(got) != len(want) {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ }
+}
diff --git a/releases_local_test.go b/releases_local_test.go
new file mode 100644
index 000000000..556133f50
--- /dev/null
+++ b/releases_local_test.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// manifestCountingStore is the production store shape in miniature — a bare
+// DirStore, ListMod and all (struct embedding keeps the method) — counting
+// manifest reads specifically, so the tag reads ListTags does don't muddy
+// the assertion.
+type manifestCountingStore struct {
+ *release.DirStore
+ manifestGets int
+}
+
+func (c *manifestCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.HasSuffix(key, "/manifest.json") {
+ c.manifestGets++
+ }
+ return c.DirStore.Get(ctx, key)
+}
+
+// TestLocalReleasesBoundsManifestReads: `carlos releases` direct mode reads
+// only the manifests it will print, chosen by manifest mod time exactly as
+// the console does (release.VersionCandidates is shared) — version names
+// here are numbered AGAINST recency so a name-ranked selection fails, and a
+// fetch-everything regression fails the read count.
+func TestLocalReleasesBoundsManifestReads(t *testing.T) {
+ dir := t.TempDir()
+ store := &manifestCountingStore{DirStore: release.NewDirStore(dir)}
+ ctx := context.Background()
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ const shipped = releasesCap + 5
+ for i := 1; i <= shipped; i++ {
+ v := fmt.Sprintf("v%02d", i)
+ mod := base.Add(time.Duration(shipped-i) * time.Minute) // v01 newest
+ m := release.Manifest{
+ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }
+ if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("bin " + v)}); err != nil {
+ t.Fatal(err)
+ }
+ key := release.ManifestKey(release.OpsAccount, "kass", v)
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ }
+ store.manifestGets = 0 // Ship reads during setup don't count
+ rows, err := localReleases(ctx, store, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != releasesCap {
+ t.Fatalf("len = %d, want releasesCap %d", len(rows), releasesCap)
+ }
+ for i := 0; i < releasesCap; i++ {
+ want := fmt.Sprintf("v%02d", i+1)
+ if rows[i].Version != want {
+ t.Fatalf("rows[%d] = %s, want %s (rank by mod time, not name)", i, rows[i].Version, want)
+ }
+ }
+ if rows[0].Kind != "binary" || rows[0].Err != "" {
+ t.Fatalf("rows[0] = %+v, want a rendered release row", rows[0])
+ }
+ if store.manifestGets != releasesCap {
+ t.Fatalf("read %d manifests for %d shipped versions, want %d", store.manifestGets, shipped, releasesCap)
+ }
+}