diff --git a/docs/prs/2026-09-04-PR#000-follow-up-builds.md b/docs/prs/2026-09-04-PR#000-follow-up-builds.md new file mode 100644 index 0000000..22e1665 --- /dev/null +++ b/docs/prs/2026-09-04-PR#000-follow-up-builds.md @@ -0,0 +1,188 @@ +> **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#000.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#000.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#000.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#000.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#000.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#000.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#000.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) + +## 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#000.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#000.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#000.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. + +## 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.