settings-2 task 5: pruning and purging remove promotion records
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+71
−1
internal/release/promotion.go+4 −1internal/release/prune.go+6 −0internal/release/record_lifecycle_test.go+53 −0internal/release/trash.go+8 −0
diff --git a/internal/release/promotion.go b/internal/release/promotion.go| index ed73eef25..25bdc721e 100644 |
| --- a/internal/release/promotion.go |
| +++ b/internal/release/promotion.go |
| @@ -11,11 +11,14 @@ import ( |
| "time" |
| ) |
| +// PromotedPrefix is every promotion record of an app: what PurgeApp sweeps. |
| +func PromotedPrefix(acct, app string) string { return "releases/" + acct + "/" + app + "/promoted/" } |
| + |
| // PromotionRecordKey is the durable record of one promoted build (spec §4.1). |
| // It sits outside AppPrefix, beside the account's pointer digest marker, so |
| // PurgeApp sweeps it explicitly. |
| func PromotionRecordKey(acct, app, version string) string { |
| - return "releases/" + acct + "/" + app + "/promoted/" + version |
| + return PromotedPrefix(acct, app) + version |
| } |
| // PromotionRecord binds a version name to the exact manifest that was |
diff --git a/internal/release/prune.go b/internal/release/prune.go| index ca41da232..9fe60d0dc 100644 |
| --- a/internal/release/prune.go |
| +++ b/internal/release/prune.go |
| @@ -13,6 +13,7 @@ import ( |
| "crypto/ed25519" |
| "errors" |
| "fmt" |
| + "os" |
| "strings" |
| "time" |
| ) |
| @@ -137,6 +138,11 @@ func claimRawDeleteVersion(ctx context.Context, s Store, acct, app, version stri |
| return err |
| } |
| } |
| + // The version's promotion record goes with it (spec §4.4 keeps only a |
| + // protected build's record). Idempotent like every delete here. |
| + if err := s.Delete(ctx, PromotionRecordKey(acct, app, version)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| return nil |
| } |
| func DeleteVersion(ctx context.Context, s Store, acct, app, version string) (claimResult0 error) { |
diff --git a/internal/release/record_lifecycle_test.go b/internal/release/record_lifecycle_test.go| new file mode 100644 |
| index 000000000..6729a71b4 |
| --- /dev/null |
| +++ b/internal/release/record_lifecycle_test.go |
| @@ -0,0 +1,53 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Review Focus 5. |
| +func TestPruneThenReshipGetsAFreshRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("the pruned version's record survived: %v", err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one again, other bytes")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/y", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); err != nil { |
| + t.Fatalf("the reshipped, promoted name has no valid record: %v", err) |
| + } |
| +} |
| + |
| +func TestPurgeRemovesEveryRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Make the app purgeable: trash it and backdate the tombstone |
| + if err := TrashApp(ctx, s, "bab", "km", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "km", time.Now().Add(31*24*time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, _ := s.List(ctx, PromotedPrefix("bab", "km")) |
| + if len(keys) != 0 { |
| + t.Fatalf("records survived the purge: %v", keys) |
| + } |
| +} |
diff --git a/internal/release/trash.go b/internal/release/trash.go| index 62cbeed62..797f09e60 100644 |
| --- a/internal/release/trash.go |
| +++ b/internal/release/trash.go |
| @@ -286,6 +286,14 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str |
| return fmt.Errorf("release: list %s: %w", LedgerAppPrefix(acct, app), err) |
| } |
| keys = append(keys, ledgerKeys...) |
| + // The app's promotion records (settings spec §4.1) live outside its |
| + // prefix too, for the same reason as the ledgers: a new app of this name |
| + // must not find them. |
| + promoted, err := s.List(ctx, PromotedPrefix(acct, app)) |
| + if err != nil { |
| + return fmt.Errorf("release: list %s: %w", PromotedPrefix(acct, app), err) |
| + } |
| + keys = append(keys, promoted...) |
| for _, key := range keys { |
| if key == marker { |
| continue // last, below |