Files
werkator/docs/prs/2026-09-13-PR#59-history-survives-the-branch.md
miandmhoennig c26a4c7e32
werkator build succeeded after 01:41
History survives branch (#59)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #59
2026-09-13 11:47:56 +02:00

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 retentionPerBranch and retentionMaxAge govern 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 sets 90d.
  • 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 SUCCESS build of branch feature/x
    • and feature/x has been deleted from origin after its merge
  • 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, latestGreenFor and latestPerName do not return it, so the branches view and the repository overview are unchanged
Verified by

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/x is on origin again and a poll cycle prunes retention
  • Then the entry's branchGoneAt is 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 retentionPerBranch and retentionMaxAge
  • 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 keepLatestGreen keeps none of them, because the permanent /branches/<branch-key>/… link it exists for serves artifacts that are gone
Verified by

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 RUNNING build 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, latestGreenFor and latestPerName skip 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 in DELETE /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.branchGone carries the fact to the browser, and BuildRowView drops the Gitea branch link for such a row. builds.html and werkator.js render the same n/a with 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 DELETE route still works for anyone who wants one gone early.

Additional Changes

  • AGENTS.md and 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.