// 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) var cands []versionStamp ml, ranked := s.(ModLister) if ranked { cands, err = rankedVersionStamps(ctx, ml, prefix) if err != nil { return nil, false, err } if len(cands) > max { cands = cands[:max] } } else { keys, err := s.List(ctx, prefix) if err != nil { return nil, false, err } index := map[string]int{} for _, k := range keys { cands = noteKey(cands, index, prefix, k, time.Time{}) } } versions = make([]string, len(cands)) for i, c := range cands { versions[i] = c.version } return versions, ranked, nil } // versionStamp is one distinct version under a releases prefix with the // stamp candidate ranking uses: manifest.json's own mod time once seen, // else the newest object's. type versionStamp struct { version string mod time.Time manifest bool } // noteKey folds one listed key into the per-version aggregation — the // manifest-stamp-wins rule VersionCandidates has always applied, shared so // prune's selector and the render path can never disagree about a // version's age. func noteKey(cands []versionStamp, index map[string]int, prefix, key string, mod time.Time) []versionStamp { v, rest, ok := strings.Cut(strings.TrimPrefix(key, prefix), "/") if !ok || v == "" { return cands } isManifest := rest == "manifest.json" i, seen := index[v] if !seen { index[v] = len(cands) return append(cands, versionStamp{version: v, mod: mod, manifest: isManifest}) } switch { case isManifest: cands[i].mod, cands[i].manifest = mod, true case !cands[i].manifest && mod.After(cands[i].mod): cands[i].mod = mod } return cands } // rankedVersionStamps lists EVERY distinct version under prefix from a // mod-aware store, newest first by the manifest-stamp-wins rule, ties // broken by version name descending. Uncapped: VersionCandidates caps for // rendering; PruneEligible needs the whole history. func rankedVersionStamps(ctx context.Context, ml ModLister, prefix string) ([]versionStamp, error) { keys, err := ml.ListMod(ctx, prefix) if err != nil { return nil, err } var cands []versionStamp index := map[string]int{} for _, km := range keys { cands = noteKey(cands, index, prefix, 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 }) return cands, nil }