release: PruneEligible selector + DeleteVersion, sharing the candidate ranking core
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+450
−37
internal/release/candidates.go+60 −37internal/release/prune.go+134 −0internal/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) |
| + } |
| +} |