Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #59
9.3 KiB
WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
The Problem
The History page shows no history.
On the instance serving this repository it listed three builds on 2026-09-12, all of them from that same afternoon, and nothing before them.
The page was right: GET /api/builds/history returned exactly those three entries, so nothing was lost in the rendering.
What was lost were the builds themselves, and two rules of retention did it together.
artifacts.retentionPerBranch was 3, and three merges into main in one afternoon (PR#56, PR#57, PR#58) were enough to push the morning out of the list.
The second rule is the one that matters.
FileBuildResultRepository.prune dropped every result whose branch is no longer on origin.
A merged pull request's branch is deleted right after the squash-merge, so its build — the build that decided whether the merge was safe — disappeared within one poll cycle.
The record of a branch was tied to the existence of that branch, which leaves a "history" that can only ever show work in progress.
The two rules compound: a repository that merges through short-lived branches keeps only the builds of the branches it happens to have open right now, plus the last three of main.
Non-Goals
- No new configuration key: the existing
retentionPerBranchandretentionMaxAgegovern the surviving entries too, decided as option A of the three offered. - No keeping of artifacts or logs beyond the branch: the stored directory still goes when the branch does, so the disk footprint of this change is the JSON entry alone.
- No unbounded history: an instance that merges branches forever needs
retentionMaxAge, and this repository now sets90d. - No change to the branches view, to the watcher's enqueue decisions, to worktree pruning, or to the sizing advice.
The Scenarios
Feature: a build result outlives the branch it was built from
Background
- A pool is one branch and one build definition (
BuildDefinition.poolName); retention counts per pool name. - A branch is gone when it is not among the origin branches the watcher passes to
prune. - A gone branch's entries are marked with
BuildResult.branchGoneAt, the time retention first found the branch gone.
Scenario#59.01: the build of a merged branch stays in the history
So that the History page can answer what was built last week, not only what is open right now.
- Given a
SUCCESSbuild of branchfeature/x- and
feature/xhas been deleted from origin after its merge
- and
- When a poll cycle prunes retention
- Then the entry is still in
history(), with its status, commit, times and duration- and it is marked with
branchGoneAt - and
latestFor,latestGreenForandlatestPerNamedo not return it, so the branches view and the repository overview are unchanged
- and it is marked with
Verified by
- FileBuildResultRepositoryTest — prune marks a named pool's entries once its underlying branch is gone from origin, and keeps them
- FileBuildResultRepositoryTest — latestFor, latestGreenFor and latestPerName skip the entries of gone branches, history keeps them
- WatcherTest — poll keeps the results of branches gone from origin, marked, and prunes their artifacts and worktrees
Scenario#59.02: a branch that comes back loses the mark
So that a branch pushed again under the same name is an ordinary branch, not a half-deleted one.
- Given a marked entry of branch
feature/x - When
feature/xis on origin again and a poll cycle prunes retention - Then the entry's
branchGoneAtis cleared- and the entry counts as the pool's latest again
Verified by
Scenario#59.03: a gone branch's entries live by the count and the age alone
So that the history stays bounded, and the two exemptions that only make sense for a living branch expire with it.
- Given marked entries of a branch gone from origin
- When retention applies
retentionPerBranchandretentionMaxAge - Then entries beyond the count are dropped as for any other pool
- and even the newest of them is dropped once it is older than the cutoff, because a gone branch has no current status to keep
- and
keepLatestGreenkeeps none of them, because the permanent/branches/<branch-key>/…link it exists for serves artifacts that are gone
Verified by
- FileBuildResultRepositoryTest — prune keeps the entries of branches missing from origin within the count, marked as gone
- FileBuildResultRepositoryTest — prune age-prunes even the newest entry of a gone branch
- FileBuildResultRepositoryTest — prune with keepLatestGreen does not keep a gone branch's green build beyond the count
Scenario#59.04: the row of a gone branch offers nothing that would 404
So that a surviving row never promises a page that cannot exist.
- Given a history row whose branch is gone from origin
- When the page renders it, server-side or from
GET /api/builds/history - Then it shows status, branch name, commit, start and duration as before
- and the branch name is plain text, not a link to a Gitea branch page that is gone
- and the artifacts column shows
n/a, titled "Branch deleted from origin — artifacts dropped" - and the commit still links to Gitea, because the commit survives the branch
Verified by
Scenario#59.05: a queued or running build is never marked
So that a branch deleted while its last build is still in flight does not lose that build from the views that drive it.
- Given a
RUNNINGbuild of a branch already deleted from origin - When retention runs
- Then the entry is neither marked nor removed
- and it is still the pool's latest
Verified by
The Solution
BuildResult gains branchGoneAt, and prune marks instead of dropping.
The mark is set on the first pass that finds the branch missing from origin, cleared on the first pass that finds it back, and never applied to a PENDING or RUNNING entry.
Everything else follows from the mark being read in exactly three places:
latestFor,latestGreenForandlatestPerNameskip marked entries, so the branches view, the repository overviews, the watcher's recovery and the permanent latest-green links behave exactly as before.history()is the one accessor that returns them — which is the point.- The artifact store is pruned to
history().withArtifacts(), the entries of living branches, in the watcher's cycle and inDELETE /api/builds/{key}alike. The stored directory and the log of a gone branch are therefore deleted on the same cycle that marks its entries, as before this change. BuildResultDto.branchGonecarries the fact to the browser, andBuildRowViewdrops the Gitea branch link for such a row.builds.htmlandwerkator.jsrender the samen/awith the same title.
prune was rewritten to work on positions rather than on a set of values, because two runs of one build can be equal in every field and a value set would keep or drop them together.
The returned "removed" list reports the entries as they were stored: an entry dropped in the very pass that would have marked it never carried the mark.
The default retentionPerBranch goes from 3 to 5 — three was one afternoon of merges — in the data class, the init templates, docs/configuration.md and docs/llms.md.
This repository's own .werkator.yml sets retentionPerBranch: 5 and, because it merges through short-lived branches, retentionMaxAge: 90d.
Open Questions
- The build-duration trend (
GET /api/builds/durations) now sees the builds of merged branches, which used to vanish. This widens the sample of every definition rather than distorting it, and the trend is grouped by definition, so the behaviour is left as it is. - Amber rows are not offered a way to delete a marked entry by hand; the
DELETEroute still works for anyone who wants one gone early.
Additional Changes
AGENTS.mdand the architecture skill state the new retention invariant.BuildResultRepository's Kdoc says which accessor returns marked entries and which do not.
Follow-up PRs
- Once the History page reaches beyond a few days, it will need paging or a range selector; it currently renders every retained entry in one table.