From 3c2e6a10ffd6735fa9920bf90c5f07f4e7e113f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:28:15 +0000 Subject: [PATCH 1/2] Retention: count prerelease_superseded_for_days from the later publication prerelease_superseded_for_days started its clock at the superseding release's publication only, so a prerelease published after its release (a lagging CI version bump, a maintenance line's rc after a newer major) was hard-deleted on the next run. The clock now starts at max(release.created_at, prerelease.created_at): a prerelease always has its N days. docs/retention.md says so; a unit test and the worker test cover a prerelease published after its release. Fixes #757 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJbobLATLgHyeaU5Gqa --- crates/core/src/retention/evaluate.rs | 45 +++++++++++++++++++++++++-- crates/core/tests/retention_worker.rs | 6 +++- docs/changes/2.2.0/757.fixed.md | 5 +++ docs/retention.md | 2 +- 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 docs/changes/2.2.0/757.fixed.md diff --git a/crates/core/src/retention/evaluate.rs b/crates/core/src/retention/evaluate.rs index 0f5ca676..b78b7b8e 100644 --- a/crates/core/src/retention/evaluate.rs +++ b/crates/core/src/retention/evaluate.rs @@ -32,7 +32,8 @@ //! it was published — so a fresh push is not cold on its first run), //! `prerelease_superseded_for_days` (a prerelease or snapshot, once the //! first listed release of its package that orders after it has been -//! published that long), `unlisted_for_days` (unlisted that long, by +//! published that long, counted from the prerelease's own publication +//! when that came later), `unlisted_for_days` (unlisted that long, by //! `unlisted_at`) and `max_versions_per_package` (not among the newest N //! of its partition of the pool, listed or not). //! 5. Protection overrides selection. Otherwise the highest-severity action @@ -445,7 +446,10 @@ fn triggers_match( any_trigger = true; // A release is never superseded; a prerelease only by a release. let release = inputs.superseded_by?; - if days_since(now, release.created_at) < days as i64 { + // The clock starts when both exist: a prerelease published after its + // release (a lagging CI bump, a maintenance line) has its N days too. + let since = release.created_at.max(cand.created_at); + if days_since(now, since) < days as i64 { return None; } reason.prerelease_superseded_for_days = Some(days); @@ -2454,6 +2458,43 @@ mod tests { assert!(d.is_empty(), "{d:?}"); } + #[test] + fn a_prerelease_published_after_its_release_has_its_days_too() { + // 2.3.0 has been out for 30 days; CI's lagging bump published + // 2.3.0-ci.500 today and 2.3.0-ci.499 20 days ago. A maintenance + // line's 1.9.1-rc.1 came out 3 days ago, months after 2.0.0. + let cands = vec![ + comp(1, "lib", "1.9.1-rc.1", 3, None), + comp(2, "lib", "2.0.0", 200, None), + comp(3, "lib", "2.3.0-ci.1", 60, None), + comp(4, "lib", "2.3.0", 30, None), + comp(5, "lib", "2.3.0-ci.499", 20, None), + comp(6, "lib", "2.3.0-ci.500", 0, None), + ]; + let d = evaluate( + &fmt(), + now(), + &hard_delete(superseded(14)), + &Selectors::none(), + &cands, + ); + // ci.1 counts from 2.3.0 (30 days), ci.499 from itself (20 days); + // ci.500 and rc.1 are younger than 14 days. + assert_eq!(deleted_versions(&d), vec!["2.3.0-ci.1", "2.3.0-ci.499"]); + let late = d.iter().find(|d| d.version == "2.3.0-ci.499").unwrap(); + assert_eq!(late.reason.superseded_by.as_deref(), Some("2.3.0")); + + // 25 days: ci.1 still goes, ci.499 (20 days old) stays. + let d = evaluate( + &fmt(), + now(), + &hard_delete(superseded(25)), + &Selectors::none(), + &cands, + ); + assert_eq!(deleted_versions(&d), vec!["2.3.0-ci.1"]); + } + #[test] fn unlisted_for_days_deletes_versions_unlisted_long_enough() { let mut long_ago = unlisted(comp(1, "lib", "1.0.0", 400, None)); diff --git a/crates/core/tests/retention_worker.rs b/crates/core/tests/retention_worker.rs index 4820df97..e4b53732 100644 --- a/crates/core/tests/retention_worker.rs +++ b/crates/core/tests/retention_worker.rs @@ -1437,7 +1437,8 @@ async fn a_scheduled_policy_runs_next_at_its_schedule() { /// #185: `unlisted_for_days` deletes versions unlisted long enough, by the /// time the unlisting recorded; `prerelease_superseded_for_days` deletes a -/// prerelease once its release has been out long enough. +/// prerelease once its release has been out long enough, and (#757) once it +/// has itself been out that long when it was published after its release. #[tokio::test] async fn unlisted_and_superseded_versions_go_after_their_days() { let pool = pool().await; @@ -1452,6 +1453,8 @@ async fn unlisted_and_superseded_versions_go_after_their_days() { ("old", "1.2.0", 400), ("app", "2.0.0-ci.1", 60), ("app", "2.0.0", 30), + // Published after 2.0.0 (a lagging CI bump): kept for its 14 days. + ("app", "2.0.0-ci.2", 2), ("app", "2.1.0-ci.1", 5), ] .into_iter() @@ -1500,6 +1503,7 @@ async fn unlisted_and_superseded_versions_go_after_their_days() { assert!(component_exists(&pool, repo.id, "old", "1.2.0").await); assert!(!component_exists(&pool, repo.id, "app", "2.0.0-ci.1").await); assert!(component_exists(&pool, repo.id, "app", "2.0.0").await); + assert!(component_exists(&pool, repo.id, "app", "2.0.0-ci.2").await); assert!(component_exists(&pool, repo.id, "app", "2.1.0-ci.1").await); let reasons: Vec<(String, Value)> = sqlx::query_as( "SELECT rule_id, reason FROM retention_audit WHERE repository_id = $1 ORDER BY rule_id", diff --git a/docs/changes/2.2.0/757.fixed.md b/docs/changes/2.2.0/757.fixed.md new file mode 100644 index 00000000..b3e9fe9f --- /dev/null +++ b/docs/changes/2.2.0/757.fixed.md @@ -0,0 +1,5 @@ +Retention's `prerelease_superseded_for_days` counted only from the +superseding release's publication, so a prerelease published after its +release (a lagging CI version bump, a maintenance line's `1.9.1-rc.1` after +`2.0.0`) was deleted on the next run. Its clock now starts at the later of +the two publications: a prerelease always has its N days. diff --git a/docs/retention.md b/docs/retention.md index 6569bdd1..a21b15cf 100644 --- a/docs/retention.md +++ b/docs/retention.md @@ -188,7 +188,7 @@ needs a trigger; a rule with protections only is refused. | --- | --- | | `older_than_days` | published at least N days ago. | | `not_downloaded_for_days` | not downloaded for N days; a version never downloaded counts from its publication. | -| `prerelease_superseded_for_days` | that is a prerelease (or a snapshot) once its package has had, for N days, a **listed release that comes after it**: for SemVer, a release of the same or a higher version, so `2.3.0-ci.417` goes 14 days after `2.3.0` (or `2.4.0`) was published. The clock starts at the first such release's publication. A release is never selected. | +| `prerelease_superseded_for_days` | that is a prerelease (or a snapshot) once its package has had, for N days, a **listed release that comes after it**: for SemVer, a release of the same or a higher version, so `2.3.0-ci.417` goes 14 days after `2.3.0` (or `2.4.0`) was published. The clock starts at the first such release's publication, or at the prerelease's own when it was published later (a lagging CI bump, a maintenance line's `1.9.1-rc.1` after `2.0.0`): a prerelease always has its N days. A release is never selected. | | `unlisted_for_days` | that has been unlisted for N days (a listed version never). Only on `hard_delete` rules: the second step of "unlist, then delete later". | | `max_versions_per_package` | that is not among the N newest versions of its package (listed or not) the rule's filters admit. A pure count: no age needed. | From 26d4a5d2f71d2178c8bcca88dd15562a12ab5cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:52:59 +0000 Subject: [PATCH 2/2] Retention: publish the preview test's CI builds before their release The preview test meant its CI builds to come before 1.0.0, but it left them at now(), after the release. Since #757 such a build keeps its 14 days, so the test now backdates them as its comment says. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJbobLATLgHyeaU5Gqa --- crates/server/tests/admin_retention.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/server/tests/admin_retention.rs b/crates/server/tests/admin_retention.rs index a86ba980..7c31c5fc 100644 --- a/crates/server/tests/admin_retention.rs +++ b/crates/server/tests/admin_retention.rs @@ -1439,8 +1439,9 @@ async fn preview_shows_the_new_triggers_decisions_and_the_next_runs() { ], ) .await; - // 1.0.0 was published 20 days ago, the CI builds before it; 1.1.0 has - // been unlisted for 100 days. + // 1.0.0 was published 20 days ago, the CI builds 30 days ago, before it + // (a prerelease published after its release keeps its days, #757); + // 1.1.0 has been unlisted for 100 days. sqlx::query( "UPDATE component SET created_at = now() - interval '20 days' \ WHERE repository_id = $1 AND version = '1.0.0'", @@ -1449,6 +1450,14 @@ async fn preview_shows_the_new_triggers_decisions_and_the_next_runs() { .execute(&srv.pool) .await .unwrap(); + sqlx::query( + "UPDATE component SET created_at = now() - interval '30 days' \ + WHERE repository_id = $1 AND version LIKE '1.0.0-ci.%'", + ) + .bind(repo_id) + .execute(&srv.pool) + .await + .unwrap(); sqlx::query( "UPDATE component SET listed = false WHERE repository_id = $1 AND version = '1.1.0'", )