Review of werkrevisor: the check refused a docker.context or docker.dockerfile that nothing reads, beside a native build or a used-as-is image, and could stop a start for nothing. The init template, docs/configuration.md and the KDoc of DockerConfig.dockerfile no longer speak of a rebuild when stale. The architecture skill keeps the line of main and says the change in lines of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
66 KiB
Werkator Configuration Reference
Werkator is configured via YAML files. Settings are merged from several sources in order — later layers override earlier ones.
Config File Locations
| Layer | Path | Committed to Git | Purpose |
|---|---|---|---|
| Instance config | ~/.werkator.yml |
No | The repository registry and the instance-level settings; optional defaults below every repository (see below) |
| Project config | .werkator.yml |
Yes | Shared team settings |
| Applied instance fragment | .git/werkator/.werkator.applied.yml |
No | Instance parameters installed by init --apply |
| Repo installation config | .git/werkator/.werkator.yml |
No | Machine- or user-specific overrides, secrets |
| Branch config | .werkator.yml committed on a branch |
Yes | That branch's build settings and build definitions |
The repo install config (.git/werkator/.werkator.yml) wins on any key present in several files; the applied fragment wins over the project config. Typically the repo install config sets git.token and git.account without committing them.
A key present in a layer wins even when its value is empty, which is right for a setting — an empty gitea.baseUrl is a way of saying "report nowhere".
The one exception is a credential: a blank git.account or git.token in a repository's layers is dropped rather than honoured, so it cannot disable the defaults.git it was meant to inherit.
There is no build that wants to authenticate as nobody, and a credential left empty is an unfilled placeholder, not a choice.
Werkator says so once per file, naming the file to fix.
The applied instance fragment (init --apply)
werkator init --apply FILE installs a YAML fragment in this very schema as its own layer (step 23) — the file a deployment wrapper hands over instead of patching configs.
The fragment is validated strictly before installing: an unknown key is refused loudly, never ignored, because a typo would otherwise install a value that silently does nothing.
It is then copied verbatim (comments included) to .git/werkator/.werkator.applied.yml; re-applying replaces the file, so nothing accumulates or duplicates, and the hand-edited repo install config — which always wins — is never rewritten.
--apply may be given more than once (PR#87).
The fragments are merged in order, the later one winning, and each is validated on its own before the merge is validated once more.
The merge is written as YAML under a header naming the files; one fragment alone is still copied verbatim.
This is how a self-deploying instance keeps its deployment block apart from the sandbox policy every repository gets, see A build that restarts the instance.
Which Werkator a file is written for
Every configuration file may declare the Werkator it was written for. Without it, a version that renames or drops a key does not fail — it silently ignores what it no longer understands, and the effect shows up as a build that does the wrong thing.
werkator:
version:
since: "0.9.16" # enforced: an older Werkator refuses to read this file
below: "2.0" # your release marker; Werkator decides how strictly to take it
There is deliberately no version of the file format (no apiVersion): no API is
involved — Werkator reads its own configuration — and only one configuration generation is
ever supported. The declaration exists to make an incompatibility nameable, never to run
two parsers.
since is a hard floor and covers both directions:
- a newer file on an older Werkator is refused instead of being half-understood;
- a file written before a breaking change and read after it is refused as well —
Werkator knows in which version its configuration format last broke, so the message can
name the change: "is written for Werkator 1.4.0, but the configuration format changed
incompatibly in 2.0.0:
builds:is nowbuildSpec:".
below is optional and names the first version this file was not released for. The
bound is exclusive, so below: "2.0" means everything up to 2.0.0. On its own it only
warns — a caution marker nobody maintained must never stop a CI. The refusal above comes
from Werkator's own knowledge of its breaking changes, not from this value. The intended
routine is the one known from IDE plugins: a new version appears, the warning shows up, you
try it (on a test host, or in production with a rollback ready), and then raise below and
commit that.
A file that declares nothing is read as before, with a hint in the log — a missing line
must never stop a server either. werkator init writes the running version into the
generated config.
How far a violation reaches depends on the file, following the same rule as everything else here: the machine and project configs abort the start (the message names the file and the rollback), while an incompatible branch config fails only the builds of that branch. A branch that was cut before a migration must never stop the server or hold up the branches that are fine.
The branch layer: a branch describes its own CI
The .werkator.yml committed on a branch is applied as a third layer on top of the two
above, giving the precedence branch > repo install > project. It takes precedence for
everything that describes how this branch is built: the whole builds section — its own
definitions and its overrides of the definitions from the project config, with
buildCommand, cleanCommand, artifactDirs, log file names, and
docker.image/dockerfile/context/env and werkdock.env inside them. That is how a new configuration is tried out: change it on a branch, and
no other branch's builds are affected.
The branch layer is used in both places where it matters: the watcher reads the committed
config of each origin branch (via git show, only when the branch moved) to decide which
of its builds are due, and the build itself resolves its settings from the worktree of
the commit being built.
A branch's definitions apply to that branch alone. Their selectors are evaluated for it
only, so a definition committed on one branch can never trigger builds of another — even
when its branches selector names one.
A pinned set is always taken from the repo install/project config, because none of it describes this branch's build. Which of those two files is expected to carry a key gives the two names used throughout this documentation.
host-pinned — only the machine can know it, and it never belongs in the repository:
- secrets: the whole
gitsection; - the host's own addresses and ports: the whole
serversection.
master-pinned — it belongs in the repository, where changing it needs a review, but no single branch may decide it:
- the repository-side settings: the whole
gitea,executor, andwatchersections; - the trust gate:
requirePullRequest, the Gitea status context:statusContext, and whether the build replaces the running instance:restartsInstance; - the container sandbox policy:
docker.enabled/docker.network/docker.socketandwerkdock.enabled/werkdock.rootfs/werkdock.binary/werkdock.gradleHome— host-pinned as long as only the host's configuration sets them, master-pinned once the committed configuration does; - explicitly host-authored
triggerblocks, and the trigger of a privileged follow-up build. A branch may say what its deployment does, never that — or for which branches — it happens. - a pull-request target build as a whole: a branch can neither add one nor change any of its keys.
The distinction is documentary. Werkator applies one rule: every pinned key is stripped from the branch layer, and the value then resolves from whichever remaining layer sets it. 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, handing itself the Docker socket, bypassing its own pull-request gate, or deploying itself. 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
install/project config only, and only while nothing defines a build at all.
~/.werkator.yml — the instance configuration
One Werkator instance serves a set of repositories (ADR 0009): one service, one port, one UI, one watcher loop.
The set and everything shared by it live in .werkator.yml in the home directory of the user running Werkator — one instance per OS user.
The file name is deliberately the same everywhere; only the location carries the meaning: home is the instance, the repository root is the project, .git is the machine.
WERKATOR_HOME overrides the directory the file is looked up in.
Without this file Werkator serves the current working directory exactly as before.
With it, the registry wins over the current directory: werkator server serves the registered repositories wherever it is started.
werkator:
version:
since: "0.9.16" # like every other config file
repositories: # the registry; each entry is one served repository
- path: ~/repos/werkator # absolute, or relative to the home directory (~ expands)
- path: ~/repos/werkbaum
name: baum # optional; default is the directory basename
# - path: ~/werkator
# selfDeploys: true # the repository the instance deploys itself from; only it may set restartsInstance
server: # the instance's server section — port, bind address, public URL, nginx
port: 18080
executor:
maxConcurrent: 2 # the global cap over all repositories
maxConcurrentPerRepository: 1 # optional: at most this many builds of one repository at a time
maxConcurrentPullRequestTargets: 2 # pull-request target builds, a pool of their own
maxConcurrentPullRequestTargetsPerRepository: 1 # … and of one repository at a time
buildTimeout: 30m # the longest any build here may run; a build may ask for less
overheadBudget: 30s # warn when a build spends more than this outside its own commands
watcher:
pollInterval: 10s # one loop, one delay
metrics: # optional: the filesystems the System page shows, one gauge each
disks:
- label: SSD # the gauge title
path: "~" # a directory on that filesystem (~ expands; quoted, a bare ~ is YAML null)
- label: HDD
path: /home/storage/mih09/users/werkator
key: hdd # optional: the series name in history and API; default = the label lowercased
swapWarningGib: 0.5 # swap in use above this many GiB shows amber; null = no warning; default 0.5
swapOutWarningMibPerSecond: 0.1 # swap-out (pages written, 5 min) from which swap shows amber; default 0.1
swapOutCriticalMibPerSecond: 1.0 # … and red; null turns either off; default 1.0
defaults: # optional: repository-level keys merged BELOW every repository's own layers
git:
account: ci-bot
token: "…" # secrets may then live here instead of in each repository
gitea:
baseUrl: https://git.example.org
Key ownership once this file exists:
- Instance-level — read from this file alone: the whole
serversection, the wholeexecutorsection (maxConcurrent,maxConcurrentPerRepository,maxConcurrentPullRequestTargets,maxConcurrentPullRequestTargetsPerRepository,buildTimeout,overheadBudget),watcher.pollInterval, and themetricssection. A repository file still carrying one of them is ignored on that key, with one warning naming both files; it is never merged silently. - Repository defaults — the
defaultsblock, in the repository config schema: merged below each repository's project config, applied fragment, machine config, and branch layer, so a repository's own value always wins. Pinning is unchanged: home defaults and the repository's machine config are both host-side layers, and a branch still cannot reach a pinned key. - Repository-level — everything else stays in the repository's own files:
gitea.*,git.*credentials,builds, retention, the otherwatcherkeys (pullRequestGate,newBranchMaxAge,fastForwardLocalRefs), and the sandbox policy.
Notes on metrics.disks
Without the section the System page shows one disk gauge: the filesystem of the served repository's checkout, labelled Disk.
With it, every entry becomes its own gauge, with its own history, peaks, health reasons and sizing chip, in the order listed.
The reading is the tightest of the user quota, the group quota, and the volume of that filesystem, exactly as for the single gauge.
On a Hostsharing Managed Webspace that separates the SSD home directory from the HDD /home/storage/<pac>/users/<user> tree, one entry per filesystem shows both budgets side by side.
The key names the series (disk.<key>.usedGib) in the history state and in GET /api/system/history; it defaults to the label lowercased, so give it explicitly when the label may change later, or the history starts over under the new key.
The history recorded before the section existed continues as the first entry's series.
metrics.swapWarningGib tints the swap-out card amber, on the System page and on the home page, once more swap is in use than that many GiB.
The default of 0.5 GiB follows Hostsharing, where swapped pages still go into the backup and the DRBD mirror; null turns the warning off.
It is a display only: swap in use is no waiting, so the health verdict and the sizing ignore it, as RFC 0004 says.
What costs on Hostsharing is swap-out, not swap that stays and not swap read back: DRBD sends every changed block to the mirror over the network.
metrics.swapOutWarningMibPerSecond and metrics.swapOutCriticalMibPerSecond judge the pages written to the swap over the last five minutes (pswpout of /proc/vmstat).
Swap-out from the warning value turns the swap-out card amber, from the critical value red; the occupancy alone never turns it red.
The defaults, 0.1 and 1.0 MiB/s, are set low on purpose: 0.1 MiB/s for a day is about 8 GB through the mirror.
The card's bar and chart are scaled against the critical value, its dashed lines mark both values.
The swap-out is recorded as the history series swapOut; no verdict and no sizing reads it yet.
Until v1.39.0 the keys were swapTrafficWarningMibPerSecond and swapTrafficCriticalMibPerSecond and counted the pages read back as well; the old names are still read, with a warning.
Repository names are the --repo selector of werkator build, retry, and status (without it a command means the current directory when served, otherwise the first registered repository) and will become the route segment of the web UI.
Two entries resolving to the same name abort the start, as does an entry that is not a git repository.
A repository whose configuration this Werkator must not read (see the version declaration) is skipped with an error; the others are served.
Inspect the Effective Config
java -jar build/libs/werkator.jar config:print # only explicitly set values
java -jar build/libs/werkator.jar config:print --full # all values including defaults
git.token is masked as *** by default, so the output can safely be shared or pasted.
Add --show-secrets to print it in clear text.
Convert to Forgejo Actions
tools/werkator-to-forgejo converts the committed .werkator.yml into Forgejo Actions workflows (RFC 0006, step 1); it runs werkator export:forgejo.
tools/werkator-to-forgejo [REPO] [--stdout] [--strict] [--output-dir DIR] [--runs-on LABEL]
REPOis the target repository, or any directory inside it (default: the current directory): the.werkator.ymlin its root is converted, and the workflows are written into it.--output-diris relative to the repository root (default.forgejo/workflows); a Gitea forge reads.gitea/workflows.- Only the committed file is read; the
.gitlayers, the applied fragment and~/.werkator.ymlare runner and instance configuration in Forgejo terms, and they hold the secrets. - Every build definition that triggers on its own becomes
.forgejo/workflows/werkator-<build>.yml; its follow-ups become jobs of that workflow withneeds, and literaltrigger.branchesof a follow-up become anifongithub.ref_name. - A job inherits nothing in a workflow, so each one carries the effective settings,
builds.defaultapplied. *in a branch selector becomes**, because it matches across/in Werkator; exclusions go last.- What has no workflow counterpart is written as a comment where it belongs and listed on stderr;
--strictwrites nothing and exits 1 instead. trigger.onPullRequestTargetbecomeson: pull_request_targetwith the typesopened,reopened,synchronizeandready_for_review, a job condition skipping drafts, a checkout ofgithub.event.pull_request.base.sha, and theWERKATOR_PR_*/WERKATOR_GITEA_*variables from the event and the repository context.--runs-onnames the runner label of every job (defaultdocker); the host's sandbox has no workflow counterpart.
.werkator.yml
Values shown are the defaults.
# The Werkator this file is written for (see the section above).
werkator:
version:
since: "0.9.18" # enforced: older Werkator refuses this file
below: "2.0" # optional release marker; warns, does not block
server:
# Public base URL of this Werkator installation — used for all links posted to Gitea.
publicBaseUrl: https://ci.example.org/
# HTTP port of the `server` subcommand (default 18080, like legacy)
port: 18080
# bind address of the `server` subcommand; loopback only, because the UI and the API
# are unauthenticated — set 0.0.0.0 only deliberately (see the note below)
bindAddress: 127.0.0.1
# optional Impressum (legal disclosure) and privacy-policy links in the web UI footer
# ("Imprint", "Privacy"); empty hides the link
impressumUrl: ""
privacyUrl: ""
# websites whose scripts may read artifact files, e.g. a CodeCharta viewer loading a
# code city: ["https://javagil.de"]; empty allows none, and no credentials are ever shared.
# Applies to every artifact of a public repository and to the released artifacts
# (builds.<name>.publicArtifacts) of a private one; preflights are answered alike
corsAllowedOrigins: []
# Resource limits of the systemd user unit generated by `init --systemd`; empty = directive omitted.
# Needed where the service shares a memory slice, e.g. Hostsharing Managed Webspaces, where a
# runaway Gradle build would starve everything else in the package.
systemd:
memoryMax: "" # e.g. 1G — written as `MemoryMax=` into the unit
tasksMax: "" # e.g. 512 — written as `TasksMax=` into the unit
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
# a usable reverse proxy (ADR 0005; see notes below and deployment.md).
nginx:
# manage an nginx Docker container with Let's Encrypt certificates
enabled: false
# public DNS name served by nginx and used for the certificate; required when enabled
serverName: ""
# host port published as nginx port 80 (ACME challenge + HTTPS redirect)
httpPort: 8080
# host port published as nginx port 443
httpsPort: 8443
# host nginx proxies to; empty = serverName (the container cannot reach localhost)
upstreamHost: ""
# name of the managed container; empty = werkator-nginx-<repo-name>
containerName: ""
# directory for nginx config, certificates, and logs;
# empty = $XDG_STATE_HOME (or ~/.local/state) plus /werkator/nginx/<repo-key>
stateDir: ""
# e-mail for the Let's Encrypt account; empty registers without one
letsencryptEmail: ""
# Gitea integration for fetching commits and posting build statuses.
gitea:
baseUrl: https://git.example.org # base URL of the Gitea instance
owner: my-org # repository owner (user or organisation) for Gitea API (e.g. status checks)
repo: my-repo # repository name
statusContext: werkator # label shown on Gitea commit status checks (default: werkator)
# Build execution settings, enforced for all builds regardless of their trigger
# (watcher, UI restart, CLI build/retry).
executor:
# How many builds may run at the same time.
# At most one build per branch runs regardless; each branch builds in its own
# git worktree under .git/werkator/worktrees/, never in the primary checkout.
# Changing this value requires a restart.
maxConcurrent: 1
# How many builds of ONE repository may run at the same time, below maxConcurrent.
# Unset leaves only the global cap. For a machine that takes one heavy build plus a
# light one, but not two heavy ones: maxConcurrent: 2 with maxConcurrentPerRepository: 1.
# A build waits for its repository's slot before it takes a global one, so a waiting
# build never blocks another repository. Changing this value requires a restart.
# maxConcurrentPerRepository: 1
# Pull-request target builds (trigger.onPullRequestTarget) have a pool of their own:
# they take no slot of maxConcurrent or maxConcurrentPerRepository, and no CI build
# takes one of theirs. Each pool has its own queue, so a waiting review never stands
# behind a waiting CI build, and the other way round. Changing them requires a restart.
# How many run at the same time over all repositories:
maxConcurrentPullRequestTargets: 2
# … and how many of ONE repository, below the value above:
maxConcurrentPullRequestTargetsPerRepository: 1
# The longest ANY build on this host may run before it is stopped and recorded as
# `timed_out`. It is a ceiling, not a suggestion: a build definition may set its own,
# shorter `timeout`, and a longer one is capped to this value. Since the whole
# `executor` section is stripped from a branch's own configuration, a branch cannot
# raise it either. Empty means no limit.
buildTimeout: 30m
# How much time a build may spend outside its own commands before the journal says so:
# preparing the worktree, preparing the build runtime (a container image, a sandbox
# rootfs), storing the artifacts. The wait for a free slot does not count — queueing on a
# host that runs one build at a time is the machine doing what it was configured to do.
# The warning names the slowest of those phases. It stops nothing; it only makes a
# regression in Werkator's own overhead visible. Empty means no warning.
overheadBudget: 30s
# Named build definitions (jobs, see notes below); every key names a build.
builds:
# "default" is the base every other definition inherits its settings from — never its
# trigger — and, with a trigger of its own, the build of every branch it selects.
# Without this entry an implicit default build (onPush over all branches) applies;
# writing it replaces that implicit one, so a default without a trigger is a settings
# base and nothing else.
default:
# How long this build may run, e.g. 10m. Empty takes the host's executor.buildTimeout;
# a longer value is capped to it, so this key can only ever shorten. It is therefore one
# of the few settings a branch's own configuration may set: it can give itself less time,
# never more. A build stopped by it is recorded as `timed_out`.
# timeout: 10m
# When this build runs and for which branches — the only part a definition does NOT
# inherit from builds.default; everything below the block does.
trigger:
onPush: true # build every new commit of the selected branches
# 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 (branch-configurable in a sandbox)
# run before each build
cleanCommand: rm -rf build
# shell command for each build
buildCommand: ./gradlew --console=plain --no-daemon test
# directories copied as build artifacts: relative paths inside the build's
# workspace, without '..' (Werkator refuses anything else); each is archived at its own
# workspace-relative path, except build/reports, which archives as reports/
# and is browsed by the artifact page's report index
artifactDirs:
- build/reports
- build/doc
stdoutLog: build.stdout.log # filename for captured stdout (a plain file name, never a path)
stderrLog: build.stderr.log # filename for captured stderr
# Build a selected branch only while its head commit matches a pull-request head on
# origin (refs/pull/*/head — read via plain git, no API token needed; see notes below).
# Pinned: a branch cannot set this in its own committed config.
requirePullRequest: false
# Gitea check this build reports as; empty uses gitea.statusContext. Two builds of one
# commit under the same context overwrite each other. Pinned like requirePullRequest.
statusContext: ""
# This build installs the version the instance is to run next: after it ends SUCCESS
# nothing new starts, the builds still executing are finished and recorded, and then
# the process ends so the service manager starts the new version. Pinned, and refused
# on `default` and on a build without trigger.branches (see notes below).
restartsInstance: false
# Artifacts a private repository serves without the control token: true for all, or
# globs over workspace paths as in artifactDirs. Only a branch named literally in the primary
# trigger.branches of this build publishes (see notes below).
# publicArtifacts: ["build/reports/codecharta/**"]
# Optional Docker build runtime; when enabled, the clean and build commands
# run inside a container instead of natively (see notes below).
docker:
# run clean/build commands in a Docker container (pinned)
enabled: false
# image for the build container; required when enabled. With dockerfile set, the
# build runs in the image Werkator builds, werkator-build/<scope>:current, and this
# name is only its label org.werkator.image
image: ""
# Dockerfile the image is built from before every command; empty pulls the image as-is
dockerfile: ""
# Docker build context used with dockerfile; dockerfile and context are paths
# inside the build's workspace, relative and without '..' (Werkator refuses anything else)
context: "."
# Docker network mode for the build container; empty = Docker default (pinned)
network: ""
# mount the host's Docker socket into the build container, so the build can start
# containers of its own (Testcontainers). Off by default: the daemon acts outside
# every boundary this runner sets, so a container the build starts can reach paths
# the build cannot. Turn it on only for builds that need a daemon (pinned)
socket: false
# additional environment variables set inside the build container
env: {}
# Every further job inherits the settings above and brings its own trigger.
# The nightly rebuild runs the full check instead of the quick on-commit one and is
# recorded separately as master@pitest:
pitest:
trigger:
atTimes: ["01:00"]
branches: ["master", "release/*"]
activeWithin: 24h
buildCommand: ./gradlew -PfullPitTest --console=plain --no-daemon piTestFull
artifactDirs: [build/reports, build/libs]
statusContext: werkator/pitest
# A pull-request target build: once per new head of every open, non-draft pull request,
# on the base branch, with its status on the head. Host-only, see the notes below.
# review:
# trigger:
# onPullRequestTarget: true
# buildCommand: tools/review
# statusContext: werkator/review
# Build artifact storage and retention.
artifacts:
# Root directory for stored build artifacts.
# Empty means the platform default: $XDG_STATE_HOME (or ~/.local/state) plus /werkator/artifacts/<repo-key>,
# where <repo-key> is the sanitized absolute repository path.
# A leading ~/ expands to the home directory; a relative path is resolved against the repository.
rootDir: ""
# Number of builds per build name whose artifacts and log are kept.
# Older builds stay in the history as a record — status, commit, times, duration — up to the
# newest 100 per build name, so the duration trend of a nightly or release build has data;
# their row offers no artifacts. A build of a branch deleted from origin loses its artifacts at once.
retentionPerBranch: 5
# Additionally drop the artifacts of builds older than this age; suffixes s (seconds), m (minutes),
# h (hours), d (days). Empty means no age limit.
# Combines with retentionPerBranch: a build keeps its artifacts only while it satisfies both limits.
# A branch's newest build is never age-pruned, so dormant branches keep their last status.
retentionMaxAge: ""
# Keep each branch's latest green (successful) build even beyond retentionPerBranch and retentionMaxAge.
# This backs the permanent artifact URLs /branches/<branch-key>/... — they always serve
# the latest green build of a branch and stay valid while newer builds fail.
# Once its branch is deleted from origin the artifacts are dropped and the entry is kept like any other.
keepLatestGreen: true
# Controls the branch-polling loop.
watcher:
# delay between poll cycles; suffixes s (seconds), m (minutes), h (hours), d (days)
pollInterval: 10s
# max commit age for new origin branches to be pulled automatically
newBranchMaxAge: 5d
# Honor the builds.<name>.requirePullRequest gates (see notes below).
# Set false for a plain git origin without pull-request refs (no Gitea/GitHub);
# gated branches then build on new commits like any other branch.
pullRequestGate: true
# At the end of each poll cycle, fast-forward the primary checkout's local branch refs
# to their origin counterparts (see notes below). Fast-forward only — a diverged or
# ahead local branch is never touched. Set false to leave refs/heads/* alone entirely.
fastForwardLocalRefs: true
Notes on server.bindAddress
The default is 127.0.0.1.
Neither the web UI nor the JSON API authenticates read access — which is intended, so build states and artifacts can be linked from anywhere — so Werkator is meant to sit behind the host's reverse proxy rather than on a public interface.
Set 0.0.0.0 only deliberately — for the managed nginx container (which reaches Werkator over the Docker bridge, not over loopback), or when the proxy runs on another host.
Installations created before v0.9.9 have bindAddress: 0.0.0.0 written into their .werkator.yml and keep it; the new default only applies where the key is absent or init writes a fresh file.
Notes on server.nginx
With nginx.enabled, the server subcommand also starts a managed nginx Docker container that serves Werkator over HTTPS (ADR 0005).
This is meant for hosts that provide Docker but no usable reverse proxy (e.g. Hostsharing managed containers); otherwise prefer the reverse-proxy setup in deployment.md.
Certificates are obtained and renewed via Let's Encrypt (certbot Docker container, webroot mode), so serverName must be a public DNS name pointing at the host and httpPort must be reachable from the internet as port 80 (or via a port forward).
When server.publicBaseUrl is empty and serverName is set, it defaults to https://<serverName>/.
All nginx/certificate failures are non-fatal warnings; the plain HTTP server keeps running without the proxy.
The container is labelled org.hoennig.werkator; stale nginx containers of the repository are removed before each start, and the container is removed on shutdown.
server.port must differ from httpPort and httpsPort.
Notes on gitea: pull request links
With gitea.baseUrl, gitea.owner, gitea.repo and a git.token, the pages show the open pull request of a branch as PR#<number> next to the branch name, linked to the pull request in Gitea.
There is no key for it: the connection that publishes the statuses is the one that reads the pull requests.
The watcher reads the open pull requests once per poll cycle (watcher.pollInterval), so a new pull request shows within one cycle.
Only pull requests from a branch of the repository itself count; a pull request from a fork names a branch of another repository.
Without a token, no pull request is shown; the pull-request gate (requirePullRequest) needs none and is not affected.
Notes on builds.<name>.requirePullRequest
The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds).
A manual werkator build <branch> always builds, regardless of this setting.
Detection works without a Gitea API token:
the watcher lists refs/pull/*/head on origin via git ls-remote and builds a branch only when its head commit equals one of those pull-request head commits.
This ls-remote call is made at most once per poll cycle, and only when a branch requiring a pull request is otherwise due.
Because matching is by commit id, a closed pull request whose head ref still equals the branch head also counts. Distinguishing open from closed pull requests would require the Gitea API.
To build pull-request branches only, gate the default build and give the permanent branches a build of their own:
builds:
default:
trigger:
onPush: true
branches: ["*", "!main"]
requirePullRequest: true
main:
trigger:
onPush: true
branches: ["main"]
requirePullRequest: false
Without that second definition, direct pushes and merges to main would never build — merge commits do not match any pull-request head.
The !main exclusion keeps the default build off it, so a push is built once instead of by both definitions.
A plain git origin (no Gitea/GitHub) serves no refs/pull/*/head at all, so gated branches would never build there.
For such origins, disable all gates globally with watcher.pullRequestGate: false — typically in the machine-specific .git/werkator/.werkator.yml, so the committed configuration keeps the gates for forge-backed environments.
Notes on builds (build definitions)
Every key of the builds section names a build definition (a job) over the branches — ADR 0007.
A build definition has a trigger block — when it runs and for which branches — and the settings that say what it does.
The split is structural because trigger is the one part never inherited from builds.default.
Writing any of its keys outside the block is refused with a message naming the definition: ignoring them would leave the build without a trigger, and a job that silently stops running is worse than a configuration that refuses to load.
Triggers: onPush: true builds every new commit of the selected branches; atTimes: ["HH:MM", …] rebuilds their heads once per day and slot (UTC).
onPullRequestTarget: true builds every new head of the open pull requests on their base, and stands alone — see its notes.
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.
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.
Branches may add and change trigger.afterSuccessOf for builds running in Docker or Werkdock without Docker socket access or restartsInstance.
The decision uses the primary sandbox policy, including inherited defaults; a branch cannot grant itself a sandbox exemption or restart permission.
For example, a branch can define samples-linux-amd64 after default, and later add an ARM64 sample build.
The samples run at the tested commit and can declare their own commands and artifact directories.
A trigger block explicitly written in the instance's defaults, the applied fragment or the repository's machine configuration stays pinned, even when the build runs in a sandbox.
Follow-ups running directly on the host, exposing the Docker socket or restarting the instance also keep their primary trigger pinned.
A branch cannot add afterSuccessOf to such a privileged definition.
Configure deployment triggers on the host, including deployments that use credentials inside a sandbox.
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.
# .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
# .werkator.yml — the repository's part: what
builds:
deploy:
cleanCommand: ""
buildCommand: scripts/deploy-prod.sh -y "$DEPLOY_TARGET"
Werkator deploys itself this way too, which is the one case where the deployment restarts the very instance that is running it: the build only installs the new runtime, because a restart from inside the build would end the build and have it re-run by the startup recovery — see RFC 0005 and Self-Deployment.
A build that restarts the instance
restartsInstance: true says that this build installs the version the instance is to run next.
When such a build ends SUCCESS — and only then; a failed, timed-out, cancelled or interrupted deployment restarts nothing — the executor stops taking builds, lets the builds still executing finish and be recorded, and then closes its context.
Restart=always in the systemd unit starts the version the build installed.
Builds that were queued stay PENDING and are re-enqueued by the startup recovery; while the drain runs, /api/watcher reports restarting and the UI says so in its banner, because that is the one case where the queue stands still and nothing is wrong.
The build itself knows: the executor sets WERKATOR_RESTARTS_INSTANCE=true in its environment, so a deployment script can leave the restart to the instance instead of arranging one.
The key is pinned, and two host configurations are refused at start:
- on
builds.default, where every build would inherit it and every push would end the instance; - on a definition without
trigger.branches, which would install whatever branch went green last.
With a registry, a third one is refused (PR#87): restartsInstance in any repository whose entry in ~/.werkator.yml does not say selfDeploys: true.
The registry refuses to start when more than one entry says it.
A repository's own files cannot tell whether the instance runs from it, and a deployment block copied into a foreign repository would run that repository's build command on its green main and then restart the instance.
Such a repository is not served, and the log names the file and the entry to fix.
Without a registry the instance serves one repository, and that one may restart it.
Artifacts a private repository serves to everyone
A private repository serves nothing to a visitor without the control token (PR#43), its artifacts included.
publicArtifacts releases some of them, for a viewer on another site such as CodeCharta (PR#114):
builds:
release:
trigger:
atTimes: ["01:00"]
branches: ["master"]
publicArtifacts:
- build/reports/codecharta/**
- The value is
truefor every artifact of the build, or a list of globs over the workspace paths, written asartifactDirswrites them.build/reportsis stored and served asreports/, sobuild/reports/codecharta/**releases what the URL shows under…/reports/codecharta/. A*stays within a directory,**crosses directories. - What is released comes from the
.werkator.ymlof the built commit, so the list changes with the code that writes the files and goes through its review. - Who may release does not: a branch writes that file for itself.
A build releases only when the primary configuration — the checkout's
.werkator.ymland the host layers, never a branch's own file — names its branch literally in the definition'strigger.branches. A glob does not count, since it also selects the branches somebody pushes tomorrow;builds.defaultwith["*", "!master"]therefore releases nothing. - Released are the files under the permanent links
/repos/<name>/branches/<build-key>/<path>and/repos/<name>/artifacts/<key>/<path>, a directory through its page. The branch pages, the build list and the artifact index stay hidden, and every other path answers the 404 of an unknown repository. - A public repository serves everything anyway; the key changes nothing there.
- A released artifact is readable by the scripts of the websites in
server.corsAllowedOrigins, preflight included; an artifact that is not released never carries a CORS header, not even for a browser holding the control token. - Forgejo serves an artifact exactly as visible as its repository; the export names the key in a comment.
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.
activeWithin (e.g. 24h) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches.
Both parts combine as an intersection.
Settings: buildCommand, cleanCommand, artifactDirs, stdoutLog/stderrLog, requirePullRequest, statusContext, restartsInstance, publicArtifacts, 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, restartsInstance, docker.enabled, docker.network, docker.socket, werkdock.enabled, werkdock.rootfs, werkdock.binary, and werkdock.gradleHome are pinned (master-pinned, see the branch layer): they are read from the repo install/project config even when a branch sets them in its own committed config.
Host-authored triggers and privileged follow-up triggers are pinned; ordinary sandboxed follow-ups are branch-configurable.
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.
The implicit default build (onPush: true, all branches) preserves the behavior without any definitions; defining other builds does not disable it, builds.default.onPush: false does.
The default build records under the plain branch name; every other build records under <branch>@<name> with its own row in the branches view (sorted after its branch), its own retentionPerBranch count, latest status, and permanent latest-green artifact link.
The URL key is the sanitized pool name — master@pitest is served as /branches/master_pitest/….
The pools live as long as the underlying branch exists on origin.
Restart, werkator retry, and the startup recovery re-run a build under its recorded definition, resolving the settings from the current configuration — the job definition is the source of truth, not the historical run.
The builds still run in their branch's worktree, one build per branch at a time.
A build that fails before its commands give a result — the worktree, the image import, a docker run or werkdock run that ends with its own exit code 125 — reports build environment failed after … instead of build failed after …: there is no verdict of the build, and a restart may help.
The Gitea commit status is reported per commit under gitea.statusContext, so two builds of the same commit overwrite each other's check — give the second one its own statusContext (werkator/quick, say), or keep them apart with an exclusion pattern.
The concurrency limit that used to live in this section moved to executor.maxConcurrent without an alias.
A leftover builds.maxConcurrent key (or any other scalar where a definition belongs) is ignored with a warning, not a startup failure — a committed config cannot always be changed right away.
What a build finds in its environment
Every command of every build runs with CI=true, natively, in the Docker container and in the werkdock sandbox.
Build tools ask this variable to tell an unattended run from a developer's terminal: no prompts, no progress bars, no watch mode.
A definition that needs another value sets it in docker.env or werkdock.env; those come last and win.
Natively there is no such section, so a native build always sees CI=true.
Werkator also sets branch to the branch being built, WERKATOR_RESTARTS_INSTANCE=true for a build with restartsInstance (see Deployment), and the pull-request variables of a pull-request target build (see below).
Notes on trigger.onPullRequestTarget
A pull-request target build judges a pull request with the host's secrets, the way Forgejo's and GitHub's on: pull_request_target does — an automated code review whose verdict is a merge gate, say.
# .git/werkator/.werkator.yml — the host's part
builds:
review:
trigger:
onPullRequestTarget: true
statusContext: werkator/review
cleanCommand: ""
buildCommand: tools/review
werkdock:
env:
REVIEW_API_KEY: "…"
When: the watcher reads the open pull requests once per poll cycle, and a build is due for every pull request that is not a draft and whose current head commit has no build of this definition yet.
Gitea marks a pull request as a draft by its WIP: or [WIP] title prefix; once it is ready, its current head is due.
A new head commit of a ready pull request is due again; a head that was built is not built again, whatever the outcome — a restart re-runs it by hand.
Pull requests from forks are left out, as for the pull-request links.
Where: the build checks out the base branch at the commit the forge reports as its head, never the pull request's head, in a fresh workspace of its own.
No file of the pull request is executed by it; a review tool reads the change through the forge's API.
The status is published on the pull request's head commit under the definition's statusContext: green for exit 0, red otherwise and for a timeout, pending while it runs or after an interruption.
The result is recorded under the head branch as <head-branch>@<build>, with the pull request beside it, so the pull-request links work as for every build.
Restart, werkator retry and the startup recovery re-run the recorded pull request — the same head on the same base.
What the build learns: WERKATOR_PR_NUMBER, WERKATOR_PR_HEAD_SHA, WERKATOR_PR_HEAD_REF, WERKATOR_PR_BASE_REF, WERKATOR_PR_BASE_SHA, and WERKATOR_GITEA_BASE_URL, WERKATOR_GITEA_OWNER, WERKATOR_GITEA_REPO — natively, in the Docker container, and in the werkdock sandbox.
Secrets reach it through the host's werkdock.env/docker.env of this definition, like those of a deployment.
What a target build does not share: every other build of the repository runs the code of whatever branch it builds, so nothing those builds can write reaches a target build.
In the werkdock sandbox it gets a fresh, empty home as /root, created beside its fresh workspace and removed with it, never the repository's toolchain home .git/werkator/buildenv/home — no .bashrc, no sitecustomize.py, no werkdock.sock another build left there — and no werkdock.gradleHome.
In Docker it gets neither the per-repository Gradle cache volume nor the Docker socket; docker.socket: true on it, also inherited from builds.default, is refused at start.
Nor does it build an image: docker.dockerfile on it, also inherited, is refused at start, and it runs in the host's docker.image as it is.
Its workspace is the base commit of the pull request, and any contributor can create a base branch and open a pull request into it.
It never runs on the host itself, where it would share the host user's HOME: a target build without docker.enabled or werkdock.enabled (after builds.default is applied) is refused at start.
Its caches start empty every time, so it downloads what it needs on every run; a review tool should need little.
Host-only: the definition is read from the primary configuration alone — the instance defaults, the checkout's .werkator.yml, the applied fragment and the machine config.
A branch's committed file cannot change it in any key, nor set it to null, and a definition it writes with onPullRequestTarget is ignored with a warning; the build does not even inherit from a builds.default the branch rewrote.
Otherwise the pull request under review could replace the command by exit 0 and fake its own gate, or print the secrets.
A manual werkator build of such a definition fails: it runs only for a pull request the watcher found open.
It stands alone, and the start is refused, naming the definition, when it is combined with onPush, atTimes, branches, activeWithin or afterSuccessOf, with requirePullRequest, on builds.default, or when another build follows it.
It needs gitea.baseUrl, gitea.owner, gitea.repo and git.token; without them it never runs, and the watcher says so once.
Concurrency: a target build mostly waits for a language model, so it does not wait for the CI.
It runs in a pool of its own, executor.maxConcurrentPullRequestTargets over all repositories (default 2) and executor.maxConcurrentPullRequestTargetsPerRepository per repository (default 1), and takes no slot of maxConcurrent or maxConcurrentPerRepository.
It is not serialized with the builds of its head branch either, since it never touches the branch's worktree.
Two runs of the same pull request and definition, an old head and a new one, still run one after the other: they share the fresh workspace named <head-branch>@<build>.
A run that is overtaken by a new head is not cancelled; its status belongs to the old head commit.
The Current Builds view marks such a build as PR target, and the System page counts it in a tile of its own.
Not covered: a base branch that moves while the head stays starts no new build. Protect the base branch in Gitea with "Block merge if pull request is outdated", so a stale verdict cannot be merged. Review approvals start nothing either.
The legacy branches section
Before build definitions existed, the settings lived in a per-branch branches section, with branches.default as the fallback for every branch not listed.
That section is deprecated and will be removed.
It is still read, but only while the merged configuration defines no build at all: as soon as one real definition exists, branches is ignored completely and a warning names it.
builds.maxConcurrent is not a definition — a configuration carrying only that leftover still uses branches.
Either or, never both: a definition now carries the complete description of its build, and two half-answers would silently pull against each other.
The decision is made on the merged configuration, so a machine config that defines builds switches branches off for every branch, including the branches whose committed config still has one.
branches.<name>.autoBuild (enabled + times) is the pre-ADR-0007 schedule that goes with it: it rebuilds the branch's own pool with its regular command and logs a deprecation warning.
autoBuild.times entries carrying their own buildCommand/name (a short-lived v0.9.13 syntax) are no longer supported — use a build definition.
To migrate, move branches.default to builds.default, add onPush: true, and turn every other branch entry into a definition with a branches selector of its own.
Notes on watcher.fastForwardLocalRefs
Builds run in worktrees that share the primary checkout's .git, so a build tool can read refs/heads/* there.
Werkator itself never needs those refs to be current — it builds the commit refs/remotes/origin/<branch> points at — but build tools do.
A common case is a check that refuses to run when the local main branch differs from its origin counterpart; without this key it would fail on every build once origin moved on, because nothing would ever advance the local ref.
The fast-forward runs at the end of the poll cycle, after the due branches were enqueued.
That order is required, not cosmetic: a local ref lagging behind origin is exactly how the watcher recognizes new commits, so a ref kept in sync earlier — by this key, a cron job, or a mirroring fetch refspec (+refs/heads/*:refs/heads/*) — would silently stop the branch from ever being built.
Only fast-forwards are applied, as a compare-and-swap against the commit just read.
A local branch that diverged from origin or is ahead of it stays untouched, so local work in the primary checkout is never lost.
The branch checked out in the primary checkout is advanced with git merge --ff-only, which refuses to overwrite conflicting uncommitted changes; a refusal is logged and the cycle continues.
Notes on builds.<name>.docker
With docker.enabled, Werkator shells out to the docker CLI; the docker command must be on the PATH.
When dockerfile is set, docker build runs before every command of the build; Docker's layer cache keeps an unchanged build short,
and a changed file of the context is never missed.
docker build runs in the build's workspace, with the real paths of the Dockerfile and the context;
a path that leads out of the workspace, also through a symlink, stops the build before the image is built.
Such an image is tagged werkator-build/<scope>:current, with a scope per repository path, branch and image;
image then only names it in the label org.werkator.image.
So a branch never replaces the image another branch runs in, nor, since a pull-request target build builds none, the image of such a build.
The image label org.werkator.build-inputs-sha256 records the Dockerfile content and the two paths it was built from.
A Gradle cache volume werkator-gradle-<repo-key> is created per repository and mounted as GRADLE_USER_HOME.
The build worktree is bind-mounted into the container; after each command the ownership of build/ and .gradle/ is repaired to the host user.
Git works inside the container: the primary repository's .git is mounted read-only (so build steps can run read-only git commands like git log or git describe), with .git/werkator/ masked by an empty tmpfs so the build can never read the machine config (git.token) or the control token.
Note that the rest of .git — including .git/config — is visible to builds; Werkator never stores credentials there, and neither should you.
The Docker socket is mounted into the container and DOCKER_HOST/TESTCONTAINERS_* variables are set, so Testcontainers-based builds work inside the container.
TESTCONTAINERS_HOST_OVERRIDE tells Testcontainers where it reaches the ports it publishes.
Werkator sets it from the daemon before every build:
- a rootless daemon whose rootlesskit reports the
slirp4netnsport driver publishes ports on its child address only, usually10.0.2.100; Werkator asks rootlesskit's API (/run/user/<uid>/dockerd-rootless/api.sock) and passes that address, in both network modes; - otherwise it is
localhostwithnetwork: hostandhost.docker.internalotherwise.
A daemon switched between port drivers needs no configuration change; the next build follows it.
A value in docker.env still wins, as the escape hatch for a daemon Werkator cannot ask.
Build scripts that publish ports themselves should read the same variable instead of assuming 127.0.0.1.
All Werkator containers carry org.hoennig.werkator labels; stale build containers of the repository are removed before the first Docker build after a restart.
Notes on builds.<name>.werkdock
With werkdock.enabled, Werkator runs the build in a bubblewrap sandbox instead of native execution.
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see docs/plan/17-bwrap-build-runtime.md and ADR 0008.
Since step 21 session C the sandbox is executed by the werkdock CLI (werkdock.binary, default: resolved via PATH) — Werkator no longer invokes bwrap itself; bwrap must be installed for werkdock.
The section was called bwrap and its binary key bwrap.werkdock until v1.2.0; both are still read, with a warning naming the file, so an installation can be migrated at its next configuration edit rather than at the next update.
werkdock doctor checks the host's capability (it replaced the retired tools/werkator-build-prerequisites.sh in step 23).
werkdock.rootfs names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since debootstrap is unavailable on the target.
It is a local path or an http(s) URL; a URL is downloaded once into .git/werkator/buildenv/.
Build the archive with tools/build-bwrap-rootfs.sh on any machine with Docker.
The archive is imported once per source as the werkdock image werkator-buildenv-<hash> into werkdock's store ($WERKDOCK_HOME, default ~/.werkdock) — shared by every repository of this OS user; the hash derives from the source string, so a changed rootfs imports a fresh image.
Only one build of the instance imports an image at a time, across all repositories: a build that needs the same image waits for that import and then uses the image; if the import failed, it tries again itself.
An image whose source no repository of the instance names any more is removed again by the watcher (PR#77): at most once an hour, never while a build executes, and only images Werkator imported — whatever else the store holds (a Testcontainers postgres, an alpine) belongs to whoever pulled it.
The download cache of a URL source under .git/werkator/buildenv/<hash>/ is removed the same way; the sandbox home and archives placed there by hand are not touched.
Werkator uses werkdock import ARCHIVE IMAGE (docker import semantics) and falls back to the older werkdock load -i ARCHIVE --name IMAGE when the installed werkdock does not know the verb yet, so Werkator and werkdock can be updated in either order.
The image keeps its untagged name (it equals werkator-buildenv-<hash>:latest in werkdock's docker-style naming), so an existing store needs no re-import.
Per-repo Gradle caches persist in .git/werkator/buildenv/home, bound as /root.
werkdock.gradleHome names a host directory that every sandbox naming the same path shares as its Gradle user home instead: it is mounted as /gradle-user-home with GRADLE_USER_HOME pointing there, so one dependency cache and one provisioned JDK serve all repositories of the host.
Gradle locks its caches, so builds may run concurrently against it.
It is pinned and deliberately opt-in: the path is mounted read-write, and a shared cache is shared trust — a build that can write it can feed a tampered dependency to every other repository, so share it only among repositories you would let write each other's caches.
Set it once for the whole instance in the defaults block of ~/.werkator.yml; a per-repository .gradle left under /root stays untouched and can be deleted by hand.
werkdock.env adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
The values of a sandboxed build's variables — werkdock.env, docker.env and what Werkator sets, such as branch — never stand on the command line of werkdock run or docker run, where every ps shows them (PR#166).
- werkdock: the command line names each variable as a bare
-e KEY, and its value travels in the environment of thewerkdockprocess. That process gets nothing else of the server's environment but what it reads itself. werkdock 0.13.2 keeps the values offbwrap's command line too, and 0.13.3 startsbwrapwith an empty environment. - Docker: the values go to a file of mode
0600below.git/werkator/docker-env/, whichdocker run --env-filereads; the file goes when the client ends. The docker client's own environment is no place for them: it startssshand credential helpers with it. A variable that docker's file format cannot carry, i.e. a value with a line break or a line of 64 KiB or more, stays--env KEY=VALUEon the command line.
For werkdock, a name that werkdock or the host's loader reads itself keeps its value on the command line, because in werkdock's environment it would steer werkdock instead of the build:
PATH, HOME, USER, LOGNAME, SHELL, TMPDIR, TZ, LANG, DISPLAY, WAYLAND_DISPLAY, the proxy variables,
the Go runtime settings (GODEBUG, GOGC, GOMAXPROCS, GOMEMLIMIT, GOTRACEBACK),
glibc's variables that are unsafe for a privileged process (e.g. GCONV_PATH, LOCPATH, GLIBC_TUNABLES),
and every name starting with LD_, LC_, XDG_, DOCKER_, WERKDOCK_, SSH_, SSL_ or OPENSSL_, in any case.
Never give a secret such a name, nor a line break to a secret of a Docker build.
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
docker and werkdock are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
Git works inside the sandbox exactly as inside the Docker container: the primary .git is mounted read-only with .git/werkator/ masked, so builds can run read-only git commands but never reach the machine config or the control token.
.git/werkator/.werkator.yml (not committed)
# Machine- or user-specific overrides and secrets. Keys here win over .werkator.yml.
git:
account: my-user # technical username for git HTTPS authentication
token: glpat-xxxxxxxxxxxxxxxxxxxx # Gitea API token — never commit this
init writes this file with the git block commented out.
On an instance with a defaults.git (see the instance config), that is all a repository needs: leaving the block commented out is how it inherits the shared credentials.
Uncomment and fill it in only where this repository authenticates as someone else on this machine.
A file that holds nothing but comments is a layer that sets nothing — not an error.