added age-based build retention: artifacts.retentionMaxAge (e.g. 30d, empty = no limit) drops builds older than the given age; combines with retentionPerBranch as independent caps — a build is kept only while it satisfies both limits; a branch's newest build is never age-pruned and keepLatestGreen now shields the latest green build from both limits, keeping the permanent /branches/... links valid

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hoennig
2026-07-08 22:35:31 +02:00
co-authored by Claude Fable 5
parent b104eeee05
commit d0b38c557a
10 changed files with 246 additions and 9 deletions
@@ -220,6 +220,90 @@ class FileBuildResultRepositoryTest : FunSpec() {
)
}
test("prune drops entries older than the retention cutoff even within the retention count") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
val removed =
repository.prune(
originBranches = listOf("main"),
retentionPerBranch = 3,
retentionCutoff = baseTime.plusSeconds(90),
)
removed shouldContainExactlyInAnyOrder
listOf(
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 0),
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60),
)
repository.history() shouldContainExactly
listOf(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
}
test("prune never age-prunes a branch's newest entry") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 0))
val removed =
repository.prune(
originBranches = listOf("main"),
retentionPerBranch = 3,
retentionCutoff = baseTime.plusSeconds(300),
)
removed.shouldBeEmpty()
repository.history() shouldContainExactly
listOf(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 0))
}
test("prune applies the retention count and cutoff as independent limits") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
val removed =
repository.prune(
originBranches = listOf("main"),
// the count drops the entry at 0, the cutoff drops the entry at 60
retentionPerBranch = 2,
retentionCutoff = baseTime.plusSeconds(90),
)
removed shouldContainExactlyInAnyOrder
listOf(
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 0),
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60),
)
repository.history() shouldContainExactly
listOf(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
}
test("prune with keepLatestGreen keeps the newest green build beyond the retention cutoff") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
val removed =
repository.prune(
originBranches = listOf("main"),
retentionPerBranch = 3,
keepLatestGreen = true,
retentionCutoff = baseTime.plusSeconds(90),
)
removed shouldContainExactly
listOf(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60))
repository.history() shouldContainExactly
listOf(
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120),
result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0),
)
}
test("prune with keepLatestGreen keeps the newest green build beyond the retention count") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
@@ -420,6 +420,18 @@ class WatcherTest : FunSpec() {
dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED)
}
test("poll drops builds older than retentionMaxAge but keeps the branch's newest build") {
// seeds start one hour before the fixed clock, so a 30m age limit cuts them off
val harness = Harness(GitTallyConfig(artifacts = ArtifactsConfig(retentionMaxAge = "30m")))
harness.seed("main", BuildStatus.FAILED, commit = "commit-1")
harness.seed("main", BuildStatus.FAILED, commit = "commit-2")
every { harness.gitService.originBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir)
harness.repository.history().map { it.commit } shouldContainExactly listOf("commit-2")
}
test("worktrees of queued or running builds are never pruned") {
val harness = Harness()
harness.seed("busy", BuildStatus.RUNNING, commit = "commit-1")