Build definitions with onPush/atTimes replace branch-owned schedules

ADR 0007: the YAML builds section (next to the reserved maxConcurrent
key) defines named builds (jobs) with onPush/atTimes triggers, branch
selectors (name globs, activeWithin age filter), and build-setting
overrides applied last over the merged branch config. The implicit
default build (onPush over all branches) preserves the previous
behavior; the section is pinned against the worktree layer.

Results record the job name; restart, retry, and startup recovery
re-run by it, resolving settings from the current config. A non-default
build records under the <branch>@<build> pool with its own row,
retention count, and permanent latest-green link.

branches.*.autoBuild stays as a deprecated alias (plain times only);
the unreleased-in-practice v0.9.13 per-slot buildCommand/name syntax is
removed again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 20:06:02 +02:00
co-authored by Claude Fable 5
parent 5051c7bb99
commit 0e8db18e69
23 changed files with 595 additions and 405 deletions
@@ -0,0 +1,111 @@
package de.hoennig.gittally.config
import java.time.Duration
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.
*
* 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
* permanent latest-green link.
*/
data class BuildDefinition(
/** Build every new commit of the selected branches. */
val onPush: Boolean = false,
/** Daily UTC times `HH:MM`; each slot rebuilds the selected branches' heads once per day. */
val atTimes: List<String> = emptyList(),
/**
* Branch names or glob patterns (`*` matches any characters, also across `/`);
* empty selects all origin branches.
*/
val branches: List<String> = emptyList(),
/**
* Only branches whose origin head commit is younger than this (e.g. `24h`);
* empty applies no age filter. Combines with [branches] as an intersection.
*/
val activeWithin: String = "",
/** Overrides the branch's build command; null inherits it. */
val buildCommand: String? = null,
/** Overrides the branch's clean command; null inherits it. */
val cleanCommand: String? = null,
/** Overrides the branch's artifact directories; null inherits them. */
val artifactDirs: List<String>? = null,
/** Overrides the branch's stdout log file name; null inherits it. */
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. */
val docker: DockerOverrides? = null,
) {
/** True when [branch] matches the [branches] patterns (or none are configured). */
fun selectsByName(branch: String): Boolean = branches.isEmpty() || branches.any { globToRegex(it).matches(branch) }
/**
* True when [branch] passes both selector parts; [headCommittedAt] is the branch
* head's committer time, only consulted while [activeWithin] is set (null then
* deselects the branch).
*/
fun selects(
branch: String,
headCommittedAt: () -> Instant?,
now: Instant,
): Boolean {
if (!selectsByName(branch)) {
return false
}
if (activeWithin.isBlank()) {
return true
}
val committedAt = headCommittedAt() ?: return false
return committedAt >= now.minus(maxAge())
}
fun maxAge(): Duration = DurationParser.parse(activeWithin)
/** The branch settings with this build's overrides applied; unset values fall through. */
fun applyTo(branchConfig: BranchConfig): BranchConfig =
branchConfig.copy(
buildCommand = buildCommand ?: branchConfig.buildCommand,
cleanCommand = cleanCommand ?: branchConfig.cleanCommand,
artifactDirs = artifactDirs ?: branchConfig.artifactDirs,
stdoutLog = stdoutLog ?: branchConfig.stdoutLog,
stderrLog = stderrLog ?: branchConfig.stderrLog,
docker =
branchConfig.docker.copy(
image = docker?.image ?: branchConfig.docker.image,
dockerfile = docker?.dockerfile ?: branchConfig.docker.dockerfile,
context = docker?.context ?: branchConfig.docker.context,
env = docker?.env ?: branchConfig.docker.env,
),
)
companion object {
/** Name of the implicit build that preserves the pre-ADR-0007 behavior: `onPush` over all branches. */
const val DEFAULT = "default"
/** The result-pool name of [build] on [branch]: the plain branch for the default build. */
fun poolName(
branch: String,
build: String,
): String = if (build == DEFAULT) branch else "$branch@$build"
private fun globToRegex(pattern: String): Regex =
Regex(
pattern
.split('*')
.joinToString(".*") { Regex.escape(it) },
)
}
}
/** Nullable docker image overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class DockerOverrides(
val image: String? = null,
val dockerfile: String? = null,
val context: String? = null,
val env: Map<String, String>? = null,
)