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
@@ -138,7 +138,7 @@ class FileArtifactStore(
log.warn("build {} has no workspace; storing only its logs", build.artifactKey)
return
}
for (artifactDir in branchConfig(build.branch, workspace).artifactDirs) {
for (artifactDir in buildSettings(build, workspace).artifactDirs) {
if (artifactDir.isBlank()) {
continue
}
@@ -159,14 +159,16 @@ class FileArtifactStore(
"reports/$artifactDir"
}
/** The build config for [branch], with the build [workspace]'s `.gittally.yml` layered on top (see [ConfigLoader.loadForWorktree]). */
private fun branchConfig(
branch: String,
/**
* The settings [build] ran with, from the build [workspace]'s `.gittally.yml` layered
* on top of the primary config (see [ConfigLoader.loadForWorktree]) — resolved through
* [GitTallyConfig.buildSettings], so a job's own `artifactDirs` are archived and not
* only the ones its branch would have used.
*/
private fun buildSettings(
build: BuildResult,
workspace: Path,
): BranchConfig {
val branches = configLoader.loadForWorktree(workingDir, workspace).branches
return branches[branch] ?: branches["default"] ?: BranchConfig()
}
): BranchConfig = configLoader.loadForWorktree(workingDir, workspace).buildSettings(build.branch, build.build)
private fun copyChildren(
sourceDir: Path,
@@ -175,17 +175,40 @@ class InitCommand(
# how many builds may run at the same time (at most one build per branch regardless)
maxConcurrent: 1
# Named build definitions (jobs) over the branches; every key names a build.
# A branch may add or override definitions in its own committed .gittally.yml —
# they then apply to that branch alone, so a new job can be tried out on a branch.
# Named build definitions (jobs); every key names a build.
# "default" is the base every other definition inherits its settings from — never
# its trigger — and is itself the build of every branch as long as it has one.
# A branch may add or override definitions in its own committed .gittally.yml;
# they apply to that branch alone, so a new job can be tried out on one branch.
builds:
# Example definition — triggers (onPush/atTimes), branch selector
# (branches/activeWithin), and overrides of the branch settings:
default:
onPush: true # build every new commit of the selected branches
# run before each build
cleanCommand: rm -rf build
# shell command for each build
buildCommand: ./gradlew --console=plain --no-daemon test
# directories copied as build artifacts
artifactDirs:
- build/reports
stdoutLog: build.stdout.log # filename for captured stdout
stderrLog: build.stderr.log # filename for captured stderr
# build only while the branch head matches a pull-request head on origin
# (refs/pull/*/head — read via plain git, no API token needed);
# pinned: a branch cannot set this in its own committed config
requirePullRequest: false
docker:
enabled: false # run clean/build in a container instead of natively (pinned)
image: "" # image for the build container; required when enabled
dockerfile: "" # Dockerfile to (re)build the image from when missing or stale; empty pulls the image as-is
context: "." # Docker build context used with dockerfile
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
env: {} # additional environment variables set inside the build container
# Further jobs inherit those settings and add their own trigger and selector:
# pitest:
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
# branches: ["master"] # names or glob patterns; default: all branches
# activeWithin: 24h # only branches with recent commits
# buildCommand: ./gradlew piTestFull
# buildCommand: ./gradlew pitestFull
# Build artifact storage and retention.
artifacts:
@@ -206,40 +229,12 @@ class InitCommand(
pollInterval: 10s
# max commit age for new origin branches to be pulled automatically
newBranchMaxAge: 5d
# honor branches.<name>.requirePullRequest; set false for a plain git origin
# honor builds.<name>.requirePullRequest; set false for a plain git origin
# without pull-request refs (refs/pull/*/head) — gated branches then build on new commits
pullRequestGate: true
# after enqueueing, fast-forward the primary checkout's local branch refs to origin,
# so build tools reading the shared .git see the same refs (diverged branches stay untouched)
fastForwardLocalRefs: true
# Per-branch build configuration.
# Use "default" as the fallback for all branches not listed explicitly.
branches:
default:
# run before each build
cleanCommand: rm -rf build
# shell command for each build
buildCommand: ./gradlew --console=plain --no-daemon test
# directories copied as build artifacts
artifactDirs:
- build/reports
stdoutLog: build.stdout.log # filename for captured stdout
stderrLog: build.stderr.log # filename for captured stderr
# build only while the branch head matches a pull-request head on origin
# (refs/pull/*/head — read via plain git, no API token needed)
requirePullRequest: false
# DEPRECATED: define a build with atTimes in the builds section instead
autoBuild:
enabled: false # whether to rebuild on schedule
times: ["01:00"] # UTC times HH:MM for scheduled builds
docker:
enabled: false # run clean/build commands in a Docker container instead of natively
image: "" # image for the build container; required when enabled
dockerfile: "" # Dockerfile to (re)build the image from when missing or stale; empty pulls the image as-is
context: "." # Docker build context used with dockerfile
network: "" # Docker network mode for the build container; empty = Docker default
env: {} # additional environment variables set inside the build container
""".trimIndent()
file.toFile().writeText(content + "\n")
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
@@ -6,8 +6,12 @@ import java.time.Instant
/**
* A named build (job) over the branches — ADR 0007. In YAML these live in the
* top-level `builds` section next to the reserved execution key `maxConcurrent`
* (split apart by [ConfigLoader]); a build definition always comes from the repo
* install/project config, never from a build worktree.
* (split apart by [ConfigLoader]).
*
* A definition carries the complete description of one build. The `default` entry is
* additionally the base every other definition inherits its settings from — but never
* its trigger, see [SELECTOR_KEYS][ConfigLoader]. Unset values fall through to the
* [BranchConfig] defaults.
*
* The `default` build records its results under the plain branch name; every other
* build records under `<branch>@<name>` with its own history, retention pool, and
@@ -42,7 +46,13 @@ data class BuildDefinition(
val stdoutLog: String? = null,
/** Overrides the branch's stderr log file name; null inherits it. */
val stderrLog: String? = null,
/** Overrides of the branch's docker image settings; the sandbox policy (`enabled`, `network`) is not overridable. */
/**
* The watcher builds a selected branch only while its head commit matches a
* pull-request head; null inherits. Pinned — a branch's own committed config can
* never set it, or it would bypass its own gate.
*/
val requirePullRequest: Boolean? = null,
/** Overrides of the branch's docker settings; null inherits them. */
val docker: DockerOverrides? = null,
) {
/** True when [branch] matches the [branches] patterns (or none are configured). */
@@ -78,11 +88,14 @@ data class BuildDefinition(
artifactDirs = artifactDirs ?: branchConfig.artifactDirs,
stdoutLog = stdoutLog ?: branchConfig.stdoutLog,
stderrLog = stderrLog ?: branchConfig.stderrLog,
requirePullRequest = requirePullRequest ?: branchConfig.requirePullRequest,
docker =
branchConfig.docker.copy(
enabled = docker?.enabled ?: branchConfig.docker.enabled,
image = docker?.image ?: branchConfig.docker.image,
dockerfile = docker?.dockerfile ?: branchConfig.docker.dockerfile,
context = docker?.context ?: branchConfig.docker.context,
network = docker?.network ?: branchConfig.docker.network,
env = docker?.env ?: branchConfig.docker.env,
),
)
@@ -106,8 +119,12 @@ data class BuildDefinition(
}
}
/** Nullable docker image overrides of a [BuildDefinition]; null values inherit the branch's setting. */
/** Nullable docker overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class DockerOverrides(
/** Run the build in a container instead of natively. Pinned — a branch must not escape its sandbox. */
val enabled: Boolean? = null,
/** Docker network mode. Pinned — a branch must not change the sandbox's reachability. */
val network: String? = null,
val image: String? = null,
val dockerfile: String? = null,
val context: String? = null,
@@ -34,6 +34,9 @@ class ConfigLoader(
/** Version warnings already reported; the config is loaded on every poll cycle, per branch. */
private val warnedVersions = ConcurrentHashMap.newKeySet<String>()
/** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */
private val warnedSections = ConcurrentHashMap.newKeySet<String>()
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig = toConfig(loadRaw(workingDir))
/**
@@ -84,7 +87,7 @@ class ConfigLoader(
if (raw.isEmpty()) {
GitTallyConfig()
} else {
yaml.convertValue(mergeBranchDefaults(dropNonDefinitionBuilds(raw)), GitTallyConfig::class.java)
yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), GitTallyConfig::class.java)
}
return defaultPublicBaseUrl(config)
}
@@ -118,7 +121,8 @@ class ConfigLoader(
/**
* Removes the keys a branch must never override: the secret and host-side top-level
* sections, the per-branch trust gate, and the docker sandbox policy.
* sections, the trust gate, and the docker sandbox policy — the latter two wherever
* they may appear, in a `builds` definition as well as in a legacy `branches` entry.
* See [loadWithBranchLayer].
*/
@Suppress("UNCHECKED_CAST")
@@ -128,19 +132,19 @@ class ConfigLoader(
}
val result = branchLayer.toMutableMap()
PINNED_TOP_LEVEL_KEYS.forEach { result.remove(it) }
val branches = result["branches"] as? Map<String, Any?>
if (branches != null) {
result["branches"] = branches.mapValues { (_, value) -> stripPinnedBranchKeys(value) }
for (section in listOf("builds", "branches")) {
val entries = result[section] as? Map<String, Any?> ?: continue
result[section] = entries.mapValues { (_, value) -> stripPinnedSettings(value) }
}
return result
}
@Suppress("UNCHECKED_CAST")
private fun stripPinnedBranchKeys(value: Any?): Any? {
val branch = value as? Map<String, Any?> ?: return value
val result = branch.toMutableMap()
PINNED_BRANCH_KEYS.forEach { result.remove(it) }
val docker = branch["docker"] as? Map<String, Any?>
private fun stripPinnedSettings(value: Any?): Any? {
val entry = value as? Map<String, Any?> ?: return value
val result = entry.toMutableMap()
PINNED_SETTING_KEYS.forEach { result.remove(it) }
val docker = entry["docker"] as? Map<String, Any?>
if (docker != null) {
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
@@ -148,6 +152,82 @@ class ConfigLoader(
return result
}
/**
* Decides which of the two sections describes the builds: `builds` or the legacy
* `branches`, never both. As soon as the merged configuration carries one real build
* definition — `builds.maxConcurrent` alone is not one, it is already dropped by
* [dropNonDefinitionBuilds] — a `branches` section is ignored altogether, because a
* definition now carries the complete description of its build and two half-answers
* would silently pull against each other.
*
* Deliberately decided on the *merged* map, after all layers are in: a branch that
* brings its own `builds` therefore also switches the host's `branches` off for its
* own builds, and — the reason this order matters — a build the branch defines and
* the host has never heard of still inherits the host's `builds.default`, sandbox
* policy included. Were the sections resolved per layer, that build would start with
* an empty docker policy and run natively on the host, which is exactly the escape
* the pinned keys exist to prevent.
*/
private fun resolveBuildSections(raw: Map<String, Any?>): Map<String, Any?> {
@Suppress("UNCHECKED_CAST")
val definitions = raw["builds"] as? Map<String, Any?> ?: emptyMap()
if (definitions.isEmpty()) {
return mergeBranchDefaults(raw)
}
if (raw.containsKey("branches") && warnedSections.add(LEGACY_BRANCHES_WARNING)) {
log.warn(
"ignoring the branches section: this configuration defines builds, and a build definition " +
"carries its own settings; move what is still needed into builds — branches is going away",
)
}
warnWhenNothingIsTriggered(definitions)
return mergeBuildDefaults(raw - "branches")
}
/**
* An explicit `builds.default` replaces the implicit on-push build, so a set of
* definitions can end up with no trigger at all — an instance that will never build
* anything. That is a plausible intention for a moment and a mistake for a week, so
* it is said out loud once instead of being enforced.
*/
private fun warnWhenNothingIsTriggered(definitions: Map<String, Any?>) {
if (BuildDefinition.DEFAULT !in definitions || definitions.values.any { isTriggered(it) }) {
return
}
if (warnedSections.add(NO_TRIGGER_WARNING)) {
log.warn("no build defines onPush or atTimes; the watcher will never start a build on its own")
}
}
private fun isTriggered(definition: Any?): Boolean {
val entry = definition as? Map<*, *> ?: return false
return entry["onPush"] == true || (entry["atTimes"] as? List<*>)?.isNotEmpty() == true
}
/**
* Applies `builds.default` as the base of every other build definition — the settings
* only. A trigger is never inherited: `onPush` and `atTimes` say when *this* build
* runs, and the selectors say for which branches, so inheriting them would make every
* job fire whenever the default one does.
*/
@Suppress("UNCHECKED_CAST")
private fun mergeBuildDefaults(raw: Map<String, Any?>): Map<String, Any?> {
val builds = raw["builds"] as? Map<String, Any?> ?: return raw
val base = (builds[BuildDefinition.DEFAULT] as? Map<String, Any?>)?.minus(SELECTOR_KEYS) ?: return raw
if (base.isEmpty()) {
return raw
}
val merged =
builds.mapValues { (name, value) ->
if (name == BuildDefinition.DEFAULT) {
value
} else {
deepMerge(base, value as? Map<String, Any?> ?: emptyMap())
}
}
return raw + ("builds" to merged)
}
/** Legacy default: an empty `server.publicBaseUrl` becomes `https://<nginx.serverName>/`. */
private fun defaultPublicBaseUrl(config: GitTallyConfig): GitTallyConfig {
if (config.server.publicBaseUrl.isNotBlank() ||
@@ -260,19 +340,28 @@ class ConfigLoader(
* the credentials, report statuses to another repository, raise the global
* concurrency, or turn off the pull-request gate for the whole watcher.
* The `builds` section is deliberately *not* pinned: it describes what the branch
* builds, and a branch can already run any command via `branches.*.buildCommand`.
* builds, which is the branch's own business — only the individual settings in
* [PINNED_SETTING_KEYS] and [PINNED_DOCKER_KEYS] are taken out of it.
*/
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server", "executor", "watcher")
/**
* Per-branch keys a branch must never override: the trust gate that decides
* whether the watcher builds this branch at all.
* Settings keys a branch must never override, in a build definition as well as in
* a legacy branch entry: the trust gate that decides whether the watcher builds
* this branch at all.
*/
private val PINNED_BRANCH_KEYS = setOf("requirePullRequest")
private val PINNED_SETTING_KEYS = setOf("requirePullRequest")
/** Per-branch `docker` keys a branch must never override: the sandbox policy. */
/** `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
/** Keys of a build definition that say *when* it runs; never inherited from `builds.default`. */
private val SELECTOR_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
private const val NO_TRIGGER_WARNING = "no-build-triggered"
private const val ROLLBACK_HINT =
"Migrate the file, or roll back to the GitTally version it was written for."
@@ -6,7 +6,6 @@ import de.hoennig.gittally.build.BuildExecutor
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.GitWorktreeWorkspaces
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.BuildDefinition
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.DurationParser
@@ -209,7 +208,7 @@ class Watcher(
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
val changed = (changedLocal + newOrigin).distinct()
for (branch in changed) {
val onPush = definitionsFor(branch, heads[branch], workingDir).filterValues { it.onPush }
val onPush = definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.onPush }
for ((buildName, definition) in onPush) {
if (selects(definition, branch, headCommitTimes)) {
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
@@ -228,17 +227,20 @@ class Watcher(
* their selectors are evaluated for it alone, so a definition committed on one branch
* can never schedule builds of another.
*
* Cached per branch by its head commit, so the `git show` runs only when the branch
* moved. An unreadable branch config falls back to the primary definitions instead of
* failing the poll cycle.
* Cached per branch by its head commit *and* the primary configuration it was merged
* with, so the `git show` runs only when the branch moved — but an edited machine or
* project config takes effect on the next poll instead of waiting for a commit that
* may never come. An unreadable branch config falls back to the primary definitions
* instead of failing the poll cycle.
*/
private fun definitionsFor(
branch: String,
headCommit: String?,
workingDir: Path,
primary: GitTallyConfig,
): Map<String, BuildDefinition> {
val commit = headCommit ?: return configLoader.load(workingDir).effectiveBuildDefinitions()
branchDefinitions[branch]?.takeIf { it.commit == commit }?.let { return it.definitions }
val commit = headCommit ?: return primary.effectiveBuildDefinitions()
branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions }
val definitions =
try {
configLoader
@@ -254,12 +256,13 @@ class Watcher(
)
configLoader.load(workingDir).effectiveBuildDefinitions()
}
branchDefinitions[branch] = CachedDefinitions(commit, definitions)
branchDefinitions[branch] = CachedDefinitions(commit, primary, definitions)
return definitions
}
private class CachedDefinitions(
val commit: String,
val primary: GitTallyConfig,
val definitions: Map<String, BuildDefinition>,
)
@@ -298,7 +301,7 @@ class Watcher(
return false
}
if (config.watcher.pullRequestGate &&
branchConfig(config, branch).requirePullRequest &&
config.buildSettings(branch, build).requirePullRequest &&
commit !in pullRequestHeads.value
) {
log.info("not enqueueing branch {}: no pull request has head commit {}", branch, commit)
@@ -309,11 +312,6 @@ class Watcher(
return true
}
private fun branchConfig(
config: GitTallyConfig,
branch: String,
): BranchConfig = config.branches[branch] ?: config.branches["default"] ?: BranchConfig()
/**
* Fires the due `atTimes` slot of every build definition for its selected branches,
* once per day and slot per result pool. Rebuilding the already-built commit is
@@ -332,7 +330,8 @@ class Watcher(
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
for (branch in originBranches) {
val scheduled = definitionsFor(branch, heads[branch], workingDir).filterValues { it.atTimes.isNotEmpty() }
val scheduled =
definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.atTimes.isNotEmpty() }
for ((buildName, definition) in scheduled) {
if (!selects(definition, branch, headCommitTimes)) {
continue
@@ -223,11 +223,10 @@ class BuildExecutorTest : FunSpec() {
val h =
Harness(
"""
branches:
builds:
default:
buildCommand: "echo regular-${'$'}branch"
cleanCommand: ""
builds:
pitest:
buildCommand: "echo nightly-${'$'}branch"
""".trimIndent(),
@@ -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")
}
@@ -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")