A build definition says when it runs in a trigger block of its own

`onPush`, `atTimes`, `branches`, and `activeWithin` move into a nested
`trigger`. The split is structural on purpose: the inheritance from
`builds.default` now subtracts one key instead of a list of four, so a
selector added to `TriggerConfig` later is non-inheritable by
construction rather than because someone remembered to extend the list.

A definition still writing those keys flat is refused by name, per file
and scoped like the version check — the machine and project config abort
the start, a branch's committed config fails only that branch. Ignoring
them would leave the build with no trigger at all, which is a job that
quietly stops running: the failure this refusal exists to prevent.

Two more things a definition can now say:

- A `!` prefix in `trigger.branches` excludes, and an exclusion wins
  whatever the order. `["*", "!master"]` gives one branch a build of its
  own without the default build running over it as well — until now the
  only way out of that double build was to drop the second definition's
  push trigger.
- `statusContext` overrides the Gitea check this build reports as, empty
  keeping the repository-wide one. Two builds of a commit shared a
  context and overwrote each other's result, so a quick check beside a
  long build was not readable in Gitea. Pinned like `requirePullRequest`:
  a branch that could pick its context could take over the check a branch
  protection rule depends on.

Fixed on the way: a branch whose builds all belong to named definitions
rendered an empty row in the branches view, reading as "never built"
directly beside its real builds. That row was unreachable before the
exclusion patterns made such a branch possible.
This commit is contained in:
mhoennig
2026-08-29 12:05:10 +02:00
parent 0fbb25b47f
commit f0f996a53c
22 changed files with 486 additions and 178 deletions
@@ -132,9 +132,9 @@ class BuildExecutorTest : FunSpec() {
liveLog shouldContain "out-main"
liveLog shouldContain "err-main"
verify { h.giteaClient.publishStatus("abc123", BuildStatus.PENDING, any(), null, h.workingDir) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.RUNNING, any(), null, h.workingDir) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.SUCCESS, any(), null, h.workingDir) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.PENDING, any(), null, h.workingDir, any()) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.RUNNING, any(), null, h.workingDir, any()) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.SUCCESS, any(), null, h.workingDir, any()) }
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
}
@@ -395,7 +395,7 @@ class BuildExecutorTest : FunSpec() {
h.events.map { it.result.status } shouldContainExactly
listOf(BuildStatus.PENDING, BuildStatus.RUNNING, BuildStatus.INTERRUPTED)
Files.readString(build.liveLogFile) shouldContain "build interrupted by shutdown"
verify { h.giteaClient.publishStatus("abc123", BuildStatus.INTERRUPTED, any(), null, h.workingDir) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.INTERRUPTED, any(), null, h.workingDir, any()) }
verify { h.artifactStore.persist(match { it.status == BuildStatus.INTERRUPTED }, build.stagingDir, any()) }
}
@@ -426,7 +426,7 @@ class BuildExecutorTest : FunSpec() {
h.events
.filter { it.result.artifactKey == second.artifactKey }
.map { it.result.status } shouldContainExactly listOf(BuildStatus.PENDING)
verify(exactly = 0) { h.giteaClient.publishStatus("sha-2", BuildStatus.RUNNING, any(), any(), any()) }
verify(exactly = 0) { h.giteaClient.publishStatus("sha-2", BuildStatus.RUNNING, any(), any(), any(), any()) }
}
test("shutdown without any build in flight is a no-op") {
@@ -464,7 +464,7 @@ class BuildExecutorTest : FunSpec() {
test("a Gitea failure does not fail the build") {
val h = harness("echo ok")
every {
h.giteaClient.publishStatus(any(), any(), any(), any(), any())
h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any())
} throws RuntimeException("gitea down")
h.executor.startBuild("main", "abc123", h.workingDir)
@@ -2,6 +2,8 @@ package de.hoennig.gittally.config
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
@@ -69,9 +71,10 @@ class ConfigLoaderTest : FunSpec() {
maxConcurrent: 2
builds:
pitest:
atTimes: ["01:00"]
branches: ["master", "release/*"]
activeWithin: 24h
trigger:
atTimes: ["01:00"]
branches: ["master", "release/*"]
activeWithin: 24h
buildCommand: ./gradlew piTestFull
""".trimIndent(),
)
@@ -83,14 +86,17 @@ class ConfigLoaderTest : FunSpec() {
mapOf(
"pitest" to
BuildDefinition(
atTimes = listOf("01:00"),
branches = listOf("master", "release/*"),
activeWithin = "24h",
trigger =
TriggerConfig(
atTimes = listOf("01:00"),
branches = listOf("master", "release/*"),
activeWithin = "24h",
),
buildCommand = "./gradlew piTestFull",
),
)
// the implicit default build (onPush over all branches) stays in place
config.effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = true)
config.effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(trigger = TriggerConfig(onPush = true))
}
test("an explicit builds.default entry overrides the implicit default build") {
@@ -99,11 +105,12 @@ class ConfigLoaderTest : FunSpec() {
"""
builds:
default:
onPush: false
trigger:
onPush: false
""".trimIndent(),
)
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false)
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(trigger = TriggerConfig(onPush = false))
}
test("a config that needs a newer GitTally is refused, naming the file and both versions") {
@@ -209,7 +216,8 @@ class ConfigLoaderTest : FunSpec() {
"""
builds:
pitest:
atTimes: ["01:00"]
trigger:
atTimes: ["01:00"]
buildCommand: ./gradlew piTestPartial
""".trimIndent(),
)
@@ -220,7 +228,8 @@ class ConfigLoaderTest : FunSpec() {
pitest:
buildCommand: ./gradlew piTestFull
experiment:
onPush: true
trigger:
onPush: true
""".trimIndent(),
)
@@ -228,8 +237,12 @@ class ConfigLoaderTest : FunSpec() {
// the branch layer merges into the definition instead of replacing it
config.buildDefinitions.getValue("pitest").buildCommand shouldBe "./gradlew piTestFull"
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("01:00")
config.buildDefinitions.getValue("experiment").onPush shouldBe true
config.buildDefinitions
.getValue("pitest")
.trigger.atTimes shouldBe listOf("01:00")
config.buildDefinitions
.getValue("experiment")
.trigger.onPush shouldBe true
}
test("a branch cannot raise the concurrency limit or reach the sandbox policy through a build definition") {
@@ -239,6 +252,7 @@ class ConfigLoaderTest : FunSpec() {
builds:
default:
requirePullRequest: true
statusContext: GitTally
docker:
enabled: true
network: none
@@ -255,6 +269,7 @@ class ConfigLoaderTest : FunSpec() {
builds:
default:
requirePullRequest: false
statusContext: GitTally/impersonated
docker:
enabled: false
network: host
@@ -270,6 +285,7 @@ class ConfigLoaderTest : FunSpec() {
settings.requirePullRequest shouldBe true
settings.docker.enabled shouldBe true
settings.docker.network shouldBe "none"
settings.statusContext shouldBe "GitTally"
// everything that describes the build itself stays the branch's own business
settings.docker.image shouldBe "attacker-image"
}
@@ -291,7 +307,8 @@ class ConfigLoaderTest : FunSpec() {
"""
builds:
invented:
atTimes: ["03:00"]
trigger:
atTimes: ["03:00"]
buildCommand: ./gradlew whatever
docker:
enabled: false
@@ -309,20 +326,119 @@ class ConfigLoaderTest : FunSpec() {
settings.requirePullRequest shouldBe true
}
test("an exclusion pattern takes a branch out of a build that would otherwise select it") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
builds:
default:
trigger:
onPush: true
branches: ["*", "!master"]
release:
trigger:
onPush: true
branches: ["master"]
""".trimIndent(),
)
val definitions = loader.load(dir).buildDefinitions
definitions
.getValue("default")
.trigger
.selectsByName("mihoe/feature")
.shouldBeTrue()
definitions
.getValue("default")
.trigger
.selectsByName("master")
.shouldBeFalse()
definitions
.getValue("release")
.trigger
.selectsByName("master")
.shouldBeTrue()
definitions
.getValue("release")
.trigger
.selectsByName("mihoe/feature")
.shouldBeFalse()
}
test("an exclusion wins over a matching pattern, whatever their order") {
val trigger = TriggerConfig(branches = listOf("!release/hotfix", "release/*"))
trigger.selectsByName("release/1.0").shouldBeTrue()
trigger.selectsByName("release/hotfix").shouldBeFalse()
}
test("a trigger key written outside the trigger block is refused, naming the definition") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
builds:
nightly:
atTimes: ["01:00"]
buildCommand: ./gradlew check
""".trimIndent(),
)
// ignoring it would leave the build without a trigger — a job that silently
// stops running is worse than a configuration that refuses to load
val thrown = shouldThrow<ConfigFormatException> { loader.load(dir) }
thrown.message.shouldContain(".gittally.yml")
thrown.message.shouldContain("builds.nightly: atTimes")
thrown.message.shouldContain("trigger:")
}
test("a branch writing its trigger flat fails only its own builds") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
builds:
default:
trigger:
onPush: true
""".trimIndent(),
)
shouldThrow<ConfigFormatException> {
loader.loadWithBranchLayer(
dir,
"""
builds:
experiment:
onPush: true
""".trimIndent(),
)
}.message.shouldContain("this branch")
// the primary config is untouched, so every other branch keeps building
loader
.load(dir)
.buildDefinitions
.getValue("default")
.trigger.onPush
.shouldBeTrue()
}
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"]
trigger:
onPush: true
branches: ["master"]
buildCommand: ./gradlew check
artifactDirs: [build/reports]
docker:
image: shared-image
nightly:
atTimes: ["01:00"]
trigger:
atTimes: ["01:00"]
artifactDirs: [build/reports, build/libs]
""".trimIndent(),
)
@@ -333,9 +449,9 @@ class ConfigLoaderTest : FunSpec() {
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")
nightly.trigger.onPush shouldBe false
nightly.trigger.branches shouldBe emptyList<String>()
nightly.trigger.atTimes shouldBe listOf("01:00")
}
test("branches is honored while no build is defined and ignored as soon as one is") {
@@ -587,14 +703,17 @@ class ConfigLoaderTest : FunSpec() {
default:
buildCommand: from-branch
pitest:
atTimes: ["03:00"]
trigger:
atTimes: ["03:00"]
buildCommand: ./gradlew piTestFull
""".trimIndent(),
)
config.git.token shouldBe "real-secret"
config.buildSettings("main", "default").buildCommand shouldBe "from-branch"
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("03:00")
config.buildDefinitions
.getValue("pitest")
.trigger.atTimes shouldBe listOf("03:00")
}
test("loadWithBranchLayer without a branch config equals load") {
@@ -6,6 +6,7 @@ import com.github.tomakehurst.wiremock.client.WireMock.equalTo
import com.github.tomakehurst.wiremock.client.WireMock.equalToJson
import com.github.tomakehurst.wiremock.client.WireMock.get
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath
import com.github.tomakehurst.wiremock.client.WireMock.okJson
import com.github.tomakehurst.wiremock.client.WireMock.post
import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor
@@ -102,6 +103,34 @@ class GiteaClientTest :
)
}
test("a build's own status context replaces the repository-wide one") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
// without this, two builds of one commit overwrite each other's check
client.publishStatus(
sha = "abc123",
status = BuildStatus.SUCCESS,
description = "d",
context = "GitTally/quick",
) shouldBe true
server.verify(
postRequestedFor(urlEqualTo(publishUrl))
.withRequestBody(matchingJsonPath("${'$'}.context", equalTo("GitTally/quick"))),
)
}
test("a blank build status context falls back to the repository-wide one") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
client.publishStatus("abc123", BuildStatus.SUCCESS, "d", context = "") shouldBe true
server.verify(
postRequestedFor(urlEqualTo(publishUrl))
.withRequestBody(matchingJsonPath("${'$'}.context", equalTo("GitTally"))),
)
}
test("omits target_url when none is given") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
@@ -31,6 +31,19 @@ class BranchListingTest : FunSpec() {
every { repository.latestPerName() } returns emptyList()
}
test("a branch built only by named definitions loses its empty default row") {
val releaseResult = mainResult.copy(build = "release", name = "master@release")
every { gitService.originBranchHeads(any()) } returns mapOf("master" to "aaa", "idle" to "bbb")
every { repository.latestPerName() } returns listOf(releaseResult.copy(branch = "master"))
every { repository.latestFor(any()) } returns null
every { repository.latestGreenFor(any()) } returns null
val rows = listing.branches()
// no bare "master" row next to master@release — it would read as "never built"
rows.map { it.name } shouldBe listOf("master@release", "idle")
}
test("orders main/master first, then flat names, then hierarchical names") {
every { gitService.originBranchHeads(any()) } returns
mapOf(
@@ -14,6 +14,7 @@ import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.BuildDefinition
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.config.TriggerConfig
import de.hoennig.gittally.config.WatcherConfig
import de.hoennig.gittally.git.GitService
import io.kotest.assertions.throwables.shouldThrow
@@ -394,8 +395,7 @@ class WatcherTest : FunSpec() {
mapOf(
"pitest" to
BuildDefinition(
atTimes = listOf("11:00"),
branches = listOf("main", "release/*"),
trigger = TriggerConfig(atTimes = listOf("11:00"), branches = listOf("main", "release/*")),
),
),
),
@@ -421,7 +421,7 @@ class WatcherTest : FunSpec() {
Harness(
GitTallyConfig(
buildDefinitions =
mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), activeWithin = "24h")),
mapOf("pitest" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00"), activeWithin = "24h"))),
),
)
every { harness.gitService.originBranches(any()) } returns listOf("active", "dormant")
@@ -443,7 +443,7 @@ class WatcherTest : FunSpec() {
Harness(
GitTallyConfig(
buildDefinitions =
mapOf("lint" to BuildDefinition(onPush = true, branches = listOf("main"))),
mapOf("lint" to BuildDefinition(trigger = TriggerConfig(onPush = true, branches = listOf("main")))),
),
)
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/x")
@@ -463,7 +463,7 @@ class WatcherTest : FunSpec() {
test("builds.default with onPush false disables the implicit on-push build") {
val harness =
Harness(GitTallyConfig(buildDefinitions = mapOf("default" to BuildDefinition(onPush = false))))
Harness(GitTallyConfig(buildDefinitions = mapOf("default" to BuildDefinition(trigger = TriggerConfig(onPush = false)))))
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.localBranches(any()) } returns listOf("main")
every { harness.gitService.hasNewCommits("main", any()) } returns true
@@ -490,7 +490,7 @@ class WatcherTest : FunSpec() {
val harness = Harness()
val branchLayer =
GitTallyConfig(
buildDefinitions = mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"))),
buildDefinitions = mapOf("pitest" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))),
)
every { harness.gitService.originBranches(any()) } returns listOf("main", "experiment")
every { harness.gitService.originBranchHeads(any()) } returns
@@ -515,7 +515,7 @@ class WatcherTest : FunSpec() {
val branchLayer =
GitTallyConfig(
buildDefinitions =
mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), branches = listOf("main"))),
mapOf("pitest" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00"), branches = listOf("main")))),
)
every { harness.gitService.originBranches(any()) } returns listOf("main", "experiment")
every { harness.gitService.originBranchHeads(any()) } returns
@@ -558,7 +558,7 @@ class WatcherTest : FunSpec() {
// caching the definitions by head commit alone would never notice
val edited =
GitTallyConfig(
buildDefinitions = mapOf("nightly" to BuildDefinition(atTimes = listOf("11:00"))),
buildDefinitions = mapOf("nightly" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))),
)
every { harness.configLoader.load(any()) } returns edited
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited