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>