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

126 lines
8.9 KiB
Markdown

# 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)
```yaml
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.default``branches.<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.