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
@@ -236,7 +236,49 @@ class ConfigLoaderTest : FunSpec() {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
branches:
builds:
default:
requirePullRequest: true
docker:
enabled: true
network: none
image: host-image
""".trimIndent(),
)
val worktree = Files.createTempDirectory("gittally-test-worktree")
worktree.resolve(".gittally.yml").toFile().writeText(
"""
executor:
maxConcurrent: 99
watcher:
pullRequestGate: false
builds:
default:
requirePullRequest: false
docker:
enabled: false
network: host
image: attacker-image
""".trimIndent(),
)
val config = loader.loadForWorktree(dir, worktree)
val settings = config.buildSettings("any-branch", "default")
config.executor.maxConcurrent shouldBe 1
config.watcher.pullRequestGate shouldBe true
settings.requirePullRequest shouldBe true
settings.docker.enabled shouldBe true
settings.docker.network shouldBe "none"
// everything that describes the build itself stays the branch's own business
settings.docker.image shouldBe "attacker-image"
}
test("a build the branch invents inherits the host's sandbox policy") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
builds:
default:
requirePullRequest: true
docker:
@@ -247,38 +289,85 @@ class ConfigLoaderTest : FunSpec() {
val worktree = Files.createTempDirectory("gittally-test-worktree")
worktree.resolve(".gittally.yml").toFile().writeText(
"""
executor:
maxConcurrent: 99
watcher:
pullRequestGate: false
branches:
default:
requirePullRequest: false
builds:
default:
invented:
atTimes: ["03:00"]
buildCommand: ./gradlew whatever
docker:
enabled: false
network: host
""".trimIndent(),
)
val config = loader.loadForWorktree(dir, worktree)
val branchConfig = config.branches.getValue("default")
// the host has never heard of this build, so there is no lower layer to fall
// back to — it must inherit the policy from builds.default, not the data class
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "invented")
config.executor.maxConcurrent shouldBe 1
config.watcher.pullRequestGate shouldBe true
branchConfig.requirePullRequest shouldBe true
// a build definition has no enabled/network at all, so it cannot reintroduce them
config.buildDefinitions
.getValue("default")
.applyTo(branchConfig)
.docker
.enabled shouldBe true
config.buildDefinitions
.getValue("default")
.applyTo(branchConfig)
.docker
.network shouldBe "none"
settings.buildCommand shouldBe "./gradlew whatever"
settings.docker.enabled shouldBe true
settings.docker.network shouldBe "none"
settings.requirePullRequest shouldBe true
}
test("builds.default is the base of every other build, but never its trigger") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
builds:
default:
onPush: true
branches: ["master"]
buildCommand: ./gradlew check
artifactDirs: [build/reports]
docker:
image: shared-image
nightly:
atTimes: ["01:00"]
artifactDirs: [build/reports, build/libs]
""".trimIndent(),
)
val nightly = loader.load(dir).buildDefinitions.getValue("nightly")
nightly.buildCommand shouldBe "./gradlew check"
nightly.docker?.image shouldBe "shared-image"
nightly.artifactDirs shouldBe listOf("build/reports", "build/libs")
// a trigger says when *this* build runs; inheriting it would fire every job at once
nightly.onPush shouldBe false
nightly.branches shouldBe emptyList()
nightly.atTimes shouldBe listOf("01:00")
}
test("branches is honored while no build is defined and ignored as soon as one is") {
val dir = Files.createTempDirectory("gittally-test")
val legacy =
"""
branches:
default:
buildCommand: from-branches
docker:
enabled: true
""".trimIndent()
dir.resolve(".gittally.yml").toFile().writeText(legacy)
// the leftover execution key is not a definition, so the legacy section still wins
loader.load(dir).buildSettings("main", "default").buildCommand shouldBe "from-branches"
dir.resolve(".gittally.yml").toFile().writeText("builds:\n maxConcurrent: 1\n" + legacy)
loader.load(dir).buildSettings("main", "default").buildCommand shouldBe "from-branches"
dir.resolve(".gittally.yml").toFile().writeText(
legacy +
"\n" +
"""
builds:
default:
buildCommand: from-builds
""".trimIndent(),
)
val settings = loader.load(dir).buildSettings("main", "default")
settings.buildCommand shouldBe "from-builds"
settings.docker.enabled shouldBe false
}
test("repo install config overrides project config for same keys") {
@@ -482,7 +571,7 @@ class ConfigLoaderTest : FunSpec() {
"""
git:
token: real-secret
branches:
builds:
default:
buildCommand: from-git
""".trimIndent(),
@@ -495,17 +584,16 @@ class ConfigLoaderTest : FunSpec() {
git:
token: stolen
builds:
default:
buildCommand: from-branch
pitest:
atTimes: ["03:00"]
buildCommand: ./gradlew piTestFull
branches:
default:
buildCommand: from-branch
""".trimIndent(),
)
config.git.token shouldBe "real-secret"
config.branches.getValue("default").buildCommand shouldBe "from-branch"
config.buildSettings("main", "default").buildCommand shouldBe "from-branch"
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("03:00")
}