Author SHA1 Message Date
mhoennigandClaude Opus 5 2c64329d22 docs(prs): PR#18 — tools/remote drives any host layout
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:59:40 +02:00
mhoennigandClaude Opus 5 e1477eb8d0 docs(deployment): tools/remote drives any host layout
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:58:11 +02:00
mhoennigandClaude Opus 5 389fae388e fix(remote): upload before stopping the service, and verify the transfer
instance-update stopped the unit and only then started the upload, so a transfer
that dies mid-way leaves the host with no running Werkator and nothing to start
again. That is not theoretical: deploying to vm4006 on 2026-09-03 failed with
"scp: Connection closed" with the service already stopped.

The upload now happens before the stop, and each artifact is transferred to a
.part file whose sha256 is compared with the local one before it is moved into
place, retrying twice. A truncated archive would otherwise unpack into a broken
runtime, which is worse than the failed transfer it came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:55:23 +02:00
mhoennigandClaude Opus 5 506e817c82 feat(remote): the host layout is configurable, not the mih convention
tools/remote assumed the layout instance-install creates: the watched repository
in $WERKATOR_PATH/werkator, the runtime in $WERKATOR_PATH/.werkator, a werkdock
binary and a rootfs beside it, and the unit hardcoded as werkator-werkator.service.
An installation that predates the script — vm4006, a docker host with the repository
in ~/hs.hsadmin.ng and the runtime in ~/opt — could not be deployed with it at all.

WERKATOR_REPO_DIR, WERKATOR_INSTALL_DIR and WERKATOR_SANDBOX name the three values
that actually differ; their defaults are what instance-install writes, so the
existing env files resolve to exactly the same paths as before. The unit name is
derived from the repository directory the way SystemdServiceFiles.unitName does it,
instead of being spelled out. With WERKATOR_SANDBOX=docker the werkdock binary and
the rootfs archive are neither built nor uploaded — a docker host has no sandbox to
install, and check-prerequisites asks the docker daemon instead of werkdock doctor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:53:04 +02:00
43 changed files with 155 additions and 1587 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
--- ---
name: architecture name: architecture
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and the werkdock sandbox), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md. description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and bwrap), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
--- ---
# Werkator Architecture # Werkator Architecture
@@ -49,7 +49,7 @@ Werkator is configured by three YAML files, deep-merged by `ConfigLoader` (later
Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure. Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure.
On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` (the section was called `bwrap` until v1.2.0; `renameLegacySandbox` maps the old name onto the new one on every raw layer, before merging). On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock`.
Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable. Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable.
@@ -71,9 +71,9 @@ Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`). On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `WerkdockBuildRunner` when `werkdock.enabled` — docker and werkdock are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds. The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
`WerkdockBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`werkdock.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `werkdock.enabled`/`werkdock.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. The generic sandbox machinery is the standalone tool [Werkdock](https://git.javagil.de/mi/werkdock) (plan step 21: grown in `werkdock/`, consumed via the CLI since session C, its own repository since session E); the runner delegates to the `werkdock` CLI and this repository no longer carries its source. `BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. The generic sandbox machinery is the standalone tool [Werkdock](https://git.javagil.de/mi/werkdock) (plan step 21: grown in `werkdock/`, consumed via the CLI since session C, its own repository since session E); the runner delegates to the `werkdock` CLI and this repository no longer carries its source.
## Watcher ## Watcher
+3 -4
View File
@@ -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/<name>/…` and `/api/repos/<name>/…`, 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. - 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/<name>/…` and `/api/repos/<name>/…`, 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`. - 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. - 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, 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 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 bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) 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`, `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. - 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`, `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`. - 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`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
- `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. - `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. - 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. - Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
@@ -68,7 +68,6 @@ Keep sentences short.
- `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit). - `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit).
- `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand. - `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand.
- `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script. - `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script.
- `docs/rfcs/` — requests for comments: proposals that are larger than one PR and not yet a decision (an accepted RFC becomes an ADR or a plan step).
- `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc. - `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc.
## Key Architectural Decisions ## Key Architectural Decisions
+1 -1
View File
@@ -13,7 +13,7 @@ group = "de.hoennig"
// so the UI footer (BuildProperties), --version and the release notes identify what is // so the UI footer (BuildProperties), --version and the release notes identify what is
// actually running; a deployment bundles whatever was committed since the last one. // actually running; a deployment bundles whatever was committed since the last one.
// ReleaseVersionConsistencyTest fails the build if this and the top releases.html entry disagree. // ReleaseVersionConsistencyTest fails the build if this and the top releases.html entry disagree.
version = "1.2.0" version = "1.1.2"
java { java {
toolchain { toolchain {
+13 -59
View File
@@ -71,7 +71,7 @@ above, giving the precedence **branch > repo install > project**. It takes prece
everything that describes how this branch is built: the whole `builds` section — its own everything that describes how this branch is built: the whole `builds` section — its own
definitions and its overrides of the definitions from the project config, with definitions and its overrides of the definitions from the project config, with
`buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and `buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
`docker.image`/`dockerfile`/`context`/`env` and `werkdock.env` inside them. That is how a new configuration is tried out: change it on a branch, and `docker.image`/`dockerfile`/`context`/`env` and `bwrap.env` inside them. That is how a new configuration is tried out: change it on a branch, and
no other branch's builds are affected. no other branch's builds are affected.
The branch layer is used in both places where it matters: the watcher reads the committed The branch layer is used in both places where it matters: the watcher reads the committed
@@ -99,11 +99,9 @@ single branch may decide it:
- the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections; - the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`; - the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
- the container sandbox policy: `docker.enabled`/`docker.network` and - the container sandbox policy: `docker.enabled`/`docker.network` and
`werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` — host-pinned as `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock` — host-pinned as
long as only the host's configuration sets them, master-pinned once the committed 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. The distinction is documentary.
Werkator applies one rule: every pinned key is stripped from the branch layer, and the Werkator applies one rule: every pinned key is stripped from the branch layer, and the
@@ -112,7 +110,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, This keeps a branch from reaching credentials, reporting statuses to another repository,
raising the global concurrency, disabling its own build container, changing its network raising the global concurrency, disabling its own build container, changing its network
mode, bypassing its own pull-request gate, or deploying itself. Everything else is the branch's to decide — mode, or bypassing its own pull-request gate. Everything else is the branch's to decide —
it can already run any command through `buildCommand`. it can already run any command through `buildCommand`.
The pinned settings are stripped wherever they appear, in a build definition as well as in 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 a legacy `branches` entry. The deprecated `branches` section itself is read from the repo
@@ -254,7 +252,6 @@ builds:
# branches: ["*", "!master"] # names or globs; a "!" pattern excludes; default: all # branches: ["*", "!master"] # names or globs; a "!" pattern excludes; default: all
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05) # atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
# activeWithin: 24h # only branches with commits in the last 24h # 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 # run before each build
cleanCommand: rm -rf build cleanCommand: rm -rf build
# shell command for each build # shell command for each build
@@ -400,60 +397,18 @@ 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). 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. 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. 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 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. 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.
Werkator logs a warning once when no definition has a trigger at all, because such an instance never builds anything on its own. 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. 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. 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. That is how a branch gets a build of its own without being built by the default one as well.
`activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches. `activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches.
Both parts combine as an intersection. Both parts combine as an intersection.
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `werkdock` with all their keys. Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` 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. 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. `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock` 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. 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. 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. 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.
@@ -509,23 +464,22 @@ Note that the rest of `.git` — including `.git/config` — is visible to build
The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container. The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container.
All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart. All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart.
### Notes on `builds.<name>.werkdock` ### Notes on `builds.<name>.bwrap`
With `werkdock.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution. With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008. This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008.
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`werkdock.binary`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock. Since step 21 session C the sandbox is executed by the `werkdock` CLI (`bwrap.werkdock`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
The section was called `bwrap` and its binary key `bwrap.werkdock` until v1.2.0; both are still read, with a warning naming the file, so an installation can be migrated at its next configuration edit rather than at the next update.
`werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23). `werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23).
`werkdock.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target. `bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`. It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`.
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker. Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker.
The archive is loaded once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store. The archive is loaded once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store.
Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`. Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
`werkdock.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in. `bwrap.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user. Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
`docker` and `werkdock` are mutually exclusive per branch: enabling both is rejected at start, not silently picked. `docker` and `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token. Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token.
## `.git/werkator/.werkator.yml` (not committed) ## `.git/werkator/.werkator.yml` (not committed)
+2 -2
View File
@@ -185,7 +185,7 @@ The tarball unpacks to a `werkator/` directory, so it must not be extracted over
Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start. Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start.
`tools/remote --env-file .env.<instance> werkator instance-update` does the same sequence for any host, not only the webspace layout it was written for. `tools/remote --env-file .env.<instance> werkator instance-update` does the same sequence for any host, not only the webspace layout it was written for.
Three optional keys in the env file name what differs (see the script's header): `WERKATOR_REPO_DIR` (directory of the watched repository, which also names the systemd unit), `WERKATOR_INSTALL_DIR` (where the runtime bundle is unpacked), and `WERKATOR_SANDBOX` (`werkdock`, the default, or `docker` — a Docker host has no werkdock binary and no rootfs archive to upload). Three optional keys in the env file name what differs (see the script's header): `WERKATOR_REPO_DIR` (directory of the watched repository, which also names the systemd unit), `WERKATOR_INSTALL_DIR` (where the runtime bundle is unpacked), and `WERKATOR_SANDBOX` (`bwrap`, the default, or `docker` — a Docker host has no werkdock binary and no rootfs archive to upload).
Their defaults are the layout `instance-install` creates, so an env file that names none of them behaves exactly as before. Their defaults are the layout `instance-install` creates, so an env file that names none of them behaves exactly as before.
The upload happens before the service is stopped and every artifact is checksum-verified after the transfer, so a dropped connection costs the transfer and not the running service. The upload happens before the service is stopped and every artifact is checksum-verified after the transfer, so a dropped connection costs the transfer and not the running service.
@@ -329,7 +329,7 @@ Werkator runs as a systemd *user* service on the assigned localhost port ("eigen
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006). Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches. All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored. Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored.
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.werkdock.*`); the env file only says where and how to reach the host. The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.bwrap.*`); the env file only says where and how to reach the host.
```bash ```bash
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
@@ -1,98 +0,0 @@
> **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
The build sandbox for hosts without Docker was configured as `bwrap`, named after the mechanism rather than after the thing Werkator runs.
Since step 21 session C (v1.0.0) Werkator does not invoke `bwrap` at all: it shells out to the [werkdock](https://git.javagil.de/mi/werkdock) CLI, which assembles the bubblewrap invocation and owns the image store.
The name outlived its truth, and the clearest symptom was the key `bwrap.werkdock` — a section naming its own executor.
It also leaked outward.
`tools/remote` gained a `WERKATOR_SANDBOX` key in PR#18 whose value had to be `bwrap` while the very thing it switches on is *uploading the werkdock binary*, and the question that prompted this PR — "is there also `WERKATOR_SANDBOX=werkdock`?" — is one nobody would ask about a name that matched.
## Non-Goals
- Refusing the old name. That belongs to the release which sets `ConfigVersions.FORMAT_BROKE_IN` (plan step 18): only there can a file that declares no version be caught by name at all, and only there is one migration asked of the operator instead of two.
- Renaming `tools/build-bwrap-rootfs.sh` or `docs/plan/17-bwrap-build-runtime.md`. The script really does build a bubblewrap rootfs, and plan documents are historic records.
- Touching ADR 0008, which decided the *runtime* and is a snapshot of that decision.
## The Scenarios
### Feature: the sandbox is named after the tool that runs it
#### Background
- `builds.<name>.werkdock` replaces `builds.<name>.bwrap`, and `werkdock.binary` replaces `bwrap.werkdock`.
- The pinned set is unchanged in meaning: `enabled`, `rootfs` and the binary stay host-pinned, under their new names.
#### Scenario#19.01: A configuration written for the old name keeps working
So that no installation has to be edited before it can be updated — the section lives in machine configurations that no repository tracks.
- **Given** a configuration writing `builds.default.bwrap` with `enabled`, `rootfs`, `werkdock` and `env`
- **When** the configuration is loaded
- **Then** the settings appear as `werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary` and `werkdock.env`, and the file is named once in a warning
##### Verified by
- [ConfigLoaderTest — "the legacy bwrap section is read as werkdock, its werkdock key as binary"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#19.02: The old name is not a way around the pinning
So that a branch cannot escape its sandbox by writing the section a branch is not allowed to write under its previous name.
- **Given** a host configuration with `werkdock.enabled: true` and a rootfs
- **When** a branch's committed config sets `bwrap.enabled: false` with a foreign rootfs
- **Then** the sandbox stays enabled and the host's rootfs is used
##### Verified by
- [ConfigLoaderTest — "a legacy bwrap section on a branch is pinned exactly like the new name"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#19.03: The new name behaves exactly as the old one did
So that the rename is a rename, not a change of behavior.
- **Given** configurations using `werkdock` throughout
- **When** builds are dispatched, pinned keys stripped, and both sandboxes enabled at once
- **Then** the werkdock runner is selected, the branch cannot override the pinned keys, and enabling docker and werkdock together is rejected naming both
##### Verified by
- [DispatchingBuildRunnerTest — "runs in the werkdock sandbox when the branch enables it (and not Docker)"](../../src/test/kotlin/de/hoennig/werkator/build/DispatchingBuildRunnerTest.kt)
- [ConfigLoaderTest — "a branch cannot disable its werkdock sandbox …"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) and "enabling both docker and werkdock on a build is rejected, not picked silently"
- [WerkdockBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt), unchanged in substance and renamed with the runner
## The Solution
`BwrapConfig``WerkdockConfig` (field `werkdock``binary`), `BwrapOverrides``WerkdockOverrides`, `BranchConfig.bwrap``.werkdock`, `BwrapBuildRunner``WerkdockBuildRunner`, and `PINNED_BWRAP_KEYS``PINNED_WERKDOCK_KEYS` with `binary` in place of `werkdock`.
The compatibility lives in exactly one function, `ConfigLoader.renameLegacySandbox`, applied in `loadFile` and `parseYaml` — the two places a raw layer enters — so it runs before merging, before pinning and before binding, and every consumer downstream knows one name.
That placement is what makes Scenario#19.02 hold without a second thought: the branch layer is normalised *before* `stripPinned` reads it, so the old name cannot smuggle a pinned key past a check that looks for the new one.
Where a file writes both sections, the explicit `werkdock` one wins, because it is the name that is meant.
The warning is emitted once per file (`warnedSections`), like the other section-level warnings, since the config is re-read on every poll cycle.
The version is bumped to 1.2.0 with a release note: the minor, because this changes the configuration schema, and a deployment must be identifiable as the one that introduced it.
## Open Questions
- **Should `WERKATOR_SANDBOX` keep accepting `bwrap`?** It does today, normalised on read and documented as the former name. The env files are local and gitignored, so this alias costs one line and can go whenever the config alias does.
## Additional Changes
- None beyond the rename and its documentation.
## Deployment note
The order matters, and only in one direction: deploy v1.2.0 to a host *before* rewriting its init fragment (`.env.<instance>.yml`) to the new key names.
A fragment carrying `werkdock:` applied by an older Werkator fails the fragment's strict schema validation — which is the safe outcome, but a failed `repo-init` nonetheless.
The reverse never breaks: v1.2.0 reads every existing `bwrap:` fragment and machine config as before.
## Prerequisite PRs
- [PR#18](2026-09-03-PR%2318-remote-host-layout.md) introduced `WERKATOR_SANDBOX`, whose value this PR renames.
## Follow-up PRs
- Plan step 18 (removing the legacy `branches` section) sets `ConfigVersions.FORMAT_BROKE_IN`; the `bwrap` alias should be dropped in the same release, refusing the key by name.
@@ -1,42 +0,0 @@
> **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
The build for commit `7028ca8` on `main` failed on the mih09 production instance with
`BuildExecutorTest > with maxConcurrent 1 a second branch stays PENDING until the first finished`,
while a retry of the very same commit passed, and the test passes locally.
The test was timing-dependent.
It started a build for `branch-a` whose build command was `sleep 1`, immediately started a second build for `branch-b`,
and then asserted — without any synchronization at all — that `branch-b` was still `PENDING`.
That assertion only held as long as the test thread reached it within the one second `branch-a` slept.
On a shared host under CPU contention the executor can get through `branch-a` entirely (queued, running, slept, succeeded) first,
and `branch-b` is then already `RUNNING` or `SUCCESS` when the assertion runs.
The failure therefore says nothing about the executor; it is pure scheduling noise that costs a build and a retry every time it hits.
## Non-Goals
- No change to production code — `BuildExecutor` is not touched, its queueing behaviour is unchanged.
- No sweep of the other timing-sensitive tests in the suite; only the one that actually flaked is fixed.
## The Solution
`branch-a` no longer sleeps for a fixed time, it blocks until the test says so:
its build command is `until [ -f gate ]; do sleep 0.05; done`, and the build workspace is the test's working directory.
The test now
1. waits (via `eventually`) until `branch-a` is `RUNNING`, so the single executor slot is provably occupied,
2. asserts that `branch-b` is `PENDING` — which cannot race anything, because `branch-a` cannot finish before the gate file exists,
3. creates the gate file, and only then awaits both builds' `SUCCESS`.
The assertion on the event transitions (`branch-b` goes `RUNNING` only after `branch-a` reached `SUCCESS`) is unchanged.
A blocking gate was chosen over a mocked `BuildRunner` because it keeps the test on the real `ProcessBuildRunner`,
so it still covers the actual process handling rather than only the executor's bookkeeping.
Verified by running `BuildExecutorTest` five times on an idle machine and three more times with twice `nproc` busy-loops saturating the CPU,
which is the condition that produced the original failure.
- [BuildExecutorTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt)
@@ -1,30 +0,0 @@
> **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
The repository's home is `https://git.javagil.de/mi/werkator.git` since the move to the own Gitea instance,
but `tools/remote` still defaulted `WERKATOR_REPO_URL` to the GitHub mirror.
The mih09 production instance was cloned from that mirror and consequently watched a `main` that nobody pushes to any more:
it kept reporting the last GitHub state as green while three merged pull requests sat unbuilt on the real `main`.
The failure that started this — a flaky test already fixed on Gitea's `main` — could not be re-verified live,
because the instance had no way to see the fix.
## Non-Goals
- The existing clone on mih09 is not touched by this change; its remote was repointed by hand (`git remote set-url`), and `repo-init` skips an existing clone.
- The generic placeholder URLs in `docs/deployment.md` stay as they are — they describe cloning *any* watched repository, not werkator's own.
- No decision about mirroring to GitHub; the mirror simply stops being the source an instance builds from.
## The Solution
`REPO_URL` in [tools/remote](../../tools/remote) defaults to the Gitea URL, and the usage comment says so.
The value stays overridable via `WERKATOR_REPO_URL` in the instance's env file,
so an installation that deliberately watches a different remote is unaffected.
Anonymous HTTPS works against Gitea exactly as it did against GitHub, so no deploy key or token is involved.
Note that pull requests opened via AGit-Flow create no branch in Gitea,
so an instance watching this remote sees `main` only — branch builds require pushing real branches.
@@ -1,199 +0,0 @@
> **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.
-151
View File
@@ -1,151 +0,0 @@
# RFC 0001: Web UI Redesign — the Instrument Panel
**Status:**
- proposed: 2026-09-03
- accepted: -
- rejected: -
**Proposal:** The Werkator web UI adopts the **Instrument Panel** direction: a teal palette in a light and a dark mode, IBM Plex typography, a repository strip that previews the state of every served repository, a title hierarchy that names the view first and explains it second, and a tab bar at the foot that becomes the mobile navigation.
The architecture does not change: server-rendered Thymeleaf, one `werkator.css`, one hand-written `werkator.js`, JSON polling, no framework, no frontend build pipeline.
## Context and Problem Statement
The current UI is a functional port of the legacy generated pages: a table per view, pill badges, system font, blue links.
It is correct and calm, but it looks like every other CI page and gives no hint of the other repositories an instance serves (ADR 0009).
The brief for this RFC was "fancy, but serious and trustworthy", with two references from the same author for visual kinship:
- [werkbaum.javagil.de](https://werkbaum.javagil.de/) — light paper with a fine grid, IBM Plex, a petrol accent, panel labels in small caps.
- [javagil.de/vibe-engineering](https://javagil.de/vibe-engineering) — a dark instrument panel: ink and petrol, clay for warnings, monospaced spaced labels, a tab bar at the foot.
A hard constraint of this RFC is honesty towards the data.
The mockups show only what the API delivers today; nothing is invented to make a screen look richer.
### What the UI Has to Work With
Per build row (`BuildRowView`, `BuildResultDto`): status, branch name, commit (12-character abbreviation, full id for copying), started at (`yyyy-MM-dd HH:mm`), duration (`m:ss`; a pending build shows its wait time in italics), artifact key with the artifact, permalink and live-log links, and the actions restart and delete (history has no restart).
Statuses: `pending`, `running`, `success`, `failed`, `interrupted`, `cancelled`, plus `unknown` for a never-built branch and the client-side `finished` on a card whose build has left the current list.
Views: Latest (one build per name), Branches (every origin branch and its latest build), History (all stored builds), Current (running builds with their live log), System (seven metric rows with current/min/max/avg, warn from 80 %, critical from 90 %), the artifact page, and the release notes.
Live state: the indicator is `static`, `live` or `error`; the watcher banner reports `watcher stopped`, `origin unreachable` or `poll cycle failed`.
Multi-repo: the repository switcher is a server-rendered `<select>` of names; `WatcherState.repositories` already carries a per-repository watcher state that the UI does not show.
What does **not** exist, and therefore appears in no mockup: a commit subject line, a typical or expected duration, an ETA, a per-branch build history, test counts on a row, and any cross-repository status summary in the API.
## Considered Options
Six directions were sketched on a shared design canvas, two rounds of three, all with the same sample rows.
| Option | Idea | Why | Tradeoff |
|---|---|---|---|
| A · Quiet Console | Today's design refined: top bar, dot-plus-word statuses, hover actions | Smallest step, everything stays valid | Least distinctive |
| B · Mission Board | Health tiles, one card per branch with a history strip, running build with progress | Answers "is everything fine?" at a glance | Needs data the API does not have (history, typical duration) |
| C · Ledger | Warm paper, serif masthead, hairline rules, typographic status marks | The most "serious"; reads like a signed record | Leaves the system font, needs its own dark theme |
| D · Paper Rail | Werkbaum's paper and grid, a repository rail on the left with per-branch dots | Family resemblance to Werkbaum; other repositories visible | 250 px of table width lost; empty with one repository |
| **E · Instrument Panel** | Vibe-Engineering's dark panel, repositories as tiles, tab bar at the foot | Reads like a control room; failures in clay stay serious without alarm | Dark-only as drawn; needs a light palette |
| F · Fleet Overview | A new landing page with one panel per repository, ledger typography on paper | One page answers the question for the whole instance | Becomes a list beyond five repositories |
Round one (AC) still contained invented data; it is kept on the canvas for the visual ideas only.
**E was chosen**, and round three worked out what it lacked: the light mode, the ten-repository case, the title hierarchy, and the phone layout.
## The Design
### Palette
Both modes are CSS custom properties on `:root`, switched by `prefers-color-scheme` as today (`color-scheme: light dark`).
Failures use clay, not red, so they stay serious without shouting; the accent is teal in both modes.
| Token | Dark | Light | Used for |
|---|---|---|---|
| bg | `#061C1F` | `#EAF4F2` | page ground |
| panel | `#0A2A2E` | `#FFFFFF` | tables, cards, chips |
| panel-2 | `#0F3A3D` | `#D6ECE8` | the current repository, the active tab |
| line | `#17474B` | `#C9DFDB` | borders and rules |
| text | `#E4EEEC` | `#0B2B2E` | body text |
| text-2 | `#B4CBC8` | `#35595B` | timestamps |
| muted | `#7DA19E` | `#5E8583` | labels, footers |
| accent | `#5FD3C7` | `#0E8079` | links, success, running, the live indicator |
| accent-2 | `#1E9A93` | `#149A90` | underlines, the current repository's border |
| clay | `#E09070` | `#B0563B` | failed, error, delete, watcher warnings |
| clay-2 | `#C4664A` | `#C4664A` | the border of a failing repository chip |
| ghost | `#4A7370` | `#BFD4D1` | cancelled, interrupted, unknown |
Tinted rows: a running row gets 16 % (dark) or 10 % (light) of accent-2 as background, a failed row 12 % or 10 % of clay-2.
The reference's background grid was tried and dropped: it competes with the table, especially in light mode.
### Typography
IBM Plex Sans for text, IBM Plex Mono for commits, timestamps, durations and every label.
Labels are 10 px Mono, uppercase, letter-spaced 0.12 em, in `muted`; statuses are 11 px Mono uppercase in their status color, each preceded by an 8 px dot (outlined for pending, pulsing for running).
Fallback stacks: `"IBM Plex Sans", "Segoe UI", system-ui, sans-serif` and `"IBM Plex Mono", ui-monospace, Consolas, monospace`.
Whether Plex is bundled under `static/` or the fallback stack is accepted is an open question below.
### Anatomy of a Page (desktop)
1. **Header**, 52 px: logo, `Werkator` with the Gitea repository name in accent, a small label `updated HH:mm:ss`; right: the live indicator as an outlined chip with a pulsing dot, the reload button.
2. **Repository strip**: see below.
3. **Panel** with the view's title: the view name at 22 px semibold with a 2 px accent-2 underline, followed by a one-line label that explains it (`Latest``one build per branch, newest first`; `Branches``every origin branch and its latest build`; `History``all stored builds, newest first`; `System``instance metrics since first start`); on the right a Mono line with the row count, the last poll and the watcher state.
4. **Table**, columns as today (Status, Branch, Commit, Started, Duration, Artifacts, Actions), rows 9 px padding on a 1 px `line` rule; copy buttons as outlined 13 px icons; artifact links and actions as stroke icons (no emoji).
5. **Footer**: version and copyright left, the navigation as a Mono tab bar in the middle (Latest, Branches, History, System with icons; the active tab in panel-2 with an accent underline), Impressum and Privacy right.
### The Repository Strip
The `<select>` switcher is replaced by a strip below the header that shows every served repository with its state, so a failure elsewhere is visible without leaving the page.
- Up to about three repositories: **tiles** (220 px), each with `current` or `repo` label, the name, one dot per branch in the branch's latest status, a summary line (`6 builds · 1 failed · 1 running`), and the watcher warning in clay when that repository's watcher reports an error.
- More repositories: **chips** (30 px), each with one dot for the worst status in the repository, the name, an optional short finding (`1 failed`, `main`, `never built`), and a warning triangle when the watcher reports an error; the current repository has an accent-2 border on panel-2, a failing one a clay-2 border on the clay tint.
- Order is *failing first*: the current repository, then failing, running, then green; a summary line above (`10 served · 2 failing · 1 unreachable · 2 running`) and a sort control on the right.
- The strip **scrolls**: horizontally on the phone, and on the desktop it wraps to a second row up to about ten repositories and becomes a horizontally scrollable band beyond that, with the failing chips pinned at the front so they never scroll out of view.
- Beyond roughly twenty repositories the strip shows only the conspicuous chips (failing, running, unreachable) plus a search field for the rest.
- With a single served repository the strip is omitted, as the switcher is today.
### Phone (below 680 px)
The existing breakpoint behaviour is kept and restyled: rows become cards with the `data-label` captions, the live indicator collapses to a dot.
The header stacks `Werkator` over the repository name; the repository strip scrolls horizontally under its summary line; the panel title keeps its hierarchy; each card carries the status line with the branch, then commit, started and duration, then the artifact icons and the actions as 44 px targets.
The footer tab bar becomes a fixed bottom tab bar with icons — the same four entries as on the desktop.
No painted status bar or keyboard; the device provides those.
### What Is Deliberately Not in the Proposal
- The `DE` language button in the mockups is a leftover of the reference; the UI stays English-only.
- No commit subjects, typical durations, ETAs or history strips: they need data the server does not have, and each would be its own RFC with its own storage.
- No manual theme toggle; `prefers-color-scheme` decides, as today.
## Consequences
### Backend
- One new endpoint, `GET /api/repos`: for every served repository its name, its UI root (`/repos/<name>`), whether it is the current one, the latest status per build name (the Latest view's `latestPerName` reduced to counts, plus the worst status), and its `RepoWatcherState` (`lastFetchError`, `lastPollError`, `lastPollAt`).
With one served repository the endpoint returns a list of one and the strip stays hidden.
- `werkator.js` polls it on the table interval (10 s) and renders the strip; every fetch keeps the timeout and the explicit error badge.
- `UiFormats` and `werkator.js` keep producing identical formats; the palette and the title labels are template and CSS only.
### Rollout, One Concern per Pull Request
1. Palette, typography and the title hierarchy in `werkator.css` and the fragments — no data change, both modes.
2. Header and footer tab bar, including the phone tab bar.
3. `GET /api/repos` and the repository strip, replacing the `<select>`.
4. Card refinements on the phone and the System and artifact pages in the new vocabulary.
Each step leaves the UI usable, and the tests in `server` that assert on markup are adjusted with the step that changes it.
## Open Questions
- **Fonts:** bundle IBM Plex Sans and Mono under `static/fonts/` (about 100150 KB in WOFF2 for the four faces), or accept the fallback stack on hosts without the font; the reference sites load Plex from a CDN, which the deployment behind a strict reverse proxy may not want.
- **Current view:** it is reachable today only from a running row's live icon; the tab bar has room for it as a fifth entry with a count badge, or it stays a link from the row.
- **Instance pages:** `/system` and `/releases` are instance-level; in the tab bar they sit next to the per-repository views, which the strip makes visible enough, or they move to the footer's right side.
## Design Sources
The design canvas with all eleven artboards (rounds one to three, desktop and phone) is a private Claude artifact of the author; its renderings live next to this RFC under `0001-web-ui-instrument-panel/`.
The sample rows are real field shapes with invented values; the repositories other than `werkator` are invented.
The proposal:
- [E · dark, desktop](0001-web-ui-instrument-panel/e-dark-desktop.png) · [E · light, desktop](0001-web-ui-instrument-panel/e-light-desktop.png)
- [E · dark, ten repositories](0001-web-ui-instrument-panel/e-dark-10-repos.png) · [E · light, ten repositories](0001-web-ui-instrument-panel/e-light-10-repos.png)
- [E · dark, phone](0001-web-ui-instrument-panel/e-dark-phone.png) · [E · light, phone](0001-web-ui-instrument-panel/e-light-phone.png)
The alternatives, for the record:
- [A · Quiet Console](0001-web-ui-instrument-panel/a-quiet-console.png), [B · Mission Board](0001-web-ui-instrument-panel/b-mission-board.png), [C · Ledger](0001-web-ui-instrument-panel/c-ledger.png) — round one, still with invented data.
- [D · Paper Rail](0001-web-ui-instrument-panel/d-paper-rail.png), [F · Fleet Overview](0001-web-ui-instrument-panel/f-fleet-overview.png) — round two.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

@@ -119,7 +119,7 @@ class BuildExecutor(
artifactKey = runningBuild.artifactKey, artifactKey = runningBuild.artifactKey,
) )
repo.results.append(pending) repo.results.append(pending)
eventPublisher.publishEvent(BuildStatusChangedEvent(pending, repo)) eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
val activeBuild = ActiveBuild(runningBuild, repo) val activeBuild = ActiveBuild(runningBuild, repo)
builds[runningBuild.artifactKey] = activeBuild builds[runningBuild.artifactKey] = activeBuild
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null) publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
@@ -384,7 +384,7 @@ class BuildExecutor(
duration = duration, duration = duration,
artifactKey = runningBuild.artifactKey, artifactKey = runningBuild.artifactKey,
).also { build.repo.results.append(it) } ).also { build.repo.results.append(it) }
eventPublisher.publishEvent(BuildStatusChangedEvent(updated, build.repo)) eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
publishGiteaStatus(build, status, duration) publishGiteaStatus(build, status, duration)
return updated return updated
} }
@@ -43,17 +43,17 @@ class ProcessBuildRunner : BuildRunner {
} }
/** /**
* Selects the runtime per branch: Docker when `builds.<name>.docker.enabled`, the * Selects the runtime per branch: Docker when `branches.<name>.docker.enabled`,
* werkdock sandbox when `builds.<name>.werkdock.enabled`, native shell execution * bubblewrap when `branches.<name>.bwrap.enabled`, native shell execution otherwise
* otherwise (the unchanged default). Docker and werkdock are mutually exclusive per * (the unchanged default). Docker and bwrap are mutually exclusive per branch and are
* branch and are rejected together at config load, so the order here never has to "pick". * rejected together at config load, so the branch order here never has to "pick".
*/ */
@Primary @Primary
@Component @Component
class DispatchingBuildRunner( class DispatchingBuildRunner(
private val processBuildRunner: ProcessBuildRunner, private val processBuildRunner: ProcessBuildRunner,
private val dockerBuildRunner: DockerBuildRunner, private val dockerBuildRunner: DockerBuildRunner,
private val werkdockBuildRunner: WerkdockBuildRunner, private val bwrapBuildRunner: BwrapBuildRunner,
) : BuildRunner { ) : BuildRunner {
override fun start( override fun start(
command: String, command: String,
@@ -66,7 +66,7 @@ class DispatchingBuildRunner(
val runner = val runner =
when { when {
branchConfig.docker.enabled -> dockerBuildRunner branchConfig.docker.enabled -> dockerBuildRunner
branchConfig.werkdock.enabled -> werkdockBuildRunner branchConfig.bwrap.enabled -> bwrapBuildRunner
else -> processBuildRunner else -> processBuildRunner
} }
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess) return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
@@ -1,7 +1,7 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.WerkdockConfig import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.git.GitCommandRunner import de.hoennig.werkator.git.GitCommandRunner
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -13,7 +13,7 @@ import java.security.MessageDigest
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008), * Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed * for hosts without root and without a Docker daemon (e.g. Hostsharing managed
* webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv: * webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv:
* it shells out to the `werkdock` CLI (`werkdock.binary`, default via PATH) the same * it shells out to the `werkdock` CLI (`bwrap.werkdock`, default via PATH) the same
* pattern as git and docker, CLI, no library. * pattern as git and docker, CLI, no library.
* *
* The rootfs archive becomes a werkdock *image*, loaded once per source * The rootfs archive becomes a werkdock *image*, loaded once per source
@@ -36,10 +36,10 @@ import java.security.MessageDigest
* native builds. * native builds.
*/ */
@Component @Component
class WerkdockBuildRunner( class BwrapBuildRunner(
private val commandRunner: GitCommandRunner, private val commandRunner: GitCommandRunner,
) : BuildRunner { ) : BuildRunner {
private val log = LoggerFactory.getLogger(WerkdockBuildRunner::class.java) private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */ /** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
internal var processStarter: (List<String>, Path) -> Process = { command, dir -> internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
@@ -54,14 +54,14 @@ class WerkdockBuildRunner(
branchConfig: BranchConfig, branchConfig: BranchConfig,
onAuxProcess: (Process) -> Unit, onAuxProcess: (Process) -> Unit,
): Process { ): Process {
val sandbox = branchConfig.werkdock val bwrap = branchConfig.bwrap
require(sandbox.rootfs.isNotBlank()) { "builds.<name>.werkdock.rootfs must be set when werkdock.enabled is true" } require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
val werkdock = sandbox.binary.ifBlank { "werkdock" } val werkdock = bwrap.werkdock.ifBlank { "werkdock" }
val image = imageName(sandbox.rootfs) val image = imageName(bwrap.rootfs)
ensureImage(werkdock, image, sandbox, repoDir, onAuxProcess) ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess)
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR) val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
Files.createDirectories(homeDir) Files.createDirectories(homeDir)
val args = invocation(command, workingDir, environment, repoDir, sandbox, werkdock, image, homeDir) val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir)
return processStarter(args, repoDir) return processStarter(args, repoDir)
} }
@@ -73,7 +73,7 @@ class WerkdockBuildRunner(
private fun ensureImage( private fun ensureImage(
werkdock: String, werkdock: String,
image: String, image: String,
sandbox: WerkdockConfig, bwrap: BwrapConfig,
repoDir: Path, repoDir: Path,
onAuxProcess: (Process) -> Unit, onAuxProcess: (Process) -> Unit,
) { ) {
@@ -81,10 +81,10 @@ class WerkdockBuildRunner(
if (image in loaded) { if (image in loaded) {
return return
} }
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(sandbox.rootfs)) val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs))
Files.createDirectories(envDir) Files.createDirectories(envDir)
val archive = localArchive(sandbox.rootfs, envDir, repoDir, onAuxProcess) val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess)
log.info("loading build environment {} as werkdock image {}", sandbox.rootfs, image) log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image)
commandRunner.runOrThrow( commandRunner.runOrThrow(
listOf(werkdock, "load", "-i", archive, "--name", image), listOf(werkdock, "load", "-i", archive, "--name", image),
repoDir, repoDir,
@@ -93,7 +93,7 @@ class WerkdockBuildRunner(
} }
/** /**
* Resolves [WerkdockConfig.rootfs] to a local archive path: a bare or `file:` path is * Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache. * used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
*/ */
private fun localArchive( private fun localArchive(
@@ -123,7 +123,7 @@ class WerkdockBuildRunner(
workspace: Path, workspace: Path,
environment: Map<String, String>, environment: Map<String, String>,
repoDir: Path, repoDir: Path,
sandbox: WerkdockConfig, bwrap: BwrapConfig,
werkdock: String, werkdock: String,
image: String, image: String,
homeDir: Path, homeDir: Path,
@@ -148,7 +148,7 @@ class WerkdockBuildRunner(
for ((key, value) in environment) { for ((key, value) in environment) {
args += listOf("-e", "$key=$value") args += listOf("-e", "$key=$value")
} }
for ((key, value) in sandbox.env) { for ((key, value) in bwrap.env) {
args += listOf("-e", "$key=$value") args += listOf("-e", "$key=$value")
} }
args += listOf("-w", "$workspaceAbs") args += listOf("-w", "$workspaceAbs")
@@ -35,12 +35,7 @@ data class RunningBuild(
var runningSince: Instant? = null 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( data class BuildStatusChangedEvent(
val result: BuildResult, val result: BuildResult,
val repo: RepoContext,
) )
@@ -1,11 +1,9 @@
package de.hoennig.werkator.commands package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry import de.hoennig.werkator.repo.RepoRegistry
import de.hoennig.werkator.watcher.FollowUpTrigger
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
@@ -29,7 +27,6 @@ class BuildCommand(
private val gitService: GitService, private val gitService: GitService,
private val consoleBuildRunner: ConsoleBuildRunner, private val consoleBuildRunner: ConsoleBuildRunner,
private val registry: RepoRegistry, private val registry: RepoRegistry,
private val followUpTrigger: FollowUpTrigger,
) : Callable<Int> { ) : Callable<Int> {
@Mixin @Mixin
var repoOption = RepoOption() var repoOption = RepoOption()
@@ -61,33 +58,9 @@ class BuildCommand(
} }
println("building branch $branch at commit ${commit.take(12)}") println("building branch $branch at commit ${commit.take(12)}")
val status = consoleBuildRunner.buildAndStream(repo, branch, commit) val status = consoleBuildRunner.buildAndStream(repo, branch, commit)
if (status == BuildStatus.SUCCESS) {
reportSkippedFollowUps(branch, commit)
}
return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE 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. */ /** A one-shot build should still work offline, from the last fetched origin state. */
private fun fetchBestEffort() { private fun fetchBestEffort() {
try { try {
@@ -222,7 +222,6 @@ class InitCommand(
# branches: ["*", "!master"] # names or globs; "!" excludes; default: all # branches: ["*", "!master"] # names or globs; "!" excludes; default: all
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05) # atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
# activeWithin: 24h # only branches with recent commits # 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 # run before each build
cleanCommand: rm -rf build cleanCommand: rm -rf build
# shell command for each build # shell command for each build
@@ -243,13 +242,12 @@ class InitCommand(
context: "." # Docker build context used with dockerfile context: "." # Docker build context used with dockerfile
network: "" # Docker network mode for the build container; empty = Docker default (pinned) network: "" # Docker network mode for the build container; empty = Docker default (pinned)
env: {} # additional environment variables set inside the build container env: {} # additional environment variables set inside the build container
# werkdock sandbox (bubblewrap user namespace) — for hosts without root and # bubblewrap user-namespace sandbox — for hosts without root and without a
# without a Docker daemon (e.g. Hostsharing managed webspaces). Mutually # Docker daemon (e.g. Hostsharing managed webspaces). Mutually exclusive with docker.
# exclusive with docker. Called bwrap before v1.2.0, still read under that name. bwrap:
werkdock: enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
enabled: false # run clean/build in the sandbox instead of natively (pinned)
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned) rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
binary: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned) werkdock: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned)
env: {} # additional environment variables set inside the sandbox env: {} # additional environment variables set inside the sandbox
# Gitea check this build reports as; empty uses gitea.statusContext. # Gitea check this build reports as; empty uses gitea.statusContext.
# Two builds of one commit under the same context overwrite each other. # Two builds of one commit under the same context overwrite each other.
@@ -42,8 +42,8 @@ data class BuildDefinition(
val statusContext: String? = null, val statusContext: String? = null,
/** Overrides of the docker settings; null inherits them. */ /** Overrides of the docker settings; null inherits them. */
val docker: DockerOverrides? = null, val docker: DockerOverrides? = null,
/** Overrides of the werkdock settings; null inherits them. */ /** Overrides of the bwrap settings; null inherits them. */
val werkdock: WerkdockOverrides? = null, val bwrap: BwrapOverrides? = null,
) { ) {
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */ /** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
fun applyTo(branchConfig: BranchConfig): BranchConfig = fun applyTo(branchConfig: BranchConfig): BranchConfig =
@@ -64,12 +64,12 @@ data class BuildDefinition(
network = docker?.network ?: branchConfig.docker.network, network = docker?.network ?: branchConfig.docker.network,
env = docker?.env ?: branchConfig.docker.env, env = docker?.env ?: branchConfig.docker.env,
), ),
werkdock = bwrap =
branchConfig.werkdock.copy( branchConfig.bwrap.copy(
enabled = werkdock?.enabled ?: branchConfig.werkdock.enabled, enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
rootfs = werkdock?.rootfs ?: branchConfig.werkdock.rootfs, rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
binary = werkdock?.binary ?: branchConfig.werkdock.binary, werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock,
env = werkdock?.env ?: branchConfig.werkdock.env, env = bwrap?.env ?: branchConfig.bwrap.env,
), ),
) )
@@ -89,9 +89,8 @@ data class BuildDefinition(
* When a build runs and for which branches — the `trigger` block of a build definition, * 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`. * and the one part of it that is never inherited from `builds.default`.
* *
* A definition with neither [onPush] nor [atTimes] nor [afterSuccessOf] never triggers * A definition with neither [onPush] nor [atTimes] never triggers automatically; that is
* automatically; that is how `builds.default` is written when it is meant as a settings * how `builds.default` is written when it is meant as a settings base only.
* base only.
*/ */
data class TriggerConfig( data class TriggerConfig(
/** Build every new commit of the selected branches. */ /** Build every new commit of the selected branches. */
@@ -113,19 +112,7 @@ data class TriggerConfig(
* empty applies no age filter. Combines with [branches] as an intersection. * empty applies no age filter. Combines with [branches] as an intersection.
*/ */
val activeWithin: String = "", 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. */ /** True when [branch] matches the [branches] patterns (or none are configured) and none excludes it. */
fun selectsByName(branch: String): Boolean { fun selectsByName(branch: String): Boolean {
val (excluding, including) = branches.partition { it.startsWith(EXCLUDE_PREFIX) } val (excluding, including) = branches.partition { it.startsWith(EXCLUDE_PREFIX) }
@@ -182,13 +169,13 @@ data class DockerOverrides(
val env: Map<String, String>? = null, val env: Map<String, String>? = null,
) )
/** Nullable werkdock overrides of a [BuildDefinition]; null values inherit the branch's setting. */ /** Nullable bubblewrap overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class WerkdockOverrides( data class BwrapOverrides(
/** Run the build in the sandbox instead of natively. Pinned — a branch must not escape its sandbox. */ /** Run the build in the bwrap sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
val enabled: Boolean? = null, val enabled: Boolean? = null,
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */ /** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
val rootfs: String? = null, val rootfs: String? = null,
/** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */ /** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */
val binary: String? = null, val werkdock: String? = null,
val env: Map<String, String>? = null, val env: Map<String, String>? = null,
) )
@@ -90,8 +90,7 @@ class ConfigLoader(
fun loadForWorktree( fun loadForWorktree(
workingDir: Path, workingDir: Path,
worktreeDir: Path, worktreeDir: Path,
branch: String? = null, ): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile()))
): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile()), branch)
/** /**
* The primary/`.git` config with the committed `.werkator.yml` of one branch * The primary/`.git` config with the committed `.werkator.yml` of one branch
@@ -108,53 +107,30 @@ class ConfigLoader(
* (`requirePullRequest`, which decides whether the branch is built at all). * (`requirePullRequest`, which decides whether the branch is built at all).
* They are stripped from the branch layer before merging, so a branch can neither * 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 * escape its container, nor bypass its own pull-request gate, nor raise the global
* concurrency, nor reach the credentials. The trigger of a follow-up build is pinned * concurrency, nor reach the credentials.
* 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( fun loadWithBranchLayer(
workingDir: Path, workingDir: Path,
branchConfigYaml: String?, branchConfigYaml: String?,
branch: String? = null, ): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml))
): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml), branch)
private fun withBranchLayer( private fun withBranchLayer(
workingDir: Path, workingDir: Path,
branchLayer: Map<String, Any?>, branchLayer: Map<String, Any?>,
branch: String?,
): WerkatorConfig { ): WerkatorConfig {
// scoped to this branch: an incompatible branch config fails its own builds and // 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 // must never stop the server or hold up the branches that are fine
checkVersion(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT) checkVersion(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT)
checkTriggerBlocks(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT) checkTriggerBlocks(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT)
val primary = loadRaw(workingDir) return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer)))
return toConfig(deepMerge(primary, stripPinned(branchLayer, primary, branch)), MissingPredecessor.WARN)
} }
/** private fun toConfig(raw: Map<String, Any?>): 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<String, Any?>,
missingPredecessor: MissingPredecessor = MissingPredecessor.REFUSE,
): WerkatorConfig {
val config = val config =
if (raw.isEmpty()) { if (raw.isEmpty()) {
WerkatorConfig() WerkatorConfig()
} else { } else {
yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw), missingPredecessor), WerkatorConfig::class.java) yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java)
} }
return defaultPublicBaseUrl(config) return defaultPublicBaseUrl(config)
} }
@@ -202,11 +178,7 @@ class ConfigLoader(
* second as soon as the committed configuration carries them. * second as soon as the committed configuration carries them.
*/ */
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun stripPinned( private fun stripPinned(branchLayer: Map<String, Any?>): Map<String, Any?> {
branchLayer: Map<String, Any?>,
primary: Map<String, Any?>,
branch: String?,
): Map<String, Any?> {
if (branchLayer.isEmpty()) { if (branchLayer.isEmpty()) {
return branchLayer return branchLayer
} }
@@ -216,55 +188,9 @@ class ConfigLoader(
val entries = result[section] as? Map<String, Any?> ?: continue val entries = result[section] as? Map<String, Any?> ?: continue
result[section] = entries.mapValues { (_, value) -> stripPinnedSettings(value) } result[section] = entries.mapValues { (_, value) -> stripPinnedSettings(value) }
} }
(result["builds"] as? Map<String, Any?>)?.let { builds ->
val hostBuilds = primary["builds"] as? Map<String, Any?> ?: emptyMap()
result["builds"] = builds.mapValues { (name, value) -> stripPinnedTrigger(name, value, hostBuilds[name], branch) }
}
return result 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<String, Any?> ?: return value
val trigger = definition["trigger"] as? Map<String, Any?> ?: 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") @Suppress("UNCHECKED_CAST")
private fun stripPinnedSettings(value: Any?): Any? { private fun stripPinnedSettings(value: Any?): Any? {
val entry = value as? Map<String, Any?> ?: return value val entry = value as? Map<String, Any?> ?: return value
@@ -275,10 +201,10 @@ class ConfigLoader(
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } } val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
} }
val werkdock = entry["werkdock"] as? Map<String, Any?> val bwrap = entry["bwrap"] as? Map<String, Any?>
if (werkdock != null) { if (bwrap != null) {
val strippedWerkdock = werkdock.toMutableMap().apply { PINNED_WERKDOCK_KEYS.forEach { remove(it) } } val strippedBwrap = bwrap.toMutableMap().apply { PINNED_BWRAP_KEYS.forEach { remove(it) } }
if (strippedWerkdock.isEmpty()) result.remove("werkdock") else result["werkdock"] = strippedWerkdock if (strippedBwrap.isEmpty()) result.remove("bwrap") else result["bwrap"] = strippedBwrap
} }
return result return result
} }
@@ -299,16 +225,12 @@ class ConfigLoader(
* an empty docker policy and run natively on the host, which is exactly the escape * an empty docker policy and run natively on the host, which is exactly the escape
* the pinned keys exist to prevent. * the pinned keys exist to prevent.
*/ */
private fun resolveBuildSections( private fun resolveBuildSections(raw: Map<String, Any?>): Map<String, Any?> {
raw: Map<String, Any?>,
missingPredecessor: MissingPredecessor,
): Map<String, Any?> {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val definitions = raw["builds"] as? Map<String, Any?> ?: emptyMap() val definitions = raw["builds"] as? Map<String, Any?> ?: emptyMap()
if (definitions.isEmpty()) { if (definitions.isEmpty()) {
return mergeBranchDefaults(raw) return mergeBranchDefaults(raw)
} }
checkFollowUps(definitions, missingPredecessor)
if (raw.containsKey("branches") && warnedSections.add(LEGACY_BRANCHES_WARNING)) { if (raw.containsKey("branches") && warnedSections.add(LEGACY_BRANCHES_WARNING)) {
log.warn( log.warn(
"ignoring the branches section: this configuration defines builds, and a build definition " + "ignoring the branches section: this configuration defines builds, and a build definition " +
@@ -330,59 +252,13 @@ class ConfigLoader(
return return
} }
if (warnedSections.add(NO_TRIGGER_WARNING)) { if (warnedSections.add(NO_TRIGGER_WARNING)) {
log.warn("no build defines onPush, atTimes, or afterSuccessOf; the watcher will never start a build on its own") log.warn("no build defines onPush or atTimes; the watcher will never start a build on its own")
} }
} }
private fun isTriggered(definition: Any?): Boolean { private fun isTriggered(definition: Any?): Boolean {
val trigger = (definition as? Map<*, *>)?.get("trigger") as? Map<*, *> ?: return false val trigger = (definition as? Map<*, *>)?.get("trigger") as? Map<*, *> ?: return false
return trigger["onPush"] == true || return trigger["onPush"] == true || (trigger["atTimes"] as? List<*>)?.isNotEmpty() == 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<String, Any?>,
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.",
)
}
}
} }
/** /**
@@ -550,10 +426,7 @@ class ConfigLoader(
checkVersion(raw, fragment.toString(), ROLLBACK_HINT) checkVersion(raw, fragment.toString(), ROLLBACK_HINT)
checkTriggerBlocks(raw, fragment.toString(), ROLLBACK_HINT) checkTriggerBlocks(raw, fragment.toString(), ROLLBACK_HINT)
try { try {
strictYaml.convertValue( strictYaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java)
resolveBuildSections(dropNonDefinitionBuilds(raw), MissingPredecessor.SKIP),
WerkatorConfig::class.java,
)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
throw IllegalArgumentException( throw IllegalArgumentException(
"instance fragment $fragment does not match the configuration schema: ${e.message}", "instance fragment $fragment does not match the configuration schema: ${e.message}",
@@ -608,62 +481,14 @@ class ConfigLoader(
private fun loadFile(file: File): Map<String, Any?> { private fun loadFile(file: File): Map<String, Any?> {
if (!file.exists()) return emptyMap() if (!file.exists()) return emptyMap()
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return renameLegacySandbox(yaml.readValue(file, Map::class.java) as Map<String, Any?>, file.toString()) return yaml.readValue(file, Map::class.java) as Map<String, Any?>
} }
/** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */ /** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */
private fun parseYaml(text: String?): Map<String, Any?> { private fun parseYaml(text: String?): Map<String, Any?> {
if (text.isNullOrBlank()) return emptyMap() if (text.isNullOrBlank()) return emptyMap()
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val raw = yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap() return yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
return renameLegacySandbox(raw, "the branch configuration")
}
/**
* Reads the pre-PR#19 `bwrap` section under its new name `werkdock`, including its
* `werkdock` key which is `binary` now. Done on the raw map of every layer, before
* merging, so nothing downstream — merging, pinning, binding — knows two names.
*
* Renaming rather than rejecting: the section is written in the machine configuration
* of every webspace instance, which no repository tracks. The warning is what makes
* the old name go away; the hard refusal belongs to the release that sets
* [ConfigVersions.FORMAT_BROKE_IN], where a file declaring no version can be caught
* by name at all.
*/
@Suppress("UNCHECKED_CAST")
private fun renameLegacySandbox(
raw: Map<String, Any?>,
source: String,
): Map<String, Any?> {
var renamed = false
val result =
raw.mapValues { (section, value) ->
if (section != "builds" && section != "branches") {
return@mapValues value
}
val entries = value as? Map<String, Any?> ?: return@mapValues value
entries.mapValues inner@{ (_, entry) ->
val settings = entry as? Map<String, Any?> ?: return@inner entry
val legacy = settings["bwrap"] as? Map<String, Any?> ?: return@inner entry
renamed = true
val moved =
legacy.mapKeys { (key, _) -> if (key == "werkdock") "binary" else key }
val existing = settings["werkdock"] as? Map<String, Any?> ?: emptyMap()
settings.toMutableMap().apply {
remove("bwrap")
// an explicit werkdock section wins: the new name is the one meant
put("werkdock", moved + existing)
}
}
}
if (renamed && warnedSections.add("bwrap-renamed:$source")) {
log.warn(
"reading the 'bwrap' section of {} as 'werkdock' (and 'bwrap.werkdock' as 'werkdock.binary'); " +
"rename it — the old name goes away with the next breaking configuration change",
source,
)
}
return result
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -725,8 +550,8 @@ class ConfigLoader(
/** `docker` keys a branch must never override: the sandbox policy. */ /** `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network") private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
/** `werkdock` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */ /** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
private val PINNED_WERKDOCK_KEYS = setOf("enabled", "rootfs", "binary") private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock")
/** /**
* The one key of a build definition that says *when* and *for which branches* it * The one key of a build definition that says *when* and *for which branches* it
@@ -737,7 +562,7 @@ class ConfigLoader(
private val TRIGGER_KEYS = setOf("trigger") private val TRIGGER_KEYS = setOf("trigger")
/** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */ /** 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", "afterSuccessOf") private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
/** /**
* Top-level sections owned by the instance once a home config exists (ADR 0009): * Top-level sections owned by the instance once a home config exists (ADR 0009):
@@ -38,9 +38,9 @@ data class WerkatorConfig(
): BranchConfig { ): BranchConfig {
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig() val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
if (settings.docker.enabled && settings.werkdock.enabled) { if (settings.docker.enabled && settings.bwrap.enabled) {
throw IllegalArgumentException( throw IllegalArgumentException(
"builds.$build on '$branch' enables both docker and werkdock; a build runs in exactly one sandbox. " + "builds.$build on '$branch' enables both docker and bwrap; a build runs in exactly one sandbox. " +
"Disable one of them.", "Disable one of them.",
) )
} }
@@ -179,22 +179,17 @@ data class BranchConfig(
val statusContext: String = "", val statusContext: String = "",
val autoBuild: AutoBuildConfig = AutoBuildConfig(), val autoBuild: AutoBuildConfig = AutoBuildConfig(),
val docker: DockerConfig = DockerConfig(), val docker: DockerConfig = DockerConfig(),
/** werkdock sandbox (bubblewrap user namespace); mutually exclusive with [docker]. */ /** bubblewrap user-namespace sandbox; mutually exclusive with [docker]. */
val werkdock: WerkdockConfig = WerkdockConfig(), val bwrap: BwrapConfig = BwrapConfig(),
) )
/** /**
* The werkdock build sandbox (Step 17, executed by the werkdock CLI since step 21): * bubblewrap build sandbox (Step 17): runs the build in an unprivileged user namespace
* runs the build in an unprivileged bubblewrap user namespace over a prepared Debian * with a prepared Debian root filesystem. For hosts without root and without a Docker
* root filesystem. For hosts without root and without a Docker daemon (e.g. Hostsharing * daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
* managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
*
* The section was called `bwrap` until PR#19 and is still read under that name, with a
* warning: `bwrap` named the mechanism one layer below the tool that actually runs it,
* which made `bwrap.werkdock` the key naming its own executor.
*/ */
data class WerkdockConfig( data class BwrapConfig(
/** Run the clean and build commands in the sandbox instead of natively. */ /** Run the clean and build commands in a bwrap sandbox instead of natively. */
val enabled: Boolean = false, val enabled: Boolean = false,
/** /**
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`), * Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
@@ -205,9 +200,8 @@ data class WerkdockConfig(
/** /**
* The werkdock CLI executing the sandbox (step 21 session C); empty or the default * The werkdock CLI executing the sandbox (step 21 session C); empty or the default
* resolves via PATH. Pinned — a branch must not substitute the executing binary. * resolves via PATH. Pinned — a branch must not substitute the executing binary.
* Was `bwrap.werkdock` until PR#19.
*/ */
val binary: String = "werkdock", val werkdock: String = "werkdock",
/** Additional environment variables set inside the sandbox. */ /** Additional environment variables set inside the sandbox. */
val env: Map<String, String> = emptyMap(), val env: Map<String, String> = emptyMap(),
) )
@@ -1,134 +0,0 @@
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<String> = 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<String> {
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<String, BuildDefinition> {
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()
}
}
}
@@ -41,7 +41,6 @@ class Watcher(
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val clock: Clock, private val clock: Clock,
private val followUpTrigger: FollowUpTrigger,
) { ) {
private val log = LoggerFactory.getLogger(Watcher::class.java) private val log = LoggerFactory.getLogger(Watcher::class.java)
@@ -84,14 +83,11 @@ class Watcher(
* Runs the startup recovery of every repository and schedules the poll loop with the * 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, * fixed delay `watcher.pollInterval` — one loop, one delay: the instance's setting,
* which every repository's effective config carries; the first poll runs immediately. * 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 @Synchronized
fun start(repos: List<RepoContext>) { fun start(repos: List<RepoContext>) {
check(scheduler == null) { "watcher is already running" } check(scheduler == null) { "watcher is already running" }
require(repos.isNotEmpty()) { "no repository to watch" } require(repos.isNotEmpty()) { "no repository to watch" }
followUpTrigger.arm()
repos.forEach { recoverSafely(it) } repos.forEach { recoverSafely(it) }
val interval = DurationParser.parse(configLoader.load(repos.first().workingDir).watcher.pollInterval) val interval = DurationParser.parse(configLoader.load(repos.first().workingDir).watcher.pollInterval)
scheduler = scheduler =
@@ -115,7 +111,6 @@ class Watcher(
@Synchronized @Synchronized
fun stop() { fun stop() {
followUpTrigger.disarm()
scheduler?.shutdownNow() scheduler?.shutdownNow()
scheduler = null scheduler = null
state = state.copy(running = false) state = state.copy(running = false)
@@ -329,7 +324,6 @@ class Watcher(
.loadWithBranchLayer( .loadWithBranchLayer(
workingDir, workingDir,
ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) }, ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) },
branch,
).effectiveBuildDefinitions() ).effectiveBuildDefinitions()
} catch (e: Exception) { } catch (e: Exception) {
log.warn( log.warn(
@@ -7,16 +7,6 @@
<div th:replace="~{fragments :: nav(${view})}"></div> <div th:replace="~{fragments :: nav(${view})}"></div>
<div class="panel release-notes"> <div class="panel release-notes">
<h2>v1.2.0 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>The build sandbox for hosts without Docker is configured as <code>werkdock</code> now,
not <code>bwrap</code> (PR#19), and its <code>bwrap.werkdock</code> key — which named its
own executor — is <code>werkdock.binary</code>. The old section is still read, with a
warning naming the file, so no installation has to be changed before its next
configuration edit. <code>bwrap</code> named the mechanism one layer below the tool that
actually runs it: builds have been executed by the werkdock CLI since v1.0.0.</li>
</ul>
<h2>v1.1.2 <span class="muted">— 2026-09-03</span></h2> <h2>v1.1.2 <span class="muted">— 2026-09-03</span></h2>
<ul> <ul>
<li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks <li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks
@@ -292,42 +292,6 @@ class BuildExecutorTest : FunSpec() {
.build shouldBe "default" .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") { test("a build whose definition was removed from the config falls back to the branch's settings") {
val h = harness(buildCommand = "echo regular-\$branch") val h = harness(buildCommand = "echo regular-\$branch")
@@ -541,8 +505,6 @@ class BuildExecutorTest : FunSpec() {
} }
test("with maxConcurrent 1 a second branch stays PENDING until the first finished") { test("with maxConcurrent 1 a second branch stays PENDING until the first finished") {
// branch-a blocks on a gate file the test creates, so the PENDING assertion
// below cannot race the first build finishing on a loaded machine
val h = val h =
Harness( Harness(
""" """
@@ -550,7 +512,7 @@ class BuildExecutorTest : FunSpec() {
maxConcurrent: 1 maxConcurrent: 1
branches: branches:
branch-a: branch-a:
buildCommand: "until [ -f gate ]; do sleep 0.05; done" buildCommand: "sleep 1"
cleanCommand: "" cleanCommand: ""
branch-b: branch-b:
buildCommand: "echo ok" buildCommand: "echo ok"
@@ -561,13 +523,8 @@ class BuildExecutorTest : FunSpec() {
h.executor.startBuild(h.repo, "branch-a", "sha-a") h.executor.startBuild(h.repo, "branch-a", "sha-a")
h.executor.startBuild(h.repo, "branch-b", "sha-b") h.executor.startBuild(h.repo, "branch-b", "sha-b")
eventually(30.seconds) {
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
}
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
Files.createFile(h.workingDir.resolve("gate"))
awaitStatus(h, "branch-b", BuildStatus.SUCCESS) awaitStatus(h, "branch-b", BuildStatus.SUCCESS)
awaitStatus(h, "branch-a", BuildStatus.SUCCESS) awaitStatus(h, "branch-a", BuildStatus.SUCCESS)
val transitions = h.events.map { it.result.branch to it.result.status } val transitions = h.events.map { it.result.branch to it.result.status }
@@ -1,7 +1,7 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.WerkdockConfig import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.git.GitCommandResult import de.hoennig.werkator.git.GitCommandResult
import de.hoennig.werkator.git.GitCommandRunner import de.hoennig.werkator.git.GitCommandRunner
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
@@ -15,20 +15,20 @@ import io.mockk.verify
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
class WerkdockBuildRunnerTest : FunSpec() { class BwrapBuildRunnerTest : FunSpec() {
private val commandRunner = mockk<GitCommandRunner>() private val commandRunner = mockk<GitCommandRunner>()
private lateinit var runner: WerkdockBuildRunner private lateinit var runner: BwrapBuildRunner
private lateinit var repoDir: Path private lateinit var repoDir: Path
private lateinit var workspace: Path private lateinit var workspace: Path
private val captured = mutableListOf<List<String>>() private val captured = mutableListOf<List<String>>()
private fun werkdockBranchConfig( private fun bwrapBranchConfig(
rootfs: String = "/srv/buildenv.tar.zst", rootfs: String = "/srv/buildenv.tar.zst",
env: Map<String, String> = emptyMap(), env: Map<String, String> = emptyMap(),
): BranchConfig = ): BranchConfig =
BranchConfig( BranchConfig(
werkdock = bwrap =
WerkdockConfig( BwrapConfig(
enabled = true, enabled = true,
rootfs = rootfs, rootfs = rootfs,
env = env, env = env,
@@ -52,9 +52,9 @@ class WerkdockBuildRunnerTest : FunSpec() {
beforeEach { beforeEach {
clearMocks(commandRunner) clearMocks(commandRunner)
captured.clear() captured.clear()
repoDir = Files.createTempDirectory("werkator-werkdock-runner") repoDir = Files.createTempDirectory("werkator-bwrap-runner")
workspace = repoDir.resolve("workspace") workspace = repoDir.resolve("workspace")
runner = WerkdockBuildRunner(commandRunner) runner = BwrapBuildRunner(commandRunner)
runner.processStarter = { command, _ -> runner.processStarter = { command, _ ->
captured += command captured += command
ProcessBuilder("true").start() ProcessBuilder("true").start()
@@ -64,7 +64,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
test("assembles the exact werkdock run command for a loaded image") { test("assembles the exact werkdock run command for a loaded image") {
givenImageLoaded() givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
captured.single() shouldBe captured.single() shouldBe
listOf( listOf(
@@ -99,7 +99,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
) )
} returns GitCommandResult(0, "", "") } returns GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
verify { verify {
commandRunner.runOrThrow( commandRunner.runOrThrow(
@@ -114,7 +114,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
test("does not load an image werkdock already has") { test("does not load an image werkdock already has") {
givenImageLoaded() givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
} }
@@ -124,7 +124,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
GitCommandResult(0, imageName() + "\n", "") GitCommandResult(0, imageName() + "\n", "")
val branchConfig = val branchConfig =
BranchConfig( BranchConfig(
werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", binary = "/opt/bin/werkdock"), bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"),
) )
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig) runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
@@ -136,7 +136,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
givenImageLoaded() givenImageLoaded()
val relativeWorkspace = repoDir.relativize(workspace) val relativeWorkspace = repoDir.relativize(workspace)
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig()) runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
val args = captured.single() val args = captured.single()
val absolute = workspace.toAbsolutePath().normalize().toString() val absolute = workspace.toAbsolutePath().normalize().toString()
@@ -145,7 +145,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
args.count { it == "$absolute:$absolute" } shouldBe 1 args.count { it == "$absolute:$absolute" } shouldBe 1
} }
test("adds the sandbox env after the branch environment") { test("adds bwrap env after the branch environment") {
givenImageLoaded() givenImageLoaded()
runner.start( runner.start(
@@ -153,7 +153,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
workspace, workspace,
mapOf("branch" to "main"), mapOf("branch" to "main"),
repoDir, repoDir,
werkdockBranchConfig(env = mapOf("FOO" to "bar")), bwrapBranchConfig(env = mapOf("FOO" to "bar")),
) )
val args = captured.single() val args = captured.single()
@@ -171,7 +171,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n") Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
givenImageLoaded() givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
val args = captured.single() val args = captured.single()
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v" args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
@@ -190,7 +190,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
givenImageLoaded() givenImageLoaded()
Files.createDirectories(workspace) Files.createDirectories(workspace)
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
val args = captured.single() val args = captured.single()
val gitDir = repoDir.resolve(".git") val gitDir = repoDir.resolve(".git")
@@ -199,21 +199,21 @@ class WerkdockBuildRunnerTest : FunSpec() {
} }
test("fails without a configured rootfs") { test("fails without a configured rootfs") {
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true)) val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true))
val exception = val exception =
shouldThrow<IllegalArgumentException> { shouldThrow<IllegalArgumentException> {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
} }
exception.message shouldContain "werkdock.rootfs" exception.message shouldContain "bwrap.rootfs"
} }
test("downloads a URL rootfs once before loading it") { test("downloads a URL rootfs once before loading it") {
val url = "https://example.test/buildenv.tar.zst" val url = "https://example.test/buildenv.tar.zst"
val downloadTarget = val downloadTarget =
repoDir repoDir
.resolve(WerkdockBuildRunner.BUILDENV_DIR) .resolve(BwrapBuildRunner.BUILDENV_DIR)
.resolve(url.sha12()) .resolve(url.sha12())
.resolve("buildenv.tar.zst") .resolve("buildenv.tar.zst")
givenImageMissing() givenImageMissing()
@@ -222,7 +222,7 @@ class WerkdockBuildRunnerTest : FunSpec() {
every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns
GitCommandResult(0, "", "") GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig(rootfs = url)) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url))
verify { verify {
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
@@ -1,8 +1,8 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.config.DockerConfig import de.hoennig.werkator.config.DockerConfig
import de.hoennig.werkator.config.WerkdockConfig
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.Called import io.mockk.Called
@@ -15,13 +15,13 @@ import java.nio.file.Paths
class DispatchingBuildRunnerTest : FunSpec() { class DispatchingBuildRunnerTest : FunSpec() {
private val processBuildRunner = mockk<ProcessBuildRunner>() private val processBuildRunner = mockk<ProcessBuildRunner>()
private val dockerBuildRunner = mockk<DockerBuildRunner>() private val dockerBuildRunner = mockk<DockerBuildRunner>()
private val werkdockBuildRunner = mockk<WerkdockBuildRunner>() private val bwrapBuildRunner = mockk<BwrapBuildRunner>()
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, werkdockBuildRunner) private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, bwrapBuildRunner)
private val process = mockk<Process>() private val process = mockk<Process>()
private val dir = Paths.get(".") private val dir = Paths.get(".")
init { init {
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, werkdockBuildRunner) } beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) }
test("runs natively by default") { test("runs natively by default") {
val branchConfig = BranchConfig() val branchConfig = BranchConfig()
@@ -30,7 +30,7 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { dockerBuildRunner wasNot Called } verify { dockerBuildRunner wasNot Called }
verify { werkdockBuildRunner wasNot Called } verify { bwrapBuildRunner wasNot Called }
} }
test("runs in Docker when the branch enables it") { test("runs in Docker when the branch enables it") {
@@ -40,12 +40,12 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { processBuildRunner wasNot Called } verify { processBuildRunner wasNot Called }
verify { werkdockBuildRunner wasNot Called } verify { bwrapBuildRunner wasNot Called }
} }
test("runs in the werkdock sandbox when the branch enables it (and not Docker)") { test("runs in bwrap when the branch enables it (and not Docker)") {
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst")) val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
every { werkdockBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process every { bwrapBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
@@ -4,7 +4,6 @@ import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry import de.hoennig.werkator.repo.RepoRegistry
import de.hoennig.werkator.watcher.FollowUpTrigger
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -23,31 +22,16 @@ class BuildCommandTest : FunSpec() {
private val dir: Path = Paths.get(".") private val dir: Path = Paths.get(".")
private val repo = RepoContext("test", dir, mockk(), mockk()) private val repo = RepoContext("test", dir, mockk(), mockk())
private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo } private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo }
private val followUpTrigger = mockk<FollowUpTrigger>()
private fun command(fragment: String? = null) = private fun command(fragment: String? = null) =
BuildCommand(gitService, consoleBuildRunner, registry, followUpTrigger).apply { BuildCommand(gitService, consoleBuildRunner, registry).apply {
branchFragment = fragment branchFragment = fragment
} }
init { init {
beforeEach { beforeEach {
clearMocks(gitService, consoleBuildRunner, followUpTrigger) clearMocks(gitService, consoleBuildRunner)
justRun { gitService.fetchOrigin(dir) } 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") { test("builds the current branch at its local head when no branch is given") {
@@ -496,7 +496,7 @@ class ConfigLoaderTest : FunSpec() {
settings.docker.image shouldBe "attacker-image" settings.docker.image shouldBe "attacker-image"
} }
test("the legacy bwrap section is read as werkdock, its werkdock key as binary") { test("a branch cannot disable its bwrap sandbox or substitute a foreign rootfs through a build definition") {
val dir = Files.createTempDirectory("werkator-test") val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText( dir.resolve(".werkator.yml").toFile().writeText(
""" """
@@ -505,58 +505,6 @@ class ConfigLoaderTest : FunSpec() {
bwrap: bwrap:
enabled: true enabled: true
rootfs: /host/rootfs.tar.zst rootfs: /host/rootfs.tar.zst
werkdock: /opt/bin/werkdock
env:
FOO: bar
""".trimIndent(),
)
val settings = loader.load(dir).buildSettings("any-branch", "default")
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
settings.werkdock.binary shouldBe "/opt/bin/werkdock"
settings.werkdock.env shouldBe mapOf("FOO" to "bar")
}
test("a legacy bwrap section on a branch is pinned exactly like the new name") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
werkdock:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(),
)
val worktree = Files.createTempDirectory("werkator-test-worktree")
// the old name must not become a way around the pinning
worktree.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
bwrap:
enabled: false
rootfs: /attacker/rootfs.tar.zst
""".trimIndent(),
)
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
}
test("a branch cannot disable its werkdock sandbox or substitute a foreign rootfs through a build definition") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
werkdock:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(), """.trimIndent(),
) )
val worktree = Files.createTempDirectory("werkator-test-worktree") val worktree = Files.createTempDirectory("werkator-test-worktree")
@@ -564,7 +512,7 @@ class ConfigLoaderTest : FunSpec() {
""" """
builds: builds:
default: default:
werkdock: bwrap:
enabled: false enabled: false
rootfs: /attacker/rootfs.tar.zst rootfs: /attacker/rootfs.tar.zst
env: env:
@@ -575,10 +523,10 @@ class ConfigLoaderTest : FunSpec() {
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default") val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs // pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
settings.werkdock.enabled shouldBe true settings.bwrap.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst" settings.bwrap.rootfs shouldBe "/host/rootfs.tar.zst"
// everything that describes the build itself stays the branch's own business // everything that describes the build itself stays the branch's own business
settings.werkdock.env shouldBe mapOf("FOO" to "from-branch") settings.bwrap.env shouldBe mapOf("FOO" to "from-branch")
} }
test("a build the branch invents inherits the host's sandbox policy") { test("a build the branch invents inherits the host's sandbox policy") {
@@ -617,7 +565,7 @@ class ConfigLoaderTest : FunSpec() {
settings.requirePullRequest shouldBe true settings.requirePullRequest shouldBe true
} }
test("enabling both docker and werkdock on a build is rejected, not picked silently") { test("enabling both docker and bwrap on a build is rejected, not picked silently") {
val dir = Files.createTempDirectory("werkator-test") val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText( dir.resolve(".werkator.yml").toFile().writeText(
""" """
@@ -626,7 +574,7 @@ class ConfigLoaderTest : FunSpec() {
docker: docker:
enabled: true enabled: true
image: build-env image: build-env
werkdock: bwrap:
enabled: true enabled: true
rootfs: /srv/rootfs.tar.zst rootfs: /srv/rootfs.tar.zst
""".trimIndent(), """.trimIndent(),
@@ -638,7 +586,7 @@ class ConfigLoaderTest : FunSpec() {
config.buildSettings("any-branch", "default") config.buildSettings("any-branch", "default")
} }
exception.message shouldContain "both docker and werkdock" exception.message shouldContain "both docker and bwrap"
exception.message shouldContain "builds.default" exception.message shouldContain "builds.default"
} }
@@ -739,190 +687,6 @@ class ConfigLoaderTest : FunSpec() {
.shouldBeTrue() .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<ConfigFormatException> { 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<ConfigFormatException> { loader.load(dir) }.message.shouldContain("a -> b -> a")
project.writeText(
"""
builds:
a:
trigger:
afterSuccessOf: a
""".trimIndent(),
)
shouldThrow<ConfigFormatException> { 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<ConfigFormatException> { 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") { test("builds.default is the base of every other build, but never its trigger") {
val dir = Files.createTempDirectory("werkator-test") val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText( dir.resolve(".werkator.yml").toFile().writeText(
@@ -113,7 +113,7 @@ class PermanentBranchRoutesTest : FunSpec() {
every { registry.byName(any()) } returns null every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo every { registry.byName("test") } returns repo
every { configLoader.load(any()) } returns WerkatorConfig() every { configLoader.load(any()) } returns WerkatorConfig()
every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns WerkatorConfig() every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { gitService.showFileAtCommit(any(), any(), any()) } returns null
every { controlTokens.token() } returns "test-token" every { controlTokens.token() } returns "test-token"
every { branchListing.branches(any()) } returns emptyList() every { branchListing.branches(any()) } returns emptyList()
@@ -142,7 +142,7 @@ class UiControllerTest : FunSpec() {
server = ServerConfig(impressumUrl = "https://example.org/imprint"), server = ServerConfig(impressumUrl = "https://example.org/imprint"),
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"),
) )
every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns WerkatorConfig() every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { gitService.showFileAtCommit(any(), any(), any()) } returns null
every { controlTokens.token() } returns "test-token" every { controlTokens.token() } returns "test-token"
every { repository.latestGreenFor(any()) } returns null every { repository.latestGreenFor(any()) } returns null
@@ -385,7 +385,7 @@ class UiControllerTest : FunSpec() {
) )
every { repository.history() } returns listOf(pitestResult) every { repository.history() } returns listOf(pitestResult)
every { artifactStore.artifactDir("main-pitest-key") } returns null every { artifactStore.artifactDir("main-pitest-key") } returns null
every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns
WerkatorConfig( WerkatorConfig(
branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")), branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")),
buildDefinitions = mapOf("pitest" to BuildDefinition(buildCommand = "./gradlew pitestFull")), buildDefinitions = mapOf("pitest" to BuildDefinition(buildCommand = "./gradlew pitestFull")),
@@ -1,173 +0,0 @@
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<GitService>()
val configLoader = mockk<ConfigLoader>()
val buildExecutor = mockk<BuildExecutor>()
val repo = RepoContext("test", workingDir, mockk(), mockk())
val started = CopyOnWriteArrayList<Started>()
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<String>()
val commit = thirdArg<String>()
val build = arg<String>(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<String> = 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()
}
}
}
@@ -63,7 +63,6 @@ class WatcherTest : FunSpec() {
val artifactStore = mockk<ArtifactStore>() val artifactStore = mockk<ArtifactStore>()
val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>() val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
val configLoader = mockk<ConfigLoader>() val configLoader = mockk<ConfigLoader>()
val followUpTrigger = mockk<FollowUpTrigger>(relaxed = true)
val repo = RepoContext("test", workingDir, repository, artifactStore) val repo = RepoContext("test", workingDir, repository, artifactStore)
val watcher = val watcher =
Watcher( Watcher(
@@ -71,7 +70,6 @@ class WatcherTest : FunSpec() {
buildExecutor = buildExecutor, buildExecutor = buildExecutor,
configLoader = configLoader, configLoader = configLoader,
clock = Clock.fixed(noon, ZoneOffset.UTC), clock = Clock.fixed(noon, ZoneOffset.UTC),
followUpTrigger = followUpTrigger,
) )
private var seedCounter = 0L private var seedCounter = 0L
@@ -87,7 +85,7 @@ class WatcherTest : FunSpec() {
every { gitService.originBranchCommitTimes(any()) } returns emptyMap() every { gitService.originBranchCommitTimes(any()) } returns emptyMap()
every { gitService.originBranchHeads(any()) } returns emptyMap() every { gitService.originBranchHeads(any()) } returns emptyMap()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { gitService.showFileAtCommit(any(), any(), any()) } returns null
every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns config every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns config
every { gitService.pullRequestHeads(any()) } returns emptySet() every { gitService.pullRequestHeads(any()) } returns emptySet()
every { gitService.worktreePrune(any()) } returns Unit every { gitService.worktreePrune(any()) } returns Unit
every { gitService.fastForwardLocalBranches(any()) } returns emptyList() every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
@@ -159,19 +157,6 @@ class WatcherTest : FunSpec() {
) )
init { 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") { test("a fetch failure is exposed in the state and only retried next cycle") {
val harness = Harness() val harness = Harness()
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable") every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
@@ -541,7 +526,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranchHeads(any()) } returns every { harness.gitService.originBranchHeads(any()) } returns
mapOf("main" to "commit-main", "experiment" to "commit-exp") mapOf("main" to "commit-main", "experiment" to "commit-exp")
every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml" every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml"
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml", anyNullable()) } returns branchLayer every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp" every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
@@ -564,7 +549,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranches(any()) } returns listOf("experiment") every { harness.gitService.originBranches(any()) } returns listOf("experiment")
every { harness.gitService.originBranchHeads(any()) } returns mapOf("experiment" to "commit-exp") 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.gitService.showFileAtCommit("commit-exp", ".gittally.yml", any()) } returns "branch-yaml"
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml", anyNullable()) } returns branchLayer every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp" every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
harness.watcher.poll(harness.repo) harness.watcher.poll(harness.repo)
@@ -583,7 +568,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranchHeads(any()) } returns every { harness.gitService.originBranchHeads(any()) } returns
mapOf("main" to "commit-main", "experiment" to "commit-exp") mapOf("main" to "commit-main", "experiment" to "commit-exp")
every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml" every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml"
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml", anyNullable()) } returns branchLayer every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any" every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any"
harness.watcher.poll(harness.repo) harness.watcher.poll(harness.repo)
@@ -623,7 +608,7 @@ class WatcherTest : FunSpec() {
buildDefinitions = mapOf("nightly" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))), buildDefinitions = mapOf("nightly" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))),
) )
every { harness.configLoader.load(any()) } returns edited every { harness.configLoader.load(any()) } returns edited
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns edited every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited
harness.watcher.poll(harness.repo) harness.watcher.poll(harness.repo)
@@ -637,7 +622,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-main") 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.gitService.showFileAtCommit("commit-main", Watcher.CONFIG_FILE, any()) } returns "broken"
every { harness.configLoader.loadWithBranchLayer(any(), "broken", anyNullable()) } throws every { harness.configLoader.loadWithBranchLayer(any(), "broken") } throws
RuntimeException("mapping problem") RuntimeException("mapping problem")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
+7 -11
View File
@@ -44,15 +44,14 @@
# Optional in the env file: # Optional in the env file:
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start) # WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
# WERKATOR_REPO_URL https clone URL of the watched repository # WERKATOR_REPO_URL https clone URL of the watched repository
# (default: https://git.javagil.de/mi/werkator.git) # (default: https://github.com/mhoennig/werkator.git)
# WERKATOR_REPO_DIR directory of the watched repository, absolute or relative to # WERKATOR_REPO_DIR directory of the watched repository, absolute or relative to
# WERKATOR_PATH (default: werkator); it also names the systemd # WERKATOR_PATH (default: werkator); it also names the systemd
# unit, exactly as `init --systemd` derives it # unit, exactly as `init --systemd` derives it
# WERKATOR_INSTALL_DIR directory holding the unpacked runtime bundle, absolute or # WERKATOR_INSTALL_DIR directory holding the unpacked runtime bundle, absolute or
# relative to WERKATOR_PATH (default: .werkator) # relative to WERKATOR_PATH (default: .werkator)
# WERKATOR_SANDBOX build runtime of the host: werkdock (default, the bubblewrap # WERKATOR_SANDBOX build runtime of the host: bwrap (default) or docker; a docker
# sandbox; `bwrap` is accepted as its former name) or docker — # host needs neither the werkdock binary nor a rootfs archive
# a docker host needs neither the werkdock binary nor a rootfs
# WERKDOCK_REPO checkout of the werkdock repository, whose binary the # WERKDOCK_REPO checkout of the werkdock repository, whose binary the
# instance runs (default: <repo>/../werkdock) # instance runs (default: <repo>/../werkdock)
# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock) # WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock)
@@ -122,7 +121,7 @@ require_env WERKATOR_REMOTE WERKATOR_PATH
HOST="$WERKATOR_REMOTE" HOST="$WERKATOR_REMOTE"
TARGET_DIR="$WERKATOR_PATH" TARGET_DIR="$WERKATOR_PATH"
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}" ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}"
REPO_URL="${WERKATOR_REPO_URL:-https://git.javagil.de/mi/werkator.git}" REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}"
# The host layout is three values, not one convention: an installation that grew # The host layout is three values, not one convention: an installation that grew
# before this script existed puts them elsewhere, and the defaults are exactly what # before this script existed puts them elsewhere, and the defaults are exactly what
@@ -138,13 +137,10 @@ REPO_DIR="$(resolve_dir "${WERKATOR_REPO_DIR:-werkator}")"
INSTALL_DIR="$(resolve_dir "${WERKATOR_INSTALL_DIR:-.werkator}")" INSTALL_DIR="$(resolve_dir "${WERKATOR_INSTALL_DIR:-.werkator}")"
# where `repo-add` puts a further repository of the registry: beside the watched one # where `repo-add` puts a further repository of the registry: beside the watched one
SIBLING_DIR="$(dirname "$REPO_DIR")" SIBLING_DIR="$(dirname "$REPO_DIR")"
SANDBOX="${WERKATOR_SANDBOX:-werkdock}" SANDBOX="${WERKATOR_SANDBOX:-bwrap}"
# `bwrap` was the name of the config section until Werkator v1.2.0; accepted so an env
# file written for the older script keeps working, normalised so only one name is used.
[ "$SANDBOX" = "bwrap" ] && SANDBOX="werkdock"
case "$SANDBOX" in case "$SANDBOX" in
werkdock|docker) ;; bwrap|docker) ;;
*) die "WERKATOR_SANDBOX is 'werkdock' or 'docker', not '$SANDBOX'" ;; *) die "WERKATOR_SANDBOX is 'bwrap' or 'docker', not '$SANDBOX'" ;;
esac esac
MACHINE_CONFIG="$REPO_DIR/.git/werkator/.werkator.yml" MACHINE_CONFIG="$REPO_DIR/.git/werkator/.werkator.yml"