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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user