14 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.
Related Links
- ADR 0007 — build definitions: the
buildssection and itstriggerblock this PR extends. - 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
deployCommandnext tobuildCommand, run after a greenbuildCommandinside 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
deploysection with its own executor path, statuses, and cancellation. A second execution path next tobuildCommand, 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.
afterSuccessOfnames one build; anall ofform 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.envfor 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
triggerblock carriesafterSuccessOf: <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 ownstatusContextso 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, anatTimesslot, a UI restart,werkator retry, or the startup recovery. - The key belongs to the
triggerblock: it says when the build runs, so it is never inherited frombuilds.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
deploywithtrigger.afterSuccessOf: frontendandstatusContext: werkator/deploy- and the build
frontendof branchmainat commitc1is running
- and the build
- When that build finishes with
SUCCESS - Then a build
deployof branchmainat commitc1is 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
mainhaving moved on toc2meanwhile changes nothing about that
- and it is recorded under the pool
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
frontendofmainatc1was green anddeployran for it - When
frontendatc1is restarted from the UI, retried from the CLI, or rebuilt by anatTimesslot, and turns green again - Then
deployis enqueued forc1again
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
frontendends asFAILED,CANCELLED, orINTERRUPTED- or a build other than
frontendends asSUCCESS
- or a build other than
- Then no
deploybuild 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
deploywithtrigger: {afterSuccessOf: frontend, branches: [main]} - When a branch's committed
.werkator.ymlsetsbuilds.deploy.trigger.branches: ["*"]- or adds
afterSuccessOfto any definition's trigger
- or adds
- Then the host's trigger block of
deployis used unchanged, and the branch'safterSuccessOfis 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
- and a branch the host's selector does not name never runs
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
deploywith its trigger andwerkdock.env: {DEPLOY_TARGET: …}- and the committed
.werkator.ymlofmaindefinesdeploywithcleanCommand: ""andbuildCommand: scripts/deploy-prod.sh -y "$DEPLOY_TARGET"
- and the committed
- When
deployruns formain - 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"
- ConfigLoaderTest — "a branch supplies the command of a host-triggered follow-up"
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
afterSuccessOfnames a definition that does not exist- or whose follow-ups form a cycle (
aafterb,baftera) - or which writes
afterSuccessOfoutside thetriggerblock
- or whose follow-ups form a cycle (
- When the configuration is loaded
- Then loading fails with a message naming the definition and the reason
- and a definition with only
afterSuccessOfcounts as triggered, so the "no build triggered" warning is not raised for it
- and a definition with only
Verified by
- ConfigLoaderTest — "afterSuccessOf must name an existing definition and must not form a cycle"
- ConfigLoaderTest — "afterSuccessOf written flat is refused like every trigger key"
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 mainruns 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"
- WatcherTest — "start arms the follow-up trigger before the recovery, stop disarms it"
- BuildCommandTest — "a green CLI build names the follow-up builds the server would run, and runs none"
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, andwerkdock.envcarries the target. For Docker there is no equivalent mount today; a key passed throughdocker.envworks but is visible indocker inspect. Implemented: nothing new — the PR documents the werkdock path inconfiguration.mdand leaves a Docker mount to a follow-up if vm4006 ever deploys. - Restarting the follow-up alone.
Restart re-runs
deploywithout 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'sdeploy-prod.shdoes). - A predecessor that exists only on a branch.
The host's
afterSuccessOf: frontendrefers 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 byinit --applyis 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.
Follow-up PRs
- Werkbaum: commit the
deploydefinition to its.werkator.yml, add the host part on mih09, and retire the manualdeploy-prod.shinvocation from the README. afterSuccessOfas 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.