diff --git a/AGENTS.md b/AGENTS.md index e590e4e..ba92b3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,9 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman - Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos//…` and `/api/repos//…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and a drop-down in the page title switches between them. - When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`. - Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config. -- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and werkdock (`werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. -- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins. -- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary`. Docker and werkdock are mutually exclusive per branch — enabling both is rejected at start. The section was called `bwrap` until v1.2.0 and is still read under that name, with a warning; the hard refusal waits for the release that sets `ConfigVersions.FORMAT_BROKE_IN`. +- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and werkdock (`werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary`) sandbox policies, the trust gate (`requirePullRequest`), and the trigger of a follow-up build (`trigger.afterSuccessOf` everywhere, and the whole `trigger` block of a definition the host defines as a follow-up). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, bypass its own pull-request gate, or deploy itself; a branch's definitions apply to that branch alone. +- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `afterSuccessOf`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `afterSuccessOf` makes a definition the follow-up of another one — a deployment is a build that follows a green build (PR#23): it runs at the predecessor's commit after every green run of it, whoever started that run, and the `FollowUpTrigger` that enqueues it is armed only by `Watcher.start()`. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins. +- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary`, and the trigger of a follow-up build. Docker and werkdock are mutually exclusive per branch — enabling both is rejected at start. The section was called `bwrap` until v1.2.0 and is still read under that name, with a warning; the hard refusal waits for the release that sets `ConfigVersions.FORMAT_BROKE_IN`. - `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version. - Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats. - Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK. diff --git a/docs/configuration.md b/docs/configuration.md index 7a4add2..b084099 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,7 +101,9 @@ single branch may decide it: - the container sandbox policy: `docker.enabled`/`docker.network` and `werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` — host-pinned as long as only the host's configuration sets them, master-pinned once the committed - configuration does. + configuration does; +- the trigger of a [follow-up build](#follow-up-builds): `trigger.afterSuccessOf` in every definition, and the whole `trigger` block of a definition the host defines as a follow-up. + A branch may say what its deployment does, never that — or for which branches — it happens. The distinction is documentary. Werkator applies one rule: every pinned key is stripped from the branch layer, and the @@ -110,7 +112,7 @@ The names say where a key is meant to live, not how it is enforced. This keeps a branch from reaching credentials, reporting statuses to another repository, raising the global concurrency, disabling its own build container, changing its network -mode, or bypassing its own pull-request gate. Everything else is the branch's to decide — +mode, bypassing its own pull-request gate, or deploying itself. Everything else is the branch's to decide — it can already run any command through `buildCommand`. The pinned settings are stripped wherever they appear, in a build definition as well as in a legacy `branches` entry. The deprecated `branches` section itself is read from the repo @@ -252,6 +254,7 @@ builds: # branches: ["*", "!master"] # names or globs; a "!" pattern excludes; default: all # atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05) # activeWithin: 24h # only branches with commits in the last 24h + # afterSuccessOf: test # run after every green run of that build, at its commit (pinned) # run before each build cleanCommand: rm -rf build # shell command for each build @@ -397,9 +400,50 @@ Writing any of its keys outside the block is refused with a message naming the d Triggers: `onPush: true` builds every new commit of the selected branches; `atTimes: ["HH:MM", …]` rebuilds their heads once per day and slot (UTC). A slot may also be written as `??:MM` — that minute of every hour, expanded to its 24 slots, so the build runs hourly. Only the latest due slot of a day triggers, so slots missed while the server was down are skipped instead of piling up, and a slot whose pool is still building is retried on the next poll cycle until it succeeds. -A definition may have both; one with neither never triggers automatically — which is how `builds.default` is written when it is meant as a settings base only. +A definition may combine them; one with none of the three triggers (`onPush`, `atTimes`, `afterSuccessOf`) never runs automatically — which is how `builds.default` is written when it is meant as a settings base only. Werkator logs a warning once when no definition has a trigger at all, because such an instance never builds anything on its own. +#### Follow-up builds + +`afterSuccessOf: ` makes a definition a *follow-up* of another one, its *predecessor*: it runs on the predecessor's branch at the predecessor's commit whenever a run of the predecessor ends green. +This is how a deployment is configured: a deployment is a build that follows a green build, and it gets everything a build has — its own row in History with log and duration, its own Gitea check under its own `statusContext`, restart, cancel, artifacts, and the per-branch serialization. + +Every green run counts, whatever started it — the push watcher, an `atTimes` slot, a UI restart, `werkator retry`, or the startup recovery — and a repeated green run of the same commit triggers the follow-up again. +The follow-up builds the commit that was tested, not the branch's current origin head. +A failed, cancelled, or interrupted run triggers nothing. +The follow-up runs in the branch's worktree, after its predecessor, in the branch's sandbox or container — so a deployment tool like the Docker CLI is provided the way a compiler is. +It inherits `builds.default` like every definition, so a deployment that wants the predecessor's output has to set `cleanCommand: ""` itself; a deployment command should rather be self-contained and rebuild what it ships, because a restart of the follow-up alone runs without its predecessor. +The pull-request gate is not consulted for a follow-up: the predecessor passed it for the same commit, and the follow-up's `branches` selector is its own gate. + +The trigger of a follow-up is pinned: a branch's committed config can neither add `afterSuccessOf` to a definition nor change the `trigger` block of a definition the host defines as a follow-up, so a branch cannot deploy itself. +What the deployment *does* comes with the repository, like every build command; where and when it happens, and with which credentials, is the host's. +The host's part lives in `.git/werkator/.werkator.yml`; credentials reach the sandbox like any build setting, through `werkdock.env`/`docker.env`, and a file such as an SSH key through the werkdock sandbox's persistent toolchain home, `.git/werkator/buildenv/home/` on the host, which the sandbox mounts as `/root`. + +```yaml +# .git/werkator/.werkator.yml — the host's part: when, for which branches, with what +builds: + deploy: + trigger: + afterSuccessOf: frontend + branches: ["main"] + statusContext: werkator/deploy + werkdock: + env: + DEPLOY_TARGET: user@host:~/doms/example.org/htdocs-ssl +``` + +```yaml +# .werkator.yml — the repository's part: what +builds: + deploy: + cleanCommand: "" + buildCommand: scripts/deploy-prod.sh -y "$DEPLOY_TARGET" +``` + +A follow-up whose predecessor no definition has, and a cycle of follow-ups, refuse the start with a message naming the definition — a deployment that silently never runs is the failure the flat-key refusal exists to prevent. +A branch whose committed config drops or renames the predecessor only loses its follow-up, with a warning naming the branch. +A one-shot `werkator build` runs no follow-ups — its process ends with its build — and says which ones the server would have run. + Selector: `trigger.branches` lists branch names or glob patterns (`*` matches any characters, also across `/`); empty selects all origin branches. A pattern prefixed with `!` excludes instead, and an exclusion always wins regardless of order — `["*", "!master"]` is every branch but master. That is how a branch gets a build of its own without being built by the default one as well. @@ -409,6 +453,7 @@ Both parts combine as an intersection. Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `werkdock` with all their keys. A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults. `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config. +So is the trigger of a [follow-up build](#follow-up-builds). Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited. Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only. Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of. diff --git a/docs/prs/2026-09-04-PR#23-follow-up-builds.md b/docs/prs/2026-09-04-PR#23-follow-up-builds.md new file mode 100644 index 0000000..f845d52 --- /dev/null +++ b/docs/prs/2026-09-04-PR#23-follow-up-builds.md @@ -0,0 +1,199 @@ +> **WARNING:** This document describes only the change applied in this PR. +> It may already be outdated once the next PR is merged. +> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation. + +## Related Links + +- [ADR 0007 — build definitions](../adrs/0007-2026-08-31.build-definitions.md): the `builds` section and its `trigger` block this PR extends. +- [configuration.md — build definitions](../configuration.md#build-definitions): the reference this PR updates. +- Werkbaum's `scripts/deploy-prod.sh`: the first deployment meant to run this way. + +## The Problem + +Werkator builds and reports, but it cannot deploy. +Werkbaum's production deployment is still a script run by hand from a developer machine, after looking at the Werkator status. +The step "if the build is green, run this" is exactly what a CI system is for, and the hand-off between the two is where releases go wrong: the wrong commit gets deployed, or a red commit, or nothing. + +Three ways to add deployments were considered: + +- A `deployCommand` next to `buildCommand`, run after a green `buildCommand` inside the same run. + Simple, but it creates a second command path inside one build — one log, one status, one duration for two different things — and a failed deployment would turn a green build red. +- A separate `deploy` section with its own executor path, statuses, and cancellation. + A second execution path next to `buildCommand`, with everything the first one has to be built again. +- **A deployment is a build that follows a green build.** Chosen. + A build definition may declare that it runs whenever another definition of the same branch turns green. + Everything a build has comes for free: its own row in History with log and duration, its own Gitea check under its own `statusContext`, restart without rebuilding, cancel, artifacts, and the per-branch serialization. + +## Non-Goals + +- A native execution path for deployments. + A follow-up build runs where every build of its branch runs — in the werkdock sandbox or the Docker container — so a deployment tool such as the Docker CLI is provided the same way a compiler is. +- Waiting for *several* builds to be green. + `afterSuccessOf` names one build; an `all of` form is a follow-up PR if a repository ever needs it. +- A separate secrets mechanism. + Deployment credentials reach the sandbox the way any build setting does — the host's `werkdock.env`/`docker.env` for the definition, and files placed in the sandbox's persistent toolchain home — and the pinning below keeps them on the branches the host names. +- Handing the predecessor's artifacts to the follow-up. + The follow-up runs in the same worktree, so the predecessor's output is *usually* still there, but a deployment command must not rely on it (see Open Questions). +- Recovering a follow-up whose enqueueing was lost to a server restart between the two builds. + The operator restarts the predecessor, which triggers the follow-up again. + +## The Scenarios + +### Feature: a build that follows a green build + +#### Background + +- A *follow-up build* is a build definition whose `trigger` block carries `afterSuccessOf: `, naming another definition of the same configuration — its *predecessor*. +- The follow-up runs on the predecessor's branch at the predecessor's commit, never at the branch's current origin head, so a deployment always ships the commit that was tested. +- Like every non-default build it records under its own pool `@`, and it should carry its own `statusContext` so that Gitea shows the deployment as a check of its own. +- *Green* is every run of the predecessor that ends with `SUCCESS`, whatever started it: the push watcher, an `atTimes` slot, a UI restart, `werkator retry`, or the startup recovery. +- The key belongs to the `trigger` block: it says *when* the build runs, so it is never inherited from `builds.default`, and writing it flat is refused like every other trigger key. + +#### Scenario#23.01: A green predecessor triggers the follow-up on the same commit + +So that a deployment ships exactly the commit that was just tested, with its own log, duration, and Gitea check. + +- **Given** a definition `deploy` with `trigger.afterSuccessOf: frontend` and `statusContext: werkator/deploy` + - **and** the build `frontend` of branch `main` at commit `c1` is running +- **When** that build finishes with `SUCCESS` +- **Then** a build `deploy` of branch `main` at commit `c1` is enqueued + - **and** it is recorded under the pool `main@deploy` + - **and** its Gitea status is posted under the context `werkator/deploy` + - **and** the origin head of `main` having moved on to `c2` meanwhile changes nothing about that + +##### Verified by + +- [FollowUpTriggerTest — "a green predecessor enqueues the follow-up at the predecessor's commit"](../../src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt) + +#### Scenario#23.02: Every green run triggers again, including a repeated run of the same commit + +So that a deployment can be repeated by rerunning the build — and so that no run of a green build is silently *not* deployed, which would be more confusing than a redundant deployment. + +- **Given** the build `frontend` of `main` at `c1` was green and `deploy` ran for it +- **When** `frontend` at `c1` is restarted from the UI, retried from the CLI, or rebuilt by an `atTimes` slot, and turns green again +- **Then** `deploy` is enqueued for `c1` again + +##### Verified by + +- [FollowUpTriggerTest — "every green run of the predecessor triggers the follow-up again"](../../src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt) + +#### Scenario#23.03: A run that is not green triggers nothing + +So that nothing is ever deployed from a failed, cancelled, or interrupted build. + +- **Given** the same definitions +- **When** the build `frontend` ends as `FAILED`, `CANCELLED`, or `INTERRUPTED` + - **or** a build other than `frontend` ends as `SUCCESS` +- **Then** no `deploy` build is enqueued + +##### Verified by + +- [FollowUpTriggerTest — "only a SUCCESS of the named predecessor triggers"](../../src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt) + +#### Scenario#23.04: The trigger of a follow-up is host-pinned + +So that a branch can never deploy itself: a follow-up build is the host's way to hand real-world effects — targets, credentials — to a commit, and the host alone decides *when* and *for which branches* that happens. + +- **Given** the host configuration defines `deploy` with `trigger: {afterSuccessOf: frontend, branches: [main]}` +- **When** a branch's committed `.werkator.yml` sets `builds.deploy.trigger.branches: ["*"]` + - **or** adds `afterSuccessOf` to any definition's trigger +- **Then** the host's trigger block of `deploy` is used unchanged, and the branch's `afterSuccessOf` is dropped with a warning naming the branch + - **and** a branch the host's selector does not name never runs `deploy`, however green its own builds are + +##### Verified by + +- [ConfigLoaderTest — "a follow-up trigger is pinned to the host, a branch cannot add or widen one"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) + +#### Scenario#23.05: What the follow-up does comes with the repository + +So that the deployment command is versioned with the code it deploys, like every other build command — while the host keeps the targets and credentials. + +- **Given** the host defines `deploy` with its trigger and `werkdock.env: {DEPLOY_TARGET: …}` + - **and** the committed `.werkator.yml` of `main` defines `deploy` with `cleanCommand: ""` and `buildCommand: scripts/deploy-prod.sh -y "$DEPLOY_TARGET"` +- **When** `deploy` runs for `main` +- **Then** it runs the committed command with the host's environment, in the branch's sandbox, in the branch's worktree, after its predecessor and serialized with the branch's other builds + +##### Verified by + +- [BuildExecutorTest — "a follow-up build runs in its branch's worktree after its predecessor"](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt) +- [ConfigLoaderTest — "a branch supplies the command of a host-triggered follow-up"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) + +#### Scenario#23.06: A follow-up that could never fire is refused at start + +So that a deployment that silently never runs cannot exist — the same reasoning that refuses a flat trigger key. + +- **Given** a configuration whose `afterSuccessOf` names a definition that does not exist + - **or** whose follow-ups form a cycle (`a` after `b`, `b` after `a`) + - **or** which writes `afterSuccessOf` outside the `trigger` block +- **When** the configuration is loaded +- **Then** loading fails with a message naming the definition and the reason + - **and** a definition with only `afterSuccessOf` counts as triggered, so the "no build triggered" warning is not raised for it + +##### Verified by + +- [ConfigLoaderTest — "afterSuccessOf must name an existing definition and must not form a cycle"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) +- [ConfigLoaderTest — "afterSuccessOf written flat is refused like every trigger key"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) + +#### Scenario#23.07: Follow-ups fire in server mode only + +So that the invariant "nothing is scheduled during CLI runs or tests" holds: a CLI `build` ends when its build ends, and a follow-up enqueued into a process that is about to exit would only ever be recorded as interrupted. + +- **Given** `werkator build main` runs from the CLI and turns green +- **When** the command finishes +- **Then** no follow-up was enqueued, and the CLI says which follow-up the server would have run + +##### Verified by + +- [FollowUpTriggerTest — "the trigger listens only while the watcher runs"](../../src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt) +- [WatcherTest — "start arms the follow-up trigger before the recovery, stop disarms it"](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt) +- [BuildCommandTest — "a green CLI build names the follow-up builds the server would run, and runs none"](../../src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt) + +## The Solution + +`TriggerConfig` gets a fourth key, `afterSuccessOf: String` (empty: none). +It sits inside the `trigger` block on purpose, next to `onPush` and `atTimes`: it answers "when does this build run", so the structural rule of ADR 0007 makes it non-inheritable without touching any list, and `checkTriggerBlocks` refuses it written flat by adding it to `FLAT_TRIGGER_KEYS`. +`isTriggered` counts it, so a definition with nothing but a predecessor is not reported as never triggering. +A definition may itself be followed; `ConfigLoader` refuses an unknown predecessor and a cycle when it validates the merged configuration. + +A new `FollowUpTrigger` in the `watcher` package listens to `BuildStatusChangedEvent`. +On a `SUCCESS` it resolves the definitions of the result's branch at the result's commit — the same `definitionsFor` the watcher uses for the branch layer — and enqueues, through `BuildExecutor.startBuild(repo, branch, commit, name)`, every definition whose `afterSuccessOf` names the finished build and whose selector selects the branch. +Passing the commit explicitly is what makes Scenario#23.01's last line true; the executor's duplicate guard folds a follow-up that is already queued for the same commit. +The listener is armed by the watcher's `start()` and disarmed by `stop()`, which is how it stays silent in CLI runs and tests (Scenario#23.07). + +The pinning extends `stripPinned`: `afterSuccessOf` is removed from every trigger of the branch layer, and for every definition whose host trigger carries `afterSuccessOf` the branch layer's whole `trigger` block is dropped. +That is the smallest rule that makes Scenario#23.04 hold: a branch keeps the right to describe what its deployment does, and loses only the right to decide that — or where — it happens. +The pull-request gate is not consulted for a follow-up: the predecessor passed it already for the same commit, and the host's selector is the follow-up's own gate. + +The follow-up runs like any other build of its branch — same worktree, same sandbox, serialized behind its predecessor. +It inherits `builds.default`, so a deployment that wants the predecessor's output has to set `cleanCommand: ""` itself. + +Three places stay in sync with the new key, as the invariant demands: the data classes, the `init` templates (a commented `afterSuccessOf` line under the `trigger` block), and `docs/configuration.md`, which gets a "Follow-up builds" paragraph under build definitions and the new pinning in the branch-layer section. +`AGENTS.md` names the key in the trigger and pinning invariants. + +## Open Questions + +- **Credentials inside the sandbox.** + Werkdock clears the environment and mounts the repository's persistent toolchain home as `/root`, so an SSH key placed under `.git/werkator/buildenv/home/.ssh/` on the host is `/root/.ssh/` inside the sandbox, and `werkdock.env` carries the target. + For Docker there is no equivalent mount today; a key passed through `docker.env` works but is visible in `docker inspect`. + Implemented: nothing new — the PR documents the werkdock path in `configuration.md` and leaves a Docker mount to a follow-up if vm4006 ever deploys. +- **Restarting the follow-up alone.** + Restart re-runs `deploy` without its predecessor, in a worktree that may have been cleaned by a later build of the branch. + Implemented: allowed, and the reference recommends a self-contained deployment command that rebuilds what it ships (Werkbaum's `deploy-prod.sh` does). +- **A predecessor that exists only on a branch.** + The host's `afterSuccessOf: frontend` refers to a name the branch layer may rename. + Implemented: refused at start for the primary configuration; for a branch whose layer lacks the name, a warning once per branch and commit, and no follow-up + (verified by [ConfigLoaderTest — "a branch whose layer lacks the predecessor loses only its follow-up"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)). + An instance fragment checked by `init --apply` is not checked for its predecessor at all: the build it names may well live in the project config it is merged with, and the merged configuration is checked on every load anyway. + +## Additional Changes + +- `BuildStatusChangedEvent` now carries the `RepoContext` the result belongs to: a `BuildResult` does not know its repository, and the listener has to enqueue into the right one. +- `ConfigLoader.loadWithBranchLayer` and `loadForWorktree` take an optional branch name, used only to name the branch in the pinning warnings; the watcher passes it. +- The follow-up check distinguishes three loads: the primary configuration refuses a missing predecessor, a branch layer warns, and an instance fragment (`init --apply`) skips the check — its predecessor may live in the project config it is merged with. +- The "no build triggered" warning now names `afterSuccessOf` as the third trigger. + +## Follow-up PRs + +- Werkbaum: commit the `deploy` definition to its `.werkator.yml`, add the host part on mih09, and retire the manual `deploy-prod.sh` invocation from the README. +- `afterSuccessOf` as a list (all green), if a repository ever needs a deployment gated on more than one build. +- A Docker bind mount for credential files, if a Docker host deploys. diff --git a/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt index 0de0403..ef588ad 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt @@ -119,7 +119,7 @@ class BuildExecutor( artifactKey = runningBuild.artifactKey, ) repo.results.append(pending) - eventPublisher.publishEvent(BuildStatusChangedEvent(pending)) + eventPublisher.publishEvent(BuildStatusChangedEvent(pending, repo)) val activeBuild = ActiveBuild(runningBuild, repo) builds[runningBuild.artifactKey] = activeBuild publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null) @@ -384,7 +384,7 @@ class BuildExecutor( duration = duration, artifactKey = runningBuild.artifactKey, ).also { build.repo.results.append(it) } - eventPublisher.publishEvent(BuildStatusChangedEvent(updated)) + eventPublisher.publishEvent(BuildStatusChangedEvent(updated, build.repo)) publishGiteaStatus(build, status, duration) return updated } diff --git a/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt index dc81a83..59b161c 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt @@ -35,7 +35,12 @@ data class RunningBuild( var runningSince: Instant? = null } -/** Published via Spring's `ApplicationEventPublisher` on every persisted status transition. */ +/** + * Published via Spring's `ApplicationEventPublisher` on every persisted status transition. + * Carries the repository because a [BuildResult] does not: a listener that reacts to the + * transition — the follow-up trigger — has to act on that repository. + */ data class BuildStatusChangedEvent( val result: BuildResult, + val repo: RepoContext, ) diff --git a/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt index 08a58e3..44c7647 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt @@ -1,9 +1,11 @@ package de.hoennig.werkator.commands import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.git.GitService import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoRegistry +import de.hoennig.werkator.watcher.FollowUpTrigger import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.ExitCode @@ -27,6 +29,7 @@ class BuildCommand( private val gitService: GitService, private val consoleBuildRunner: ConsoleBuildRunner, private val registry: RepoRegistry, + private val followUpTrigger: FollowUpTrigger, ) : Callable { @Mixin var repoOption = RepoOption() @@ -58,9 +61,33 @@ class BuildCommand( } println("building branch $branch at commit ${commit.take(12)}") val status = consoleBuildRunner.buildAndStream(repo, branch, commit) + if (status == BuildStatus.SUCCESS) { + reportSkippedFollowUps(branch, commit) + } return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE } + /** + * A one-shot build ends with its process, so it never runs the follow-up builds the + * server would enqueue after a green run (PR#23) — it says which ones instead of + * leaving the operator to wonder why nothing was deployed. + */ + private fun reportSkippedFollowUps( + branch: String, + commit: String, + ) { + val followUps = + try { + followUpTrigger.followUpsOf(repo, branch, commit, BuildDefinition.DEFAULT) + } catch (e: Exception) { + System.err.println("warning: could not determine the follow-up builds (${e.message})") + return + } + if (followUps.isNotEmpty()) { + println("note: the server would now run the follow-up build(s) ${followUps.joinToString(", ")}; a CLI build does not") + } + } + /** A one-shot build should still work offline, from the last fetched origin state. */ private fun fetchBestEffort() { try { diff --git a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt index f7a8616..d9e3d41 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt @@ -222,6 +222,7 @@ class InitCommand( # branches: ["*", "!master"] # names or globs; "!" excludes; default: all # atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05) # activeWithin: 24h # only branches with recent commits + # afterSuccessOf: test # run after every green run of that build, at its commit (pinned) # run before each build cleanCommand: rm -rf build # shell command for each build diff --git a/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt b/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt index 2baaa5d..973d01c 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt @@ -89,8 +89,9 @@ data class BuildDefinition( * When a build runs and for which branches — the `trigger` block of a build definition, * and the one part of it that is never inherited from `builds.default`. * - * A definition with neither [onPush] nor [atTimes] never triggers automatically; that is - * how `builds.default` is written when it is meant as a settings base only. + * A definition with neither [onPush] nor [atTimes] nor [afterSuccessOf] never triggers + * automatically; that is how `builds.default` is written when it is meant as a settings + * base only. */ data class TriggerConfig( /** Build every new commit of the selected branches. */ @@ -112,7 +113,19 @@ data class TriggerConfig( * empty applies no age filter. Combines with [branches] as an intersection. */ val activeWithin: String = "", + /** + * Name of another definition of this configuration — the *predecessor*: this build + * runs on the predecessor's branch at the predecessor's commit whenever a run of it + * ends with `SUCCESS`, whatever started that run (PR#23). Empty means none. Sits in + * the trigger block because it says *when* this build runs, so it is never inherited; + * and it is host-pinned, because a follow-up build is the host's way to hand + * real-world effects — deployment targets, credentials — to a green commit. + */ + val afterSuccessOf: String = "", ) { + /** True when this build follows another one, see [afterSuccessOf]. */ + fun isFollowUp(): Boolean = afterSuccessOf.isNotBlank() + /** True when [branch] matches the [branches] patterns (or none are configured) and none excludes it. */ fun selectsByName(branch: String): Boolean { val (excluding, including) = branches.partition { it.startsWith(EXCLUDE_PREFIX) } diff --git a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt index 9d8b471..294ee1f 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt @@ -90,7 +90,8 @@ class ConfigLoader( fun loadForWorktree( workingDir: Path, worktreeDir: Path, - ): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile())) + branch: String? = null, + ): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile()), branch) /** * The primary/`.git` config with the committed `.werkator.yml` of one branch @@ -107,30 +108,53 @@ class ConfigLoader( * (`requirePullRequest`, which decides whether the branch is built at all). * They are stripped from the branch layer before merging, so a branch can neither * escape its container, nor bypass its own pull-request gate, nor raise the global - * concurrency, nor reach the credentials. + * concurrency, nor reach the credentials. The trigger of a follow-up build is pinned + * the same way (PR#23): a branch may say what its deployment does, never that — or + * where — it happens. [branch] only names the branch in the warnings. */ fun loadWithBranchLayer( workingDir: Path, branchConfigYaml: String?, - ): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml)) + branch: String? = null, + ): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml), branch) private fun withBranchLayer( workingDir: Path, branchLayer: Map, + branch: String?, ): WerkatorConfig { // scoped to this branch: an incompatible branch config fails its own builds and // must never stop the server or hold up the branches that are fine checkVersion(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT) checkTriggerBlocks(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT) - return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer))) + val primary = loadRaw(workingDir) + return toConfig(deepMerge(primary, stripPinned(branchLayer, primary, branch)), MissingPredecessor.WARN) } - private fun toConfig(raw: Map): WerkatorConfig { + /** + * What a follow-up whose predecessor no definition has means for this load, see + * [checkFollowUps]. + */ + private enum class MissingPredecessor { + /** The primary configuration: a deployment that could never fire refuses the start. */ + REFUSE, + + /** A branch layer on top: the branch renamed or dropped the build the host's trigger names, and only loses its follow-up. */ + WARN, + + /** A fragment checked on its own: the predecessor may well live in the project config it is merged with later. */ + SKIP, + } + + private fun toConfig( + raw: Map, + missingPredecessor: MissingPredecessor = MissingPredecessor.REFUSE, + ): WerkatorConfig { val config = if (raw.isEmpty()) { WerkatorConfig() } else { - yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java) + yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw), missingPredecessor), WerkatorConfig::class.java) } return defaultPublicBaseUrl(config) } @@ -178,7 +202,11 @@ class ConfigLoader( * second as soon as the committed configuration carries them. */ @Suppress("UNCHECKED_CAST") - private fun stripPinned(branchLayer: Map): Map { + private fun stripPinned( + branchLayer: Map, + primary: Map, + branch: String?, + ): Map { if (branchLayer.isEmpty()) { return branchLayer } @@ -188,9 +216,55 @@ class ConfigLoader( val entries = result[section] as? Map ?: continue result[section] = entries.mapValues { (_, value) -> stripPinnedSettings(value) } } + (result["builds"] as? Map)?.let { builds -> + val hostBuilds = primary["builds"] as? Map ?: emptyMap() + result["builds"] = builds.mapValues { (name, value) -> stripPinnedTrigger(name, value, hostBuilds[name], branch) } + } return result } + /** + * The follow-up part of the pinning (PR#23): a branch's definition loses its + * `afterSuccessOf`, and where the host's definition of the same name is a follow-up, + * the branch's whole `trigger` block — otherwise a branch could widen the host's + * selector to include itself, and deploy itself with the host's credentials. Said + * out loud, because a trigger the branch wrote and does not see in effect is a + * question it would otherwise ask the log in vain. + */ + @Suppress("UNCHECKED_CAST") + private fun stripPinnedTrigger( + name: String, + value: Any?, + hostDefinition: Any?, + branch: String?, + ): Any? { + val definition = value as? Map ?: return value + val trigger = definition["trigger"] as? Map ?: return value + val where = branch?.let { "branch '$it'" } ?: "this branch" + if (predecessorOf(hostDefinition) != null) { + log.warn( + "ignoring the trigger block of builds.{} in the committed {} of {}: the host defines that build as a follow-up, " + + "and when and where a follow-up runs is the host's decision alone", + name, + ConfigFiles.COMMITTED, + where, + ) + return definition - "trigger" + } + if (predecessorOf(definition) == null) { + return value + } + log.warn( + "ignoring builds.{}.trigger.afterSuccessOf in the committed {} of {}: a branch cannot make a build follow another, " + + "only the host can", + name, + ConfigFiles.COMMITTED, + where, + ) + val stripped = trigger - "afterSuccessOf" + return if (stripped.isEmpty()) definition - "trigger" else definition + ("trigger" to stripped) + } + @Suppress("UNCHECKED_CAST") private fun stripPinnedSettings(value: Any?): Any? { val entry = value as? Map ?: return value @@ -225,12 +299,16 @@ class ConfigLoader( * an empty docker policy and run natively on the host, which is exactly the escape * the pinned keys exist to prevent. */ - private fun resolveBuildSections(raw: Map): Map { + private fun resolveBuildSections( + raw: Map, + missingPredecessor: MissingPredecessor, + ): Map { @Suppress("UNCHECKED_CAST") val definitions = raw["builds"] as? Map ?: emptyMap() if (definitions.isEmpty()) { return mergeBranchDefaults(raw) } + checkFollowUps(definitions, missingPredecessor) if (raw.containsKey("branches") && warnedSections.add(LEGACY_BRANCHES_WARNING)) { log.warn( "ignoring the branches section: this configuration defines builds, and a build definition " + @@ -252,13 +330,59 @@ class ConfigLoader( return } if (warnedSections.add(NO_TRIGGER_WARNING)) { - log.warn("no build defines onPush or atTimes; the watcher will never start a build on its own") + log.warn("no build defines onPush, atTimes, or afterSuccessOf; the watcher will never start a build on its own") } } private fun isTriggered(definition: Any?): Boolean { val trigger = (definition as? Map<*, *>)?.get("trigger") as? Map<*, *> ?: return false - return trigger["onPush"] == true || (trigger["atTimes"] as? List<*>)?.isNotEmpty() == true + return trigger["onPush"] == true || + (trigger["atTimes"] as? List<*>)?.isNotEmpty() == true || + predecessorOf(definition) != null + } + + private fun predecessorOf(definition: Any?): String? { + val trigger = (definition as? Map<*, *>)?.get("trigger") as? Map<*, *> ?: return null + return (trigger["afterSuccessOf"] as? String)?.takeIf { it.isNotBlank() } + } + + /** + * A follow-up build that could never fire must not exist, for the same reason a flat + * trigger key is refused: a deployment that silently never runs is worse than a + * configuration that refuses to load (PR#23). Refused are a predecessor no definition + * has, and a cycle of follow-ups (a build following itself included) — a cycle is a + * configuration error whoever wrote it, while a missing predecessor depends on what + * is being loaded ([MissingPredecessor]). + */ + private fun checkFollowUps( + definitions: Map, + missingPredecessor: MissingPredecessor, + ) { + val predecessors = definitions.mapValues { (_, definition) -> predecessorOf(definition) } + val effective = definitions.keys + BuildDefinition.DEFAULT + for ((name, predecessor) in predecessors) { + if (predecessor == null || predecessor in effective) continue + val message = "builds.$name follows '$predecessor' (trigger.afterSuccessOf), but no build of that name is defined" + when (missingPredecessor) { + MissingPredecessor.REFUSE -> throw ConfigFormatException("$message. Name an existing build, or remove the follow-up.") + MissingPredecessor.WARN -> log.warn("$message on this branch; the follow-up will not run for it") + MissingPredecessor.SKIP -> {} + } + } + for (start in predecessors.keys) { + val path = mutableListOf(start) + var current = predecessors[start] + while (current != null && current !in path) { + path += current + current = predecessors[current] + } + if (current == start) { + throw ConfigFormatException( + "builds.$start follows itself through trigger.afterSuccessOf (${path.joinToString(" -> ")} -> $start); " + + "a follow-up build cannot wait for its own success.", + ) + } + } } /** @@ -426,7 +550,10 @@ class ConfigLoader( checkVersion(raw, fragment.toString(), ROLLBACK_HINT) checkTriggerBlocks(raw, fragment.toString(), ROLLBACK_HINT) try { - strictYaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java) + strictYaml.convertValue( + resolveBuildSections(dropNonDefinitionBuilds(raw), MissingPredecessor.SKIP), + WerkatorConfig::class.java, + ) } catch (e: IllegalArgumentException) { throw IllegalArgumentException( "instance fragment $fragment does not match the configuration schema: ${e.message}", @@ -610,7 +737,7 @@ class ConfigLoader( private val TRIGGER_KEYS = setOf("trigger") /** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */ - private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin") + private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin", "afterSuccessOf") /** * Top-level sections owned by the instance once a home config exists (ADR 0009): diff --git a/src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt b/src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt new file mode 100644 index 0000000..b4852c8 --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt @@ -0,0 +1,134 @@ +package de.hoennig.werkator.watcher + +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.build.BuildStatusChangedEvent +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.config.ConfigFiles +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.git.GitService +import de.hoennig.werkator.repo.RepoContext +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.Clock +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Runs the follow-up builds (PR#23): whenever a build ends with `SUCCESS`, every + * definition of that branch whose `trigger.afterSuccessOf` names the finished build — + * and whose selector selects the branch — is enqueued on the same branch at the *same + * commit*, never at the branch's current origin head, so a deployment always ships the + * commit that was tested. Every green run counts, whatever started it, and a repeated + * green run of the same commit triggers again: a run of a green build that is silently + * not deployed would be more confusing than a redundant deployment. + * + * The definitions are resolved with the branch's committed config at the finished + * build's commit — the same layering the watcher applies — so the follow-up's command + * comes with the repository while its trigger stays the host's (pinned by + * [ConfigLoader]). The pull-request gate is not consulted: the predecessor passed it for + * this very commit, and the host's selector is the follow-up's own gate. + * + * Armed by [Watcher.start] and disarmed by [Watcher.stop], so a CLI `build` — whose + * process ends with its build — never enqueues a follow-up into a JVM that is about to + * exit; the CLI names the follow-ups the server would have run instead. + */ +@Component +class FollowUpTrigger( + private val gitService: GitService, + private val configLoader: ConfigLoader, + private val buildExecutor: BuildExecutor, + private val clock: Clock, +) { + private val log = LoggerFactory.getLogger(FollowUpTrigger::class.java) + + private val armed = AtomicBoolean(false) + + fun arm() { + armed.set(true) + } + + fun disarm() { + armed.set(false) + } + + fun isArmed(): Boolean = armed.get() + + @EventListener + fun onBuildStatusChanged(event: BuildStatusChangedEvent) { + if (!armed.get() || event.result.status != BuildStatus.SUCCESS) { + return + } + val result = event.result + try { + for (name in followUpsOf(event.repo, result)) { + log.info( + "[{}] enqueueing follow-up build {} of branch {} at commit {}, after {}", + event.repo.name, + name, + result.branch, + result.commit, + result.build, + ) + buildExecutor.startBuild(event.repo, result.branch, result.commit, name) + } + } catch (e: Exception) { + log.error("[{}] could not enqueue the follow-ups of {} at {}", event.repo.name, result.name, result.commit, e) + } + } + + /** The names of the builds that follow a green [result], in the order of their definitions; nothing is enqueued. */ + fun followUpsOf( + repo: RepoContext, + result: BuildResult, + ): List = followUpsOf(repo, result.branch, result.commit, result.build) + + /** The names of the builds that follow a green run of [build] on [branch] at [commit]; nothing is enqueued. */ + fun followUpsOf( + repo: RepoContext, + branch: String, + commit: String, + build: String, + ): List { + val workingDir = repo.workingDir + val definitions = definitionsAt(repo, branch, commit) + val headCommittedAt = lazy { gitService.originBranchCommitTimes(workingDir)[branch] } + return definitions + .filter { (_, definition) -> definition.trigger.afterSuccessOf == build } + .filter { (_, definition) -> definition.trigger.selects(branch, { headCommittedAt.value }, clock.instant()) } + .keys + .toList() + } + + /** + * The branch's definitions at [commit] — not at its head, which may have moved on + * since the predecessor started. An unreadable branch config falls back to the + * primary definitions, like the watcher does. + */ + private fun definitionsAt( + repo: RepoContext, + branch: String, + commit: String, + ): Map { + val workingDir = repo.workingDir + return try { + configLoader + .loadWithBranchLayer( + workingDir, + ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) }, + branch, + ).effectiveBuildDefinitions() + } catch (e: Exception) { + log.warn( + "[{}] ignoring the committed {} of branch {} at {} for its follow-ups: {}", + repo.name, + ConfigFiles.COMMITTED, + branch, + commit, + e.message ?: e.javaClass.simpleName, + ) + configLoader.load(workingDir).effectiveBuildDefinitions() + } + } +} diff --git a/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt index bdb627d..d09e797 100644 --- a/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt @@ -41,6 +41,7 @@ class Watcher( private val buildExecutor: BuildExecutor, private val configLoader: ConfigLoader, private val clock: Clock, + private val followUpTrigger: FollowUpTrigger, ) { private val log = LoggerFactory.getLogger(Watcher::class.java) @@ -83,11 +84,14 @@ class Watcher( * Runs the startup recovery of every repository and schedules the poll loop with the * fixed delay `watcher.pollInterval` — one loop, one delay: the instance's setting, * which every repository's effective config carries; the first poll runs immediately. + * Arms the [FollowUpTrigger] first, so the recovery's re-enqueued builds get their + * follow-ups too. */ @Synchronized fun start(repos: List) { check(scheduler == null) { "watcher is already running" } require(repos.isNotEmpty()) { "no repository to watch" } + followUpTrigger.arm() repos.forEach { recoverSafely(it) } val interval = DurationParser.parse(configLoader.load(repos.first().workingDir).watcher.pollInterval) scheduler = @@ -111,6 +115,7 @@ class Watcher( @Synchronized fun stop() { + followUpTrigger.disarm() scheduler?.shutdownNow() scheduler = null state = state.copy(running = false) @@ -324,6 +329,7 @@ class Watcher( .loadWithBranchLayer( workingDir, ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) }, + branch, ).effectiveBuildDefinitions() } catch (e: Exception) { log.warn( diff --git a/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt index cb6a0a9..435ebc4 100644 --- a/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt @@ -292,6 +292,42 @@ class BuildExecutorTest : FunSpec() { .build shouldBe "default" } + test("a follow-up build runs in its branch's worktree after its predecessor") { + val h = + Harness( + """ + executor: + maxConcurrent: 2 + builds: + default: + trigger: + onPush: true + cleanCommand: "" + buildCommand: "sleep 1; echo built > output.txt" + deploy: + trigger: + afterSuccessOf: default + buildCommand: "cat output.txt" + """.trimIndent(), + ) + + h.executor.startBuild(h.repo, "main", "c1", "default") + h.executor.startBuild(h.repo, "main", "c1", "deploy") + + awaitStatus(h, "main@deploy", BuildStatus.SUCCESS) + awaitIdle(h) + // same branch, same commit: the same worktree, and the follow-up saw the predecessor's output + h.workspaceCalls shouldContainExactly listOf("main" to "c1", "main" to "c1") + val predecessor = h.repository.latestFor("main").shouldNotBeNull() + val followUp = h.repository.latestFor("main@deploy").shouldNotBeNull() + predecessor.status shouldBe BuildStatus.SUCCESS + followUp.runningSince + .shouldNotBeNull() + .isBefore(predecessor.runningSince.shouldNotBeNull()) + .shouldBeFalse() + Files.readString(h.workingDir.resolve("output.txt")).trim() shouldBe "built" + } + test("a build whose definition was removed from the config falls back to the branch's settings") { val h = harness(buildCommand = "echo regular-\$branch") diff --git a/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt index 0b4dc8e..9293a1f 100644 --- a/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt @@ -4,6 +4,7 @@ import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.git.GitService import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoRegistry +import de.hoennig.werkator.watcher.FollowUpTrigger import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain @@ -22,16 +23,31 @@ class BuildCommandTest : FunSpec() { private val dir: Path = Paths.get(".") private val repo = RepoContext("test", dir, mockk(), mockk()) private val registry = mockk().also { every { it.current() } returns repo } + private val followUpTrigger = mockk() private fun command(fragment: String? = null) = - BuildCommand(gitService, consoleBuildRunner, registry).apply { + BuildCommand(gitService, consoleBuildRunner, registry, followUpTrigger).apply { branchFragment = fragment } init { beforeEach { - clearMocks(gitService, consoleBuildRunner) + clearMocks(gitService, consoleBuildRunner, followUpTrigger) justRun { gitService.fetchOrigin(dir) } + every { followUpTrigger.followUpsOf(any(), any(), any(), any()) } returns emptyList() + } + + test("a green CLI build names the follow-up builds the server would run, and runs none") { + every { gitService.currentBranch(dir) } returns "main" + every { gitService.localHeadCommit("main", dir) } returns "local-head" + every { gitService.hasNewCommits("main", dir) } returns false + every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.SUCCESS + every { followUpTrigger.followUpsOf(repo, "main", "local-head", "default") } returns listOf("deploy") + + val console = captureConsole { command().call() } + + console.stdout.shouldContain("follow-up build(s) deploy") + verify(exactly = 1) { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } } test("builds the current branch at its local head when no branch is given") { diff --git a/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt index 46b29ed..5c0d63e 100644 --- a/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt @@ -739,6 +739,190 @@ class ConfigLoaderTest : FunSpec() { .shouldBeTrue() } + test("afterSuccessOf must name an existing definition and must not form a cycle") { + val dir = Files.createTempDirectory("werkator-test") + val project = dir.resolve(".werkator.yml").toFile() + project.writeText( + """ + builds: + default: + trigger: + onPush: true + deploy: + trigger: + afterSuccessOf: default + branches: ["main"] + buildCommand: scripts/deploy.sh + """.trimIndent(), + ) + // a follow-up with nothing but its predecessor is a triggered build, and it binds + val deploy = loader.load(dir).buildDefinitions.getValue("deploy") + deploy.trigger.afterSuccessOf shouldBe "default" + deploy.trigger.isFollowUp().shouldBeTrue() + deploy.trigger.onPush.shouldBeFalse() + + project.writeText( + """ + builds: + deploy: + trigger: + afterSuccessOf: frontend + """.trimIndent(), + ) + shouldThrow { loader.load(dir) }.message.let { + it.shouldContain("builds.deploy") + it.shouldContain("frontend") + } + + project.writeText( + """ + builds: + a: + trigger: + afterSuccessOf: b + b: + trigger: + afterSuccessOf: a + """.trimIndent(), + ) + shouldThrow { loader.load(dir) }.message.shouldContain("a -> b -> a") + + project.writeText( + """ + builds: + a: + trigger: + afterSuccessOf: a + """.trimIndent(), + ) + shouldThrow { loader.load(dir) }.message.shouldContain("builds.a follows itself") + } + + test("a branch whose layer lacks the predecessor loses only its follow-up") { + val dir = Files.createTempDirectory("werkator-test") + dir.resolve(".werkator.yml").toFile().writeText( + """ + builds: + frontend: + trigger: + onPush: true + deploy: + trigger: + afterSuccessOf: frontend + """.trimIndent(), + ) + // the branch renamed the predecessor: a warning, not a failed load — the branch's + // own builds must keep running, its follow-up simply never fires for it + val config = + loader.loadWithBranchLayer( + dir, + """ + builds: + frontend: null + ui: + trigger: + onPush: true + """.trimIndent(), + ) + config.buildDefinitions + .getValue("ui") + .trigger.onPush + .shouldBeTrue() + config.buildDefinitions + .getValue("deploy") + .trigger.afterSuccessOf shouldBe "frontend" + } + + test("afterSuccessOf written flat is refused like every trigger key") { + val dir = Files.createTempDirectory("werkator-test") + dir.resolve(".werkator.yml").toFile().writeText( + """ + builds: + default: + trigger: + onPush: true + deploy: + afterSuccessOf: default + """.trimIndent(), + ) + val thrown = shouldThrow { loader.load(dir) } + thrown.message.shouldContain("builds.deploy: afterSuccessOf") + thrown.message.shouldContain("trigger:") + } + + test("a follow-up trigger is pinned to the host, a branch cannot add or widen one") { + val dir = Files.createTempDirectory("werkator-test") + dir.resolve(".werkator.yml").toFile().writeText( + """ + builds: + frontend: + trigger: + onPush: true + deploy: + trigger: + afterSuccessOf: frontend + branches: ["main"] + """.trimIndent(), + ) + val config = + loader.loadWithBranchLayer( + dir, + """ + builds: + deploy: + trigger: + afterSuccessOf: frontend + branches: ["*"] + nightly: + trigger: + atTimes: ["01:00"] + afterSuccessOf: frontend + """.trimIndent(), + "feature/x", + ) + // the host's trigger block of the follow-up is used unchanged + val deploy = config.buildDefinitions.getValue("deploy").trigger + deploy.afterSuccessOf shouldBe "frontend" + deploy.branches shouldBe listOf("main") + deploy.selectsByName("feature/x").shouldBeFalse() + // and a branch cannot make any build of its own a follow-up + val nightly = config.buildDefinitions.getValue("nightly").trigger + nightly.afterSuccessOf shouldBe "" + nightly.atTimes shouldBe listOf("01:00") + } + + test("a branch supplies the command of a host-triggered follow-up") { + val dir = Files.createTempDirectory("werkator-test") + Files.createDirectories(dir.resolve(".git/werkator")) + dir.resolve(".git/werkator/.werkator.yml").toFile().writeText( + """ + builds: + deploy: + trigger: + afterSuccessOf: default + branches: ["main"] + werkdock: + env: + DEPLOY_TARGET: host:/srv/www + """.trimIndent(), + ) + val worktree = Files.createTempDirectory("werkator-test-worktree") + worktree.resolve(".werkator.yml").toFile().writeText( + """ + builds: + deploy: + cleanCommand: "" + buildCommand: scripts/deploy-prod.sh -y "${'$'}DEPLOY_TARGET" + """.trimIndent(), + ) + + val settings = loader.loadForWorktree(dir, worktree, "main").buildSettings("main", "deploy") + + settings.buildCommand shouldBe "scripts/deploy-prod.sh -y \"${'$'}DEPLOY_TARGET\"" + settings.cleanCommand shouldBe "" + settings.werkdock.env shouldBe mapOf("DEPLOY_TARGET" to "host:/srv/www") + } + test("builds.default is the base of every other build, but never its trigger") { val dir = Files.createTempDirectory("werkator-test") dir.resolve(".werkator.yml").toFile().writeText( diff --git a/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt b/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt index aa47ab8..4be9aed 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt @@ -113,7 +113,7 @@ class PermanentBranchRoutesTest : FunSpec() { every { registry.byName(any()) } returns null every { registry.byName("test") } returns repo every { configLoader.load(any()) } returns WerkatorConfig() - every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig() + every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns WerkatorConfig() every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { controlTokens.token() } returns "test-token" every { branchListing.branches(any()) } returns emptyList() diff --git a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt index 2b6c83e..cfd9005 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt @@ -142,7 +142,7 @@ class UiControllerTest : FunSpec() { server = ServerConfig(impressumUrl = "https://example.org/imprint"), gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), ) - every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig() + every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns WerkatorConfig() every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { controlTokens.token() } returns "test-token" every { repository.latestGreenFor(any()) } returns null @@ -385,7 +385,7 @@ class UiControllerTest : FunSpec() { ) every { repository.history() } returns listOf(pitestResult) every { artifactStore.artifactDir("main-pitest-key") } returns null - every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns + every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns WerkatorConfig( branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")), buildDefinitions = mapOf("pitest" to BuildDefinition(buildCommand = "./gradlew pitestFull")), diff --git a/src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt b/src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt new file mode 100644 index 0000000..5b7e25a --- /dev/null +++ b/src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt @@ -0,0 +1,173 @@ +package de.hoennig.werkator.watcher + +import de.hoennig.werkator.build.ArtifactKeys +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.build.BuildStatusChangedEvent +import de.hoennig.werkator.build.RunningBuild +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.config.TriggerConfig +import de.hoennig.werkator.config.WerkatorConfig +import de.hoennig.werkator.git.GitService +import de.hoennig.werkator.repo.RepoContext +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.mockk.every +import io.mockk.mockk +import java.nio.file.Files +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.concurrent.CopyOnWriteArrayList + +class FollowUpTriggerTest : FunSpec() { + private val noon = Instant.parse("2026-09-04T12:00:00Z") + + /** Which build was started on which branch at which commit. */ + private data class Started( + val branch: String, + val commit: String, + val build: String, + ) + + private inner class Harness( + config: WerkatorConfig, + ) { + val workingDir = Files.createTempDirectory("werkator-followup-test") + val gitService = mockk() + val configLoader = mockk() + val buildExecutor = mockk() + val repo = RepoContext("test", workingDir, mockk(), mockk()) + val started = CopyOnWriteArrayList() + val trigger = FollowUpTrigger(gitService, configLoader, buildExecutor, Clock.fixed(noon, ZoneOffset.UTC)) + + init { + every { configLoader.load(any()) } returns config + every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns config + every { gitService.showFileAtCommit(any(), any(), any()) } returns null + // the branch moved on since the predecessor started + every { gitService.originHeadCommit(any(), any()) } returns "c2" + every { gitService.originBranchCommitTimes(any()) } returns mapOf("main" to noon.minusSeconds(60)) + every { buildExecutor.startBuild(any(), any(), any(), any()) } answers { + val branch = secondArg() + val commit = thirdArg() + val build = arg(3) + started += Started(branch, commit, build) + val staging = Files.createTempDirectory("werkator-followup-staging") + RunningBuild( + repo = repo, + branch = branch, + build = build, + commit = commit, + artifactKey = ArtifactKeys.buildKey(BuildDefinition.poolName(branch, build), noon), + startedAt = noon, + stagingDir = staging, + liveLogFile = staging.resolve("build.log"), + ) + } + } + + fun finished( + build: String, + status: BuildStatus, + branch: String = "main", + commit: String = "c1", + ) { + val result = + BuildResult( + branch = branch, + build = build, + commit = commit, + status = status, + startedAt = noon, + artifactKey = ArtifactKeys.buildKey(BuildDefinition.poolName(branch, build), noon), + ) + trigger.onBuildStatusChanged(BuildStatusChangedEvent(result, repo)) + } + } + + private fun deployAfter( + predecessor: String, + branches: List = emptyList(), + ): WerkatorConfig = + WerkatorConfig( + buildDefinitions = + mapOf( + "frontend" to BuildDefinition(trigger = TriggerConfig(onPush = true)), + "backend" to BuildDefinition(trigger = TriggerConfig(onPush = true)), + "deploy" to + BuildDefinition( + trigger = TriggerConfig(afterSuccessOf = predecessor, branches = branches), + buildCommand = "scripts/deploy.sh", + ), + ), + ) + + init { + test("a green predecessor enqueues the follow-up at the predecessor's commit") { + val h = Harness(deployAfter("frontend", branches = listOf("main"))) + h.trigger.arm() + + h.finished("frontend", BuildStatus.SUCCESS, commit = "c1") + + // c1, not the origin head c2 the branch has moved on to + h.started shouldContainExactly listOf(Started("main", "c1", "deploy")) + } + + test("every green run of the predecessor triggers the follow-up again") { + val h = Harness(deployAfter("frontend")) + h.trigger.arm() + + h.finished("frontend", BuildStatus.SUCCESS, commit = "c1") + h.finished("frontend", BuildStatus.SUCCESS, commit = "c1") + + h.started shouldContainExactly + listOf( + Started("main", "c1", "deploy"), + Started("main", "c1", "deploy"), + ) + } + + test("only a SUCCESS of the named predecessor triggers") { + val h = Harness(deployAfter("frontend", branches = listOf("main"))) + h.trigger.arm() + + h.finished("frontend", BuildStatus.FAILED) + h.finished("frontend", BuildStatus.CANCELLED) + h.finished("frontend", BuildStatus.INTERRUPTED) + h.finished("frontend", BuildStatus.PENDING) + h.finished("frontend", BuildStatus.RUNNING) + h.finished("backend", BuildStatus.SUCCESS) + // a branch the host's selector does not name never runs the follow-up + h.finished("frontend", BuildStatus.SUCCESS, branch = "feature/x") + + h.started.shouldBeEmpty() + } + + test("the trigger listens only while the watcher runs") { + val h = Harness(deployAfter("frontend")) + + h.finished("frontend", BuildStatus.SUCCESS) + h.started.shouldBeEmpty() + + h.trigger.arm() + h.finished("frontend", BuildStatus.SUCCESS) + h.started shouldContainExactly listOf(Started("main", "c1", "deploy")) + + h.trigger.disarm() + h.finished("frontend", BuildStatus.SUCCESS) + h.started shouldContainExactly listOf(Started("main", "c1", "deploy")) + } + + test("followUpsOf names the follow-ups without enqueueing anything") { + val h = Harness(deployAfter("default")) + + h.trigger.followUpsOf(h.repo, "main", "c1", "default") shouldContainExactly listOf("deploy") + h.trigger.followUpsOf(h.repo, "main", "c1", "frontend").shouldBeEmpty() + h.started.shouldBeEmpty() + } + } +} diff --git a/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt index 218d798..7489fa3 100644 --- a/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt @@ -63,6 +63,7 @@ class WatcherTest : FunSpec() { val artifactStore = mockk() val startedBuilds = CopyOnWriteArrayList>() val configLoader = mockk() + val followUpTrigger = mockk(relaxed = true) val repo = RepoContext("test", workingDir, repository, artifactStore) val watcher = Watcher( @@ -70,6 +71,7 @@ class WatcherTest : FunSpec() { buildExecutor = buildExecutor, configLoader = configLoader, clock = Clock.fixed(noon, ZoneOffset.UTC), + followUpTrigger = followUpTrigger, ) private var seedCounter = 0L @@ -85,7 +87,7 @@ class WatcherTest : FunSpec() { every { gitService.originBranchCommitTimes(any()) } returns emptyMap() every { gitService.originBranchHeads(any()) } returns emptyMap() every { gitService.showFileAtCommit(any(), any(), any()) } returns null - every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns config + every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns config every { gitService.pullRequestHeads(any()) } returns emptySet() every { gitService.worktreePrune(any()) } returns Unit every { gitService.fastForwardLocalBranches(any()) } returns emptyList() @@ -157,6 +159,19 @@ class WatcherTest : FunSpec() { ) init { + test("start arms the follow-up trigger before the recovery, stop disarms it") { + val harness = Harness() + + harness.watcher.start(listOf(harness.repo)) + try { + verify(exactly = 1) { harness.followUpTrigger.arm() } + verify(exactly = 0) { harness.followUpTrigger.disarm() } + } finally { + harness.watcher.stop() + } + verify(exactly = 1) { harness.followUpTrigger.disarm() } + } + test("a fetch failure is exposed in the state and only retried next cycle") { val harness = Harness() every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable") @@ -526,7 +541,7 @@ class WatcherTest : FunSpec() { every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-main", "experiment" to "commit-exp") every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml" - every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer + every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml", anyNullable()) } returns branchLayer every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" @@ -549,7 +564,7 @@ class WatcherTest : FunSpec() { every { harness.gitService.originBranches(any()) } returns listOf("experiment") every { harness.gitService.originBranchHeads(any()) } returns mapOf("experiment" to "commit-exp") every { harness.gitService.showFileAtCommit("commit-exp", ".gittally.yml", any()) } returns "branch-yaml" - every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer + every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml", anyNullable()) } returns branchLayer every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp" harness.watcher.poll(harness.repo) @@ -568,7 +583,7 @@ class WatcherTest : FunSpec() { every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-main", "experiment" to "commit-exp") every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml" - every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer + every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml", anyNullable()) } returns branchLayer every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any" harness.watcher.poll(harness.repo) @@ -608,7 +623,7 @@ class WatcherTest : FunSpec() { buildDefinitions = mapOf("nightly" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))), ) every { harness.configLoader.load(any()) } returns edited - every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited + every { harness.configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns edited harness.watcher.poll(harness.repo) @@ -622,7 +637,7 @@ class WatcherTest : FunSpec() { every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-main") every { harness.gitService.showFileAtCommit("commit-main", Watcher.CONFIG_FILE, any()) } returns "broken" - every { harness.configLoader.loadWithBranchLayer(any(), "broken") } throws + every { harness.configLoader.loadWithBranchLayer(any(), "broken", anyNullable()) } throws RuntimeException("mapping problem") every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"