carlos / platform Public

release: PruneEligible selector + DeleteVersion, sharing the candidate ranking core

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1f6172c39900cbc53066f8f97a99c6000b4c4d14 parent 0ccd255
3 files changed, +450 −37
  • internal/release/candidates.go +60 −37
  • internal/release/prune.go +134 −0
  • internal/release/prune_test.go +256 −0
diff --git a/internal/release/candidates.go b/internal/release/candidates.go
index d95fb5ecf..1528f2491 100644
--- a/internal/release/candidates.go
+++ b/internal/release/candidates.go
@@ -40,47 +40,13 @@ import (
// 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
- }
- }
+ var cands []versionStamp
ml, ranked := s.(ModLister)
if ranked {
- keys, err := ml.ListMod(ctx, prefix)
+ cands, err = rankedVersionStamps(ctx, ml, 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]
}
@@ -89,8 +55,9 @@ func VersionCandidates(ctx context.Context, s Store, acct, app string, max int)
if err != nil {
return nil, false, err
}
+ index := map[string]int{}
for _, k := range keys {
- note(k, time.Time{})
+ cands = noteKey(cands, index, prefix, k, time.Time{})
}
}
versions = make([]string, len(cands))
@@ -99,3 +66,59 @@ func VersionCandidates(ctx context.Context, s Store, acct, app string, max int)
}
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
+}
diff --git a/internal/release/prune.go b/internal/release/prune.go
new file mode 100644
index 000000000..5091440cb
--- /dev/null
+++ b/internal/release/prune.go
@@ -0,0 +1,134 @@
+// Prune selection and deletion for shipped releases (issue #136, spec
+// 2026-08-10-releases-retention-design.md §5): which of an app's versions
+// are safe to delete under a keep-newest-N retention policy, and how one
+// version's objects are removed.
+//
+// Everything here is FAIL CLOSED. Any listing, read, or signature error
+// aborts the whole app's answer — the caller skips the app this round.
+// "Prune what we could see" is exactly the bug this posture prevents.
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// PruneEligible returns the versions of acct/app that are safe to delete,
+// oldest first. A version is protected — never returned — if it is any of:
+//
+// – among the newest keep versions (manifest-stamp ranking, PR #134's rule)
+// – stamped inside the longest bake hold (HoldFor's fail-closed 72h), so
+// an in-flight promote can never race its own prune
+// – named by any channel pointer: Version or anything in History (a
+// rollback must keep somewhere to go); pointers are signature-verified,
+// and one that does not verify aborts the whole answer
+// – named by any tag's Release field (tags are durable names)
+// – in alsoProtected — the box-side caller passes its own registry's
+// adopted/previous versions; the console passes nil (its preview can
+// only overcount, never undercount, what a box will delete)
+//
+// Pointer AUDIT entries are deliberately NOT protected (ruling in the spec):
+// the audit trail is a log, not a rollback target.
+func PruneEligible(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, keep int, now time.Time, alsoProtected map[string]bool) ([]string, error) {
+ if len(pub) == 0 {
+ return nil, errors.New("prune: no release public key — refusing to select without verifying pointers")
+ }
+ if keep < KeepFloor {
+ return nil, fmt.Errorf("prune: keep %d below floor %d", keep, KeepFloor)
+ }
+ ml, ok := s.(ModLister)
+ if !ok {
+ return nil, errors.New("prune: store cannot rank versions by age — refusing to select")
+ }
+ stamps, err := rankedVersionStamps(ctx, ml, ReleasesPrefix(acct, app))
+ if err != nil {
+ return nil, err
+ }
+ protected := make(map[string]bool, keep+len(alsoProtected))
+ cutoff := now.Add(-HoldFor(DefaultHolds, ""))
+ for i, st := range stamps {
+ if i < keep || st.mod.After(cutoff) {
+ protected[st.version] = true
+ }
+ }
+ chPrefix := ChannelsPrefix(acct, app)
+ chKeys, err := s.List(ctx, chPrefix)
+ if err != nil {
+ return nil, err
+ }
+ for _, k := range chKeys {
+ name, ok := strings.CutSuffix(strings.TrimPrefix(k, chPrefix), ".json")
+ if !ok || name == "" {
+ continue
+ }
+ ptr, err := Channel(ctx, s, pub, acct, app, name)
+ if err != nil {
+ return nil, fmt.Errorf("prune: channel %s: %w", name, err)
+ }
+ protected[ptr.Version] = true
+ for _, v := range ptr.History {
+ protected[v] = true
+ }
+ }
+ tags, err := ListTags(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ for _, t := range tags {
+ protected[t.Release] = true
+ }
+ for v, on := range alsoProtected {
+ if on && v != "" {
+ protected[v] = true
+ }
+ }
+ var eligible []string
+ for i := len(stamps) - 1; i >= 0; i-- {
+ v := stamps[i].version
+ // A name that fails ValidVersion is never turned into a delete key;
+ // skipping it is safe — we simply refuse to touch it.
+ if !protected[v] && ValidVersion(v) {
+ eligible = append(eligible, v)
+ }
+ }
+ return eligible, nil
+}
+
+// DeleteVersion removes one shipped version's objects, MANIFEST FIRST: a
+// half-deleted version then reads as an aborted ship (VersionCandidates'
+// error row), never as a complete version with missing artifacts. Built
+// from idempotent Deletes, so re-running after a partial failure — or
+// racing another box — finishes the job instead of erroring.
+func DeleteVersion(ctx context.Context, s Store, acct, app, version string) error {
+ if !ValidAccount(acct) {
+ return fmt.Errorf("delete version: invalid account %q", acct)
+ }
+ if !ValidApp(app) {
+ return fmt.Errorf("delete version: invalid app %q", app)
+ }
+ if !ValidVersion(version) {
+ return fmt.Errorf("delete version: invalid version %q", version)
+ }
+ prefix := ReleasesPrefix(acct, app) + version + "/"
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return err
+ }
+ manifest := ManifestKey(acct, app, version)
+ if err := s.Delete(ctx, manifest); err != nil {
+ return err
+ }
+ for _, k := range keys {
+ if k == manifest || !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ if err := s.Delete(ctx, k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/release/prune_test.go b/internal/release/prune_test.go
new file mode 100644
index 000000000..2fa121555
--- /dev/null
+++ b/internal/release/prune_test.go
@@ -0,0 +1,256 @@
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// pruneFixture seeds n versions v01..vNN with ascending hour-apart stamps
+// ending 30 days before now, so age protection (72h) never applies unless a
+// test asks for it. Each manifest is a real, parseable one (not just a
+// stamp): TestPruneEligibleProtectsPointerVersionAndHistory promotes fixture
+// versions, and Promote's GetManifest call parses the body — a version's
+// SHA256 is a fixed placeholder, never checked against the artifact bytes
+// (validateManifest only checks the hex shape). Returns the signing pair for
+// pointer/production writes.
+func pruneFixture(t *testing.T, n int) (dir string, s *DirStore, now time.Time) {
+ t.Helper()
+ dir = t.TempDir()
+ s = NewDirStore(dir)
+ now = time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
+ base := now.Add(-30 * 24 * time.Hour)
+ ctx := context.Background()
+ for i := 1; i <= n; i++ {
+ v := version(i)
+ mod := base.Add(time.Duration(i) * time.Hour)
+ m := Manifest{
+ Schema: 1, App: "kass", Version: v, Kind: "binary",
+ Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 1}},
+ }
+ raw, err := json.Marshal(&m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifestKey := ManifestKey("bab", "kass", v)
+ if _, err := s.Put(ctx, manifestKey, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(manifestKey)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod)
+ }
+ return dir, s, now
+}
+
+func version(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
+
+// restamp updates an already-written key's mod time in place. modStamp's Put
+// is create-only (it exists to seed a fresh key + stamp in one call), so
+// re-stamping a key pruneFixture already wrote needs the bare Chtimes half
+// of that pattern on its own.
+func restamp(t *testing.T, dir, key string, mod time.Time) {
+ t.Helper()
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPruneEligibleKeepsNewestNAndReturnsOldestFirst(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ seedB64, pubB64, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = seedB64
+ pub, err := ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 25 versions, keep 20 → the 5 oldest are eligible, oldest first.
+ want := []string{"v01", "v02", "v03", "v04", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsTheBakeCeiling(t *testing.T) {
+ dir, s, now := pruneFixture(t, 25)
+ // Restamp the OLDEST version to 1h ago: inside the 72h ceiling, so even
+ // though it ranks outside keep-20 it must not be eligible.
+ restamp(t, dir, ManifestKey("bab", "kass", "v01"), now.Add(-time.Hour))
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, v := range got {
+ if v == "v01" {
+ t.Fatal("a version inside the 72h bake ceiling was eligible")
+ }
+ }
+ // v01 now ranks NEWEST, so keep-20 shifts: v02..v06 become the eligible five.
+ want := []string{"v02", "v03", "v04", "v05", "v06"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsPointerVersionAndHistory(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ seedB64, pubB64, _ := GenerateKey()
+ priv, _ := ParseSigningKey(seedB64, "test")
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ // Promote v01 then v02 onto edge: pointer names v02, History carries v01.
+ if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v01"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v02"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"v03", "v04", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v (pointer + history protected)", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsTagReleases(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ // A tag whose Release names v03 — Tag.Release is the protected field,
+ // Tag.Version is just the semver name.
+ raw := []byte(`{"schema":1,"version":"1.2.0","release":"v03","tagged_at":"2026-08-01T00:00:00Z"}`)
+ if _, err := s.Put(ctx, TagKey("bab", "kass", "1.2.0"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"v01", "v02", "v04", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v (tagged release protected)", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsCallerExtras(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now,
+ map[string]bool{"v02": true, "v04": true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"v01", "v03", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v (box pins protected)", got, want)
+ }
+}
+
+func TestPruneEligibleAbortsOnUnverifiablePointer(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ // Garbage where a signed pointer should be: the whole app's prune must
+ // abort — never "prune what we could verify".
+ if _, err := s.Put(ctx, ChannelKey("bab", "kass", "edge"), []byte(`{"not":"signed"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil); err == nil {
+ t.Fatal("an unverifiable pointer did not abort the prune")
+ }
+}
+
+func TestPruneEligibleFailClosedGates(t *testing.T) {
+ _, s, now := pruneFixture(t, 5)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ if _, err := PruneEligible(ctx, s, nil, "bab", "kass", 20, now, nil); err == nil {
+ t.Fatal("nil pubkey accepted")
+ }
+ if _, err := PruneEligible(ctx, s, pub, "bab", "kass", KeepFloor-1, now, nil); err == nil {
+ t.Fatal("keep below floor accepted")
+ }
+ if _, err := PruneEligible(ctx, plainStore{s}, pub, "bab", "kass", 20, now, nil); err == nil {
+ t.Fatal("a store that cannot rank by age was accepted")
+ }
+}
+
+func TestPruneEligibleManifestlessAgedShipIsEligible(t *testing.T) {
+ dir, s, now := pruneFixture(t, 25)
+ // An aborted ship (artifact only, no manifest), OLDER than every shipped
+ // version (the fixture's stamps start 30 days back): junk, outside
+ // keep-N, eligible. (Stamped newer it would occupy a keep slot instead —
+ // aborted ships surface in listings by design, PR #134.)
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), now.Add(-31*24*time.Hour))
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, v := range got {
+ found = found || v == "dead1"
+ }
+ if !found {
+ t.Fatalf("eligible = %v, want dead1 included (aged aborted ship)", got)
+ }
+}
+
+// deleteRecorder pins the manifest-first rule: the order of Deletes is the
+// difference between a half-deleted version that reads as an aborted ship
+// and one that reads as a complete version with missing artifacts.
+type deleteRecorder struct {
+ Store
+ order []string
+}
+
+func (d *deleteRecorder) Delete(ctx context.Context, key string) error {
+ d.order = append(d.order, key)
+ return d.Store.Delete(ctx, key)
+}
+
+func TestDeleteVersionManifestFirstThenIdempotent(t *testing.T) {
+ dir, s, _ := pruneFixture(t, 1)
+ _ = dir
+ rec := &deleteRecorder{Store: s}
+ ctx := context.Background()
+ if err := DeleteVersion(ctx, rec, "bab", "kass", "v01"); err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.order) < 2 || rec.order[0] != ManifestKey("bab", "kass", "v01") {
+ t.Fatalf("delete order = %v, want manifest first", rec.order)
+ }
+ keys, err := s.List(ctx, ReleasesPrefix("bab", "kass")+"v01/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 0 {
+ t.Fatalf("v01 keys after delete = %v, want none", keys)
+ }
+ // Deleting again is a no-op, not an error.
+ if err := DeleteVersion(ctx, s, "bab", "kass", "v01"); err != nil {
+ t.Fatal(err)
+ }
+}