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
@@ -1,62 +0,0 @@
package de.hoennig.gittally.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
/**
* Accepts both YAML forms of an [AutoBuildSlot] entry: a plain `HH:MM` string, or an
* object with `time` and optional `buildCommand` and `name`.
*/
class AutoBuildSlotDeserializer : JsonDeserializer<AutoBuildSlot>() {
override fun deserialize(
parser: JsonParser,
context: DeserializationContext,
): AutoBuildSlot {
val node = parser.readValueAsTree<JsonNode>()
if (node.isTextual) {
return AutoBuildSlot(time = node.asText())
}
if (node.isObject) {
val time =
node.get("time")?.takeIf { it.isTextual }?.asText()
?: throw context.instantiationException(AutoBuildSlot::class.java, "auto-build slot object needs a 'time' (HH:MM)")
return AutoBuildSlot(
time = time,
buildCommand = node.get("buildCommand")?.asText() ?: "",
name = node.get("name")?.asText() ?: "",
)
}
throw context.instantiationException(
AutoBuildSlot::class.java,
"auto-build slot must be an HH:MM string or an object with 'time' and optional 'buildCommand' and 'name'",
)
}
}
/** Writes the compact form back: a plain string unless the slot carries its own command or name. */
class AutoBuildSlotSerializer : JsonSerializer<AutoBuildSlot>() {
override fun serialize(
slot: AutoBuildSlot,
generator: JsonGenerator,
provider: SerializerProvider,
) {
if (slot.buildCommand.isBlank() && slot.name.isBlank()) {
generator.writeString(slot.time)
return
}
generator.writeStartObject()
generator.writeStringField("time", slot.time)
if (slot.buildCommand.isNotBlank()) {
generator.writeStringField("buildCommand", slot.buildCommand)
}
if (slot.name.isNotBlank()) {
generator.writeStringField("name", slot.name)
}
generator.writeEndObject()
}
}
@@ -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,
)
@@ -29,10 +29,11 @@ class ConfigLoader {
* `docker.image`/`env`, …).
*
* The [pinned][stripPinned] keys are the exception: secrets (`git`), `gitea`/`server`
* settings, and the docker sandbox policy (`docker.enabled`/`docker.network`) always
* come from `.git`/primary — a branch must never be able to disable its own container,
* change its network mode, or reach the credentials. They are stripped from the
* worktree layer before it is merged, so a worktree cannot set them at all.
* settings, the whole `builds` section (job definitions and execution settings), and
* the docker sandbox policy (`docker.enabled`/`docker.network`) always come from
* `.git`/primary — a branch must never be able to disable its own container, change
* its network mode, redefine jobs, or reach the credentials. They are stripped from
* the worktree layer before it is merged, so a worktree cannot set them at all.
*
* With no worktree `.gittally.yml` this is identical to [load].
*/
@@ -50,11 +51,30 @@ class ConfigLoader {
if (raw.isEmpty()) {
GitTallyConfig()
} else {
yaml.convertValue(mergeBranchDefaults(raw), GitTallyConfig::class.java)
yaml.convertValue(splitBuildsSection(mergeBranchDefaults(raw)), GitTallyConfig::class.java)
}
return defaultPublicBaseUrl(config)
}
/**
* The YAML `builds` section carries the reserved execution key `maxConcurrent`
* next to the named build definitions (ADR 0007); the schema separates them into
* [GitTallyConfig.builds] and [GitTallyConfig.buildDefinitions].
*/
@Suppress("UNCHECKED_CAST")
private fun splitBuildsSection(raw: Map<String, Any?>): Map<String, Any?> {
val builds = raw["builds"] as? Map<String, Any?> ?: return raw
val definitions = builds.filterKeys { it !in RESERVED_BUILDS_KEYS }
if (definitions.isEmpty()) {
return raw
}
return raw +
mapOf(
"builds" to builds.filterKeys { it in RESERVED_BUILDS_KEYS },
"buildDefinitions" to definitions,
)
}
/**
* Removes the keys a build worktree must never override: the secret/server-side
* top-level sections and the per-branch docker sandbox policy. See [loadForWorktree].
@@ -141,8 +161,15 @@ class ConfigLoader {
}
companion object {
/** Top-level sections a build worktree must never override: secrets and server-side settings. */
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server")
/**
* Top-level sections a build worktree must never override: secrets, server-side
* settings, and the build definitions with their execution settings (a branch
* must not be able to redefine jobs or raise concurrency).
*/
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server", "builds")
/** Keys of the YAML `builds` section that are execution settings, not build definitions. */
private val RESERVED_BUILDS_KEYS = setOf("maxConcurrent")
/** Per-branch `docker` keys the worktree must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
@@ -1,8 +1,5 @@
package de.hoennig.gittally.config
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
data class GitTallyConfig(
val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(),
@@ -11,7 +8,17 @@ data class GitTallyConfig(
val artifacts: ArtifactsConfig = ArtifactsConfig(),
val watcher: WatcherConfig = WatcherConfig(),
val branches: Map<String, BranchConfig> = mapOf("default" to BranchConfig()),
)
/**
* Named build definitions (jobs) over the branches (ADR 0007). The implicit
* [BuildDefinition.DEFAULT] build (`onPush` over all branches) applies unless this
* map overrides it; see [effectiveBuildDefinitions].
*/
val buildDefinitions: Map<String, BuildDefinition> = emptyMap(),
) {
/** The configured [buildDefinitions] plus the implicit `default` build unless overridden. */
fun effectiveBuildDefinitions(): Map<String, BuildDefinition> =
mapOf(BuildDefinition.DEFAULT to BuildDefinition(onPush = true)) + buildDefinitions
}
data class ServerConfig(
/**
@@ -149,32 +156,13 @@ data class DockerConfig(
val env: Map<String, String> = emptyMap(),
)
/**
* Deprecated per-branch schedule (pre-ADR-0007), kept for compatibility: mapped to a
* daily rebuild of the branch's own pool with its regular command. New configurations
* define a build with `atTimes` in the top-level `builds` section instead.
*/
data class AutoBuildConfig(
val enabled: Boolean = false,
/**
* Daily UTC slots. Each entry is either a plain `HH:MM` time or an object with
* `time` and its own `buildCommand` — so a nightly slot can run a fuller check
* than the on-commit builds of the same branch.
*/
val times: List<AutoBuildSlot> = listOf(AutoBuildSlot("01:00")),
)
/**
* One scheduled auto-build slot; in YAML either a plain `HH:MM` string or an object
* (`time` plus optional `buildCommand` and `name`) — see [AutoBuildSlotDeserializer].
*/
@JsonDeserialize(using = AutoBuildSlotDeserializer::class)
@JsonSerialize(using = AutoBuildSlotSerializer::class)
data class AutoBuildSlot(
/** UTC time of day, `HH:MM`. */
val time: String,
/** Command this slot's build runs; empty runs the branch's [BranchConfig.buildCommand]. */
val buildCommand: String = "",
/**
* Build name this slot's builds are recorded under (e.g. `master@nightly`); empty
* records them under the branch name. A named slot gets its own history, retention
* pool, latest status, and permanent latest-green artifact link, so its builds are
* not displaced by the branch's regular builds.
*/
val name: String = "",
/** Daily UTC times `HH:MM`. */
val times: List<String> = listOf("01:00"),
)