* Step 22 B: RepoContext over the current repository
A RepoContext bundles a repository's primary checkout with the state that lives
inside or is keyed by it (results, artifact store) and carries its name. Today
there is exactly one, opened over the current working directory; the result and
artifact-store beans now come from it, so nothing else changes yet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Step 22 B: the watcher polls a RepoContext
start/poll/recoverOnStartup take the context instead of a working directory and
read results and artifacts from it; the per-repository poll memory (logged fetch
error, deprecation warning, cached branch definitions) moves into a RepoWatch
keyed by context, so the next session can iterate contexts without one
repository's outage silencing another's. The shared WatcherState is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Step 22 B: the executor runs builds of a RepoContext
startBuild takes the context first; builds serialize per (context, branch) and
share the global maxConcurrent cap across repositories, results and artifacts go
to the build's own context. ConsoleBuildRunner, the build/retry commands and the
builds API restart pass the current repository's context along.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Step 22 B: the UI and the branch listing read their RepoContext
UiController and BuildsApiController take the current repository's context
instead of a settable working directory; BranchListing lists the branches of a
context and reads the results from it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Step 22 B: document the RepoContext, PR-doc for PR #11
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
'werkator init --apply FILE' installs a config-schema YAML fragment as
its own layer: validated strictly (an unknown key is refused loudly,
never ignored — a typo must not install a silent no-op), then copied
verbatim to .git/werkator/.werkator.applied.yml, above the project
config and below the hand-edited machine config, which always wins.
Deviation from the plan sketch, recorded there: a separate layer
instead of an in-place merge, because merging would re-serialize the
machine config — destroying its comments and rewriting the file that
holds the secrets; re-apply is a plain file replacement.
init --systemd now also generates werkator.htaccess beside the units
whenever a publicBaseUrl is configured — generated host integration for
the managed-webspace Apache, copied into the docroot by the wrapper.
New subcommand 'werkator control-token' prints (and lazily creates) the
token via ControlTokenService, so no wrapper needs its own generator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runner no longer assembles raw bwrap argv: it checks image
existence via 'werkdock images', loads the rootfs archive once per
source as image werkator-buildenv-<hash> into werkdock's store (shared
across every repository of the OS user — resolving step 22's
buildenv-sharing question), and runs builds through 'werkdock run --rm'
with the git-metadata mask expressed as ordered -v/--tmpfs flags.
New pinned config key bwrap.werkdock (the executing binary, default via
PATH) — a branch must not substitute the executing binary; synced in
WerkatorConfig, BwrapOverrides, the pinned-key strip, the init
template, docs/configuration.md, and AGENTS.md.
werkdock's --clearenv means the server environment no longer leaks into
builds; the TMPDIR workaround is gone with the raw invocation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every artifactDir landed below reports/ — the legacy
archived_artefact_dir_path layout — which mislabeled non-report outputs
(reports/werkdock/dist/werkdock) AND hid them: the artifact page's
report index only scans reports/ for HTML pages, so a built binary was
stored but never shown.
Now build/reports keeps archiving as reports/ (the browsable anchor and
every existing link), every other directory archives at its
workspace-relative path, and the artifact page gains a plain-files list
for everything outside reports/ (log files stay in their own section;
capped at 200 entries). Existing stored artifacts keep their old layout
and remain served; only new builds use the new one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bwrap inherits the server's environment; on hosts with pam_tmpdir that
includes TMPDIR=/tmp/user/<uid>, which does not exist on the sandbox's
fresh tmpfs /tmp. Every tool honoring TMPDIR then fails — seen live on
mih34 as go's 'creating work dir: stat /tmp/user/120957: no such file
or directory'. The JVM ignores TMPDIR, so Gradle builds never noticed.
Explicit environment and bwrap.env entries can still override.
(Werkdock's own engine is immune by design: it runs --clearenv.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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)
* renaming from gitTally to Werkator
* Rename GitTally to Werkator
`gitTally` is the name of another product in the git space, so the
rename is a precaution; nothing about what the build system does changes.
The name follows one rule: `Werkator` where it is prose, capitalized
where it is a Kotlin type and its file, lowercase everywhere a machine
reads it — the command, packages, paths, configuration keys and values,
the Gitea check context. Environment variables keep their convention and
are uppercase throughout.
Every configuration file is still found under its pre-rename name
(`ConfigFiles`): `.gittally.yml` at the repository root, in a build
worktree and as committed on a branch, `.git/gittally/.gittally.yml` for
the machine layer. The current name wins where both exist, and the old
file is then ignored rather than merged — two files side by side are a
half-done rename, not a layering. Without the fallback an installation
that updated without renaming would not fail: a configuration that is
not found leaves every setting at its default, so it would come up
looking healthy while having forgotten its credentials and its builds.
`docs/werkator-migrationsplan.md` lists what the fallback does not
cover and has to be moved by hand — above all the state directory
`.git/werkator/`, which holds the build history, the control token and
the worktrees, and has no fallback of its own.
`docs/migration-from-legacy.md` is deleted with this: it mapped the
legacy script's environment variables, and every host it addressed has
long since moved to the YAML configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Move the pre-rename state directory at the first start
The configuration is found under either name, the state is not: build
history, control token, auto-build slots and worktrees live at one fixed
path. An installation that updates without moving `.git/gittally` would
not fail — it would come up with an empty history and a fresh control
token, quietly. So the first start moves it instead of the release notes
asking for it.
Only when the old directory exists and the new one does not. Where both
exist nothing is touched and a warning names the leftover: which of the
two is the live state is not something to guess. A failed move is an
error in the log, never an abort — a CI must not hang on it.
The worktrees are dropped rather than moved, since they point at their
old path in both directions; `GitWorktreeWorkspaces` prunes the stale
admin entry and recreates each on its branch's next build. A generated
systemd unit moves with the directory and leaves its symlink dangling,
which is warned about — the running service is unaffected, the next
start is not.
Runs from `CliRunner`, before any command resolves a path under the
directory, and so before the second context of `server` exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Document PR#1: the rename to Werkator
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Drop the legacy env-to-YAML conversion from the setup tool
The old bash script configured itself through `GITTALLY_*` environment
variables. The blanket rename rewrote those literals, so the converter
was looking for `WERKATOR_*` — a spelling no host has ever written. Fed
a real legacy file it would have found nothing and written an almost
empty configuration, without an error, which is the same silent failure
this rename is otherwise careful to avoid.
The conversion has served its purpose with the vm2176 to vm4006
migration, so it goes instead of being repaired. What remains is the
setup of a new instance: the preconditions, the credential prompt, and
the machine configuration written mode 600 — now carrying the host's
public URL as well, since that is host-specific too. Everything the
repository builds comes from `init` and its templates.
It also stops emitting a legacy `branches:` section, which step 18 is
about to reject outright.
`docs/plan/00-legacy-analysis.md` and `13-nginx-tls.md` get the real
`GITTALLY_*` spelling back: they record what the old script read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Describe this repository's build with a build definition
Its own `.werkator.yml` still used the deprecated `branches` section
with an `autoBuild` schedule that was switched off. That section is read
only while nothing defines a build at all, and step 18 rejects it by
name — so this repository would have blocked the precondition of that
step, which asks that no configuration still in play carries it.
Nothing about the build changes: `builds.default` with `trigger.onPush`
is a build of every new commit on every branch, which is what the branch
section said. `config:print --full` resolves the definition completely
and logs no deprecation warning any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Stop documenting the pre-rename fallback for users
Exactly one repository is configured with the old names, and it is
migrated by hand in the same move as this release. The fallback is
therefore a transition of days, not a feature anyone reading the release
notes or the configuration reference has to plan around.
Removed from `releases.html` and `docs/configuration.md`. The mechanism
itself is unchanged and stays described where it is worked on: in
`ConfigFiles`, in `StateDirMigration`, and in the migration plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Bring the PR-doc to its final state
The `statusContext` question is answered and marked as decided rather
than left standing: it is the one value a human reads as a label, and it
stays lowercase because Gitea matches it and the client reads it back,
which makes it a value.
Also records that the pre-rename fallback is deliberately absent from
the release notes and the configuration reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Release v1.0.0: Werkator
The release after 0.9.21 is 1.0.0, because a product that changes its
name is better off counting from one under it. The release note says as
much, so the jump is not read as a claim about maturity — plan steps 14,
17 and 18 are still open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Correct the PR-doc about the version
It claimed the PR carries no version bump, which the release commit made
untrue, and records why the number is 1.0.0 instead of 0.9.22.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Point the legacy references at the history
`legacy/gitTally` was removed from the tree with the rename, but the
README still described it as a reference kept in the repository, and the
plan told an executing session to read parts of it — including step 14,
which is open.
The README section is gone; `docs/plan/README.md`, step 14 and the
legacy analysis now say where the script actually is
(`git show 7f55068^:legacy/gitTally`). Executed steps and ADR 0004 keep
their wording: they record what was true when they ran.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Retarget the links in the historic PR-docs
The package rename moved every file the older PR-docs link to, leaving
60 dead links. Only the link targets are rewritten, never the visible
text and never a statement: those documents record what was true when
they were written, GitTally in the prose included. A snapshot may be
outdated; it should still be navigable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add the v1.0.0 deployment procedure for vm4006
Measured, not estimated: the state directory is 878 MB, of which 878 MB
are the nine build worktrees. What cannot be recreated is 84 KB, so the
snapshot before an in-place switch is instant and the rollback is one
sequence of moves.
Records the three expected non-failures — a cold Gradle volume, one
image rebuild, containers left under the old label — and that
`gitea.statusContext` needs no attention because it comes from the
watched repository's committed configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Rename the machine configuration along with its directory
Found by the deployment to vm4006: the move renames the directory and
leaves the file inside it alone, so the machine configuration ended up at
`.git/werkator/.gittally.yml` — a pair of names the lookup did not
expect, because it pairs directory and file name. The instance resolved
empty credentials, no public URL and none of the host's build
definitions, and said nothing about it. That is the exact failure this
change exists to prevent, produced by the change itself.
`StateDirMigration` now renames the configuration with the directory,
unless one under the current name is already there. `ConfigFiles` carries
`.git/werkator/.gittally.yml` as a third candidate as well, for a
directory somebody moved by hand, where the migration never runs and so
can rename nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Let init see a configuration under its previous name
`init --systemd` runs `init`, and its "already exists" check knew only
the current name. On the one repository still carrying `.gittally.yml`
it therefore wrote a fresh template `.werkator.yml` beside it — and
since the current name wins, that repository would have built the
template's `./gradlew test` instead of what its own configuration says.
Found on vm4006, where the file was created in the watched working tree
and removed again by hand.
Both checks now ask `ConfigFiles`, so init decides existence by the same
rule the loader uses to read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Record the v1.0.0 deployment to vm4006
Deployed from the branch as the final test of PR#1, and it did what a
final test is for: it found two silent-failure defects before the
service was started, both fixed and redeployed in the same window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Drop the control token left under the old localStorage key
The key is named after the product, so the rename left every browser
with a token under `gittally.controlToken`, which nothing reads any more
and which "forget token" can no longer reach. It is a write-scope token
in a browser store, not a password, but a secret nobody owns is worth
one line to remove.
Removed on load. The token on the server is unchanged, so re-entering it
once per browser is all the rename costs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Build the branch's current head from the branches view
A row on /branches stands for a branch, not for a past run, so its
restart button now builds what origin has now (`atOriginHead`). Repeating
an overtaken commit answers a question nobody asked, and a build gate
comparing HEAD against origin — hsadmin-ng's `prQuickCheck` — cannot even
pass on it: the retry of a superseded master commit is a guaranteed red.
Latest and history are unchanged. There a row *is* a recorded run, and
repeating it means that commit.
The row keeps its build definition either way: a retry of `main@pitest`
runs the new commit as `pitest`, on the branch the record names. A branch
that is gone from origin is refused by name instead of silently falling
back to the old commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Document the branches-view restart (PR#000)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Release v1.0.1: the branches view builds the current head
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* renaming from gitTally to Werkator
* Rename GitTally to Werkator
`gitTally` is the name of another product in the git space, so the
rename is a precaution; nothing about what the build system does changes.
The name follows one rule: `Werkator` where it is prose, capitalized
where it is a Kotlin type and its file, lowercase everywhere a machine
reads it — the command, packages, paths, configuration keys and values,
the Gitea check context. Environment variables keep their convention and
are uppercase throughout.
Every configuration file is still found under its pre-rename name
(`ConfigFiles`): `.gittally.yml` at the repository root, in a build
worktree and as committed on a branch, `.git/gittally/.gittally.yml` for
the machine layer. The current name wins where both exist, and the old
file is then ignored rather than merged — two files side by side are a
half-done rename, not a layering. Without the fallback an installation
that updated without renaming would not fail: a configuration that is
not found leaves every setting at its default, so it would come up
looking healthy while having forgotten its credentials and its builds.
`docs/werkator-migrationsplan.md` lists what the fallback does not
cover and has to be moved by hand — above all the state directory
`.git/werkator/`, which holds the build history, the control token and
the worktrees, and has no fallback of its own.
`docs/migration-from-legacy.md` is deleted with this: it mapped the
legacy script's environment variables, and every host it addressed has
long since moved to the YAML configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Move the pre-rename state directory at the first start
The configuration is found under either name, the state is not: build
history, control token, auto-build slots and worktrees live at one fixed
path. An installation that updates without moving `.git/gittally` would
not fail — it would come up with an empty history and a fresh control
token, quietly. So the first start moves it instead of the release notes
asking for it.
Only when the old directory exists and the new one does not. Where both
exist nothing is touched and a warning names the leftover: which of the
two is the live state is not something to guess. A failed move is an
error in the log, never an abort — a CI must not hang on it.
The worktrees are dropped rather than moved, since they point at their
old path in both directions; `GitWorktreeWorkspaces` prunes the stale
admin entry and recreates each on its branch's next build. A generated
systemd unit moves with the directory and leaves its symlink dangling,
which is warned about — the running service is unaffected, the next
start is not.
Runs from `CliRunner`, before any command resolves a path under the
directory, and so before the second context of `server` exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Document PR#1: the rename to Werkator
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Drop the legacy env-to-YAML conversion from the setup tool
The old bash script configured itself through `GITTALLY_*` environment
variables. The blanket rename rewrote those literals, so the converter
was looking for `WERKATOR_*` — a spelling no host has ever written. Fed
a real legacy file it would have found nothing and written an almost
empty configuration, without an error, which is the same silent failure
this rename is otherwise careful to avoid.
The conversion has served its purpose with the vm2176 to vm4006
migration, so it goes instead of being repaired. What remains is the
setup of a new instance: the preconditions, the credential prompt, and
the machine configuration written mode 600 — now carrying the host's
public URL as well, since that is host-specific too. Everything the
repository builds comes from `init` and its templates.
It also stops emitting a legacy `branches:` section, which step 18 is
about to reject outright.
`docs/plan/00-legacy-analysis.md` and `13-nginx-tls.md` get the real
`GITTALLY_*` spelling back: they record what the old script read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Describe this repository's build with a build definition
Its own `.werkator.yml` still used the deprecated `branches` section
with an `autoBuild` schedule that was switched off. That section is read
only while nothing defines a build at all, and step 18 rejects it by
name — so this repository would have blocked the precondition of that
step, which asks that no configuration still in play carries it.
Nothing about the build changes: `builds.default` with `trigger.onPush`
is a build of every new commit on every branch, which is what the branch
section said. `config:print --full` resolves the definition completely
and logs no deprecation warning any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Stop documenting the pre-rename fallback for users
Exactly one repository is configured with the old names, and it is
migrated by hand in the same move as this release. The fallback is
therefore a transition of days, not a feature anyone reading the release
notes or the configuration reference has to plan around.
Removed from `releases.html` and `docs/configuration.md`. The mechanism
itself is unchanged and stays described where it is worked on: in
`ConfigFiles`, in `StateDirMigration`, and in the migration plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Bring the PR-doc to its final state
The `statusContext` question is answered and marked as decided rather
than left standing: it is the one value a human reads as a label, and it
stays lowercase because Gitea matches it and the client reads it back,
which makes it a value.
Also records that the pre-rename fallback is deliberately absent from
the release notes and the configuration reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Release v1.0.0: Werkator
The release after 0.9.21 is 1.0.0, because a product that changes its
name is better off counting from one under it. The release note says as
much, so the jump is not read as a claim about maturity — plan steps 14,
17 and 18 are still open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Correct the PR-doc about the version
It claimed the PR carries no version bump, which the release commit made
untrue, and records why the number is 1.0.0 instead of 0.9.22.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Point the legacy references at the history
`legacy/gitTally` was removed from the tree with the rename, but the
README still described it as a reference kept in the repository, and the
plan told an executing session to read parts of it — including step 14,
which is open.
The README section is gone; `docs/plan/README.md`, step 14 and the
legacy analysis now say where the script actually is
(`git show 7f55068^:legacy/gitTally`). Executed steps and ADR 0004 keep
their wording: they record what was true when they ran.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Retarget the links in the historic PR-docs
The package rename moved every file the older PR-docs link to, leaving
60 dead links. Only the link targets are rewritten, never the visible
text and never a statement: those documents record what was true when
they were written, GitTally in the prose included. A snapshot may be
outdated; it should still be navigable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add the v1.0.0 deployment procedure for vm4006
Measured, not estimated: the state directory is 878 MB, of which 878 MB
are the nine build worktrees. What cannot be recreated is 84 KB, so the
snapshot before an in-place switch is instant and the rollback is one
sequence of moves.
Records the three expected non-failures — a cold Gradle volume, one
image rebuild, containers left under the old label — and that
`gitea.statusContext` needs no attention because it comes from the
watched repository's committed configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Rename the machine configuration along with its directory
Found by the deployment to vm4006: the move renames the directory and
leaves the file inside it alone, so the machine configuration ended up at
`.git/werkator/.gittally.yml` — a pair of names the lookup did not
expect, because it pairs directory and file name. The instance resolved
empty credentials, no public URL and none of the host's build
definitions, and said nothing about it. That is the exact failure this
change exists to prevent, produced by the change itself.
`StateDirMigration` now renames the configuration with the directory,
unless one under the current name is already there. `ConfigFiles` carries
`.git/werkator/.gittally.yml` as a third candidate as well, for a
directory somebody moved by hand, where the migration never runs and so
can rename nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Let init see a configuration under its previous name
`init --systemd` runs `init`, and its "already exists" check knew only
the current name. On the one repository still carrying `.gittally.yml`
it therefore wrote a fresh template `.werkator.yml` beside it — and
since the current name wins, that repository would have built the
template's `./gradlew test` instead of what its own configuration says.
Found on vm4006, where the file was created in the watched working tree
and removed again by hand.
Both checks now ask `ConfigFiles`, so init decides existence by the same
rule the loader uses to read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Record the v1.0.0 deployment to vm4006
Deployed from the branch as the final test of PR#1, and it did what a
final test is for: it found two silent-failure defects before the
service was started, both fixed and redeployed in the same window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Drop the control token left under the old localStorage key
The key is named after the product, so the rename left every browser
with a token under `gittally.controlToken`, which nothing reads any more
and which "forget token" can no longer reach. It is a write-scope token
in a browser store, not a password, but a secret nobody owns is worth
one line to remove.
Removed on load. The token on the server is unchanged, so re-entering it
once per browser is all the rename costs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Also fixes the log-capture test added with step 19: the test JVM logs at
WARN, so the INFO line reporting the recovered fetch never reached the
appender. The helper now lowers the level explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A watcher that cannot fetch leaves the server perfectly reachable and
every branch row stale — a calm list that implies nothing changed. The
state was already recorded and served at /api/watcher; only nothing
rendered it.
The banner lives in the shared nav fragment, so every view inherits it,
and is fed from the view's own polling tick without being chained to it.
It stays separate from live-indicator: that one reports whether the
browser reaches the server, this one whether the server reaches origin.
Also caps the log volume the outage exposed: a lasting fetch failure is
logged when its message changes, not on every cycle, and the recovery is
logged once. Same for an invalid atTimes slot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`onPush`, `atTimes`, `branches`, and `activeWithin` move into a nested
`trigger`. The split is structural on purpose: the inheritance from
`builds.default` now subtracts one key instead of a list of four, so a
selector added to `TriggerConfig` later is non-inheritable by
construction rather than because someone remembered to extend the list.
A definition still writing those keys flat is refused by name, per file
and scoped like the version check — the machine and project config abort
the start, a branch's committed config fails only that branch. Ignoring
them would leave the build with no trigger at all, which is a job that
quietly stops running: the failure this refusal exists to prevent.
Two more things a definition can now say:
- A `!` prefix in `trigger.branches` excludes, and an exclusion wins
whatever the order. `["*", "!master"]` gives one branch a build of its
own without the default build running over it as well — until now the
only way out of that double build was to drop the second definition's
push trigger.
- `statusContext` overrides the Gitea check this build reports as, empty
keeping the repository-wide one. Two builds of a commit shared a
context and overwrote each other's result, so a quick check beside a
long build was not readable in Gitea. Pinned like `requirePullRequest`:
a branch that could pick its context could take over the check a branch
protection rule depends on.
Fixed on the way: a branch whose builds all belong to named definitions
rendered an empty row in the branches view, reading as "never built"
directly beside its real builds. That row was unreachable before the
exclusion patterns made such a branch possible.
`builds` and the legacy `branches` are now either/or: `branches` is read
only while the merged configuration defines no build at all — a leftover
`builds.maxConcurrent` is not one — and ignored with a warning as soon as
one exists. Two half-answers to "what does this build run" would silently
pull against each other, and the committed configs still carrying both
must not change behaviour before they are migrated.
A definition therefore gained the settings it was missing:
`requirePullRequest` and `docker.enabled`/`network`. Those stay pinned —
`stripPinned` now removes them from a branch layer wherever they appear,
in a definition as well as in a legacy branch entry.
`builds.default` becomes the base every other definition inherits its
settings from, never its trigger: `onPush`, `atTimes`, `branches`, and
`activeWithin` say when and where *this* build runs. The inheritance is
applied after all layers are merged, which is what makes a build invented
on a branch inherit the host's sandbox policy instead of the data-class
default — otherwise a branch could get a native build past the pinning by
defining a job the host has never heard of.
Two bugs found on the way, both the same shape as the build command the
artifact page used to get wrong:
- `FileArtifactStore` read the artifact directories from the plain branch
settings, so a job adding its own `artifactDirs` never had them stored.
It goes through `GitTallyConfig.buildSettings` now, like everything else
that asks what a build runs.
- `Watcher.definitionsFor` cached the per-branch definitions by head
commit alone, so an edited machine or project config only took effect
once the branch moved — on a quiet branch, never. The primary config is
part of the cache key now.
Until now a version that renames or drops a key did not fail — it silently
ignored what it no longer understood, and the effect surfaced as a build
doing the wrong thing. Both directions of that happened within two days:
a branch config using `??:00` on a GitTally that did not know it yet, and a
`builds.maxConcurrent` that had moved to another section.
gitTally:
version:
since: "0.9.18" # enforced
below: "2.0" # release marker; GitTally decides how strictly
There is deliberately no version of the file format (no `apiVersion`): no API
is involved — GitTally reads its own configuration — and only one generation
is ever supported. The declaration exists to make an incompatibility
nameable, never to run two parsers.
`since` is hard in both directions. Too new a requirement is refused, and so
is a file written before the version in which the configuration format last
broke (`ConfigVersions.FORMAT_BROKE_IN`, empty for now) — that check needs no
declared ceiling, because GitTally knows its own breaking changes.
`below` is the team's release marker and only warns: an unmaintained caution
value must never stop a CI. The routine it serves is the one known from IDE
plugins — new version, warning, try it, then raise the marker and commit.
The reach of a violation follows the layer: the machine and project configs
abort the start naming the file and the rollback, while an incompatible
branch config fails only that branch's builds. A branch cut before a
migration must not stop the server or hold up the branches that are fine.
A file that declares nothing keeps working, and the CLI prints one line
instead of a stack trace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The artifact page read `branches.<branch>.buildCommand` from the primary
config only, so for a named build on a branch with its own config it showed
a command that build never ran — on vm4006 it showed the host config's
command for a build that ran the branch definition's `pitestFull`.
Resolving "what does this build run" now has one implementation,
`GitTallyConfig.buildSettings(branch, build)`: the branch entry with the
build definition's overrides applied last. The executor uses it, and the
page resolves it against the branch layer committed at the build's own
commit — the same inputs the executor had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`atTimes: ["??:05"]` runs a build five past every hour. The pattern expands
to its 24 concrete slots before the due-slot match, so each hour is its own
slot in the trigger state and fires once — the existing per-slot semantics
carry over unchanged, including that only the latest due slot of a day
triggers and that a slot whose pool is still building is retried until it
starts.
Only the hour may be a wildcard; anything else is skipped with a warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The concurrency limit moved to executor.maxConcurrent without an alias, so
the old key binds a scalar where a build definition belongs and failed the
whole configuration. But that configuration is committed in the watched
repository, and a repository's master is not always changeable right away —
an installation must not be stuck on a key it is meant to forget.
A `builds` entry that is not a mapping is now dropped with a warning (once
per key, the config is loaded every poll cycle), naming executor.maxConcurrent
for the key that moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A branch's committed .gittally.yml describes that branch's CI, so it wins
over the project and repo-install config — the `builds` section included.
Pinning it was wrong: a new build definition can only be tried out by
committing it on a branch, and pinned it neither took effect at build time
nor existed for the watcher, so the job silently never ran.
The watcher now decides per branch from that branch's own definitions,
reading its committed config via `git show` and caching it by head commit,
so the read happens only when the branch moved; an unreadable config falls
back to the primary definitions instead of failing the poll cycle. A
branch's definitions are evaluated for that branch alone, so a definition
committed on one branch can never trigger builds of another.
The pinned set is reduced to what does not describe this branch's build:
secrets (`git`), the host and repository sections (`server`, `gitea`,
`executor`, `watcher`), the sandbox policy (`docker.enabled`/`network`),
and the trust gate (`requirePullRequest`). Letting a branch set its own
build command through a definition grants no new power — `branches.*.
buildCommand` always allowed exactly that — while the sandbox and the gate
decide whether untrusted branch code runs on the host at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
builds.maxConcurrent mixed an execution setting into the build
definitions as a reserved key. The limit now lives in the new executor
section (pinned like the builds section, enforced for all builds
regardless of trigger), default 1, without a compatibility alias — a
leftover builds.maxConcurrent key is rejected as an invalid build
definition. Recorded as a follow-up in ADR 0007.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR 0007: the YAML builds section (next to the reserved maxConcurrent
key) defines named builds (jobs) with onPush/atTimes triggers, branch
selectors (name globs, activeWithin age filter), and build-setting
overrides applied last over the merged branch config. The implicit
default build (onPush over all branches) preserves the previous
behavior; the section is pinned against the worktree layer.
Results record the job name; restart, retry, and startup recovery
re-run by it, resolving settings from the current config. A non-default
build records under the <branch>@<build> pool with its own row,
retention count, and permanent latest-green link.
branches.*.autoBuild stays as a deprecated alias (plain times only);
the unreleased-in-practice v0.9.13 per-slot buildCommand/name syntax is
removed again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A branches.<name>.autoBuild.times entry is now either a plain HH:MM
string or an object with time, its own buildCommand, and a name, so a
nightly slot can run a fuller check than the on-commit builds. The
watcher passes the slot's command and name to the executor, persisted in
the build result — UI restarts, gittally retry, and the startup recovery
repeat a build with the command and name it originally ran under.
A named slot (e.g. master@nightly) gets its own pool: repository
grouping, retention count, branches-view row (sorted after its branch),
latest status, and permanent latest-green artifact link are keyed by the
build name, while origin lookups, gone-branch pruning, worktrees, and
Gitea links/statuses stay keyed by the real branch. Without a name,
slot builds share the branch's pool as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects seen live on vm4006 when a merged branch was deleted from origin
while its last build still ran:
- The result prune removed the PENDING/RUNNING entries of branches gone from
origin, so the executing build vanished from UI and history and the queue
looked stuck. Prune now never touches a PENDING or RUNNING entry (worktrees
were already protected). The startup recovery closes out an orphaned PENDING
of a gone branch as INTERRUPTED, so the new immunity cannot leak entries.
- The apparent hang invited restart clicks, and each click stacked another
build of the same commit. startBuild now returns the already queued or
executing build of the same branch and commit instead of a duplicate;
cancel-requested builds do not block re-queueing, and re-running a finished
build stays possible.
Co-Authored-By: Claude <noreply@anthropic.com>
Build worktrees share the primary checkout's .git, so build tools can read
refs/heads there. Since the rewrite never moved those refs, they stayed frozen at
the last checkout: hs.hsadmin.ng's prQuickCheck compares master with
origin/master and therefore failed every build once origin moved on.
The sync runs after the enqueue decision on purpose — a local ref lagging behind
origin is exactly how the watcher recognizes new commits, so keeping the refs in
sync earlier (cron job, mirroring refspec, or this step moved up) would silence
the branch instead of building it.
Fast-forward only, as a compare-and-swap against the commit just read: diverged
or ahead branches stay untouched, and the checked-out branch is advanced with
merge --ff-only, which refuses to overwrite conflicting uncommitted changes.
Switched off with watcher.fastForwardLocalRefs: false.
Co-Authored-By: Claude <noreply@anthropic.com>
The artifact index listed the stored logs as bare file names, so a red build
gave no hint which of build.log, build.stdout.log and build.stderr.log actually
explains it — with stdout and stderr stored separately, the failure is usually
in only some of them.
Each log of a non-green build is now scanned for an upper-case FAILED/FAILURE,
which covers BUILD FAILED, Maven's BUILD FAILURE and Gradle's per-test
"SomeTest > works() FAILED"; lower-case prose does not count. The scan streams
line by line with an early exit and reads ISO-8859-1, so no byte sequence of a
build log can fail to decode. Logs of a successful build are not scanned at all
— that saves reading megabytes per page view and avoids an alarming badge on a
green build whose log mentions a deliberately failing sub-build.
Co-Authored-By: Claude <noreply@anthropic.com>
Closes TODO 2 of the security audit in docs/prs/2026-07-08-PR#000.
Every rendered page carried the live control token in a meta tag so that
gittally.js could send it, but no GET is authenticated — so `curl … |
grep gittally-control-token` handed the token to anyone, and read access
was effectively write access.
Reading stays fully public, which is a requirement rather than an
oversight: build states, logs and artifacts must be linkable from Gitea,
chats or tickets without a login. Only the distribution of the token
changed. The meta tag is gone; gittally.js keeps the token in
localStorage and asks for it once per browser, so knowing it requires
shell access to `.git/gittally/control-token` on the host. A token the
server rejects is dropped and asked for once more, so a rotated secret is
not a dead end. As a request header it stays inherently CSRF-safe.
The five branches of that flow (first use, reuse, stale token, cancelled
prompt, wrong token twice) were exercised against the real source with a
throwaway node harness; the UI test now asserts the token does not appear
in the rendered page. `docs/deployment.md` gained a "Control Token"
section on the public-read/token-protected-write split.
Co-Authored-By: Claude <noreply@anthropic.com>
Finishes the small items of the security audit in
docs/prs/2026-07-08-PR#000: TODO 3, 4 and 7.
The three mutating endpoints of BuildsApiController no longer accept the
control token as a `token` query parameter — only the X-GitTally-Token
header, which the bundled UI has always used. URLs end up in access logs,
proxy logs, browser history and Referer headers, and the token never
expires, so a historical log capture would yield a valid credential.
`config:print` masks git.token as `***` on both the raw and the --full
path and names the new --show-secrets flag in a leading YAML comment, so
the output stays parseable when piped. The setup script points at
--show-secrets where it used to steer the operator to the plain token.
`server.bindAddress` now defaults to 127.0.0.1: neither the UI nor the
API authenticates read access, so reaching GitTally should require the
host's reverse proxy. Existing .gittally.yml files keep their explicit
value; the managed nginx container needs `0.0.0.0` set deliberately,
which is noted in the release notes, docs/configuration.md and
docs/deployment.md.
Released as v0.9.9, which also carries the previous two commits.
Co-Authored-By: Claude <noreply@anthropic.com>
Works off the security audit in docs/prs/2026-07-08-PR#000: TODO 1, 8, 9
and 10, the four items that need no design decision.
New `SecretFiles` creates files holding secrets with mode 0600 and their
directories with 0700 *at creation*, as a file attribute, instead of
writing at the umask default and chmod-ing afterwards — that left a
window in which the Gitea token was world-readable, which matters on a
multi-tenant host. It is used by `init` for .git/gittally/.gittally.yml
and by `ControlTokenService` for the control token; the shell setup
script now writes its YAML in a `umask 077` subshell for the same reason.
`ControlTokenService.matches` hashes both sides with SHA-256 before
`MessageDigest.isEqual`, so the comparison always runs over two 32-byte
buffers and cannot return early on a length mismatch.
`GitService.checkout` and `fetchBranch` pass `--` before the refname, so
a branch named like an option cannot be read as one. `resetHardToOrigin`
keeps its plain form: `git reset --hard -- <commit>` is rejected outright
and its argument is already `origin/`-prefixed.
Co-Authored-By: Claude <noreply@anthropic.com>
A report directory holding a single page is now linked and served as a
directory, so Gradle's --profile report has a stable permanent URL although
its file name carries the build timestamp.
The permanent link moves to the build it resolves to — the branch's latest
green build — and appears on every build table instead of only the branches
view. The Current tab gave way to a link in the artifacts column, shown
while a build runs; /current itself stays routable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gradle's --profile report is archived but was unreachable: report discovery
only looked for index.html, while the profile page carries a timestamped
file name. Scan reports/ and its direct sub-directories for HTML pages that
no index covers, so a report tree cannot flood the index with inner pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of the legacy host's docker-prune.timer: 02:00 host time,
Persistent=true, docker system prune -af — but without --volumes, so
the per-repository Gradle cache volumes survive. The units are
host-global; several GitTally instances share one timer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Current cell of CPU/RAM/disk used turns orange from 80% of the
total and red from 90%. UiFormats.utilizationClass and the mirrored
utilizationClass in gittally.js apply the same thresholds; unavailable
metrics (n/a) are never highlighted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconstructed from the commit history since the first production
deployment; the initial entry is the 0.9.0 port of the legacy bash
script to Kotlin/Spring Boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A systemd stop killed the running build process and BuildExecutor
classified the death as FAILED, posting a red Gitea status; FAILED is
not restartable, so the startup recovery never re-enqueued the build.
A ContextClosedEvent listener now sets a shuttingDown flag, terminates
the process trees of executing builds, and drains until their
INTERRUPTED results are persisted. Queued builds stay PENDING without
starting a process; recovery re-enqueues both after the restart.
INTERRUPTED publishes as Gitea state "pending" instead of "failure",
since the build is going to be re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retired legacy instance now blanket-redirects its old host to the new
one, so pre-rewrite deep links like /index.html or /branches.html arrive
here — they answer 301 to the new routes instead of 404; about/license had
no successor pages and land on the start page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each listed report link now carries the failures counter parsed from the
report's Gradle-style index.html (id="failures" info box); reports with
failures get a red "N failed" badge, so a red build reveals which report to
open without clicking through all of them. Pages without such a counter
(Jacoco, profile, documentation) stay unmarked. A full-text FAILED scan was
deliberately not used: even green hsadmin-ng builds contain the word in
embedded test output.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancelling a build only killed the running build process; the synchronous
preparation phases — most notably a multi-minute Docker image build, but also
the Gradle-volume preparation — ran to completion and kept the concurrency
slot occupied, so the next queued build stayed PENDING for a long time.
Build runners now report every auxiliary process through an onAuxProcess sink
(GitCommandRunner gained an onProcess hook), and the executor registers them
like the build process, so cancellation terminates whatever is currently
running. Measured on vm4006: cancel to next-build-running is ~3s in the
normal case; the unit test covers the aux-phase case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BuildResult gains runningSince, set when a build leaves the queue; the
recorded duration now measures pure build time from that point, so build
runtimes can be tracked without queue wait. A build cancelled while still
queued records neither. The UI shows the live wait time in italics while
pending and switches to the real build time once the build runs; the Gitea
"after mm:ss" descriptions now also report pure build time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An in-progress build's artifact page only offers the build command and log —
the table now shows an hourglass instead of the document icon (server-rendered
rows and the JS-rendered rows alike) until the build finishes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes from the vm4006 rollout (docs/plan/16-git-in-docker-builds.md):
Rootless daemons map the host user to container root, so running the build
container as --user <host-uid> put it into the subuid range and it could not
even create .gradle in a fresh worktree (legacy only worked because its
ownership-repair chown had accidentally moved build/ and .gradle/ into subuid
ownership in its reused primary checkout). The container now always runs as
--user 0: the unprivileged host user under rootless, real root under rootful
where the ownership repair still applies; under rootless it degenerates to 0:0.
Git now works inside build containers: the primary .git is mounted read-only
with .git/gittally/ masked by an empty tmpfs (git.token and the control token
stay unreachable, the workspace bind resurfaces only the build's own worktree)
and the worktree admin dir mounted read-write for index-refreshing commands.
Verified on vm4006: git log/status succeed, the machine config is invisible,
ref writes fail on the read-only mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
certbot removed ssl-dhparams.pem from its repository, so the managed-nginx
startup failed with HTTP 404 on fresh installations (the legacy script only
kept working through its year-old state-dir cache). The RFC 7919 ffdhe2048
parameters are now a classpath resource; the download seam and
NginxConfigFiles.DH_PARAMS_URL are gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>