A build definition carries the whole build; branches is legacy

`builds` and the legacy `branches` are now either/or: `branches` is read
only while the merged configuration defines no build at all — a leftover
`builds.maxConcurrent` is not one — and ignored with a warning as soon as
one exists. Two half-answers to "what does this build run" would silently
pull against each other, and the committed configs still carrying both
must not change behaviour before they are migrated.

A definition therefore gained the settings it was missing:
`requirePullRequest` and `docker.enabled`/`network`. Those stay pinned —
`stripPinned` now removes them from a branch layer wherever they appear,
in a definition as well as in a legacy branch entry.

`builds.default` becomes the base every other definition inherits its
settings from, never its trigger: `onPush`, `atTimes`, `branches`, and
`activeWithin` say when and where *this* build runs. The inheritance is
applied after all layers are merged, which is what makes a build invented
on a branch inherit the host's sandbox policy instead of the data-class
default — otherwise a branch could get a native build past the pinning by
defining a job the host has never heard of.

Two bugs found on the way, both the same shape as the build command the
artifact page used to get wrong:

- `FileArtifactStore` read the artifact directories from the plain branch
  settings, so a job adding its own `artifactDirs` never had them stored.
  It goes through `GitTallyConfig.buildSettings` now, like everything else
  that asks what a build runs.
- `Watcher.definitionsFor` cached the per-branch definitions by head
  commit alone, so an edited machine or project config only took effect
  once the branch moved — on a quiet branch, never. The primary config is
  part of the cache key now.
This commit is contained in:
mhoennig
2026-08-29 11:18:35 +02:00
parent f07a399f2e
commit 729eea5e6c
12 changed files with 427 additions and 214 deletions
@@ -545,6 +545,29 @@ class WatcherTest : FunSpec() {
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-2", Watcher.CONFIG_FILE, any()) }
}
test("an edited primary config takes effect without the branch moving") {
val harness = Harness()
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
harness.watcher.poll(harness.workingDir)
harness.startedBuilds.shouldBeEmpty()
// the machine config gains a scheduled build while the branch stays where it is:
// caching the definitions by head commit alone would never notice
val edited =
GitTallyConfig(
buildDefinitions = mapOf("nightly" to BuildDefinition(atTimes = listOf("11:00"))),
)
every { harness.configLoader.load(any()) } returns edited
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited
harness.watcher.poll(harness.workingDir)
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "nightly") }
}
test("an unreadable branch config falls back to the primary definitions instead of failing the poll") {
val harness = Harness()
every { harness.gitService.originBranches(any()) } returns listOf("main")