Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f67a3a5a3 | ||
|
|
34c29ce30a | ||
|
|
2c64329d22 | ||
|
|
e1477eb8d0 | ||
|
|
389fae388e | ||
|
|
506e817c82 |
@@ -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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- 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`.
|
||||
- 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, 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`, `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`, and `werkdock.binary`. 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`.
|
||||
- `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.
|
||||
- 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/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/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.
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
@@ -101,9 +101,7 @@ single branch may decide it:
|
||||
- the container sandbox policy: `docker.enabled`/`docker.network` and
|
||||
`werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` — host-pinned as
|
||||
long as only the host's configuration sets them, master-pinned once the committed
|
||||
configuration does;
|
||||
- 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.
|
||||
configuration does.
|
||||
|
||||
The distinction is documentary.
|
||||
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,
|
||||
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`.
|
||||
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
|
||||
@@ -254,7 +252,6 @@ builds:
|
||||
# branches: ["*", "!master"] # names or globs; a "!" pattern excludes; default: all
|
||||
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
|
||||
# activeWithin: 24h # only branches with commits in the last 24h
|
||||
# afterSuccessOf: test # run after every green run of that build, at its commit (pinned)
|
||||
# run before each build
|
||||
cleanCommand: rm -rf build
|
||||
# shell command for each build
|
||||
@@ -400,50 +397,9 @@ Writing any of its keys outside the block is refused with a message naming the d
|
||||
Triggers: `onPush: true` builds every new commit of the selected branches; `atTimes: ["HH:MM", …]` rebuilds their heads once per day and slot (UTC).
|
||||
A slot may also be written as `??:MM` — that minute of every hour, expanded to its 24 slots, so the build runs hourly.
|
||||
Only the latest due slot of a day triggers, so slots missed while the server was down are skipped instead of piling up, and a slot whose pool is still building is retried on the next poll cycle until it succeeds.
|
||||
A definition may 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.
|
||||
|
||||
#### Follow-up builds
|
||||
|
||||
`afterSuccessOf: <name>` makes a definition a *follow-up* of another one, its *predecessor*: it runs on the predecessor's branch at the predecessor's commit whenever a run of the predecessor ends green.
|
||||
This is how a deployment is configured: a deployment is a build that follows a green build, and it gets everything a build has — its own row in History with log and duration, its own Gitea check under its own `statusContext`, restart, cancel, artifacts, and the per-branch serialization.
|
||||
|
||||
Every green run counts, whatever started it — the push watcher, an `atTimes` slot, a UI restart, `werkator retry`, or the startup recovery — and a repeated green run of the same commit triggers the follow-up again.
|
||||
The follow-up builds the commit that was tested, not the branch's current origin head.
|
||||
A failed, cancelled, or interrupted run triggers nothing.
|
||||
The follow-up runs in the branch's worktree, after its predecessor, in the branch's sandbox or container — so a deployment tool like the Docker CLI is provided the way a compiler is.
|
||||
It inherits `builds.default` like every definition, so a deployment that wants the predecessor's output has to set `cleanCommand: ""` itself; a deployment command should rather be self-contained and rebuild what it ships, because a restart of the follow-up alone runs without its predecessor.
|
||||
The pull-request gate is not consulted for a follow-up: the predecessor passed it for the same commit, and the follow-up's `branches` selector is its own gate.
|
||||
|
||||
The trigger of a follow-up is pinned: a branch's committed config can neither add `afterSuccessOf` to a definition nor change the `trigger` block of a definition the host defines as a follow-up, so a branch cannot deploy itself.
|
||||
What the deployment *does* comes with the repository, like every build command; where and when it happens, and with which credentials, is the host's.
|
||||
The host's part lives in `.git/werkator/.werkator.yml`; credentials reach the sandbox like any build setting, through `werkdock.env`/`docker.env`, and a file such as an SSH key through the werkdock sandbox's persistent toolchain home, `.git/werkator/buildenv/home/` on the host, which the sandbox mounts as `/root`.
|
||||
|
||||
```yaml
|
||||
# .git/werkator/.werkator.yml — the host's part: when, for which branches, with what
|
||||
builds:
|
||||
deploy:
|
||||
trigger:
|
||||
afterSuccessOf: frontend
|
||||
branches: ["main"]
|
||||
statusContext: werkator/deploy
|
||||
werkdock:
|
||||
env:
|
||||
DEPLOY_TARGET: user@host:~/doms/example.org/htdocs-ssl
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .werkator.yml — the repository's part: what
|
||||
builds:
|
||||
deploy:
|
||||
cleanCommand: ""
|
||||
buildCommand: scripts/deploy-prod.sh -y "$DEPLOY_TARGET"
|
||||
```
|
||||
|
||||
A follow-up whose predecessor no definition has, and a cycle of follow-ups, refuse the start with a message naming the definition — a deployment that silently never runs is the failure the flat-key refusal exists to prevent.
|
||||
A branch whose committed config drops or renames the predecessor only loses its follow-up, with a warning naming the branch.
|
||||
A one-shot `werkator build` runs no follow-ups — its process ends with its build — and says which ones the server would have run.
|
||||
|
||||
Selector: `trigger.branches` lists branch names or glob patterns (`*` matches any characters, also across `/`); empty selects all origin branches.
|
||||
A pattern prefixed with `!` excludes instead, and an exclusion always wins regardless of order — `["*", "!master"]` is every branch but master.
|
||||
That is how a branch gets a build of its own without being built by the default one as well.
|
||||
@@ -453,7 +409,6 @@ Both parts combine as an intersection.
|
||||
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `werkdock` with all their keys.
|
||||
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
||||
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
||||
So is the trigger of a [follow-up build](#follow-up-builds).
|
||||
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
|
||||
Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only.
|
||||
Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 (A–C) 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 100–150 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.
|
||||
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 171 KiB |
@@ -119,7 +119,7 @@ class BuildExecutor(
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
)
|
||||
repo.results.append(pending)
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(pending, repo))
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
|
||||
val activeBuild = ActiveBuild(runningBuild, repo)
|
||||
builds[runningBuild.artifactKey] = activeBuild
|
||||
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
|
||||
@@ -384,7 +384,7 @@ class BuildExecutor(
|
||||
duration = duration,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
).also { build.repo.results.append(it) }
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(updated, build.repo))
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
|
||||
publishGiteaStatus(build, status, duration)
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -35,12 +35,7 @@ data class RunningBuild(
|
||||
var runningSince: Instant? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Published via Spring's `ApplicationEventPublisher` on every persisted status transition. */
|
||||
data class BuildStatusChangedEvent(
|
||||
val result: BuildResult,
|
||||
val repo: RepoContext,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.config.BuildDefinition
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import de.hoennig.werkator.watcher.FollowUpTrigger
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
@@ -29,7 +27,6 @@ class BuildCommand(
|
||||
private val gitService: GitService,
|
||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||
private val registry: RepoRegistry,
|
||||
private val followUpTrigger: FollowUpTrigger,
|
||||
) : Callable<Int> {
|
||||
@Mixin
|
||||
var repoOption = RepoOption()
|
||||
@@ -61,33 +58,9 @@ class BuildCommand(
|
||||
}
|
||||
println("building branch $branch at commit ${commit.take(12)}")
|
||||
val status = consoleBuildRunner.buildAndStream(repo, branch, commit)
|
||||
if (status == BuildStatus.SUCCESS) {
|
||||
reportSkippedFollowUps(branch, commit)
|
||||
}
|
||||
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. */
|
||||
private fun fetchBestEffort() {
|
||||
try {
|
||||
|
||||
@@ -222,7 +222,6 @@ class InitCommand(
|
||||
# branches: ["*", "!master"] # names or globs; "!" excludes; default: all
|
||||
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
|
||||
# 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
|
||||
cleanCommand: rm -rf build
|
||||
# shell command for each build
|
||||
|
||||
@@ -89,9 +89,8 @@ data class BuildDefinition(
|
||||
* 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`.
|
||||
*
|
||||
* A definition with neither [onPush] nor [atTimes] nor [afterSuccessOf] never triggers
|
||||
* automatically; that is how `builds.default` is written when it is meant as a settings
|
||||
* base only.
|
||||
* A definition with neither [onPush] nor [atTimes] never triggers automatically; that is
|
||||
* how `builds.default` is written when it is meant as a settings base only.
|
||||
*/
|
||||
data class TriggerConfig(
|
||||
/** 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.
|
||||
*/
|
||||
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. */
|
||||
fun selectsByName(branch: String): Boolean {
|
||||
val (excluding, including) = branches.partition { it.startsWith(EXCLUDE_PREFIX) }
|
||||
|
||||
@@ -90,8 +90,7 @@ class ConfigLoader(
|
||||
fun loadForWorktree(
|
||||
workingDir: Path,
|
||||
worktreeDir: Path,
|
||||
branch: String? = null,
|
||||
): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile()), branch)
|
||||
): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile()))
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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
|
||||
* concurrency, nor reach the credentials. The trigger of a follow-up build is pinned
|
||||
* 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.
|
||||
* concurrency, nor reach the credentials.
|
||||
*/
|
||||
fun loadWithBranchLayer(
|
||||
workingDir: Path,
|
||||
branchConfigYaml: String?,
|
||||
branch: String? = null,
|
||||
): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml), branch)
|
||||
): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml))
|
||||
|
||||
private fun withBranchLayer(
|
||||
workingDir: Path,
|
||||
branchLayer: Map<String, Any?>,
|
||||
branch: String?,
|
||||
): WerkatorConfig {
|
||||
// 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
|
||||
checkVersion(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(primary, stripPinned(branchLayer, primary, branch)), MissingPredecessor.WARN)
|
||||
return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private fun toConfig(raw: Map<String, Any?>): WerkatorConfig {
|
||||
val config =
|
||||
if (raw.isEmpty()) {
|
||||
WerkatorConfig()
|
||||
} else {
|
||||
yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw), missingPredecessor), WerkatorConfig::class.java)
|
||||
yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java)
|
||||
}
|
||||
return defaultPublicBaseUrl(config)
|
||||
}
|
||||
@@ -202,11 +178,7 @@ class ConfigLoader(
|
||||
* second as soon as the committed configuration carries them.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun stripPinned(
|
||||
branchLayer: Map<String, Any?>,
|
||||
primary: Map<String, Any?>,
|
||||
branch: String?,
|
||||
): Map<String, Any?> {
|
||||
private fun stripPinned(branchLayer: Map<String, Any?>): Map<String, Any?> {
|
||||
if (branchLayer.isEmpty()) {
|
||||
return branchLayer
|
||||
}
|
||||
@@ -216,55 +188,9 @@ class ConfigLoader(
|
||||
val entries = result[section] as? Map<String, Any?> ?: continue
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")
|
||||
private fun stripPinnedSettings(value: Any?): Any? {
|
||||
val entry = value as? Map<String, Any?> ?: return value
|
||||
@@ -299,16 +225,12 @@ class ConfigLoader(
|
||||
* an empty docker policy and run natively on the host, which is exactly the escape
|
||||
* the pinned keys exist to prevent.
|
||||
*/
|
||||
private fun resolveBuildSections(
|
||||
raw: Map<String, Any?>,
|
||||
missingPredecessor: MissingPredecessor,
|
||||
): Map<String, Any?> {
|
||||
private fun resolveBuildSections(raw: Map<String, Any?>): Map<String, Any?> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val definitions = raw["builds"] as? Map<String, Any?> ?: emptyMap()
|
||||
if (definitions.isEmpty()) {
|
||||
return mergeBranchDefaults(raw)
|
||||
}
|
||||
checkFollowUps(definitions, missingPredecessor)
|
||||
if (raw.containsKey("branches") && warnedSections.add(LEGACY_BRANCHES_WARNING)) {
|
||||
log.warn(
|
||||
"ignoring the branches section: this configuration defines builds, and a build definition " +
|
||||
@@ -330,59 +252,13 @@ class ConfigLoader(
|
||||
return
|
||||
}
|
||||
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 {
|
||||
val trigger = (definition as? Map<*, *>)?.get("trigger") as? Map<*, *> ?: return false
|
||||
return trigger["onPush"] == 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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
return trigger["onPush"] == true || (trigger["atTimes"] as? List<*>)?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,10 +426,7 @@ class ConfigLoader(
|
||||
checkVersion(raw, fragment.toString(), ROLLBACK_HINT)
|
||||
checkTriggerBlocks(raw, fragment.toString(), ROLLBACK_HINT)
|
||||
try {
|
||||
strictYaml.convertValue(
|
||||
resolveBuildSections(dropNonDefinitionBuilds(raw), MissingPredecessor.SKIP),
|
||||
WerkatorConfig::class.java,
|
||||
)
|
||||
strictYaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw IllegalArgumentException(
|
||||
"instance fragment $fragment does not match the configuration schema: ${e.message}",
|
||||
@@ -737,7 +610,7 @@ class ConfigLoader(
|
||||
private val TRIGGER_KEYS = setOf("trigger")
|
||||
|
||||
/** 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):
|
||||
|
||||
@@ -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 configLoader: ConfigLoader,
|
||||
private val clock: Clock,
|
||||
private val followUpTrigger: FollowUpTrigger,
|
||||
) {
|
||||
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
|
||||
* fixed delay `watcher.pollInterval` — one loop, one delay: the instance's setting,
|
||||
* 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
|
||||
fun start(repos: List<RepoContext>) {
|
||||
check(scheduler == null) { "watcher is already running" }
|
||||
require(repos.isNotEmpty()) { "no repository to watch" }
|
||||
followUpTrigger.arm()
|
||||
repos.forEach { recoverSafely(it) }
|
||||
val interval = DurationParser.parse(configLoader.load(repos.first().workingDir).watcher.pollInterval)
|
||||
scheduler =
|
||||
@@ -115,7 +111,6 @@ class Watcher(
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
followUpTrigger.disarm()
|
||||
scheduler?.shutdownNow()
|
||||
scheduler = null
|
||||
state = state.copy(running = false)
|
||||
@@ -329,7 +324,6 @@ class Watcher(
|
||||
.loadWithBranchLayer(
|
||||
workingDir,
|
||||
ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) },
|
||||
branch,
|
||||
).effectiveBuildDefinitions()
|
||||
} catch (e: Exception) {
|
||||
log.warn(
|
||||
|
||||
@@ -292,42 +292,6 @@ class BuildExecutorTest : FunSpec() {
|
||||
.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") {
|
||||
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") {
|
||||
// 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 =
|
||||
Harness(
|
||||
"""
|
||||
@@ -550,7 +512,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
maxConcurrent: 1
|
||||
branches:
|
||||
branch-a:
|
||||
buildCommand: "until [ -f gate ]; do sleep 0.05; done"
|
||||
buildCommand: "sleep 1"
|
||||
cleanCommand: ""
|
||||
branch-b:
|
||||
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-b", "sha-b")
|
||||
|
||||
eventually(30.seconds) {
|
||||
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
|
||||
}
|
||||
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
|
||||
|
||||
Files.createFile(h.workingDir.resolve("gate"))
|
||||
|
||||
awaitStatus(h, "branch-b", BuildStatus.SUCCESS)
|
||||
awaitStatus(h, "branch-a", BuildStatus.SUCCESS)
|
||||
val transitions = h.events.map { it.result.branch to it.result.status }
|
||||
|
||||
@@ -4,7 +4,6 @@ import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import de.hoennig.werkator.watcher.FollowUpTrigger
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
@@ -23,31 +22,16 @@ class BuildCommandTest : FunSpec() {
|
||||
private val dir: Path = Paths.get(".")
|
||||
private val repo = RepoContext("test", dir, mockk(), mockk())
|
||||
private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo }
|
||||
private val followUpTrigger = mockk<FollowUpTrigger>()
|
||||
|
||||
private fun command(fragment: String? = null) =
|
||||
BuildCommand(gitService, consoleBuildRunner, registry, followUpTrigger).apply {
|
||||
BuildCommand(gitService, consoleBuildRunner, registry).apply {
|
||||
branchFragment = fragment
|
||||
}
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(gitService, consoleBuildRunner, followUpTrigger)
|
||||
clearMocks(gitService, consoleBuildRunner)
|
||||
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") {
|
||||
|
||||
@@ -739,190 +739,6 @@ class ConfigLoaderTest : FunSpec() {
|
||||
.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") {
|
||||
val dir = Files.createTempDirectory("werkator-test")
|
||||
dir.resolve(".werkator.yml").toFile().writeText(
|
||||
|
||||
@@ -113,7 +113,7 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
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 { controlTokens.token() } returns "test-token"
|
||||
every { branchListing.branches(any()) } returns emptyList()
|
||||
|
||||
@@ -142,7 +142,7 @@ class UiControllerTest : FunSpec() {
|
||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||
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 { controlTokens.token() } returns "test-token"
|
||||
every { repository.latestGreenFor(any()) } returns null
|
||||
@@ -385,7 +385,7 @@ class UiControllerTest : FunSpec() {
|
||||
)
|
||||
every { repository.history() } returns listOf(pitestResult)
|
||||
every { artifactStore.artifactDir("main-pitest-key") } returns null
|
||||
every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns
|
||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns
|
||||
WerkatorConfig(
|
||||
branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")),
|
||||
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 startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
|
||||
val configLoader = mockk<ConfigLoader>()
|
||||
val followUpTrigger = mockk<FollowUpTrigger>(relaxed = true)
|
||||
val repo = RepoContext("test", workingDir, repository, artifactStore)
|
||||
val watcher =
|
||||
Watcher(
|
||||
@@ -71,7 +70,6 @@ class WatcherTest : FunSpec() {
|
||||
buildExecutor = buildExecutor,
|
||||
configLoader = configLoader,
|
||||
clock = Clock.fixed(noon, ZoneOffset.UTC),
|
||||
followUpTrigger = followUpTrigger,
|
||||
)
|
||||
|
||||
private var seedCounter = 0L
|
||||
@@ -87,7 +85,7 @@ class WatcherTest : FunSpec() {
|
||||
every { gitService.originBranchCommitTimes(any()) } returns emptyMap()
|
||||
every { gitService.originBranchHeads(any()) } returns emptyMap()
|
||||
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.worktreePrune(any()) } returns Unit
|
||||
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
|
||||
@@ -159,19 +157,6 @@ class WatcherTest : FunSpec() {
|
||||
)
|
||||
|
||||
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") {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
||||
@@ -541,7 +526,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originBranchHeads(any()) } returns
|
||||
mapOf("main" to "commit-main", "experiment" to "commit-exp")
|
||||
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("main", any()) } returns "commit-main"
|
||||
|
||||
@@ -564,7 +549,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("experiment")
|
||||
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.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"
|
||||
|
||||
harness.watcher.poll(harness.repo)
|
||||
@@ -583,7 +568,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originBranchHeads(any()) } returns
|
||||
mapOf("main" to "commit-main", "experiment" to "commit-exp")
|
||||
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"
|
||||
|
||||
harness.watcher.poll(harness.repo)
|
||||
@@ -623,7 +608,7 @@ class WatcherTest : FunSpec() {
|
||||
buildDefinitions = mapOf("nightly" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))),
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -637,7 +622,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
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.configLoader.loadWithBranchLayer(any(), "broken", anyNullable()) } throws
|
||||
every { harness.configLoader.loadWithBranchLayer(any(), "broken") } throws
|
||||
RuntimeException("mapping problem")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
# Optional in the env file:
|
||||
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
|
||||
# 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_PATH (default: werkator); it also names the systemd
|
||||
# unit, exactly as `init --systemd` derives it
|
||||
@@ -122,7 +122,7 @@ require_env WERKATOR_REMOTE WERKATOR_PATH
|
||||
HOST="$WERKATOR_REMOTE"
|
||||
TARGET_DIR="$WERKATOR_PATH"
|
||||
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
|
||||
# before this script existed puts them elsewhere, and the defaults are exactly what
|
||||
|
||||