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,6 +1,7 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.BuildDefinition
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.gitea.GiteaClient
import org.slf4j.LoggerFactory
@@ -67,27 +68,25 @@ class BuildExecutor(
* not cancel-requested), that build is returned instead of stacking a duplicate —
* a double-triggered UI restart must not queue the same commit twice. Re-running
* a *finished* build stays possible; this only guards the active queue.
* A [buildCommandOverride] (from an auto-build slot with its own command) replaces
* the branch's configured `buildCommand`; since the command differs, such a build
* never counts as a duplicate of a regular build of the same commit.
* A [name] (from a named auto-build slot) records the result under that name
* instead of the branch name, giving the slot its own history and retention pool;
* the build still runs in the branch's worktree, serialized with the branch's
* other builds.
* The [build] names the build definition (job, ADR 0007) this run belongs to; its
* settings — command overrides and the result pool `<branch>@<build>` — are
* resolved from the current configuration when the build starts executing. A
* non-default build has its own pool, so it never counts as a duplicate of the
* branch's regular build of the same commit; it still runs in the branch's
* worktree, serialized with the branch's other builds.
*/
fun startBuild(
branch: String,
commit: String,
workingDir: Path = Paths.get("."),
buildCommandOverride: String? = null,
name: String = branch,
build: String = BuildDefinition.DEFAULT,
): RunningBuild {
val name = BuildDefinition.poolName(branch, build)
val duplicate =
builds.values.firstOrNull {
!it.cancelled.get() &&
it.runningBuild.name == name &&
it.runningBuild.commit == commit &&
it.runningBuild.buildCommandOverride == buildCommandOverride
it.runningBuild.commit == commit
}
if (duplicate != null) {
log.info("build of branch {} at commit {} is already queued or running; not queueing a duplicate", branch, commit)
@@ -98,23 +97,21 @@ class BuildExecutor(
val runningBuild =
RunningBuild(
branch = branch,
name = name,
build = build,
commit = commit,
artifactKey = ArtifactKeys.buildKey(name, startedAt),
startedAt = startedAt,
stagingDir = stagingDir,
liveLogFile = stagingDir.resolve(LIVE_LOG_FILE),
buildCommandOverride = buildCommandOverride,
)
val pending =
BuildResult(
branch = branch,
name = name,
build = build,
commit = commit,
status = BuildStatus.PENDING,
startedAt = startedAt,
duration = null,
buildCommandOverride = buildCommandOverride,
artifactKey = runningBuild.artifactKey,
)
repository.append(pending)
@@ -259,8 +256,8 @@ class BuildExecutor(
build: ActiveBuild,
workspace: Path,
): Int {
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir, workspace)
val buildCommand = build.runningBuild.buildCommandOverride ?: branchConfig.buildCommand
val branchConfig = buildConfig(build.runningBuild, build.workingDir, workspace)
val buildCommand = branchConfig.buildCommand
val stagingDir = build.runningBuild.stagingDir
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog ->
@@ -374,13 +371,12 @@ class BuildExecutor(
)
} ?: BuildResult(
branch = runningBuild.branch,
name = runningBuild.name,
build = runningBuild.build,
commit = runningBuild.commit,
status = status,
startedAt = runningBuild.startedAt,
runningSince = runningBuild.runningSince,
duration = duration,
buildCommandOverride = runningBuild.buildCommandOverride,
artifactKey = runningBuild.artifactKey,
).also { repository.append(it) }
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
@@ -433,15 +429,12 @@ class BuildExecutor(
val header =
buildString {
appendLine("building branch: ${runningBuild.branch}")
if (runningBuild.name != runningBuild.branch) {
appendLine("build name: ${runningBuild.name}")
if (runningBuild.build != BuildDefinition.DEFAULT) {
appendLine("build: ${runningBuild.build} (recorded as ${runningBuild.name})")
}
appendLine("commit: ${runningBuild.commit}")
appendLine("started: ${runningBuild.startedAt}")
appendLine("workspace: $workspace")
if (runningBuild.buildCommandOverride != null) {
appendLine("triggered by: auto-build slot with its own build command")
}
appendLine("build command: $buildCommand")
if (branchConfig.cleanCommand.isNotBlank()) {
appendLine("clean command: ${branchConfig.cleanCommand}")
@@ -470,14 +463,22 @@ class BuildExecutor(
}
}
/** The build config for [branch], with the build [worktree]'s `.gittally.yml` layered on top (see [ConfigLoader.loadForWorktree]). */
private fun branchConfig(
branch: String,
/**
* The effective settings of this run: the branch config with the build [worktree]'s
* `.gittally.yml` layered on top (see [ConfigLoader.loadForWorktree]), then the
* build definition's overrides applied last — the job wins, and it always comes
* from the primary config (`builds` is a pinned section). An unknown build name
* (a stale result whose job was removed) falls back to the plain branch settings.
*/
private fun buildConfig(
runningBuild: RunningBuild,
workingDir: Path,
worktree: Path,
): BranchConfig {
val branches = configLoader.loadForWorktree(workingDir, worktree).branches
return branches[branch] ?: branches["default"] ?: BranchConfig()
val config = configLoader.loadForWorktree(workingDir, worktree)
val branchConfig = config.branches[runningBuild.branch] ?: config.branches["default"] ?: BranchConfig()
val definition = config.effectiveBuildDefinitions()[runningBuild.build] ?: return branchConfig
return definition.applyTo(branchConfig)
}
private class ActiveBuild(
@@ -1,5 +1,6 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.BuildDefinition
import java.time.Duration
import java.time.Instant
@@ -7,12 +8,18 @@ data class BuildResult(
/** The git branch that was built — Gitea links and origin lookups always use this. */
val branch: String,
/**
* The build name this result is recorded under: the branch name, unless a named
* auto-build slot (`autoBuild.times[].name`, e.g. `master@nightly`) set its own.
* History rows, retention pools, latest status, and the permanent latest-green
* artifact links are all keyed by this name.
* The build definition (job) this result belongs to; re-runs (restart, retry,
* startup recovery) resolve their settings from the current configuration by
* this name — the job definition is the source of truth, not the recorded run.
*/
val name: String = branch,
val build: String = BuildDefinition.DEFAULT,
/**
* The pool this result is recorded under: the branch name for the default build,
* `<branch>@<build>` otherwise (see [BuildDefinition.poolName]). History rows,
* retention pools, latest status, and the permanent latest-green artifact links
* are all keyed by this name.
*/
val name: String = BuildDefinition.poolName(branch, build),
val commit: String,
val status: BuildStatus,
/** When the build was accepted (enqueued); the time until [runningSince] is queue wait. */
@@ -21,11 +28,5 @@ data class BuildResult(
val runningSince: Instant? = null,
/** Pure build execution time (from [runningSince]), without the queue wait. */
val duration: Duration? = null,
/**
* Command dictated by the auto-build slot that triggered this build; null means the
* branch's configured `buildCommand` was used. A restart or startup-recovery re-run
* repeats the build with this command, so a build always reruns what it originally ran.
*/
val buildCommandOverride: String? = null,
val artifactKey: String,
)
@@ -1,5 +1,6 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.BuildDefinition
import java.nio.file.Path
import java.time.Instant
@@ -7,8 +8,10 @@ import java.time.Instant
data class RunningBuild(
/** The git branch being built. */
val branch: String,
/** The build name the result is recorded under; the branch name unless a named auto-build slot set its own. */
val name: String = branch,
/** The build definition (job) this build runs; its settings are resolved from config at run time. */
val build: String = BuildDefinition.DEFAULT,
/** The pool the result is recorded under: the branch, or `<branch>@<build>` for non-default builds. */
val name: String = BuildDefinition.poolName(branch, build),
val commit: String,
val artifactKey: String,
val startedAt: Instant,
@@ -6,6 +6,7 @@ import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.RunningBuild
import de.hoennig.gittally.config.BuildDefinition
import de.hoennig.gittally.server.UiFormats
import org.springframework.stereotype.Component
import java.io.IOException
@@ -37,10 +38,9 @@ class ConsoleBuildRunner(
branch: String,
commit: String,
workingDir: Path = Paths.get("."),
buildCommandOverride: String? = null,
name: String = branch,
buildDefinition: String = BuildDefinition.DEFAULT,
): BuildStatus {
val build = buildExecutor.startBuild(branch, commit, workingDir, buildCommandOverride, name)
val build = buildExecutor.startBuild(branch, commit, workingDir, buildDefinition)
var printed = 0L
var result: BuildResult? = null
while (result?.status?.isTerminal != true) {
@@ -150,10 +150,18 @@ class InitCommand(
repo: ${detected.repo} # repository name
statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally)
# Build execution.
# Build execution and named build definitions (jobs); "maxConcurrent" is a
# reserved key, every other key names a build definition over the branches.
builds:
# how many branches may build at the same time (at most one build per branch regardless)
maxConcurrent: 1
# Example definition — triggers (onPush/atTimes), branch selector
# (branches/activeWithin), and overrides of the branch settings:
# pitest:
# atTimes: ["01:00"] # daily UTC times HH:MM
# branches: ["master"] # names or glob patterns; default: all branches
# activeWithin: 24h # only branches with recent commits
# buildCommand: ./gradlew piTestFull
# Build artifact storage and retention.
artifacts:
@@ -197,14 +205,10 @@ class InitCommand(
# 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
# UTC times HH:MM for scheduled builds; an entry may carry its own
# command and a name for a separate history/artifact pool:
# - time: "01:00"
# buildCommand: ./gradlew fullCheck
# name: main@nightly
times: ["01:00"]
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
@@ -50,9 +50,9 @@ class RetryCommand(
println("skipping branch ${result.branch}: gone from origin")
continue
}
println("retrying branch ${result.branch} at commit ${commit.take(12)}")
// a failed auto-slot build retries with its recorded command, under its name
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir, result.buildCommandOverride, result.name)
println("retrying build ${result.name} at commit ${commit.take(12)}")
// a failed build retries its recorded build definition (settings from the current config)
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir, result.build)
if (status != BuildStatus.SUCCESS) {
anyFailed = true
}
@@ -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"),
)
@@ -140,6 +140,19 @@ class GitService(
}.map { (branch, _) -> branch }
}
/** Committer timestamps of all origin branch heads, in one git call; used by `activeWithin` build selectors. */
fun originBranchCommitTimes(workingDir: Path = Paths.get(".")): Map<String, Instant> =
runner
.runOrThrow(
listOf("git", "for-each-ref", "--format=%(refname:strip=3) %(committerdate:unix)", "refs/remotes/origin"),
workingDir,
).lines()
.mapNotNull { line ->
val branch = line.substringBeforeLast(' ')
val epochSeconds = line.substringAfterLast(' ').toLongOrNull() ?: return@mapNotNull null
if (branch == "HEAD") null else branch to Instant.ofEpochSecond(epochSeconds)
}.toMap()
/** Switches to an existing local branch, or creates a tracking branch from origin. */
fun checkout(
branch: String,
@@ -5,6 +5,7 @@ import de.hoennig.gittally.build.BuildExecutor
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.config.BuildDefinition
import de.hoennig.gittally.git.GitService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
@@ -103,13 +104,12 @@ class BuildsApiController(
latest?.commit
?: gitService.originHeadCommit(branch, workingDir)
?: return notFound("branch '$branch' has no recorded build and no origin counterpart")
// a restarted build repeats what it originally ran: same branch, name, and command
// a restarted build re-runs its recorded build definition (settings from the current config)
val running =
buildExecutor.startBuild(
branch = latest?.branch ?: branch,
commit = commit,
buildCommandOverride = latest?.buildCommandOverride,
name = latest?.name ?: branch,
build = latest?.build ?: BuildDefinition.DEFAULT,
)
return ResponseEntity.accepted().body(
BuildResultDto(
@@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import de.hoennig.gittally.config.AutoBuildSlot
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
@@ -14,28 +13,33 @@ import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeParseException
/** One recorded auto-build trigger: [branch] was enqueued for the [slot] (UTC `HH:MM`) of [date] (ISO). */
/**
* One recorded scheduled-build trigger: the result pool [branch] (a branch, or
* `<branch>@<build>` of a named build definition) was enqueued for the [slot]
* (UTC `HH:MM`) of [date] (ISO). The field keeps its legacy name `branch` so the
* state file stays readable across versions.
*/
data class AutoBuildTrigger(
val branch: String,
val date: String,
val slot: String,
)
/** Auto-build time slot matching (UTC `HH:MM`), like legacy `auto_build_check`. */
/** Scheduled-build time slot matching (UTC `HH:MM`), like legacy `auto_build_check`. */
object AutoBuildSlots {
private val log = LoggerFactory.getLogger(AutoBuildSlots::class.java)
/** The latest valid slot at or before [now], or null when no slot is due yet today. */
fun latestDueSlot(
times: List<AutoBuildSlot>,
times: List<String>,
now: LocalTime,
): AutoBuildSlot? =
): String? =
times
.mapNotNull { slot ->
try {
LocalTime.parse(slot.time.trim()) to slot
LocalTime.parse(slot.trim()) to slot
} catch (_: DateTimeParseException) {
log.warn("skipping invalid auto-build time slot '{}': expected HH:MM", slot.time)
log.warn("skipping invalid scheduled-build time slot '{}': expected HH:MM", slot)
null
}
}.filter { (parsed, _) -> !parsed.isAfter(now) }
@@ -7,6 +7,7 @@ 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
import de.hoennig.gittally.config.GitTallyConfig
@@ -17,6 +18,7 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneOffset
@@ -48,6 +50,10 @@ class Watcher(
@Volatile
private var state = WatcherState()
/** The branches.*.autoBuild deprecation is logged once per watcher instance, not once per poll. */
@Volatile
private var warnedDeprecatedAutoBuild = false
fun state(): WatcherState = state
/**
@@ -109,9 +115,9 @@ class Watcher(
// the executor queue did not survive the restart; the re-enqueued build supersedes the stale entry
repository.updateByArtifactKey(result.artifactKey) { it.copy(status = BuildStatus.INTERRUPTED) }
}
log.info("restarting unfinished build of branch {}", result.branch)
// an interrupted auto-slot build re-runs the command its slot dictated, under its name
buildExecutor.startBuild(result.branch, commit, workingDir, result.buildCommandOverride, result.name)
log.info("restarting unfinished build {} of branch {}", result.build, result.branch)
// the re-run resolves its settings from the current config by the recorded build name
buildExecutor.startBuild(result.branch, commit, workingDir, result.build)
}
}
@@ -187,18 +193,31 @@ class Watcher(
) {
// one ls-remote per poll cycle at most, and only when a due branch requires a pull request
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
val definitions = config.effectiveBuildDefinitions()
val changedLocal =
gitService
.localBranches(workingDir)
.filter { it in originBranches && gitService.hasNewCommits(it, workingDir) }
val newOrigin =
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
for (branch in (changedLocal + newOrigin).distinct()) {
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir)
val changed = (changedLocal + newOrigin).distinct()
for ((buildName, definition) in definitions.filterValues { it.onPush }) {
for (branch in changed.filter { selects(definition, it, headCommitTimes) }) {
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
}
}
enqueueAutoBuilds(config, originBranches, pullRequestHeads, workingDir)
enqueueScheduledBuilds(definitions, config, originBranches, pullRequestHeads, headCommitTimes, workingDir)
enqueueDeprecatedAutoBuilds(config, originBranches, pullRequestHeads, workingDir)
}
private fun selects(
definition: BuildDefinition,
branch: String,
headCommitTimes: Lazy<Map<String, Instant>>,
): Boolean = definition.selects(branch, { headCommitTimes.value[branch] }, clock.instant())
/**
* Enqueues a build of the branch's origin head unless one is already pending or
* running, or that commit was already built. Builds run detached in worktrees and
@@ -217,10 +236,9 @@ class Watcher(
config: GitTallyConfig,
pullRequestHeads: Lazy<Set<String>>,
workingDir: Path,
buildCommandOverride: String? = null,
name: String = branch,
build: String = BuildDefinition.DEFAULT,
): Boolean {
val latest = repository.latestFor(name)
val latest = repository.latestFor(BuildDefinition.poolName(branch, build))
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
return false
}
@@ -235,8 +253,8 @@ class Watcher(
log.info("not enqueueing branch {}: no pull request has head commit {}", branch, commit)
return false
}
log.info("enqueueing build of branch {} at commit {}", branch, commit)
buildExecutor.startBuild(branch, commit, workingDir, buildCommandOverride, name)
log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit)
buildExecutor.startBuild(branch, commit, workingDir, build)
return true
}
@@ -245,7 +263,47 @@ class Watcher(
branch: String,
): BranchConfig = config.branches[branch] ?: config.branches["default"] ?: BranchConfig()
private fun enqueueAutoBuilds(
/**
* 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
* the point of a scheduled build.
*/
private fun enqueueScheduledBuilds(
definitions: Map<String, BuildDefinition>,
config: GitTallyConfig,
originBranches: Set<String>,
pullRequestHeads: Lazy<Set<String>>,
headCommitTimes: Lazy<Map<String, Instant>>,
workingDir: Path,
) {
val scheduled = definitions.filterValues { it.atTimes.isNotEmpty() }
if (scheduled.isEmpty()) {
return
}
val autoBuildState = FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE))
val now = clock.instant()
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
for ((buildName, definition) in scheduled) {
val slot = AutoBuildSlots.latestDueSlot(definition.atTimes, timeOfDay) ?: continue
for (branch in originBranches.filter { selects(definition, it, headCommitTimes) }) {
val pool = BuildDefinition.poolName(branch, buildName)
if (autoBuildState.isTriggered(pool, today, slot)) {
continue
}
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir, buildName)) {
autoBuildState.markTriggered(pool, today, slot)
}
}
}
}
/**
* The pre-ADR-0007 `branches.<name>.autoBuild` schedule, kept for compatibility:
* a daily rebuild of the branch's own pool with its regular command — exactly a
* `builds` entry with `atTimes` and a single-branch selector would do.
*/
private fun enqueueDeprecatedAutoBuilds(
config: GitTallyConfig,
originBranches: Set<String>,
pullRequestHeads: Lazy<Set<String>>,
@@ -258,33 +316,28 @@ class Watcher(
if (autoBuildBranches.isEmpty()) {
return
}
if (!warnedDeprecatedAutoBuild) {
warnedDeprecatedAutoBuild = true
log.warn(
"branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})",
autoBuildBranches.keys.joinToString(", "),
)
}
val autoBuildState = FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE))
val now = clock.instant()
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
for ((branch, branchConfig) in autoBuildBranches) {
val slot = AutoBuildSlots.latestDueSlot(branchConfig.autoBuild.times, timeOfDay) ?: continue
if (autoBuildState.isTriggered(branch, today, slot.time)) {
if (autoBuildState.isTriggered(branch, today, slot)) {
continue
}
if (branch !in originBranches) {
log.warn("skipping auto build of branch {}: branch is not on origin", branch)
continue
}
// rebuilding the already-built commit is the point of an auto build; a slot
// with its own command dictates it, and a named slot records under its name
val started =
startBuildIfDue(
branch,
allowSameCommit = true,
config,
pullRequestHeads,
workingDir,
slot.buildCommand.ifBlank { null },
slot.name.ifBlank { branch },
)
if (started) {
autoBuildState.markTriggered(branch, today, slot.time)
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) {
autoBuildState.markTriggered(branch, today, slot)
}
}
}