Files
werkator/docs/prs/2026-09-04-PR#23-follow-up-builds.md
T
4db294e1bc 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
2026-09-05 20:02:32 +02:00

15 KiB

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.

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

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

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

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

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

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

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

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"). 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.