Files
werkator/docs/adrs/0007-2026-08-28.build-definitions.md
T
mhoennigandClaude Opus 5 f5871a0442 The branch config takes precedence, including its build definitions
A branch's committed .gittally.yml describes that branch's CI, so it wins
over the project and repo-install config — the `builds` section included.
Pinning it was wrong: a new build definition can only be tried out by
committing it on a branch, and pinned it neither took effect at build time
nor existed for the watcher, so the job silently never ran.

The watcher now decides per branch from that branch's own definitions,
reading its committed config via `git show` and caching it by head commit,
so the read happens only when the branch moved; an unreadable config falls
back to the primary definitions instead of failing the poll cycle. A
branch's definitions are evaluated for that branch alone, so a definition
committed on one branch can never trigger builds of another.

The pinned set is reduced to what does not describe this branch's build:
secrets (`git`), the host and repository sections (`server`, `gitea`,
`executor`, `watcher`), the sandbox policy (`docker.enabled`/`network`),
and the trust gate (`requirePullRequest`). Letting a branch set its own
build command through a definition grants no new power — `branches.*.
buildCommand` always allowed exactly that — while the sandbox and the gate
decide whether untrusted branch code runs on the host at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:29:26 +02:00

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

GitTally'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 .gittally.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 .gittally.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.