Files
werkator/AGENTS.md
T
Michael Hönnig 71f1fc62c6 Bwrap build runtime und Installation in Hostsharing Managed Webspace (#4)
* Add the bubblewrap build runtime (step 17, ADR 0007)

BwrapBuildRunner: third runtime behind BuildRunner for hosts without root
and without Docker (e.g. Hostsharing managed webspaces). Shells out to the
bwrap CLI, unpacks a prepared rootfs on demand into
.git/werkator/buildenv/<envKey>/rootfs, reuses the Docker runner's git
metadata mounts, and returns the attached bwrap process for streaming and
cancellation.

Config: bwrap.enabled/rootfs/env on BranchConfig and BwrapOverrides on
BuildDefinition; enabled/rootfs are pinned like the docker sandbox policy.
Docker and bwrap are mutually exclusive per build, rejected in
buildSettings instead of picked silently. DispatchingBuildRunner routes
bwrap; InitCommand template, docs/configuration.md and AGENTS.md in sync.

* bwrap rollout tooling: remote script, prerequisites disk/quota check, absolute workspace binds

- tools/remote: central remote control script with check-prerequisites, install and build commands
- tools/werkator-build-prerequisites.sh: compact PASS/FAIL output, target-dir parameter, free-space and
  group-quota headroom checks against the ~5 GiB build footprint, home-filesystem reference
- BwrapBuildRunner: bind workspace and home at absolute paths resolved against repoDir — a relative
  path made bwrap create mountpoints inside the read-only rootfs (seen on the webspace); regression test
- TestcontainersSmokeTest: gated with enabledIf docker available (skip, never fail, without a daemon)
- docs: configuration reference, step-17 plan notes, PR-doc

* bwrap: bind the repo read-write before the workspace so mountpoints are creatable

bwrap creates mountpoints for bind destinations inside the sandbox; with only a
read-only rootfs bound at /, creating them for the workspace under .git/werkator/
worktrees failed with 'Read-only file system' (seen on the webspace). Binding the
repo dir read-write first provides the base; the git metadata mounts then layer
the usual isolation on top (read-only .git, tmpfs mask over .git/werkator,
read-write worktree admin dir).

* bwrap: pre-create bind mountpoints inside the unpacked rootfs

bwrap mkdirs mountpoints for bind destinations against the sandbox view; with the
rootfs ro-bound at / every destination missing from the rootfs (the repo dir under
/home/storage/... on the webspace) fails with 'Read-only file system'. The rootfs
directory is a plain host dir, so create the mountpoints there before launching
bwrap; it then finds them and has nothing left to create.

* bwrap: skip existing rootfs files when pre-creating bind mountpoints

/etc/resolv.conf is a file the rootfs already ships; createDirectories threw on it.
Only missing directories are created now.

* bwrap: pre-create proc/dev/tmpfs mountpoints in the rootfs too

The rootfs archive ships no /proc or /dev (excluded when packed), so bwrap failed
mkdir'ing their mountpoints against the read-only root.

* bwrap: bind the workspace after the git metadata mounts

The tmpfs mask over .git/werkator shadowed the earlier workspace bind, because the
worktree lives under .git/werkator/worktrees — chdir then failed with ENOENT. The
workspace bind now comes last and shadows the mask at exactly its own path.

* systemd resource limits and webspace start command (step 17, web access)

- server.systemd.memoryMax/tasksMax (empty = directive omitted): on platforms where
  the service runs in a shared memory slice (Hostsharing Managed Webspaces) a
  runaway Gradle build must not starve the whole package; init --systemd reads the
  effective config and bakes the values into the generated unit
- tools/remote werkator start: writes server settings (assigned port, loopback
  bind, publicBaseUrl, nginx off) plus the Apache reverse-proxy .htaccess into
  ~/doms/<domain>/subs/www, runs init --systemd and enables the user unit
- docs/configuration.md documents the new keys

* tools/remote: env-based configuration and background port-forward

All connection and deployment values come from .env in the repository root
(WERKATOR_REMOTE, WERKATOR_PATH, WERKATOR_PORT, WERKATOR_DOMAIN,
WERKATOR_LOCAL_PORT, optional WERKATOR_BRANCH/MEMORY_MAX/TASKS_MAX/ROOTFS);
missing values fail with a pointing error instead of positional parameters.

- port-forward is now 'tools/remote port-forward start|stop' with a detached
  ssh tunnel, pid file under /tmp, and idempotent start
- start restarts the systemd unit after updating the machine config
- control-token generates the token in place when the server has not yet
- the rootfs archive default moves to build/ (already gitignored)
2026-08-31 19:56:04 +02:00

8.9 KiB

Werkator — Agent Instructions

This file holds the shared, tool-agnostic instructions for all AI coding agents. Claude Code imports it from CLAUDE.md via @AGENTS.md; Claude-Code-specific instructions belong in CLAUDE.md, everything else here. Detailed guides live as Agent Skills under .claude/skills/ (SKILL.md format); they load on demand — see Skills.

Build and Test Commands

./gradlew build          # compile + ktlintCheck + test
./gradlew ktlintFormat   # auto-format before committing
./gradlew test           # run all tests (can be slow, prefer single test)
./gradlew test --tests "de.hoennig.werkator.ApplicationContextTest"  # example for running a single test class

Run the JAR directly:

java -jar build/libs/werkator.jar --help
java -jar build/libs/werkator.jar init

ktlintFormat must be run before build passes — the formatter is enforced as part of the check lifecycle.

Architecture Overview

Werkator is a lightweight, declarative CI/CD build system: git-centric, one instance per repository, builds native or in Docker, statuses reported to Gitea. It is a dual-mode application: CLI (interactive, status, config) and Server (HTTP, persistent web UI + JSON API). IMPORTANT: Before designing or modifying code in any production package, load the architecture skill — it holds the subsystem details (CLI wiring, server mode, web UI, config system, git access, build execution, watcher, metrics).

Package Structure

All production code lives under de.hoennig.werkator, with sub-packages commands (picocli subcommands), config (YAML config loading and schema), git (git CLI access), gitea (Gitea commit-status API client), build (build execution, results, workspaces), artifacts (filesystem artifact store), watcher (branch polling, auto-builds, startup recovery), metrics (system resource sampling and aggregation), and server (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under src/test/kotlin.

Hard Invariants

  • exitProcess is called only from main() — never inside CliRunner.run(); this keeps the Spring context alive during tests.
  • Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit start() in the server profile.
  • Builds run detached in worktrees under .git/werkator/worktrees/<branchKey>; the primary checkout is never used for builds; never assume a single running build.
  • 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 bubblewrap (bwrap.enabled, bwrap.rootfs) 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, bwrap.enabled, and bwrap.rootfs. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
  • builds or the legacy branches, never both: branches is read only while the merged config defines no build at all (builds.maxConcurrent is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then ConfigVersions.FORMAT_BROKE_IN gets set and a leftover branches: key must be rejected by name — the version check alone cannot catch a file that declares no version.
  • 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.

Testing

Tests use Kotest FunSpec with MockK; SpringExtension is registered globally in io.kotest.provided.ProjectConfig — do not add it per-spec. IMPORTANT: Before writing or changing tests, load the writing-tests skill — it holds the spec structure and the Spring-slice mocking patterns.

File-Formatting

Markdown

Write documentation in English in Markdown files. In Markdown, use a single line per sentence. Keep sentences short.

Documentation

  • docs/Werkator-Konzept.md — product concept and target architecture (in German): git-centric CI, builds in Docker, one instance per repository, status reported back to Gitea.
  • docs/configuration.md — configuration reference; keep in sync with WerkatorConfig and the init templates.
  • docs/bootstrapping.md — how init prepares a repository.
  • 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/prs/ — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the pr-doc skill and write the PR-doc.

Key Architectural Decisions

All major decisions are in docs/adrs/. Run adr-status (after source .envrc) for a one-line summary of each. Decisions in force:

  • Test framework: Kotest + MockK + WireMock + Testcontainers (ADR 0001)
  • Gradle: 8.14.5 (ADR 0002)
  • Spring Boot: 4.0.6 (ADR 0003)
  • Rewrite architecture: JSON-file persistence behind a repository interface, server-rendered UI with JSON polling, no managed nginx — systemd unit behind the host's reverse proxy (ADR 0004)
  • Managed nginx/TLS: revises ADR 0004 — an opt-in nginx+certbot container for hosts without a reverse proxy (e.g. Hostsharing), planned as docs/plan/13-nginx-tls.md (ADR 0005)
  • Runtime bundle distribution: ./gradlew runtimeBundle builds a jlink-trimmed JRE + jar tarball for hosts without a Java runtime; GraalVM native image and a containerized runtime were rejected (ADR 0006)

Skills

On-demand guides in the cross-tool SKILL.md format; agents with skill support load them automatically by description, all others should read the linked files when the topic comes up:

  • architecture — subsystem details: CLI wiring, server mode, web UI, config, git access, build execution, watcher, metrics.
  • writing-tests — Kotest/MockK conventions and Spring slice-test mocking patterns.
  • pr-doc — how to write the mandatory per-PR documentation in docs/prs/.