Files
werkator/docs/adrs/0007-2026-08-28.build-definitions.md
T

8.9 KiB

Build Definitions Replace Branch-Owned Schedules

Status:

  • proposed: 2026-08-28
  • accepted: 2026-08-28
  • rejected: -
  • superseded: -

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

werkator's configuration is branch-centric: branches.<name> holds the build settings, and the nightly schedule (autoBuild) hangs off the branch. v0.9.13 added a per-slot buildCommand and name to autoBuild.times[], so a nightly slot could run a fuller check recorded in its own result pool. That worked, but it is a job concept hidden inside a schedule entry: the slot carries a command, an identity, and (implicitly) a branch — everything a job has, in the wrong place.

Requirements that the branch-centric schema cannot express cleanly:

  • Several builds of the same branch with different commands (quick check on push, full PIT test nightly).
  • One scheduled build over a set of branches, e.g. "run piTestFull nightly for every branch that had commits in the last 24 hours" — a schedule owned by a branch cannot select branches.
  • A name for such a build that is not welded to a single schedule slot.

Technical Background

Since v0.9.13 the result model already carries a pool identity per build (BuildResult.name), with name-keyed history, retention, branches-view rows, and permanent latest-green links, while everything git-side (origin lookups, pruning, worktrees, Gitea) stays keyed by the real branch. So the persistence and UI side is prepared; what is missing is a first-class job in the configuration and the watcher.

Considered Options

  • Keep extending the branch-owned autoBuild (status quo of v0.9.13)
  • Top-level builds section with named build definitions (jobs)
  • Both concepts permanently in parallel

Keep Extending autoBuild

Every new need (per-slot command, per-slot name, branch selectors) grows another attribute inside autoBuild.times[].

Advantages

  • No schema change, no migration.

Disadvantages

  • The slot is a job in disguise; each extension makes the disguise worse.
  • A schedule owned by one branch can never select other branches (activeWithin is inexpressible).
  • Two buildCommand keys at different nesting levels with override semantics (the complaint that triggered v0.9.13's rework already).

Top-Level builds Section (chosen)

branches:            # unchanged: per-branch build settings, worktree-overridable
  default:
    cleanCommand: rm -rf build
    buildCommand: ./gradlew --console=plain --no-daemon prQuickCheck

builds:
  # implicit when omitted or not overridden — exactly the pre-builds behavior:
  # default:
  #   onPush: true
  pitest:
    atTimes: ["01:00"]                     # UTC HH:MM, plain strings only
    activeWithin: 24h                      # only branches with commits in the last 24h
    # branches: ["master"]                 # alternative/additional explicit selector
    buildCommand: ./gradlew -PfullPitTest --console=plain --no-daemon piTestFull

A build definition has:

  • Triggers: onPush: true|false (default false; the implicit default build has true) and atTimes: [HH:MM, …] (default empty). A build may have both.
  • Branch selector: branches: [names] (default: all origin branches) and activeWithin: <duration> (default: unset = no age filter; a branch qualifies while its origin head commit is younger than the duration). Both combine as an intersection.
  • Build-setting overrides: buildCommand, cleanCommand, artifactDirs, stdoutLog/stderrLog, and the docker image keys (image, dockerfile, context, env) — anything unset falls back to the merged branch settings.

Semantics:

  • Merge order for the effective settings of one build on one branch: config defaults → branches.defaultbranches.<branch> → the worktree's committed .werkator.yml (build keys, pinned keys stripped) → builds.<name> overrides. The build definition wins last because it is the job; it comes from the repo install/project config (server-side), never from the worktree.
  • Pool identity: the default build records under the branch name (URLs, rows, retention as before); every other build records under <branch>@<build> (URL-sanitized, e.g. /branches/master_pitest/…). Each pool keeps its own retention count, latest status, and permanent latest-green link.
  • Persistence: the result stores the build's name (build, default default) next to the branch; the derived pool name keeps keying grouping and display. The v0.9.13 buildCommandOverride field is dropped: restart, retry, and startup recovery re-resolve the command from the current config by (branch, build) — a job definition in config is the source of truth, so a re-run of an old result uses the job's current command.
  • Triggers in the watcher: onPush uses the existing change detection per pool ("already built" per pool and commit); atTimes fires once per day per slot per pool (state file keyed by pool, date, time). The branches.<name>.requirePullRequest gate stays a branch property and gates all watcher-triggered builds of that branch, as today.
  • Execution invariants unchanged: every build of a branch runs in that branch's worktree, at most one build per branch at a time, executor.maxConcurrent across branches, Gitea commit status per commit in the shared status context (last build of a commit wins).

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.<name>.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

  • Jobs are first-class: name, command, schedule, and branch selection in one place, no nesting tricks.
  • Expresses the previously impossible selector cases (activeWithin, explicit branch lists) naturally.
  • Reuses the v0.9.13 pool plumbing (result name, retention, rows, permalinks) almost unchanged.
  • The trigger keys read as English: onPush, atTimes.

Disadvantages

  • The biggest config-schema change since the rewrite: watcher enqueue logic, config layering, docs, init templates, and tests are all touched.
  • Two sections (branches, builds) must be explained: settings per branch vs. jobs over branches.
  • Re-runs follow the current job definition instead of the recorded command — a deliberate semantic change against v0.9.13.

Both Concepts Permanently

Keep autoBuild (including the v0.9.13 slot syntax) forever next to builds.

Advantages

  • Nothing ever breaks.

Disadvantages

  • Two ways to express the same job, forever; every future feature must be specified against both.

Decision Outcome

Top-level builds with onPush/atTimes, as specified above. branches.*.autoBuild stays as a deprecated, mapped alias; the v0.9.13 slot extras are reverted.

Follow-up (2026-08-28): mixing the execution key maxConcurrent into the builds section as a reserved key proved confusing — it is not a build definition. The concurrency limit moved to executor.maxConcurrent (a new section for execution settings), without a compatibility alias, so the builds section holds build definitions only.

Follow-up (2026-08-29): pinning the whole builds section against the branch layer was wrong and is reverted. A branch's committed .werkator.yml describes that branch's CI, and a new builds configuration can only be tried out by committing it on a branch — pinned, it was neither effective at build time nor visible to the watcher, so the job silently did not exist. The branch layer now carries builds too: the watcher reads each origin branch's committed config (git show, cached by head commit) to decide which of that branch's builds are due, and a branch's definitions are evaluated for that branch alone, so they can never trigger builds of another branch. The pinned set is reduced to what does not describe this branch's build: secrets (git), the host/repository sections (server, gitea, executor, watcher), the sandbox policy (docker.enabled/docker.network), and the trust gate (requirePullRequest). Letting a branch set its own buildCommand through a definition grants no new power — branches.*.buildCommand always allowed exactly that — whereas the sandbox and the gate decide whether untrusted branch code runs on the host at all, and therefore stay server-side.