Follow-up builds (#23)
A build definition may declare afterSuccessOf, running whenever the named build of the same branch turns green — the deployment path chosen over a deployCommand or a separate deploy section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
co-authored by
Claude Fable 5.1
mhoennig
parent
0f9f119687
commit
4db294e1bc
+48
-3
@@ -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: <name>` 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.
|
||||
|
||||
@@ -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: <name>`, 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 `<branch>@<name>`, 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.
|
||||
Reference in New Issue
Block a user