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:
co-authored by
Claude Fable 5
parent
5051c7bb99
commit
0e8db18e69
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user