diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 0b9978f..f651ba3 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -1,23 +1,23 @@ --- name: architecture -description: Detailed GitTally subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native and Docker), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md. +description: Detailed werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native and Docker), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md. --- -# GitTally Architecture +# werkator Architecture -GitTally is a lightweight, declarative CI/CD build system. +werkator is a lightweight, declarative CI/CD build system. It is a dual-mode application: **CLI** (interactive, status, config) and **Server** (HTTP, persistent). ## Entry Point and CLI Wiring -Spring Boot starts via `GitTallyApplication`. A separate `CliRunner` component (in the same file) implements both `CommandLineRunner` (runs picocli) and `ExitCodeGenerator` (returns the exit code). `exitProcess` is called only from `main()` via `SpringApplication.exit()` — **never** inside `run()`. This keeps the Spring context alive during tests. +Spring Boot starts via `WerkatorApplication`. A separate `CliRunner` component (in the same file) implements both `CommandLineRunner` (runs picocli) and `ExitCodeGenerator` (returns the exit code). `exitProcess` is called only from `main()` via `SpringApplication.exit()` — **never** inside `run()`. This keeps the Spring context alive during tests. -Picocli commands are Spring `@Component` beans. The root command (`GitTallyCommand`) declares subcommands as class references in `@Command(subcommands = [...])`. Picocli resolves them from the Spring context via the auto-configured `IFactory` bean. +Picocli commands are Spring `@Component` beans. The root command (`werkatorCommand`) declares subcommands as class references in `@Command(subcommands = [...])`. Picocli resolves them from the Spring context via the auto-configured `IFactory` bean. ``` -GitTallyApplication ← @SpringBootApplication +werkatorApplication ← @SpringBootApplication CliRunner ← CommandLineRunner + ExitCodeGenerator -GitTallyCommand ← root @Command, delegates to subcommands +werkatorCommand ← root @Command, delegates to subcommands commands/ InitCommand ← "init [--systemd]" ServerCommand ← "server" @@ -31,27 +31,27 @@ commands/ `build` and `retry` run builds through the async `BuildExecutor` but block until completion via `ConsoleBuildRunner`, which streams the live log to stdout and waits for the artifact persist before the JVM exits. Branch arguments resolve legacy-style name fragments (`BranchNameResolution`); the CLI reuses `UiFormats` so console and web UI display the same formats. -The web application type is set to `none` in `application.yml`, so plain CLI runs never start a web server. The `server` subcommand launches a **second** `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown. `application-server.yml` switches the web type (`spring.main.*` properties beat programmatic builder settings), `CliRunner` is `@Profile("!server")` so the second context does not run picocli again, and the watcher poll loop starts only in the `server` profile (`ServerWatcherLifecycle`). The JSON API, artifact serving, and the web UI live in the `server` package; mutating endpoints are guarded by a generated control token under `.git/gittally/control-token`. +The web application type is set to `none` in `application.yml`, so plain CLI runs never start a web server. The `server` subcommand launches a **second** `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown. `application-server.yml` switches the web type (`spring.main.*` properties beat programmatic builder settings), `CliRunner` is `@Profile("!server")` so the second context does not run picocli again, and the watcher poll loop starts only in the `server` profile (`ServerWatcherLifecycle`). The JSON API, artifact serving, and the web UI live in the `server` package; mutating endpoints are guarded by a generated control token under `.git/werkator/control-token`. ## Web UI -The UI is server-rendered Thymeleaf (`UiController`, templates under `src/main/resources/templates/`) plus one hand-written JavaScript file (`static/gittally.js`) — no SPA framework, no frontend build pipeline. Pages render the full state server-side; the script then polls the JSON API and re-renders table bodies from data. Every fetch has a timeout and failures flip an explicit error badge — never re-fetch and diff whole HTML pages, and never leave a spinner without an error path (the legacy defect). Polling pauses while the tab is hidden. `UiFormats`/`gittally.js` must produce the same display formats (timestamps, durations). +The UI is server-rendered Thymeleaf (`UiController`, templates under `src/main/resources/templates/`) plus one hand-written JavaScript file (`static/werkator.js`) — no SPA framework, no frontend build pipeline. Pages render the full state server-side; the script then polls the JSON API and re-renders table bodies from data. Every fetch has a timeout and failures flip an explicit error badge — never re-fetch and diff whole HTML pages, and never leave a spinner without an error path (the legacy defect). Polling pauses while the tab is hidden. `UiFormats`/`werkator.js` must produce the same display formats (timestamps, durations). Two independent staleness signals, never merged: the `live-indicator` badge says whether *this browser* reaches the server, and the `watcher-banner` (fed from `/api/watcher`, in the shared `nav` fragment) says whether the *server* reaches origin — a watcher that cannot fetch leaves the server perfectly reachable and every row stale. ## Configuration System -GitTally is configured by two YAML files, deep-merged by `ConfigLoader` (later wins): +werkator is configured by two YAML files, deep-merged by `ConfigLoader` (later wins): -1. `.gittally.yml` at the repo root — committed, shared team settings. -2. `.git/gittally/.gittally.yml` — not committed; machine-specific overrides and secrets (`git.account`, `git.token`). +1. `.werkator.yml` at the repo root — committed, shared team settings. +2. `.git/werkator/.werkator.yml` — not committed; machine-specific overrides and secrets (`git.account`, `git.token`). -On top of those comes the **branch layer**: the `.gittally.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, and `docker.enabled`/`docker.network`. +On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, and `docker.enabled`/`docker.network`. -Each file is version-checked before merging (`gitTally.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a GitTally, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable. +Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable. -After merging, `resolveBuildSections` decides which section describes the builds: `builds` or the legacy `branches`, never both. With no real build definition (`builds.maxConcurrent` is not one, `dropNonDefinitionBuilds` already drops it) the legacy path runs and `branches.default` is merged into every other branch entry; otherwise `branches` is dropped with a warning and `mergeBuildDefaults` applies `builds.default` as the base of every other definition — its settings only, never its `trigger` block (`TRIGGER_KEYS`, a single key so that a selector added to `TriggerConfig` later is non-inheritable by construction). `checkTriggerBlocks` refuses a definition still writing `onPush`/`atTimes`/`branches`/`activeWithin` flat, per file and scoped like the version check. Deciding this on the merged map is deliberate: a build defined on a branch and unknown to the host still inherits the host's `builds.default`, sandbox policy included, which is what keeps the pinned keys effective for it. The result is bound to the `GitTallyConfig` data classes (`config/GitTallyConfig.kt`), which define the schema and all defaults; `GitTallyConfig.buildSettings(branch, build)` is the single answer to "what does this build run". +After merging, `resolveBuildSections` decides which section describes the builds: `builds` or the legacy `branches`, never both. With no real build definition (`builds.maxConcurrent` is not one, `dropNonDefinitionBuilds` already drops it) the legacy path runs and `branches.default` is merged into every other branch entry; otherwise `branches` is dropped with a warning and `mergeBuildDefaults` applies `builds.default` as the base of every other definition — its settings only, never its `trigger` block (`TRIGGER_KEYS`, a single key so that a selector added to `TriggerConfig` later is non-inheritable by construction). `checkTriggerBlocks` refuses a definition still writing `onPush`/`atTimes`/`branches`/`activeWithin` flat, per file and scoped like the version check. Deciding this on the merged map is deliberate: a build defined on a branch and unknown to the host still inherits the host's `builds.default`, sandbox policy included, which is what keeps the pinned keys effective for it. The result is bound to the `WerkatorConfig` data classes (`config/werkatorConfig.kt`), which define the schema and all defaults; `werkatorConfig.buildSettings(branch, build)` is the single answer to "what does this build run". -Three places must stay in sync when config keys change: the `GitTallyConfig` data classes, the commented templates generated by `InitCommand`, and the reference in `docs/configuration.md`. +Three places must stay in sync when config keys change: the `WerkatorConfig` data classes, the commented templates generated by `InitCommand`, and the reference in `docs/configuration.md`. ## Git Access @@ -59,16 +59,16 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat ## Build Execution -`BuildExecutor` runs builds asynchronously: up to `executor.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `@` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. +`BuildExecutor` runs builds asynchronously: up to `executor.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `@` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`). -The runtime is selected per branch behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default) or to `DockerBuildRunner` when `branches..docker.enabled`. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.gittally.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.gittally`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/gittally/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds. +The runtime is selected per branch behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default) or to `DockerBuildRunner` when `branches..docker.enabled`. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds. ## Watcher -`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle: fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.gittally.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs. -After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/gittally/auto-builds.json`; watcher health is exposed via `Watcher.state()`. +`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle: fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs. +After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`. ## System Metrics diff --git a/.claude/skills/writing-tests/SKILL.md b/.claude/skills/writing-tests/SKILL.md index 9692448..b6a8bc3 100644 --- a/.claude/skills/writing-tests/SKILL.md +++ b/.claude/skills/writing-tests/SKILL.md @@ -1,9 +1,9 @@ --- name: writing-tests -description: GitTally testing conventions — Kotest FunSpec spec structure, MockK matchers, and the two patterns for mocking beans in Spring slice tests (springmockk @MockkBean or @TestConfiguration). Use when writing, extending, or refactoring tests. +description: werkator testing conventions — Kotest FunSpec spec structure, MockK matchers, and the two patterns for mocking beans in Spring slice tests (springmockk @MockkBean or @TestConfiguration). Use when writing, extending, or refactoring tests. --- -# Writing Tests for GitTally +# Writing Tests for werkator Tests use **Kotest `FunSpec`** style. `SpringExtension` is registered globally in `io.kotest.provided.ProjectConfig` — do not add it per-spec. @@ -23,7 +23,7 @@ Tests mirror the production package structure under `src/test/kotlin`. Run a single test class instead of the full suite while iterating: ```bash -./gradlew test --tests "de.hoennig.gittally.ApplicationContextTest" +./gradlew test --tests "de.hoennig.werkator.ApplicationContextTest" ``` ## Mocking in Spring Slice Tests diff --git a/.gitignore b/.gitignore index 1d03753..cf4db1d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ # Package Files # *.jar -# ... but builds in fresh checkouts (e.g. GitTally worktrees) need the wrapper +# ... but builds in fresh checkouts (e.g. werkator worktrees) need the wrapper !gradle/wrapper/gradle-wrapper.jar *.war *.nar @@ -30,4 +30,7 @@ replay_pid* # Gradle .gradle/ -/build/ \ No newline at end of file +/build/ + +# Other +/.local/ diff --git a/.gittally.yml b/.gittally.yml index 6862a0b..384a0e4 100644 --- a/.gittally.yml +++ b/.gittally.yml @@ -1,13 +1,13 @@ server: - # Public base URL of this GitTally installation — used for all links posted to Gitea. + # Public base URL of this werkator installation — used for all links posted to Gitea. publicBaseUrl: "" # Gitea integration for fetching commits and posting build statuses. gitea: baseUrl: https://github.com # base URL of the Gitea instance owner: mhoennig # repository owner (user or organisation) for Gitea API (e.g. status checks) - repo: gittally # repository name - statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) + repo: werkator # repository name + statusContext: werkator # label shown on Gitea commit status checks (default: werkator) # Build artifact retention. artifacts: diff --git a/AGENTS.md b/AGENTS.md index 527f1cb..c4cb33d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# GitTally — Agent Instructions +# werkator — Agent Instructions This file holds the shared, tool-agnostic instructions for all AI coding agents. Claude Code imports it from `CLAUDE.md` via `@AGENTS.md`; Claude-Code-specific instructions belong in `CLAUDE.md`, everything else here. @@ -10,40 +10,40 @@ Detailed guides live as Agent Skills under `.claude/skills/` ([SKILL.md format]( ./gradlew build # compile + ktlintCheck + test ./gradlew ktlintFormat # auto-format before committing ./gradlew test # run all tests (can be slow, prefer single test) -./gradlew test --tests "de.hoennig.gittally.ApplicationContextTest" # example for running a single test class +./gradlew test --tests "de.hoennig.werkator.ApplicationContextTest" # example for running a single test class ``` Run the JAR directly: ```bash -java -jar build/libs/gittally.jar --help -java -jar build/libs/gittally.jar init +java -jar build/libs/werkator.jar --help +java -jar build/libs/werkator.jar init ``` `ktlintFormat` must be run before `build` passes — the formatter is enforced as part of the `check` lifecycle. ## Architecture Overview -GitTally is a lightweight, declarative CI/CD build system: git-centric, one instance per repository, builds native or in Docker, statuses reported to Gitea. +werkator is a lightweight, declarative CI/CD build system: git-centric, one instance per repository, builds native or in Docker, statuses reported to Gitea. It is a dual-mode application: **CLI** (interactive, status, config) and **Server** (HTTP, persistent web UI + JSON API). IMPORTANT: Before designing or modifying code in any production package, load the [architecture skill](.claude/skills/architecture/SKILL.md) — it holds the subsystem details (CLI wiring, server mode, web UI, config system, git access, build execution, watcher, metrics). ### Package Structure -All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), `metrics` (system resource sampling and aggregation), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under `src/test/kotlin`. +All production code lives under `de.hoennig.werkator`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), `metrics` (system resource sampling and aggregation), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under `src/test/kotlin`. ### Hard Invariants - `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests. - Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile. -- Builds run detached in worktrees under `.git/gittally/worktrees/`; the primary checkout is never used for builds; never assume a single running build. -- When config keys change, three places must stay in sync: the `GitTallyConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`. -- Every config file may declare `gitTally.version.since`/`below` (the GitTally it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config. -- A branch describes its own CI: its committed `.gittally.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker sandbox policy (`docker.enabled`, `docker.network`), and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container, change its network, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. +- Builds run detached in worktrees under `.git/werkator/worktrees/`; the primary checkout is never used for builds; never assume a single running build. +- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`. +- Every config file may declare `werkator.version.since`/`below` (the werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config. +- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker sandbox policy (`docker.enabled`, `docker.network`), and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container, change its network, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. - A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins. - The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, and `docker.network`. - `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version. -- Web UI: server-rendered Thymeleaf plus one hand-written `static/gittally.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `gittally.js` must produce identical display formats. +- Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats. - Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK. ## Testing @@ -61,11 +61,11 @@ Keep sentences short. ## Documentation -- `docs/GitTally-Konzept.md` — product concept and target architecture (in German): git-centric CI, builds in Docker, one instance per repository, status reported back to Gitea. -- `docs/configuration.md` — configuration reference; keep in sync with `GitTallyConfig` and the `init` templates. +- `docs/Werkator-Konzept.md` — product concept and target architecture (in German): git-centric CI, builds in Docker, one instance per repository, status reported back to Gitea. +- `docs/configuration.md` — configuration reference; keep in sync with `WerkatorConfig` and the `init` templates. - `docs/bootstrapping.md` — how `init` prepares a repository. -- `docs/deployment.md` — running GitTally as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit). -- `docs/migration-from-legacy.md` — legacy env vars → YAML keys mapping and the manual migration steps; `legacy/gitTally` is deprecated. +- `docs/deployment.md` — running werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit). +- `docs/migration-from-legacy.md` — legacy env vars → YAML keys mapping and the manual migration steps; `legacy/werkator` is deprecated. - `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script. - `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc. diff --git a/CLAUDE.md b/CLAUDE.md index 7cc969b..659128b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# GitTally — Claude Code Instructions +# werkator — Claude Code Instructions @AGENTS.md diff --git a/README.md b/README.md index 036e238..fcf5659 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitTally +# werkator Lightweight, declarative and highly opinionated software build system (CI/CD). @@ -6,12 +6,12 @@ Lightweight, declarative and highly opinionated software build system (CI/CD). - [docs/configuration.md](docs/configuration.md) — configuration reference - [docs/bootstrapping.md](docs/bootstrapping.md) — initializing a repository with `init` -- [docs/deployment.md](docs/deployment.md) — running GitTally as a systemd service behind a reverse proxy +- [docs/deployment.md](docs/deployment.md) — running werkator as a systemd service behind a reverse proxy - [docs/migration-from-legacy.md](docs/migration-from-legacy.md) — migrating from the legacy bash script ## Legacy Script -`legacy/gitTally` (bash) is **deprecated** and kept only as a behavioral reference for the rewrite. +`legacy/werkator` (bash) is **deprecated** and kept only as a behavioral reference for the rewrite. Do not use it for new installations; see [docs/migration-from-legacy.md](docs/migration-from-legacy.md). ## Developer Setup diff --git a/build.gradle.kts b/build.gradle.kts index 1d2cbf6..f05b7e0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -69,7 +69,7 @@ springBoot { tasks.bootJar { // version-free jar name, so docs and scripts never contain the version; // the version itself stays available via BuildProperties (UI footer, --version) - archiveFileName = "gittally.jar" + archiveFileName = "werkator.jar" } kotlin { @@ -80,7 +80,7 @@ kotlin { } // Self-contained runtime bundle for hosts without a Java runtime (plan step 15, ADR 0006): -// a jlink-trimmed JRE plus gittally.jar plus the packaging/gittally launcher, packed as a tarball. +// a jlink-trimmed JRE plus werkator.jar plus the packaging/werkator launcher, packed as a tarball. // The JDK module list below was computed from the exploded boot jar via // jdeps -q --ignore-missing-deps --multi-release 21 --print-module-deps \ // --class-path 'BOOT-INF/lib/*' BOOT-INF/classes BOOT-INF/lib/*.jar @@ -115,7 +115,7 @@ val runtimeBundle by tasks.registering { val jdkHome = javaToolchains.launcherFor(java.toolchain).map { it.metadata.installationPath.asFile } val jarFile = tasks.bootJar.flatMap { it.archiveFile } - val launcherFile = layout.projectDirectory.file("packaging/gittally").asFile + val launcherFile = layout.projectDirectory.file("packaging/werkator").asFile val stagingDir = layout.buildDirectory .dir("runtime-bundle") @@ -123,7 +123,7 @@ val runtimeBundle by tasks.registering { .asFile val tarballFile = layout.buildDirectory - .file("distributions/gittally-runtime-linux-x64.tar.gz") + .file("distributions/werkator-runtime-linux-x64.tar.gz") .get() .asFile @@ -133,7 +133,7 @@ val runtimeBundle by tasks.registering { outputs.file(tarballFile) doLast { - val bundleRoot = stagingDir.resolve("gittally") + val bundleRoot = stagingDir.resolve("werkator") bundleRoot.deleteRecursively() bundleRoot.parentFile.mkdirs() @@ -154,14 +154,14 @@ val runtimeBundle by tasks.registering { val jlinkOutput = jlinkProcess.inputStream.bufferedReader().readText() check(jlinkProcess.waitFor() == 0) { "jlink failed:\n$jlinkOutput" } - jarFile.get().asFile.copyTo(bundleRoot.resolve("lib/gittally.jar").also { it.parentFile.mkdirs() }) - val launcher = launcherFile.copyTo(bundleRoot.resolve("bin/gittally").also { it.parentFile.mkdirs() }) + jarFile.get().asFile.copyTo(bundleRoot.resolve("lib/werkator.jar").also { it.parentFile.mkdirs() }) + val launcher = launcherFile.copyTo(bundleRoot.resolve("bin/werkator").also { it.parentFile.mkdirs() }) check(launcher.setExecutable(true, false)) { "cannot make $launcher executable" } tarballFile.parentFile.mkdirs() // system tar preserves the execute bits of jre/bin/* and jre/lib/jspawnhelper val tarProcess = - ProcessBuilder("tar", "-czf", tarballFile.absolutePath, "-C", stagingDir.absolutePath, "gittally") + ProcessBuilder("tar", "-czf", tarballFile.absolutePath, "-C", stagingDir.absolutePath, "werkator") .redirectErrorStream(true) .start() val tarOutput = tarProcess.inputStream.bufferedReader().readText() diff --git a/docs/GitTally-Konzept.md b/docs/Werkator-Konzept.md similarity index 87% rename from docs/GitTally-Konzept.md rename to docs/Werkator-Konzept.md index 29dbbd5..3386583 100644 --- a/docs/GitTally-Konzept.md +++ b/docs/Werkator-Konzept.md @@ -1,8 +1,8 @@ -# GitTally – Konzept und Architekturübersicht +# Werkator – Konzept und Architekturübersicht ## Motivation -GitTally ist ein bewusst minimalistisches und stark opinionated Continuous-Integration-System (CI), das später um Continuous Delivery (CD) erweitert werden kann. +Werkator ist ein bewusst minimalistisches und stark opinionated Continuous-Integration-System (CI), das später um Continuous Delivery (CD) erweitert werden kann. Ziel ist es, die Komplexität klassischer CI-Systeme wie Jenkins erheblich zu reduzieren und stattdessen einen einfachen, nachvollziehbaren und git-zentrierten Ansatz zu verfolgen. @@ -119,15 +119,15 @@ flowchart LR ### Commit-basierte Builds -GitTally baut immer einen konkreten Commit und niemals nur einen Branchnamen. +Werkator baut immer einen konkreten Commit und niemals nur einen Branchnamen. Buildergebnisse werden intern trotzdem pro Branch geführt: derselbe Commit auf zwei Branches ergibt zwei getrennte Builds mit eigenem Status, eigenen Artefakten und eigenem Worktree. Das ist gewollt, weil Buildläufe den Branchnamen einbeziehen können (Umgebungsvariable `branch`). In Gitea hängt der Commit-Status dagegen am Commit-SHA: zeigen zwei Branches auf denselben Commit, überschreiben sich ihre Statusmeldungen gegenseitig (der zuletzt gemeldete gewinnt). -Falls das je stört, kann der Branchname später in den Status-Context aufgenommen werden (z. B. `GitTally/main`), sodass ein Commit mehrere unabhängige Statuszeilen bekommt. +Falls das je stört, kann der Branchname später in den Status-Context aufgenommen werden (z. B. `werkator/main`), sodass ein Commit mehrere unabhängige Statuszeilen bekommt. ### Worktree pro Branch -Jeder Branch erhält einen eigenen, wiederverwendeten Worktree unter `.git/gittally/worktrees/`. +Jeder Branch erhält einen eigenen, wiederverwendeten Worktree unter `.git/werkator/worktrees/`. Der Branch-Key ist der dateisystem-sicher bereinigte Branchname plus 12 Zeichen SHA-256 des Originalnamens (z. B. `main-0d6e4079e367`). Der Hash schützt nur vor Kollisionen durch die Bereinigung (`feature/x` vs. `feature_x`); der Commit ist bewusst nicht Teil des Keys, damit das Verzeichnis über alle Builds des Branches stabil bleibt. Der zu bauende Commit wird darin detached ausgecheckt. @@ -145,8 +145,8 @@ Ob ein neuer Commit den laufenden Build seines Branches stattdessen abbrechen so 1. Eingebaute Defaults 2. Globale Server-Konfiguration -3. Repository-Installation (.git/gittally/.gittally.yml) -4. Projektkonfiguration (.gittally.yml) +3. Repository-Installation (.git/werkator/.werkator.yml) +4. Projektkonfiguration (.werkator.yml) 5. Branchprofile ## Bootstrapping @@ -156,18 +156,18 @@ Ob ein neuer Commit den laufenden Build seines Branches stattdessen abbrechen so - Java Runtime - Git Repository - Ausgecheckter Workspace -- GitTally JAR +- Werkator JAR ### Initialisierung ```bash -java -jar build/libs/gittally.jar init +java -jar build/libs/werkator.jar init ``` ### Serverstart ```bash -java -jar build/libs/gittally.jar server +java -jar build/libs/werkator.jar server ``` Für den Dauerbetrieb als systemd-User-Service siehe [deployment.md](deployment.md) (`init --systemd`). @@ -175,13 +175,13 @@ Für den Dauerbetrieb als systemd-User-Service siehe [deployment.md](deployment. ### Konfigurationsanzeige ```bash -java -jar build/libs/gittally.jar config:print -java -jar build/libs/gittally.jar config:print --full +java -jar build/libs/werkator.jar config:print +java -jar build/libs/werkator.jar config:print --full ``` ## Erweiterungen -- Continuous Delivery (CD; das Deployment von GitTally selbst ist in [deployment.md](deployment.md) beschrieben) +- Continuous Delivery (CD; das Deployment von Werkator selbst ist in [deployment.md](deployment.md) beschrieben) - SQLite statt Dateisystem - Mehrere BuildWorker - Multi-Repository-Verwaltung diff --git a/docs/adrs/0001-2026-06-09.test-framework.md b/docs/adrs/0001-2026-06-09.test-framework.md index 27e8848..acc228e 100644 --- a/docs/adrs/0001-2026-06-09.test-framework.md +++ b/docs/adrs/0001-2026-06-09.test-framework.md @@ -10,7 +10,7 @@ ## Context and Problem Statement -GitTally is a greenfield Kotlin/Spring Boot project. +werkator is a greenfield Kotlin/Spring Boot project. A test framework must be chosen before writing any tests. The framework shapes how tests are structured, how readable they are, and how well they integrate with the Spring Boot test slice infrastructure. diff --git a/docs/adrs/0002-2026-06-09.gradle-version.md b/docs/adrs/0002-2026-06-09.gradle-version.md index a265cfa..05677ef 100644 --- a/docs/adrs/0002-2026-06-09.gradle-version.md +++ b/docs/adrs/0002-2026-06-09.gradle-version.md @@ -10,7 +10,7 @@ ## Context and Problem Statement -GitTally is a greenfield Kotlin/Spring Boot project. +werkator is a greenfield Kotlin/Spring Boot project. A Gradle version must be chosen for the initial setup. [Gradle 9](https://docs.gradle.org/9.3.0/release-notes.html) (currently 9.5.1) is now stable and available. diff --git a/docs/adrs/0003-2026-06-09.spring-boot-version.md b/docs/adrs/0003-2026-06-09.spring-boot-version.md index 4969960..c7d86e5 100644 --- a/docs/adrs/0003-2026-06-09.spring-boot-version.md +++ b/docs/adrs/0003-2026-06-09.spring-boot-version.md @@ -10,7 +10,7 @@ ## Context and Problem Statement -GitTally is a greenfield Kotlin/Spring Boot project. +werkator is a greenfield Kotlin/Spring Boot project. A Spring Boot version must be chosen for the initial setup. The choice is constrained by the support lifecycle: as of June 2026, diff --git a/docs/adrs/0004-2026-07-07.rewrite-architecture.md b/docs/adrs/0004-2026-07-07.rewrite-architecture.md index e806b91..ae7ad5f 100644 --- a/docs/adrs/0004-2026-07-07.rewrite-architecture.md +++ b/docs/adrs/0004-2026-07-07.rewrite-architecture.md @@ -10,7 +10,7 @@ ## Context and Problem Statement -The rewrite of `legacy/gitTally` (bash) as a Kotlin/Spring application (see `docs/plan/`) required several cross-cutting architecture decisions. +The rewrite of `legacy/werkator` (bash) as a Kotlin/Spring application (see `docs/plan/`) required several cross-cutting architecture decisions. They were proposed in `docs/plan/README.md`, validated step by step during implementation, and are summarized here as one record. ### Technical Background @@ -26,7 +26,7 @@ Its two structural defects — build status not observable during a build, and a ### Persistence: JSON files behind `BuildResultRepository` -Build results are persisted as a JSON file under `.git/gittally/`, accessed only through the `BuildResultRepository` interface. +Build results are persisted as a JSON file under `.git/werkator/`, accessed only through the `BuildResultRepository` interface. #### Advantages @@ -54,7 +54,7 @@ Pages render the full state server-side; one hand-written JavaScript file polls ### Deployment: no managed nginx, systemd user unit instead -nginx/Let's Encrypt container management was not ported; `init --systemd` generates a user unit running `java -jar gittally.jar server`, and `docs/deployment.md` documents the reverse-proxy setup with the host's certbot. +nginx/Let's Encrypt container management was not ported; `init --systemd` generates a user unit running `java -jar werkator.jar server`, and `docs/deployment.md` documents the reverse-proxy setup with the host's certbot. #### Advantages @@ -64,7 +64,7 @@ nginx/Let's Encrypt container management was not ported; `init --systemd` genera #### Disadvantages -- HTTPS setup is a manual, host-specific step outside GitTally's control. +- HTTPS setup is a manual, host-specific step outside werkator's control. ## Decision Outcome diff --git a/docs/adrs/0005-2026-07-07.managed-nginx-tls.md b/docs/adrs/0005-2026-07-07.managed-nginx-tls.md index d351aef..c446bf2 100644 --- a/docs/adrs/0005-2026-07-07.managed-nginx-tls.md +++ b/docs/adrs/0005-2026-07-07.managed-nginx-tls.md @@ -6,7 +6,7 @@ - rejected: - - superseded: - -**Decision [accepted]:** GitTally optionally manages an nginx+certbot Docker container for hosts without a usable reverse proxy — revises the "no managed nginx" part of ADR 0004; deployment behind an existing reverse proxy stays the default. +**Decision [accepted]:** werkator optionally manages an nginx+certbot Docker container for hosts without a usable reverse proxy — revises the "no managed nginx" part of ADR 0004; deployment behind an existing reverse proxy stays the default. ## Context and Problem Statement @@ -15,22 +15,22 @@ That decision was carried over from the rewrite plan without validating it again ### Technical Background -GitTally must run on Hostsharing managed container environments. -These hosts provide Docker but no root access and no host web server that GitTally could sit behind. -Without the managed nginx container, GitTally cannot be served over HTTPS there at all. +werkator must run on Hostsharing managed container environments. +These hosts provide Docker but no root access and no host web server that werkator could sit behind. +Without the managed nginx container, werkator cannot be served over HTTPS there at all. The legacy script already solved this: it wrote an nginx config, ran an nginx Docker container, and obtained/renewed Let's Encrypt certificates via a certbot container in webroot mode. ## Considered Options * Keep ADR 0004 as is (host reverse proxy only) * Re-add the legacy managed nginx+certbot container as an opt-in feature -* External tooling (user-maintained compose stack next to GitTally) +* External tooling (user-maintained compose stack next to werkator) ### Host reverse proxy only #### Advantages -- No container lifecycle or certificate code in GitTally. +- No container lifecycle or certificate code in werkator. #### Disadvantages @@ -38,7 +38,7 @@ The legacy script already solved this: it wrote an nginx config, ran an nginx Do ### Opt-in managed nginx+certbot container -GitTally starts and supervises a labelled nginx container and handles certificate issuance/renewal via certbot, only when explicitly enabled in the config. +werkator starts and supervises a labelled nginx container and handles certificate issuance/renewal via certbot, only when explicitly enabled in the config. #### Advantages @@ -48,13 +48,13 @@ GitTally starts and supervises a labelled nginx container and handles certificat #### Disadvantages -- Re-adds container lifecycle and certificate renewal complexity to GitTally. +- Re-adds container lifecycle and certificate renewal complexity to werkator. ### External compose stack #### Advantages -- Keeps GitTally itself simple. +- Keeps werkator itself simple. #### Disadvantages diff --git a/docs/adrs/0006-2026-08-10.runtime-bundle-distribution.md b/docs/adrs/0006-2026-08-10.runtime-bundle-distribution.md index 1d4b110..f0955d6 100644 --- a/docs/adrs/0006-2026-08-10.runtime-bundle-distribution.md +++ b/docs/adrs/0006-2026-08-10.runtime-bundle-distribution.md @@ -6,30 +6,30 @@ - rejected: - - superseded: - -**Decision [accepted]:** GitTally is distributed for hosts without a Java runtime as a self-contained runtime bundle — a jlink-trimmed JRE plus `gittally.jar` plus a launcher script in one tarball, built by `./gradlew runtimeBundle`. -The JAR stays the primary artifact; a GraalVM native image and a containerized GitTally runtime were rejected. +**Decision [accepted]:** werkator is distributed for hosts without a Java runtime as a self-contained runtime bundle — a jlink-trimmed JRE plus `werkator.jar` plus a launcher script in one tarball, built by `./gradlew runtimeBundle`. +The JAR stays the primary artifact; a GraalVM native image and a containerized werkator runtime were rejected. ## Context and Problem Statement -GitTally must run on Hostsharing container servers (the primary target, see ADR 0005). +werkator must run on Hostsharing container servers (the primary target, see ADR 0005). These hosts provide git, Docker, make, and systemd user sessions, but no Java runtime, and nothing may be installed system-wide. -`docs/bootstrapping.md` sketched a containerized GitTally runtime as the future answer; that sketch was never validated against the operational details. +`docs/bootstrapping.md` sketched a containerized werkator runtime as the future answer; that sketch was never validated against the operational details. ## Considered Options * jlink runtime bundle (trimmed JRE + jar + launcher, one tarball) * GraalVM native image (single executable) -* Containerized GitTally runtime (the original `docs/bootstrapping.md` sketch) +* Containerized werkator runtime (the original `docs/bootstrapping.md` sketch) ### jlink Runtime Bundle -A `jlink`-generated JRE with the pinned module list, the boot jar, and a `bin/gittally` launcher script, packed as `gittally-runtime-linux-x64.tar.gz` (~66 MB) and unpacked to `~/opt/gittally/` on the target host. +A `jlink`-generated JRE with the pinned module list, the boot jar, and a `bin/werkator` launcher script, packed as `werkator-runtime-linux-x64.tar.gz` (~66 MB) and unpacked to `~/opt/werkator/` on the target host. Good: - No production-code changes and plain JVM semantics — no new failure modes. - git and docker CLIs are used from the host; build worktree paths stay host paths. -- `init --systemd` works unchanged: `java.home` and the running-jar path resolve into the bundle, so the generated unit points at `/jre/bin/java` and `/lib/gittally.jar` (verified). +- `init --systemd` works unchanged: `java.home` and the running-jar path resolve into the bundle, so the generated unit points at `/jre/bin/java` and `/lib/werkator.jar` (verified). - Every JDK 21 ships jlink — no new build-toolchain requirement. Bad: @@ -44,12 +44,12 @@ Bad: ### GraalVM Native Image -Rejected because Spring AOT evaluates bean conditions at build time, and GitTally's dual-mode wiring cannot be represented in a single AOT arrangement: +Rejected because Spring AOT evaluates bean conditions at build time, and werkator's dual-mode wiring cannot be represented in a single AOT arrangement: the CLI context runs without web and with `@Profile("!server")` `CliRunner`, while the `server` subcommand starts a second `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile gating the watcher/metrics/nginx lifecycles. Whichever profile and web type the AOT processing fixes, the other mode's beans are missing from the binary. Supporting both would require replacing the profile wiring with runtime guards and collapsing the two context shapes — an invasive rewrite with regression risk for the JVM path, on top of the usual native-image reflection work (Jackson-bound config and persistence classes, picocli). -### Containerized GitTally Runtime +### Containerized werkator Runtime Rejected for operational complexity: the image must bundle git and docker CLIs; the container needs a same-path `$HOME` mount plus a docker-socket mount and uid/gid mapping so that `DockerBuildRunner`'s `--volume $workspace:$workspace` sibling mounts keep working; and the systemd unit must be hand-edited to a `docker run` invocation. This remains the documented fallback if the runtime bundle ever becomes unworkable. diff --git a/docs/adrs/0007-2026-08-28.build-definitions.md b/docs/adrs/0007-2026-08-28.build-definitions.md index 4fe1281..381f510 100644 --- a/docs/adrs/0007-2026-08-28.build-definitions.md +++ b/docs/adrs/0007-2026-08-28.build-definitions.md @@ -11,7 +11,7 @@ ## Context and Problem Statement -GitTally's configuration is branch-centric: `branches.` holds the build settings, and the nightly schedule (`autoBuild`) hangs off the branch. +werkator's configuration is branch-centric: `branches.` holds the build settings, and the nightly schedule (`autoBuild`) hangs off the branch. v0.9.13 added a per-slot `buildCommand` and `name` to `autoBuild.times[]`, so a nightly slot could run a fuller check recorded in its own result pool. That worked, but it is a job concept hidden inside a schedule entry: the slot carries a command, an identity, and (implicitly) a branch — everything a job has, in the wrong place. @@ -73,7 +73,7 @@ A build definition has: Semantics: -- **Merge order** for the effective settings of one build on one branch: config defaults → `branches.default` → `branches.` → the worktree's committed `.gittally.yml` (build keys, pinned keys stripped) → `builds.` overrides. The build definition wins last because it is the job; it comes from the repo install/project config (server-side), never from the worktree. +- **Merge order** for the effective settings of one build on one branch: config defaults → `branches.default` → `branches.` → the worktree's committed `.werkator.yml` (build keys, pinned keys stripped) → `builds.` overrides. The build definition wins last because it is the job; it comes from the repo install/project config (server-side), never from the worktree. - **Pool identity**: the `default` build records under the branch name (URLs, rows, retention as before); every other build records under `@` (URL-sanitized, e.g. `/branches/master_pitest/…`). Each pool keeps its own retention count, latest status, and permanent latest-green link. - **Persistence**: the result stores the build's name (`build`, default `default`) next to the branch; the derived pool name keeps keying grouping and display. The v0.9.13 `buildCommandOverride` field is dropped: restart, retry, and startup recovery re-resolve the command from the *current* config by (branch, build) — a job definition in config is the source of truth, so a re-run of an old result uses the job's current command. - **Triggers in the watcher**: `onPush` uses the existing change detection per pool ("already built" per pool and commit); `atTimes` fires once per day per slot per pool (state file keyed by pool, date, time). The `branches..requirePullRequest` gate stays a branch property and gates all watcher-triggered builds of that branch, as today. @@ -119,7 +119,7 @@ Follow-up (2026-08-28): mixing the execution key `maxConcurrent` into the `build The concurrency limit moved to `executor.maxConcurrent` (a new section for execution settings), without a compatibility alias, so the `builds` section holds build definitions only. Follow-up (2026-08-29): pinning the whole `builds` section against the branch layer was wrong and is reverted. -A branch's committed `.gittally.yml` describes that branch's CI, and a new `builds` configuration can only be tried out by committing it on a branch — pinned, it was neither effective at build time nor visible to the watcher, so the job silently did not exist. +A branch's committed `.werkator.yml` describes that branch's CI, and a new `builds` configuration can only be tried out by committing it on a branch — pinned, it was neither effective at build time nor visible to the watcher, so the job silently did not exist. The branch layer now carries `builds` too: the watcher reads each origin branch's committed config (`git show`, cached by head commit) to decide which of *that branch's* builds are due, and a branch's definitions are evaluated for that branch alone, so they can never trigger builds of another branch. The pinned set is reduced to what does not describe this branch's build: secrets (`git`), the host/repository sections (`server`, `gitea`, `executor`, `watcher`), the sandbox policy (`docker.enabled`/`docker.network`), and the trust gate (`requirePullRequest`). Letting a branch set its own `buildCommand` through a definition grants no new power — `branches.*.buildCommand` always allowed exactly that — whereas the sandbox and the gate decide whether untrusted branch code runs on the host at all, and therefore stay server-side. diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md index e0b924c..656a71a 100644 --- a/docs/bootstrapping.md +++ b/docs/bootstrapping.md @@ -1,6 +1,6 @@ -# GitTally Bootstrapping +# werkator Bootstrapping -Bootstrapping prepares a git repository for use with GitTally. +Bootstrapping prepares a git repository for use with werkator. It creates the config files described in [configuration.md](configuration.md). With `init --systemd` it also generates a systemd user unit for running the server permanently, see [deployment.md](deployment.md). @@ -14,7 +14,7 @@ Run `init` once per repository, from within a checked-out working tree. ## Running `init` -First, in ``, build the application to generate the executable JAR file: +First, in ``, build the application to generate the executable JAR file: ```bash ./gradlew build @@ -23,21 +23,21 @@ First, in ``, build the application to generate the executable JA Then run `init` using the generated JAR (not the `-plain.jar`): ```bash -java -jar /build/libs/gittally.jar init +java -jar /build/libs/werkator.jar init ``` `init` performs the following steps in order: ### 1. Detect the Repository Root -GitTally resolves the repository root by running `git rev-parse --show-toplevel`. +werkator resolves the repository root by running `git rev-parse --show-toplevel`. If the current directory is not inside a git repository, `init` exits with an error. ### 2. Auto-detect Gitea Connection from `origin` -If `gitea.baseUrl`, `gitea.owner`, and `gitea.repo` are already set in `.gittally.yml`, these values are used. +If `gitea.baseUrl`, `gitea.owner`, and `gitea.repo` are already set in `.werkator.yml`, these values are used. -Otherwise, GitTally inspects the `origin` remote URL and derives the Gitea connection defaults: +Otherwise, werkator inspects the `origin` remote URL and derives the Gitea connection defaults: | Origin URL form | Detected values | |--------------------------------------------|----------------------------------------| @@ -52,7 +52,7 @@ The `.git` suffix is stripped from the repo name. The username embedded in HTTPS ### 3. Create the Repo-Install Config -Creates `.git/gittally/.gittally.yml` (and its parent directory if needed). +Creates `.git/werkator/.werkator.yml` (and its parent directory if needed). This file is **never committed** to the repository and is used for all branches, as long as not overridden by a project config. @@ -68,7 +68,7 @@ git: ### 4. Create the Branch/Project Config -Creates `.gittally.yml` in the repository root with project-level defaults. +Creates `.werkator.yml` in the repository root with project-level defaults. If the file already exists, `init` prints a notice and leaves it untouched. @@ -84,55 +84,55 @@ gitea: ... ``` -Then, you have to configure *gitTally* by amending this config file according to [configuration.md](configuration.md). +Then, you have to configure *werkator* by amending this config file according to [configuration.md](configuration.md). ## Output `init` prints one line per action taken: ``` -created .git/gittally/.gittally.yml -created .gittally.yml +created .git/werkator/.werkator.yml +created .werkator.yml ``` Or, when files already exist: ``` -.git/gittally/.gittally.yml already exists — not overwritten -.gittally.yml already exists — not overwritten +.git/werkator/.werkator.yml already exists — not overwritten +.werkator.yml already exists — not overwritten ``` ## Hosts Without a Java Runtime -GitTally is intended to run on Hostsharing Container Server environments, which provide Docker and git but no Java runtime. +werkator is intended to run on Hostsharing Container Server environments, which provide Docker and git but no Java runtime. For these hosts, `./gradlew runtimeBundle` builds a self-contained runtime bundle (jlink-trimmed JRE + JAR + launcher) — see [deployment.md](deployment.md) and ADR 0006. -A containerized GitTally runtime was considered and rejected there. +A containerized werkator runtime was considered and rejected there. ## Next Steps After `init` -1. Open `.git/gittally/.gittally.yml` and set `git.token` and `git.account`. -2. Review `.gittally.yml` and add/adjust any branch build settings. +1. Open `.git/werkator/.werkator.yml` and set `git.token` and `git.account`. +2. Review `.werkator.yml` and add/adjust any branch build settings. 3. Verify the effective configuration: ```bash - java -jar build/libs/gittally.jar config:print --full + java -jar build/libs/werkator.jar config:print --full ``` 4. Start the server: ```bash - java -jar build/libs/gittally.jar server + java -jar build/libs/werkator.jar server ``` 5. For permanent operation, install the systemd user service described in [deployment.md](deployment.md). ## Example: Test Server with a Fake Build -[examples/setup-gittally-testserver.sh](examples/setup-gittally-testserver.sh) starts a GitTally server against a scratch repository with a fake build — the setup used for the manual UI/API smoke tests during development. +[examples/setup-werkator-testserver.sh](examples/setup-werkator-testserver.sh) starts a werkator server against a scratch repository with a fake build — the setup used for the manual UI/API smoke tests during development. It creates a local bare origin plus a `work` clone, commits a slow fake build script (live log output, demo report artifact) with a `pollInterval: 5s` config, and starts the server on port 18980. The origin gets a second branch (`feature/demo`), so the Branches view shows more than one entry. No Gitea, no credentials, no Docker; `INSTALL_DIR`, `SERVER_PORT`, and `BUILD_SECONDS` can be overridden via environment variables. While the server runs, push empty commits from the `work` clone to trigger builds; a commit message containing `[fail]` makes the build fail, and pushing a new branch exercises the new-origin-branch path. -## Example: Self-Hosting GitTally +## Example: Self-Hosting werkator -[examples/setup-gittally-selfhost.sh](examples/setup-gittally-selfhost.sh) shows the full sequence as a runnable script: it sets up a GitTally instance that watches and builds GitTally itself. +[examples/setup-werkator-selfhost.sh](examples/setup-werkator-selfhost.sh) shows the full sequence as a runnable script: it sets up a werkator instance that watches and builds werkator itself. Run it from a working checkout; it builds the JAR, creates a dedicated clone, runs `init`, writes the machine-specific config, and starts the server. `INSTALL_DIR`, `ORIGIN_URL`, `SERVER_PORT`, `GIT_ACCOUNT`, and `GIT_TOKEN` can be overridden via environment variables. The script also demonstrates the kick-start trick: resetting the local ref one commit behind origin makes the very first poll build immediately, instead of waiting for the next push. diff --git a/docs/configuration.md b/docs/configuration.md index eedd9aa..22936ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,53 +1,53 @@ -# GitTally Configuration Reference +# werkator Configuration Reference -GitTally is configured via YAML files. Settings are merged from several sources in order — later layers override earlier ones. +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 | |--------------------------|----------------------------|------------------|----------------------------------------------| -| Project config | `.gittally.yml` | Yes | Shared team settings | -| Repo installation config | `.git/gittally/.gittally.yml` | No | Machine- or user-specific overrides, secrets | -| Branch config | `.gittally.yml` committed on a branch | Yes | That branch's build settings and build definitions | +| Project config | `.werkator.yml` | Yes | Shared team settings | +| 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/gittally/.gittally.yml`) wins on any key present in both files. Typically used to set `git.token` and `git.account` without committing them. +The repo install config (`.git/werkator/.werkator.yml`) wins on any key present in both files. Typically used to set `git.token` and `git.account` without committing them. -### Which GitTally a file is written for +### Which werkator a file is written for -Every configuration file may declare the GitTally it was written for. Without it, a +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. ```yaml -gitTally: +werkator: version: - since: "0.9.16" # enforced: an older GitTally refuses to read this file - below: "2.0" # your release marker; GitTally decides how strictly to take it + 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 — GitTally reads its own configuration — and only one configuration generation 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 GitTally is refused instead of being half-understood; +- 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 — - GitTally knows in which version its configuration format last broke, so the message can - name the change: *"is written for GitTally 1.4.0, but the configuration format changed + 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 now `buildSpec:`"*. `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 GitTally's own knowledge of its breaking changes, not from this value. The intended +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. `gittally init` writes the running version into the +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 @@ -58,7 +58,7 @@ branches that are fine. ### The branch layer: a branch describes its own CI -The `.gittally.yml` committed on a branch is applied as a third layer on top of the two +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 @@ -95,7 +95,7 @@ single branch may decide it: configuration does. The distinction is documentary. -GitTally applies one rule: every pinned key is stripped from the branch layer, and the +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. @@ -110,26 +110,26 @@ install/project config only, and only while nothing defines a build at all. ## Inspect the Effective Config ```bash -java -jar build/libs/gittally.jar config:print # only explicitly set values -java -jar build/libs/gittally.jar config:print --full # all values including defaults +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. -## `.gittally.yml` +## `.werkator.yml` Values shown are the defaults. ```yaml -# The GitTally this file is written for (see the section above). -gitTally: +# The werkator this file is written for (see the section above). +werkator: version: - since: "0.9.18" # enforced: older GitTally refuses this file + 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 GitTally installation — used for all links posted to Gitea. + # 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 @@ -151,10 +151,10 @@ server: httpsPort: 8443 # host nginx proxies to; empty = serverName (the container cannot reach localhost) upstreamHost: "" - # name of the managed container; empty = gittally-nginx- + # name of the managed container; empty = werkator-nginx- containerName: "" # directory for nginx config, certificates, and logs; - # empty = $XDG_STATE_HOME (or ~/.local/state) plus /gittally/nginx/ + # empty = $XDG_STATE_HOME (or ~/.local/state) plus /werkator/nginx/ stateDir: "" # e-mail for the Let's Encrypt account; empty registers without one letsencryptEmail: "" @@ -164,14 +164,14 @@ 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: GitTally # label shown on Gitea commit status checks (default: GitTally) + 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/gittally/worktrees/, never in the primary checkout. + # git worktree under .git/werkator/worktrees/, never in the primary checkout. # Changing this value requires a restart. maxConcurrent: 1 @@ -233,12 +233,12 @@ builds: activeWithin: 24h buildCommand: ./gradlew -PfullPitTest --console=plain --no-daemon piTestFull artifactDirs: [build/reports, build/libs] - statusContext: GitTally/pitest + statusContext: werkator/pitest # Build artifact storage and retention. artifacts: # Root directory for stored build artifacts. - # Empty means the platform default: $XDG_STATE_HOME (or ~/.local/state) plus /gittally/artifacts/, + # Empty means the platform default: $XDG_STATE_HOME (or ~/.local/state) plus /werkator/artifacts/, # where is the sanitized absolute repository path. # A leading ~/ expands to the home directory; a relative path is resolved against the repository. rootDir: "" @@ -275,24 +275,24 @@ watcher: ### 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 GitTally 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 GitTally 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 `.gittally.yml` and keep it; the new default only applies where the key is absent or `init` writes a fresh file. +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 GitTally over HTTPS (ADR 0005). +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](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:///`. All nginx/certificate failures are non-fatal warnings; the plain HTTP server keeps running without the proxy. -The container is labelled `org.hoennig.gittally`; stale nginx containers of the repository are removed before each start, and the container is removed on shutdown. +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 `builds..requirePullRequest` The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds). -A manual `gittally build ` always builds, regardless of this setting. +A manual `werkator build ` 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. @@ -321,7 +321,7 @@ Without that second definition, direct pushes and merges to `main` would never b 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/gittally/.gittally.yml`, so the committed configuration keeps the gates for forge-backed environments. +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) @@ -334,7 +334,7 @@ Triggers: `onPush: true` builds every new commit of the selected branches; `atTi 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 have both; one with neither never triggers automatically — which is how `builds.default` is written when it is meant as a settings base only. -GitTally logs a warning once when no definition has a trigger at all, because such an instance never builds anything on its own. +werkator logs a warning once when no definition has a trigger at all, because such an instance never builds anything on its own. 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. @@ -343,7 +343,7 @@ That is how a branch gets a build of its own without being built by the default Both parts combine as an intersection. Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` with all its keys. -A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to GitTally's own defaults. +A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to werkator's own defaults. `requirePullRequest`, `statusContext`, `docker.enabled`, and `docker.network` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config. 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. @@ -353,9 +353,9 @@ The implicit `default` build (`onPush: true`, all branches) preserves the behavi The `default` build records under the plain branch name; every other build records under `@` 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, `gittally 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. +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. -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` (`GitTally/quick`, say), or keep them apart with an exclusion pattern. +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. @@ -378,7 +378,7 @@ To migrate, move `branches.default` to `builds.default`, add `onPush: true`, and ### Notes on `watcher.fastForwardLocalRefs` Builds run in worktrees that share the primary checkout's `.git`, so a build tool can read `refs/heads/*` there. -GitTally itself never needs those refs to be current — it builds the commit `refs/remotes/origin/` points at — but build tools do. +werkator itself never needs those refs to be current — it builds the commit `refs/remotes/origin/` 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. @@ -390,20 +390,20 @@ The branch checked out in the primary checkout is advanced with `git merge --ff- ### Notes on `builds..docker` -With `docker.enabled`, GitTally shells out to the `docker` CLI; the `docker` command must be on the `PATH`. +With `docker.enabled`, werkator shells out to the `docker` CLI; the `docker` command must be on the `PATH`. When `dockerfile` is set, the image is (re)built whenever the Dockerfile content, its path, or the context path changed. -Staleness is tracked via the image label `org.gittally.build-inputs-sha256`. -A Gradle cache volume `gittally-gradle-` is created per repository and mounted as `GRADLE_USER_HOME`. +Staleness is tracked via the image label `org.werkator.build-inputs-sha256`. +A Gradle cache volume `werkator-gradle-` 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/gittally/` 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; GitTally never stores credentials there, and neither should you. +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. -All GitTally containers carry `org.hoennig.gittally` labels; stale build containers of the repository are removed before the first Docker build after a restart. +All werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart. -## `.git/gittally/.gittally.yml` (not committed) +## `.git/werkator/.werkator.yml` (not committed) ```yaml -# Machine- or user-specific overrides and secrets. Keys here win over .gittally.yml. +# 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 diff --git a/docs/deployment.md b/docs/deployment.md index 8eb2029..d9fdc94 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,8 +1,8 @@ -# GitTally Deployment +# werkator Deployment -This document describes how to run GitTally as a permanent service. +This document describes how to run werkator as a permanent service. The recommended setup is a systemd user service behind an existing reverse proxy. -By default GitTally does not manage nginx or TLS certificates itself; it relies on the host's existing web server and certbot. +By default werkator does not manage nginx or TLS certificates itself; it relies on the host's existing web server and certbot. For hosts without one, an opt-in managed nginx/TLS container is available, see [Hosts Without a Reverse Proxy](#hosts-without-a-reverse-proxy-managed-nginxtls). ## Prerequisites @@ -19,14 +19,14 @@ Build the executable jar once: ```bash ./gradlew build -ls build/libs/gittally.jar +ls build/libs/werkator.jar ``` -Copy the jar to a stable path outside any watched repository, by convention `~/bin/gittally.jar`: +Copy the jar to a stable path outside any watched repository, by convention `~/bin/werkator.jar`: ```bash mkdir -p ~/bin -cp build/libs/gittally.jar ~/bin/gittally.jar +cp build/libs/werkator.jar ~/bin/werkator.jar ``` The systemd unit generated below points at the jar that was used to run `init --systemd`. @@ -34,41 +34,41 @@ So always run it via the stable path, not via `build/libs/`. ## Install the Service -Initialize GitTally in the repository to watch (see [bootstrapping.md](bootstrapping.md) for details): +Initialize werkator in the repository to watch (see [bootstrapping.md](bootstrapping.md) for details): ```bash cd /path/to/repo -java -jar ~/bin/gittally.jar init -# fill in git.account and git.token in .git/gittally/.gittally.yml -# review .gittally.yml +java -jar ~/bin/werkator.jar init +# fill in git.account and git.token in .git/werkator/.werkator.yml +# review .werkator.yml ``` Generate the systemd user unit: ```bash -java -jar ~/bin/gittally.jar init --systemd +java -jar ~/bin/werkator.jar init --systemd ``` -This writes `.git/gittally/gittally-.service`, `.git/gittally/gittally.env`, and the nightly Docker cleanup units (`gittally-docker-prune.service`/`.timer`), and prints the install commands: +This writes `.git/werkator/werkator-.service`, `.git/werkator/werkator.env`, and the nightly Docker cleanup units (`werkator-docker-prune.service`/`.timer`), and prints the install commands: ```bash -ln -sf /path/to/repo/.git/gittally/gittally-.service ~/.config/systemd/user/gittally-.service -ln -sf /path/to/repo/.git/gittally/gittally-docker-prune.service ~/.config/systemd/user/gittally-docker-prune.service -ln -sf /path/to/repo/.git/gittally/gittally-docker-prune.timer ~/.config/systemd/user/gittally-docker-prune.timer +ln -sf /path/to/repo/.git/werkator/werkator-.service ~/.config/systemd/user/werkator-.service +ln -sf /path/to/repo/.git/werkator/werkator-docker-prune.service ~/.config/systemd/user/werkator-docker-prune.service +ln -sf /path/to/repo/.git/werkator/werkator-docker-prune.timer ~/.config/systemd/user/werkator-docker-prune.timer systemctl --user daemon-reload -systemctl --user enable --now gittally-.service -systemctl --user enable --now gittally-docker-prune.timer +systemctl --user enable --now werkator-.service +systemctl --user enable --now werkator-docker-prune.timer ``` -The unit runs `java -jar ~/bin/gittally.jar server` with the repository as working directory and `Restart=always`. +The unit runs `java -jar ~/bin/werkator.jar server` with the repository as working directory and `Restart=always`. The unit name contains the repository name, so several repositories can be served by one host, each with its own service and port. ### Nightly Docker Cleanup -The `gittally-docker-prune.timer` runs `docker system prune -af` every night at 02:00 (host time), before the usual auto-build slots. +The `werkator-docker-prune.timer` runs `docker system prune -af` every night at 02:00 (host time), before the usual auto-build slots. It removes stopped containers, unused images, unused networks, and dangling build cache, so nightly builds start from freshly built images. Unlike the legacy cleanup it does **not** prune volumes — the per-repository Gradle cache volumes survive. -The units are host-global (no repository name): with several GitTally instances on one host, every `init --systemd` generates the same files and the symlinks coincide. +The units are host-global (no repository name): with several werkator instances on one host, every `init --systemd` generates the same files and the symlinks coincide. On hosts without a `docker` CLI the service is skipped, not failed (`ExecCondition`). `Persistent=true` catches up a missed run after downtime. @@ -84,10 +84,10 @@ loginctl enable-linger "$USER" ## Operating the Service ```bash -systemctl --user status gittally-.service # state and last log lines -journalctl --user -u gittally-.service -f # follow the log -systemctl --user restart gittally-.service # restart (e.g. after config changes) -systemctl --user stop gittally-.service # stop +systemctl --user status werkator-.service # state and last log lines +journalctl --user -u werkator-.service -f # follow the log +systemctl --user restart werkator-.service # restart (e.g. after config changes) +systemctl --user stop werkator-.service # stop ``` ## Updating an Existing Installation @@ -103,67 +103,67 @@ With a jar installation: ```bash ./gradlew build # on the dev machine -scp build/libs/gittally.jar @:~/bin/gittally.jar.new +scp build/libs/werkator.jar @:~/bin/werkator.jar.new ssh @ - systemctl --user stop gittally-.service - mv ~/bin/gittally.jar ~/bin/gittally.jar.bak # rollback copy - mv ~/bin/gittally.jar.new ~/bin/gittally.jar - systemctl --user start gittally-.service - systemctl --user is-active gittally-.service + systemctl --user stop werkator-.service + mv ~/bin/werkator.jar ~/bin/werkator.jar.bak # rollback copy + mv ~/bin/werkator.jar.new ~/bin/werkator.jar + systemctl --user start werkator-.service + systemctl --user is-active werkator-.service ``` With a runtime bundle (hosts without Java, see below): ```bash ./gradlew runtimeBundle # on the dev machine -scp build/distributions/gittally-runtime-linux-x64.tar.gz @:/tmp/gittally-new.tar.gz +scp build/distributions/werkator-runtime-linux-x64.tar.gz @:/tmp/werkator-new.tar.gz ssh @ - systemctl --user stop gittally-.service - mv ~/opt/gittally ~/opt/gittally.bak # rollback copy - tar xzf /tmp/gittally-new.tar.gz -C /tmp/ && mv /tmp/gittally ~/opt/gittally - ~/opt/gittally/bin/gittally --version # expected: the new version - systemctl --user start gittally-.service - systemctl --user is-active gittally-.service - rm -f /tmp/gittally-new.tar.gz + systemctl --user stop werkator-.service + mv ~/opt/werkator ~/opt/werkator.bak # rollback copy + tar xzf /tmp/werkator-new.tar.gz -C /tmp/ && mv /tmp/werkator ~/opt/werkator + ~/opt/werkator/bin/werkator --version # expected: the new version + systemctl --user start werkator-.service + systemctl --user is-active werkator-.service + rm -f /tmp/werkator-new.tar.gz ``` -The tarball unpacks to a `gittally/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above. +The tarball unpacks to a `werkator/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above. Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start. -Then check `https:///` for the new version in the footer, and `journalctl --user -u gittally-.service -n 50` for a clean start. +Then check `https:///` for the new version in the footer, and `journalctl --user -u werkator-.service -n 50` for a clean start. Config file changes are not needed for an update; new keys take their defaults. ## Control Token Viewing is public by design: build states, logs and artifacts are readable without any login, so they can be linked from Gitea, chats or tickets. -That is safe as long as the builds themselves handle no real secrets — GitTally has no per-endpoint gating, so an installation whose build output could contain credentials must stay off the public internet (reverse proxy with access control, or `server.bindAddress: 127.0.0.1`). -Only the three mutating actions — restart, cancel, delete — require the control token from `.git/gittally/control-token`, a random secret the server generates on first start (mode `0600`; delete the file to rotate it). +That is safe as long as the builds themselves handle no real secrets — werkator has no per-endpoint gating, so an installation whose build output could contain credentials must stay off the public internet (reverse proxy with access control, or `server.bindAddress: 127.0.0.1`). +Only the three mutating actions — restart, cancel, delete — require the control token from `.git/werkator/control-token`, a random secret the server generates on first start (mode `0600`; delete the file to rotate it). The token is never embedded in a page. The first time you press one of the control buttons, the browser asks for it once and keeps it in `localStorage` for that browser; a rejected token is dropped and asked for again. Read it on the host: ```bash -cat ~//.git/gittally/control-token +cat ~//.git/werkator/control-token ``` For scripts, pass it as a header — it is not accepted as a query parameter, because URLs end up in access logs and browser history: ```bash -curl -X POST -H "X-GitTally-Token: $(cat .git/gittally/control-token)" \ +curl -X POST -H "X-werkator-Token: $(cat .git/werkator/control-token)" \ "https://ci.example.org/api/builds/restart?branch=main" ``` ## Environment File -`.git/gittally/gittally.env` is loaded by the unit as `EnvironmentFile`. +`.git/werkator/werkator.env` is loaded by the unit as `EnvironmentFile`. It only tunes the JVM process, e.g. `JAVA_OPTS=-Xmx256m`. -All GitTally configuration lives in the YAML files described in [configuration.md](configuration.md), not in environment variables. +All werkator configuration lives in the YAML files described in [configuration.md](configuration.md), not in environment variables. `init --systemd` never overwrites an existing environment file. ## Reverse Proxy (nginx) -Bind GitTally to localhost — the default since v0.9.9 — and set the public URL in `.gittally.yml`: +Bind werkator to localhost — the default since v0.9.9 — and set the public URL in `.werkator.yml`: ```yaml server: @@ -204,39 +204,39 @@ This replaces the legacy script's managed nginx/Let's Encrypt Docker container f ## Hosts Without a Java Runtime (Runtime Bundle) Some hosts provide git and Docker but no Java runtime and no way to install one, e.g. Hostsharing container servers. -For these, GitTally ships as a self-contained runtime bundle: a jlink-trimmed JRE, `gittally.jar`, and a launcher script in one tarball (ADR 0006). +For these, werkator ships as a self-contained runtime bundle: a jlink-trimmed JRE, `werkator.jar`, and a launcher script in one tarball (ADR 0006). Build the bundle on a Linux x86_64 machine whose glibc is not newer than the target's: ```bash ./gradlew runtimeBundle -ls build/distributions/gittally-runtime-linux-x64.tar.gz +ls build/distributions/werkator-runtime-linux-x64.tar.gz ``` -Copy and unpack it on the target host, by convention to `~/opt/gittally`: +Copy and unpack it on the target host, by convention to `~/opt/werkator`: ```bash -scp build/distributions/gittally-runtime-linux-x64.tar.gz user@host:/tmp/ -ssh user@host 'mkdir -p ~/opt && tar -xzf /tmp/gittally-runtime-linux-x64.tar.gz -C ~/opt' +scp build/distributions/werkator-runtime-linux-x64.tar.gz user@host:/tmp/ +ssh user@host 'mkdir -p ~/opt && tar -xzf /tmp/werkator-runtime-linux-x64.tar.gz -C ~/opt' ``` -Then use `~/opt/gittally/bin/gittally` wherever this document says `java -jar ~/bin/gittally.jar`: +Then use `~/opt/werkator/bin/werkator` wherever this document says `java -jar ~/bin/werkator.jar`: ```bash cd /path/to/repo -~/opt/gittally/bin/gittally init -~/opt/gittally/bin/gittally init --systemd +~/opt/werkator/bin/werkator init +~/opt/werkator/bin/werkator init --systemd ``` -`init --systemd` detects the bundle automatically: the generated unit's `ExecStart` points at the bundle's `jre/bin/java` and `lib/gittally.jar`, so the install commands printed by `init --systemd` work unchanged. +`init --systemd` detects the bundle automatically: the generated unit's `ExecStart` points at the bundle's `jre/bin/java` and `lib/werkator.jar`, so the install commands printed by `init --systemd` work unchanged. `JAVA_OPTS` from the environment file applies as usual. -To update GitTally, stop the service, unpack the new bundle over `~/opt/gittally`, and restart the service. +To update werkator, stop the service, unpack the new bundle over `~/opt/werkator`, and restart the service. ## Hosts Without a Reverse Proxy (Managed nginx/TLS) Some hosts provide Docker but no root access and no host web server, e.g. Hostsharing managed container environments. -For these, GitTally can manage its own nginx+certbot Docker container (ADR 0005). +For these, werkator can manage its own nginx+certbot Docker container (ADR 0005). This is opt-in; where a host web server exists, prefer the reverse-proxy setup above. Enable it in the server section of the configuration: @@ -252,12 +252,12 @@ server: letsencryptEmail: admin@example.org ``` -On server start, GitTally writes the nginx configuration, starts a labelled nginx container publishing `httpPort` and `httpsPort`, obtains a Let's Encrypt certificate via a certbot container (webroot mode), and restarts nginx with the full HTTPS configuration. +On server start, werkator writes the nginx configuration, starts a labelled nginx container publishing `httpPort` and `httpsPort`, obtains a Let's Encrypt certificate via a certbot container (webroot mode), and restarts nginx with the full HTTPS configuration. A renewal check runs daily; certificates and nginx state persist in `server.nginx.stateDir` across restarts. On shutdown the container is removed. All nginx and certificate failures are non-fatal warnings — the plain HTTP server keeps running without the proxy. `serverName` must be a public DNS name pointing at the host, reachable from the internet on port 80/443 (directly or via a port forward to `httpPort`/`httpsPort`), otherwise the ACME challenge fails. The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers. -With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes GitTally unreachable for the proxy container. +With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes werkator unreachable for the proxy container. See [configuration.md](configuration.md) for all `server.nginx.*` keys. diff --git a/docs/examples/setup-gittally-selfhost.sh b/docs/examples/setup-gittally-selfhost.sh index cdffaa0..76a5783 100755 --- a/docs/examples/setup-gittally-selfhost.sh +++ b/docs/examples/setup-gittally-selfhost.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# Example: set up a GitTally instance that watches and builds GitTally itself. -# Run from inside a working checkout of the gittally repository. +# Example: set up a werkator instance that watches and builds werkator itself. +# Run from inside a working checkout of the werkator repository. # # Usage: -# GIT_ACCOUNT=mi GIT_TOKEN=xxxx ./docs/examples/setup-gittally-selfhost.sh +# GIT_ACCOUNT=mi GIT_TOKEN=xxxx ./docs/examples/setup-werkator-selfhost.sh # (both empty is fine for a public origin — commit statuses just won't be published) set -euo pipefail -INSTALL_DIR="${INSTALL_DIR:-$HOME/gittally-selfhost}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/werkator-selfhost}" GIT_ACCOUNT="${GIT_ACCOUNT:-}" # technical Gitea user for HTTPS fetch + status API GIT_TOKEN="${GIT_TOKEN:-}" # Gitea API token — stays in the uncommitted config file SERVER_PORT="${SERVER_PORT:-18080}" @@ -15,45 +15,45 @@ SERVER_PORT="${SERVER_PORT:-18080}" DEV_CHECKOUT=$(git rev-parse --show-toplevel) ORIGIN_URL="${ORIGIN_URL:-$(git -C "$DEV_CHECKOUT" remote get-url origin)}" -# 1. Build the GitTally jar — the last Gradle run you ever start by hand. +# 1. Build the werkator jar — the last Gradle run you ever start by hand. (cd "$DEV_CHECKOUT" && ./gradlew --console=plain build) -# 2. Dedicated clone: builds run in worktrees under its .git/gittally/worktrees, +# 2. Dedicated clone: builds run in worktrees under its .git/werkator/worktrees, # completely separate from your dev checkout. mkdir -p "$INSTALL_DIR" -cp "$DEV_CHECKOUT/build/libs/gittally.jar" "$INSTALL_DIR/gittally.jar" +cp "$DEV_CHECKOUT/build/libs/werkator.jar" "$INSTALL_DIR/werkator.jar" if [ ! -d "$INSTALL_DIR/repo/.git" ]; then git clone "$ORIGIN_URL" "$INSTALL_DIR/repo" fi cd "$INSTALL_DIR/repo" # 3. Generate the config templates; existing files are kept. The clone already -# carries the committed .gittally.yml, so this only creates the machine config -# (.git/gittally/.gittally.yml). -java -jar "$INSTALL_DIR/gittally.jar" init +# carries the committed .werkator.yml, so this only creates the machine config +# (.git/werkator/.werkator.yml). +java -jar "$INSTALL_DIR/werkator.jar" init -# 4. Machine-specific overrides and secrets (.git/gittally/ is never committed; -# this file deep-merges over .gittally.yml and wins). -cat > .git/gittally/.gittally.yml < .git/werkator/.werkator.yml </dev/null; then cat > "$INSTALL_DIR/work/fake-build.sh" <<'EOF' #!/usr/bin/env bash # Fake build: visible progress for the live log, then a demo report artifact. -# GitTally exports `branch`; BUILD_SECONDS is inherited from the server process. +# werkator exports `branch`; BUILD_SECONDS is inherited from the server process. set -euo pipefail echo "fake build of branch ${branch:-unknown} at commit $(git rev-parse --short HEAD)" if git log -1 --pretty=%s | grep -qF '[fail]'; then @@ -56,7 +56,7 @@ printf '

Demo Report

branch %s, commit %s "$INSTALL_DIR/work/.gittally.yml" <<'EOF' + cat > "$INSTALL_DIR/work/.werkator.yml" <<'EOF' watcher: pollInterval: 5s @@ -67,7 +67,7 @@ branches: artifactDirs: - build/reports EOF - git -C "$INSTALL_DIR/work" add fake-build.sh .gittally.yml + git -C "$INSTALL_DIR/work" add fake-build.sh .werkator.yml git -C "$INSTALL_DIR/work" commit --quiet -m "fake build setup" git -C "$INSTALL_DIR/work" commit --quiet --allow-empty -m "kick-start build" git -C "$INSTALL_DIR/work" push --quiet origin main @@ -88,8 +88,8 @@ if [ ! -d "$INSTALL_DIR/repo/.git" ]; then git clone --quiet "$INSTALL_DIR/origin.git" "$INSTALL_DIR/repo" fi cd "$INSTALL_DIR/repo" -mkdir -p .git/gittally -cat > .git/gittally/.gittally.yml < .git/werkator/.werkator.yml <`; use `builds.default` for what used to be the global value — it is the base every other definition inherits its settings from. | Legacy environment variable | New YAML key | |---|---| -| `GITTALLY_BUILD_COMMAND` | `builds..buildCommand` | -| `GITTALLY_BUILD_CLEAN_COMMAND` | `builds..cleanCommand` | -| `GITTALLY_BUILD_ARTEFACT_DIRS` | `builds..artifactDirs` — YAML list instead of `;`-separated | -| `GITTALLY_BUILD_STDOUT_LOG` | `builds..stdoutLog` | -| `GITTALLY_BUILD_STDERR_LOG` | `builds..stderrLog` | -| `GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE` | `watcher.newBranchMaxAge` | -| `GITTALLY_BUILD_DOCKER_IMAGE` | `builds..docker.image` — also set `docker.enabled: true` (replaces the `--docker` flag) | -| `GITTALLY_BUILD_DOCKERFILE` | `builds..docker.dockerfile` | -| `GITTALLY_BUILD_DOCKER_CONTEXT` | `builds..docker.context` | -| `GITTALLY_BUILD_DOCKER_NETWORK` | `builds..docker.network` — default is now Docker's default network, not `host` | -| `GITTALLY_BUILD_DOCKER_ENV` | `builds..docker.env` — YAML map instead of space-separated assignments | -| `GITTALLY_ARTIFACT_SERVER_PORT` | `server.port` | -| `GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS` | `server.bindAddress` | -| `GITTALLY_ARTIFACT_PUBLIC_BASE_URL` | `server.publicBaseUrl` | -| `GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH` | `artifacts.retentionPerBranch` for a count, `artifacts.retentionMaxAge` for a legacy age value (`h`/`d` suffix); unlike legacy, both limits can be combined | -| `GITTALLY_IMPRESSUM_URL` | `server.impressumUrl` | -| `GITTALLY_AUTO_BUILD_BRANCHES` | a build definition with `branches: [...]` selecting them | -| `GITTALLY_AUTO_BUILD_TIMES` | `builds..atTimes` — YAML list of UTC `HH:MM` slots | -| `GITTALLY_GITEA_BASE_URL` | `gitea.baseUrl` | -| `GITTALLY_GITEA_OWNER` | `gitea.owner` | -| `GITTALLY_GITEA_REPO` | `gitea.repo` | -| `GITTALLY_GITEA_STATUS_CONTEXT` | `gitea.statusContext` | -| `GITTALLY_GITEA_GIT_USERNAME` | `git.account` — in `.git/gittally/.gittally.yml` | -| `GITTALLY_GITEA_TOKEN` | `git.token` — in `.git/gittally/.gittally.yml`, never committed | -| `GITTALLY_ARTIFACT_NGINX_SERVER_NAME` | `server.nginx.serverName` — also set `server.nginx.enabled: true` (replaces the `--nginx` flag) | -| `GITTALLY_ARTIFACT_NGINX_HTTP_PORT` | `server.nginx.httpPort` | -| `GITTALLY_ARTIFACT_NGINX_HTTPS_PORT` | `server.nginx.httpsPort` | -| `GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST` | `server.nginx.upstreamHost` | -| `GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME` | `server.nginx.containerName` | -| `GITTALLY_ARTIFACT_NGINX_STATE_DIR` | `server.nginx.stateDir` | -| `GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` | `server.nginx.letsencryptEmail` | +| `werkator_BUILD_COMMAND` | `builds..buildCommand` | +| `werkator_BUILD_CLEAN_COMMAND` | `builds..cleanCommand` | +| `werkator_BUILD_ARTEFACT_DIRS` | `builds..artifactDirs` — YAML list instead of `;`-separated | +| `werkator_BUILD_STDOUT_LOG` | `builds..stdoutLog` | +| `werkator_BUILD_STDERR_LOG` | `builds..stderrLog` | +| `werkator_NEW_BRANCH_COMMIT_MAX_AGE` | `watcher.newBranchMaxAge` | +| `werkator_BUILD_DOCKER_IMAGE` | `builds..docker.image` — also set `docker.enabled: true` (replaces the `--docker` flag) | +| `werkator_BUILD_DOCKERFILE` | `builds..docker.dockerfile` | +| `werkator_BUILD_DOCKER_CONTEXT` | `builds..docker.context` | +| `werkator_BUILD_DOCKER_NETWORK` | `builds..docker.network` — default is now Docker's default network, not `host` | +| `werkator_BUILD_DOCKER_ENV` | `builds..docker.env` — YAML map instead of space-separated assignments | +| `werkator_ARTIFACT_SERVER_PORT` | `server.port` | +| `werkator_ARTIFACT_SERVER_BIND_ADDRESS` | `server.bindAddress` | +| `werkator_ARTIFACT_PUBLIC_BASE_URL` | `server.publicBaseUrl` | +| `werkator_ARTIFACT_BUILD_RETENTION_PER_BRANCH` | `artifacts.retentionPerBranch` for a count, `artifacts.retentionMaxAge` for a legacy age value (`h`/`d` suffix); unlike legacy, both limits can be combined | +| `werkator_IMPRESSUM_URL` | `server.impressumUrl` | +| `werkator_AUTO_BUILD_BRANCHES` | a build definition with `branches: [...]` selecting them | +| `werkator_AUTO_BUILD_TIMES` | `builds..atTimes` — YAML list of UTC `HH:MM` slots | +| `werkator_GITEA_BASE_URL` | `gitea.baseUrl` | +| `werkator_GITEA_OWNER` | `gitea.owner` | +| `werkator_GITEA_REPO` | `gitea.repo` | +| `werkator_GITEA_STATUS_CONTEXT` | `gitea.statusContext` | +| `werkator_GITEA_GIT_USERNAME` | `git.account` — in `.git/werkator/.werkator.yml` | +| `werkator_GITEA_TOKEN` | `git.token` — in `.git/werkator/.werkator.yml`, never committed | +| `werkator_ARTIFACT_NGINX_SERVER_NAME` | `server.nginx.serverName` — also set `server.nginx.enabled: true` (replaces the `--nginx` flag) | +| `werkator_ARTIFACT_NGINX_HTTP_PORT` | `server.nginx.httpPort` | +| `werkator_ARTIFACT_NGINX_HTTPS_PORT` | `server.nginx.httpsPort` | +| `werkator_ARTIFACT_NGINX_UPSTREAM_HOST` | `server.nginx.upstreamHost` | +| `werkator_ARTIFACT_NGINX_CONTAINER_NAME` | `server.nginx.containerName` | +| `werkator_ARTIFACT_NGINX_STATE_DIR` | `server.nginx.stateDir` | +| `werkator_ARTIFACT_LETSENCRYPT_EMAIL` | `server.nginx.letsencryptEmail` | New keys without a legacy counterpart: `builds.maxConcurrent`, `artifacts.rootDir`, and `watcher.pollInterval`. ## Intentionally Not Ported -- Self-install and self-update (`--install`, `--pull`, `GITTALLY_INSTALL_DIR`) — replaced by jar deployment plus `init --systemd`. -- `GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND` and `GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — hsadmin-ng-specific; use `builds..docker.env` if needed. +- Self-install and self-update (`--install`, `--pull`, `werkator_INSTALL_DIR`) — replaced by jar deployment plus `init --systemd`. +- `werkator_BUILD_DOCKER_PREFLIGHT_COMMAND` and `werkator_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — hsadmin-ng-specific; use `builds..docker.env` if needed. - `HSADMIN_NG_*` environment-variable fallbacks. - Env-file configuration itself — the systemd `EnvironmentFile` now only tunes the JVM (`JAVA_OPTS`). -- `GITTALLY_GITEA_DELETED_STATUS_DESCRIPTION`, `GITTALLY_BIN_FORWARD`, `GITTALLY_CONFIG_*` — internal legacy mechanics without a counterpart. +- `werkator_GITEA_DELETED_STATUS_DESCRIPTION`, `werkator_BIN_FORWARD`, `werkator_CONFIG_*` — internal legacy mechanics without a counterpart. ## Build History @@ -80,15 +80,15 @@ curl -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json 1. Stop and remove the legacy service: ```bash - systemctl --user disable --now gitTally.service - rm -f ~/.config/systemd/user/gitTally.service + systemctl --user disable --now werkator.service + rm -f ~/.config/systemd/user/werkator.service systemctl --user daemon-reload ``` 2. Build and place the jar as described in [deployment.md](deployment.md). -3. In the repository, run `java -jar ~/bin/gittally.jar init`. -4. Transfer your settings from the legacy env file into `.gittally.yml` using the table above. -5. Put `git.account` and `git.token` into `.git/gittally/.gittally.yml`. -6. Verify the effective configuration: `java -jar ~/bin/gittally.jar config:print --full`. +3. In the repository, run `java -jar ~/bin/werkator.jar init`. +4. Transfer your settings from the legacy env file into `.werkator.yml` using the table above. +5. Put `git.account` and `git.token` into `.git/werkator/.werkator.yml`. +6. Verify the effective configuration: `java -jar ~/bin/werkator.jar config:print --full`. 7. Install and start the new service: `init --systemd` plus the printed commands, see [deployment.md](deployment.md). 8. Optionally clean up legacy state: `.git/git-watch-origin-and-test/` and the legacy artifact root. diff --git a/docs/plan/00-legacy-analysis.md b/docs/plan/00-legacy-analysis.md index 698cfd1..3bd157e 100644 --- a/docs/plan/00-legacy-analysis.md +++ b/docs/plan/00-legacy-analysis.md @@ -1,6 +1,6 @@ -# Legacy gitTally Analysis +# Legacy werkator Analysis -Condensed analysis of `legacy/gitTally` (bash, ~6000 lines) as input for the rewrite. +Condensed analysis of `legacy/werkator` (bash, ~6000 lines) as input for the rewrite. Line numbers refer to the legacy script at the time of analysis (version 0.7.8). ## What the Legacy System Does @@ -90,7 +90,7 @@ No status changes observable during a build (control loop): Verify need before porting any of these: -- `GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND`, `GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — highly hsadmin-ng-specific defaults. -- `GITTALLY_ARTIFACT_NGINX_*`, `GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` — dropped with nginx management; revived as `server.nginx.*` by step 13 (ADR 0005). -- `GITTALLY_IMPRESSUM_URL` — keep as optional simple footer link if wanted. -- `GITTALLY_INSTALL_DIR` — dropped with self-install. +- `werkator_BUILD_DOCKER_PREFLIGHT_COMMAND`, `werkator_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — highly hsadmin-ng-specific defaults. +- `werkator_ARTIFACT_NGINX_*`, `werkator_ARTIFACT_LETSENCRYPT_EMAIL` — dropped with nginx management; revived as `server.nginx.*` by step 13 (ADR 0005). +- `werkator_IMPRESSUM_URL` — keep as optional simple footer link if wanted. +- `werkator_INSTALL_DIR` — dropped with self-install. diff --git a/docs/plan/01-build-state-domain.md b/docs/plan/01-build-state-domain.md index 64b9a1e..9219fa6 100644 --- a/docs/plan/01-build-state-domain.md +++ b/docs/plan/01-build-state-domain.md @@ -9,14 +9,14 @@ A tested domain model for build results plus a persistent repository, replacing ## Design -Create package `de.hoennig.gittally.build`: +Create package `de.hoennig.werkator.build`: - `BuildStatus` enum: `PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `INTERRUPTED`, `CANCELLED`. Add `isTerminal`, `isRestartable` (pending/running/interrupted) properties. - `BuildResult` data class: branch, commit SHA, status, startedAt, duration, artifactKey. Use `java.time.Instant`/`Duration`; format only at the edges. - `BuildResultRepository` interface: append, update status of latest entry for a branch, query latest per branch, query history, delete entry, prune. -- `FileBuildResultRepository`: JSON file at `.git/gittally/build-results.json`. +- `FileBuildResultRepository`: JSON file at `.git/werkator/build-results.json`. Write atomically (write temp file, then `Files.move` with `ATOMIC_MOVE`). Reuse the Jackson YAML/JSON setup style from `ConfigLoader`. @@ -52,7 +52,7 @@ Kotest `FunSpec`, no Spring context needed. ## Execution Notes (done 2026-07-07) -Implemented as specified in `de.hoennig.gittally.build`; build green, 19 new tests. +Implemented as specified in `de.hoennig.werkator.build`; build green, 19 new tests. Deviations and details: - Added `jackson-datatype-jsr310` to `build.gradle.kts` for `Instant`/`Duration` JSON support (ISO-8601 strings). diff --git a/docs/plan/03-gitea-client.md b/docs/plan/03-gitea-client.md index ad28874..9fb78c7 100644 --- a/docs/plan/03-gitea-client.md +++ b/docs/plan/03-gitea-client.md @@ -9,7 +9,7 @@ A tested client for the Gitea commit-status API. ## Design -Create package `de.hoennig.gittally.gitea`: +Create package `de.hoennig.werkator.gitea`: - `GiteaClient` using Spring's `RestClient`. - `publishStatus(sha, state, description, targetUrl)` → `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}` with header `Authorization: token `; body fields `state`, `context`, `description`, `target_url`. @@ -19,12 +19,12 @@ Create package `de.hoennig.gittally.gitea`: - `isEnabled()` — true only when `gitea.baseUrl`, `gitea.owner`, `gitea.repo`, and `git.token` are configured. All callers must treat a disabled or failing client as non-fatal (log and continue); the legacy behaved the same but failed silently. -Configuration comes from `GitTallyConfig` (`gitea.*`, `git.token`). +Configuration comes from `WerkatorConfig` (`gitea.*`, `git.token`). ## Out of Scope - No callers yet; the build executor (step 04) wires status publishing. -- No webhook receiving; GitTally remains poll-based. +- No webhook receiving; werkator remains poll-based. ## Tests @@ -50,4 +50,4 @@ Implemented as designed; deviations and decisions: - `resolveUsername` only requires `gitea.baseUrl` and `git.token`; legacy gated it on the full status-enabled check including owner/repo, which the `/api/v1/user` endpoint does not need. - Responses are read as strings and parsed with a dedicated Jackson `ObjectMapper` instead of RestClient message converters, keeping malformed-JSON handling explicit and independent of converter auto-detection. - The legacy "Build status deleted" description marker is not ported; it belongs to the result-delete feature of later steps. -- No config changes were needed: `gitea.*` and `git.token` already exist in `GitTallyConfig`, the `init` templates, and `docs/configuration.md`. +- No config changes were needed: `gitea.*` and `git.token` already exist in `WerkatorConfig`, the `init` templates, and `docs/configuration.md`. diff --git a/docs/plan/04-build-executor.md b/docs/plan/04-build-executor.md index 3640409..2dfa03b 100644 --- a/docs/plan/04-build-executor.md +++ b/docs/plan/04-build-executor.md @@ -10,7 +10,7 @@ This step fixes the legacy defect that nothing could observe status changes whil ## Design -Create package `de.hoennig.gittally.build` (extends step 01): +Create package `de.hoennig.werkator.build` (extends step 01): - `BuildExecutor` service; one build at a time (a `ReentrantLock` or single-thread executor replaces the legacy flock file). - `startBuild(branch, commit)` runs asynchronously and returns immediately; expose `currentBuild(): RunningBuild?`. @@ -50,7 +50,7 @@ Consider `builds.timeout` only if trivial; otherwise defer. ## Execution Notes (done 2026-07-07) -Implemented as designed in `de.hoennig.gittally.build`; build green, 18 new tests +Implemented as designed in `de.hoennig.werkator.build`; build green, 18 new tests (`BuildExecutorTest`, `ProcessBuildRunnerTest`, `ArtifactKeysTest`, plus two new `FileBuildResultRepositoryTest` cases). Deviations and decisions: @@ -61,10 +61,10 @@ Deviations and decisions: The flag is checked before each command and after `waitFor`, so a cancel between clean and build commands still records `CANCELLED`. - Artifact key naming (`ArtifactKeys`) was needed here because `BuildResult` requires a key; it follows the legacy scheme (sanitized name + 12-char SHA-256 prefix + sanitized ISO timestamp + hash) using the UTC `Instant`, not local time. Step 05 should reuse it rather than re-implement. -- `ArtifactStore` is an interface in the `build` package with a logging `NoOpArtifactStore` placeholder; step 05 replaces the placeholder and implements the real store in `de.hoennig.gittally.artifacts`. +- `ArtifactStore` is an interface in the `build` package with a logging `NoOpArtifactStore` placeholder; step 05 replaces the placeholder and implements the real store in `de.hoennig.werkator.artifacts`. - The combined live log is `build.log` inside the per-build staging directory (a temp dir exposed via `RunningBuild.stagingDir`/`liveLogFile`); output is flushed per read chunk so the log grows while the build runs. - Commands run via `bash -c` with the branch name in the environment as `branch`, like legacy `run_build_command`; a failing `cleanCommand` fails the build without running `buildCommand`. -- `BuildResultRepository` is wired as a Spring bean (`BuildConfiguration`) at `.git/gittally/build-results.json` relative to the working directory, matching how `ConfigLoader` resolves the override file; `git rev-parse --git-path` style worktree resolution can come later if needed. +- `BuildResultRepository` is wired as a Spring bean (`BuildConfiguration`) at `.git/werkator/build-results.json` relative to the working directory, matching how `ConfigLoader` resolves the override file; `git rev-parse --git-path` style worktree resolution can come later if needed. - Gitea `target_url` is not published yet; the artifact page URL scheme only exists from step 07 on. - `builds.timeout` was deferred (not trivial alongside cancellation semantics); no config keys were added or changed. @@ -75,9 +75,9 @@ Refactored on request, superseding parts of the notes above: - Builds now run concurrently up to the new config key `builds.maxConcurrent` (default 1), enforced by a global semaphore sized on first use (changing it requires a restart). - At most one build per branch at a time, enforced by one serial worker per branch; a second build of the same branch queues as `PENDING` and runs afterwards ("finish, then next"). Whether a new commit should instead cancel the branch's running build is a later, possibly configurable decision (see step 06). -- Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/` (`BranchWorkspaces`/`GitWorktreeWorkspaces`), checked out detached at the requested commit — the primary checkout is never touched. +- Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/` (`BranchWorkspaces`/`GitWorktreeWorkspaces`), checked out detached at the requested commit — the primary checkout is never touched. Reuse keeps incremental build caches; `cleanCommand` decides how much of them survives. `GitService` gained `worktreeAdd`, `worktreePrune`, and `checkoutDetached` for this. - API change: `currentBuild()` became `currentBuilds(): List`, and `cancel()` became `cancel(artifactKey)`; a queued build can be cancelled too and is recorded `CANCELLED` when its worker picks it up. - The Gitea `PENDING` status is now published synchronously in `startBuild`, so queued builds are visible in Gitea while they wait for a slot. -- Branch config is still loaded from the primary repository directory, not from the branch's checked-out `.gittally.yml`; honoring the branch's own committed config would be a separate decision. +- Branch config is still loaded from the primary repository directory, not from the branch's checked-out `.werkator.yml`; honoring the branch's own committed config would be a separate decision. diff --git a/docs/plan/05-artifact-store.md b/docs/plan/05-artifact-store.md index e0c982f..41dd0fd 100644 --- a/docs/plan/05-artifact-store.md +++ b/docs/plan/05-artifact-store.md @@ -9,10 +9,10 @@ Persist build artifacts (logs plus configured report directories) with stable na ## Design -Create package `de.hoennig.gittally.artifacts`: +Create package `de.hoennig.werkator.artifacts`: - `ArtifactStore` service implementing the interface stubbed in step 04. -- Artifact root: a configurable directory (new key `artifacts.rootDir`), defaulting to `${XDG_STATE_HOME:-~/.local/state}/gittally/artifacts/`. +- Artifact root: a configurable directory (new key `artifacts.rootDir`), defaulting to `${XDG_STATE_HOME:-~/.local/state}/werkator/artifacts/`. Do NOT default to `/tmp` like legacy — artifacts vanished on reboot. - Repo key: sanitized absolute repo path (legacy `repository_key`): non `[A-Za-z0-9._-]` → `_`. - Artifact key per build: sanitized branch name + 12-char SHA-256 prefix, plus sanitized start timestamp + hash (legacy `build_artifact_key`); keep this scheme so URLs stay predictable. @@ -28,7 +28,7 @@ Create package `de.hoennig.gittally.artifacts`: ## Config New key `artifacts.rootDir` (empty = platform default above). -Update `GitTallyConfig`, `InitCommand` templates, and `docs/configuration.md` together. +Update `WerkatorConfig`, `InitCommand` templates, and `docs/configuration.md` together. ## Tests @@ -43,11 +43,11 @@ Update `GitTallyConfig`, `InitCommand` templates, and `docs/configuration.md` to ## Execution Notes (done 2026-07-07) -Implemented as `FileArtifactStore` in `de.hoennig.gittally.artifacts`; build green, 12 new tests +Implemented as `FileArtifactStore` in `de.hoennig.werkator.artifacts`; build green, 12 new tests (`FileArtifactStoreTest`, `BuildExecutorArtifactIntegrationTest`, plus a `repoKey` case in `ArtifactKeysTest`). Deviations and decisions: -- The `ArtifactStore` interface stays in `de.hoennig.gittally.build` (moving it would make `build` depend on `artifacts`). +- The `ArtifactStore` interface stays in `de.hoennig.werkator.build` (moving it would make `build` depend on `artifacts`). It gained `prune(keptResults)` and `artifactDir(artifactKey)`; the `NoOpArtifactStore` placeholder was removed. - Interface gap from step 04 resolved by an additional parameter: `persist(build, stagingDir, workspace)`. The store copies the configured `artifactDirs` out of the branch worktree itself, so the archived layout stays store knowledge. @@ -67,5 +67,5 @@ Deviations and decisions: and returns the removed keys. - `artifactDir` rejects keys outside `[A-Za-z0-9._-]+` and anything resolving outside `/branches/` (path traversal). - `artifacts.rootDir` supports a leading `~/` and resolves relative paths against the repository; - when empty, the default is `$XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/artifacts/` as designed. + when empty, the default is `$XDG_STATE_HOME` (or `~/.local/state`) + `/werkator/artifacts/` as designed. - The bean is wired in `ArtifactsConfiguration` with the working directory defaulting to `.`, mirroring `BuildConfiguration`. diff --git a/docs/plan/06-watcher.md b/docs/plan/06-watcher.md index 28e5f6c..d9d9875 100644 --- a/docs/plan/06-watcher.md +++ b/docs/plan/06-watcher.md @@ -9,7 +9,7 @@ Replace the legacy blocking main loop with a non-blocking, observable scheduler. ## Design -Create package `de.hoennig.gittally.watcher`: +Create package `de.hoennig.werkator.watcher`: - `Watcher` component with a fixed-delay poll cycle (Spring `@Scheduled` or a managed executor; enabled only in server/watch mode, not during CLI commands or tests). - One poll cycle, never blocking on a build: @@ -19,7 +19,7 @@ Create package `de.hoennig.gittally.watcher`: 4. Start builds for due branches via `startBuild(branch, commit)` (async). The executor prepares a per-branch worktree itself (step 04 amendment) — the watcher must never check out or reset the primary worktree. Multiple branches may build concurrently (`builds.maxConcurrent`); the executor already serializes builds of the same branch, so the watcher only has to avoid enqueueing a branch that is already pending or running. - 5. Run repository retention pruning and artifact pruning; also remove worktrees under `.git/gittally/worktrees/` of branches no longer on origin (`git worktree remove` or delete + `worktreePrune`). + 5. Run repository retention pruning and artifact pruning; also remove worktrees under `.git/werkator/worktrees/` of branches no longer on origin (`git worktree remove` or delete + `worktreePrune`). - Decide here (or defer with a note): when a new commit arrives for a branch whose build is still running, keep the current queue-behind behavior or cancel the running build and start fresh — this may become a per-branch config option. - Startup sequence (port of legacy recovery): mark stale running builds interrupted, then enqueue restartable branches. - Auto-builds: per-branch `autoBuild.enabled` + `times` (UTC HH:MM) from the merged `branches` config. @@ -34,7 +34,7 @@ Create package `de.hoennig.gittally.watcher`: ## Config New key `watcher.pollInterval` (e.g. `10s`, default matching legacy cadence). -Update `GitTallyConfig`, `InitCommand` templates, and `docs/configuration.md` together. +Update `WerkatorConfig`, `InitCommand` templates, and `docs/configuration.md` together. ## Tests @@ -51,7 +51,7 @@ Update `GitTallyConfig`, `InitCommand` templates, and `docs/configuration.md` to ## Execution Notes (done 2026-07-07) -Implemented as designed in `de.hoennig.gittally.watcher`; build green, 23 new tests +Implemented as designed in `de.hoennig.werkator.watcher`; build green, 23 new tests (`WatcherTest`, `AutoBuildStateTest`, plus new `DurationParserTest` and `GitServiceTest` cases). Deviations and decisions: @@ -72,7 +72,7 @@ Deviations and decisions: - Enqueue precedence per cycle: changed local branches, then recent new origin branches, then due auto-build slots; each branch at most once (an auto-build slot stays untriggered while its branch is pending/running and fires on a later cycle instead of being lost). -- Auto-build state lives in `.git/gittally/auto-builds.json` (`FileAutoBuildState`, replaces `auto-builds.tsv`); +- Auto-build state lives in `.git/werkator/auto-builds.json` (`FileAutoBuildState`, replaces `auto-builds.tsv`); entries of past days are dropped on write. Slot matching (`AutoBuildSlots`) picks the latest slot at or before the current UTC time, like legacy `auto_build_check`. Only branches named in the `branches` config (other than `default`) can auto-build; `default.autoBuild.enabled` does not extend to unlisted branches. @@ -80,7 +80,7 @@ Deviations and decisions: on origin, after a best-effort fetch (a failing fetch recovers from the last known origin state). A stale latest PENDING entry is marked INTERRUPTED before its replacement build is enqueued, because the executor queue does not survive a restart. -- Worktree cleanup deletes `.git/gittally/worktrees/` directories of branches gone from origin +- Worktree cleanup deletes `.git/werkator/worktrees/` directories of branches gone from origin (never those of queued or running builds) and then calls `git worktree prune`. - `DurationParser` was extended with `s`/`m` suffixes for `watcher.pollInterval` (it only knew `d`/`h`). - Watcher health is exposed via `Watcher.state(): WatcherState` (running, last poll time, last fetch/poll error, diff --git a/docs/plan/07-server-mode.md b/docs/plan/07-server-mode.md index 8abbcf7..c15b58a 100644 --- a/docs/plan/07-server-mode.md +++ b/docs/plan/07-server-mode.md @@ -18,7 +18,7 @@ Bootstrapping: - The watcher (step 06) is active only in the `server` profile. - New config keys `server.port` and `server.bindAddress` (defaults 18080 / 0.0.0.0, as legacy). -JSON API (package `de.hoennig.gittally.server`), replacing the legacy `/control/*` endpoints: +JSON API (package `de.hoennig.werkator.server`), replacing the legacy `/control/*` endpoints: - `GET /api/builds/latest` — latest build per branch. - `GET /api/builds/history` — all builds, newest first. @@ -54,13 +54,13 @@ After Ctrl-C, `ServerCommand` parks the command thread while the JVM shuts down; Deviations and decisions: - The live log tail is a sibling endpoint: `GET /api/builds/current` lists the running builds (with `logSize`), and `GET /api/builds/current/{artifactKey}/log?offset=` fetches the log incrementally, addressed by artifact key as required. Responses are capped at 1 MiB per chunk. -- The control token needs no config key. It is generated on first use and persisted to `.git/gittally/control-token` (mode 600); operators can write their own token there, deleting the file rotates it. Requests pass it via the `X-GitTally-Token` header or a `token` parameter; mismatch answers 403 like legacy. +- The control token needs no config key. It is generated on first use and persisted to `.git/werkator/control-token` (mode 600); operators can write their own token there, deleting the file rotates it. Requests pass it via the `X-werkator-Token` header or a `token` parameter; mismatch answers 403 like legacy. - `DELETE /api/builds/{artifactKey}` removes the result and then calls `ArtifactStore.prune(history)`, so no new store interface method was needed. - `GET /api/status/{commit}` also accepts abbreviated hashes (7–40 hex like legacy) and resolves them against the local history. The `GiteaClient` (step 03) gained 10s connect/read timeouts so the endpoint can never hang; a Gitea failure yields HTTP 200 with `status: unknown` (or the local status) plus `giteaError`. - `POST /api/builds/{branch}/restart` rebuilds the branch's last recorded commit. Branch names containing `/` would need an encoded slash, which Tomcat rejects by default — revisit in step 08 if the UI needs restart for such branches. (Resolved in step 08: the endpoint moved to `POST /api/builds/restart?branch=…`.) - Spring Boot 4 moved `@WebMvcTest` into the new `spring-boot-starter-webmvc-test` test module (added as test dependency). - The server-profile `@SpringBootTest` mocks the `Watcher` bean, so booting the test never fetches origin or enqueues builds; watcher wiring is proven by verifying `start()` was called. -Manual smoke test (2026-07-07): in a scratch repository, `java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server` started on the configured port 18981. +Manual smoke test (2026-07-07): in a scratch repository, `java -jar build/libs/werkator-0.1.0-SNAPSHOT.jar server` started on the configured port 18981. `GET /api/builds/latest` answered `[]` with HTTP 200, `GET /api/watcher` exposed the failing fetch of the origin-less repo as `lastFetchError`, `GET /api/status/` answered `unknown` with HTTP 200, and cancel without token answered 403. SIGINT (Ctrl-C) shut the process down cleanly in about 2 seconds: port closed, no exceptions in the log, exit code 130. diff --git a/docs/plan/08-web-ui.md b/docs/plan/08-web-ui.md index dfb97ec..f9117dc 100644 --- a/docs/plan/08-web-ui.md +++ b/docs/plan/08-web-ui.md @@ -48,7 +48,7 @@ Robust live updates (the actual bug fix): ## Implementation Notes (2026-07-07) -Implemented as designed: Thymeleaf templates (`fragments`, `builds`, `current`, `artifact`) rendered by `UiController`, one hand-written `static/gittally.js`, one `static/gittally.css` (loosely ported legacy look incl. dark mode and the mobile card layout), and the legacy favicon. +Implemented as designed: Thymeleaf templates (`fragments`, `builds`, `current`, `artifact`) rendered by `UiController`, one hand-written `static/werkator.js`, one `static/werkator.css` (loosely ported legacy look incl. dark mode and the mobile card layout), and the legacy favicon. Pages render the full state server-side and work without JavaScript; the script polls the JSON API (tables 10s, current builds and log tails 3s) and re-renders table bodies from data. Every fetch runs with an 8s timeout; a failure flips the nav-row indicator to an explicit `error` badge and dims the stale table — there is no loading state at all, so no spinner can get stuck. Polling pauses on `visibilitychange` and refreshes immediately when the tab becomes visible; running durations tick client-side from `data-started-at`. @@ -57,7 +57,7 @@ Deviations and decisions: - The tables show one `Started` column instead of the legacy `Commit Time` + `Status Time` pair, and client-side column sorting was not ported; the API delivers newest-first. - Status badges show the repository status; the per-row Gitea lookup (`/control/status` per commit) was deliberately not ported — that fan-out caused the legacy stuck spinners. `GET /api/status/{commit}` remains available. -- The control token is embedded as a `` tag in every rendered page (legacy embedded its cancel token in the cancel form the same way); `gittally.js` sends it via `X-GitTally-Token` for restart/cancel/delete. +- The control token is embedded as a `` tag in every rendered page (legacy embedded its cancel token in the cancel form the same way); `werkator.js` sends it via `X-werkator-Token` for restart/cancel/delete. - The restart endpoint moved from `POST /api/builds/{branch}/restart` to `POST /api/builds/restart?branch=…` so branch names with slashes work (resolves the step 07 deviation note). - Artifact links render whenever a result has an artifact key; the artifact index page itself explains a pruned/missing artifact directory instead of a per-row existence check. - `/builds/{artifactKey}` renders logs (top-level files) and the topmost `reports/**/index.html` pages from the artifact store; nested index pages below an already-listed one are skipped like legacy. Raw directory browsing is not offered. @@ -65,7 +65,7 @@ Deviations and decisions: - On `/current`, a build that leaves the running list keeps its card, marked `finished` with a link to its result page; its initial server render shows an empty log (the script fetches from offset 0). - JS builds all DOM via `createElement`/`textContent`, so re-rendered data cannot inject markup; server-side escaping is covered by a MockMvc test with a hostile branch name. - New config key `server.impressumUrl` (empty hides the footer link); the footer version comes from Spring Boot `buildInfo()` (`BuildProperties`, build time excluded for repeatability) with a `dev` fallback. -- `UiFormats` (Kotlin) and `gittally.js` intentionally produce the same timestamp/duration display formats. +- `UiFormats` (Kotlin) and `werkator.js` intentionally produce the same timestamp/duration display formats. Manual smoke test (2026-07-07): scratch repository with a bare origin, `pollInterval: 5s`, and a 25s build command; server on port 18982, observed through a real browser tab. After `git push`, the open Latest tab showed the new build without reload and its badge flipped `running` → `success` live (`pending` was too short to sample; the row itself appeared via polling). @@ -81,9 +81,9 @@ Deviation: the listing enumerates origin branches instead of legacy's local bran Addendum (2026-07-07): the legacy per-page reload button (`⟳`, top right) was also re-added on request, next to the live indicator. On polling pages it triggers an immediate data refresh via the page's poller; pages without a poller (artifact index) reload fully. -Addendum (2026-07-07): all links that leave the GitTally UI open in a new tab (`target="_blank" rel="noopener noreferrer"`). +Addendum (2026-07-07): all links that leave the werkator UI open in a new tab (`target="_blank" rel="noopener noreferrer"`). This already held for Gitea branch/commit links and the footer; it was added for the artifact page's log and report links, whose targets have no navigation. -Links between GitTally pages (nav, artifact index) stay in the same tab. +Links between werkator pages (nav, artifact index) stay in the same tab. Addendum (2026-08-10): the artifacts column carries the whole build-reachability logic, and the nav lost its `Current` entry. The permanent `🔗` link is rendered on the build it resolves to — the branch's latest green build — on every build table, instead of on each row of a branch with any green build. diff --git a/docs/plan/09-system-metrics.md b/docs/plan/09-system-metrics.md index e644f54..6e5bc8b 100644 --- a/docs/plan/09-system-metrics.md +++ b/docs/plan/09-system-metrics.md @@ -9,7 +9,7 @@ Port the legacy system page: CPU, RAM, disk, and repository size with min/max/av ## Design -Create package `de.hoennig.gittally.metrics`: +Create package `de.hoennig.werkator.metrics`: - `SystemMetricsCollector` sampling every 60s (server profile only): CPU used/idle from `/proc/stat` deltas, RAM from `/proc/meminfo`, disk from `java.nio.file.FileStore`, repo size via periodic `du -sk` (or a file walk) — throttle repo-size sampling (legacy ran `du` every cycle, which was expensive). @@ -36,10 +36,10 @@ Create package `de.hoennig.gittally.metrics`: ## Implementation Notes (2026-07-07) -Implemented as designed: `SystemMetricsCollector` in `de.hoennig.gittally.metrics` samples every 60s once `ServerMetricsLifecycle` (server profile only) calls `start()`, following the watcher's start/stop pattern. +Implemented as designed: `SystemMetricsCollector` in `de.hoennig.werkator.metrics` samples every 60s once `ServerMetricsLifecycle` (server profile only) calls `start()`, following the watcher's start/stop pattern. CPU comes from `/proc/stat` deltas, RAM from `/proc/meminfo`, disk from `java.nio.file.FileStore` (`df` semantics: used = total − unallocated, free = usable), and the repository size from a file walk. `GET /api/system` returns the snapshot plus aggregates, and `/system` renders the legacy system page in the step 08 layout, polling every 60s with the same timeout/error-badge rules. -Since the metric rows are fixed, `gittally.js` only updates the cell texts in place — nothing is rebuilt. +Since the metric rows are fixed, `werkator.js` only updates the cell texts in place — nothing is rebuilt. Deviations and decisions: @@ -51,7 +51,7 @@ Deviations and decisions: - The repository size is re-probed only every 10th sample (10 minutes) and reused in between — the throttle this step requires; legacy ran `du -sk` every cycle. The file walk sums file sizes, not disk blocks like `du`, which is close enough for a trend metric. - An unavailable source (no `/proc` outside Linux, unreadable file store) yields explicit `null` metrics over HTTP 200 and `n/a` cells; the failure is logged once, not every 60s. -- No new config keys: the 60s interval is fixed like legacy, so `GitTallyConfig`, the `init` templates, and `docs/configuration.md` are unchanged. +- No new config keys: the 60s interval is fixed like legacy, so `WerkatorConfig`, the `init` templates, and `docs/configuration.md` are unchanged. - The legacy `generation` field was not ported; it only guarded the legacy JS against monitor restarts. - The CPU count comes from `Runtime.availableProcessors()` instead of `nproc`. diff --git a/docs/plan/10-cli-commands.md b/docs/plan/10-cli-commands.md index 2b92a12..a93c05f 100644 --- a/docs/plan/10-cli-commands.md +++ b/docs/plan/10-cli-commands.md @@ -39,7 +39,7 @@ Exit codes: 0 on success, 1 on build failure, 2 on usage/config errors (align wi ## Implementation Notes (2026-07-07) -Implemented as designed: `status`, `build`, and `retry` are picocli `@Component` subcommands in `commands/`, wired into `GitTallyCommand` like the existing ones. +Implemented as designed: `status`, `build`, and `retry` are picocli `@Component` subcommands in `commands/`, wired into `werkatorCommand` like the existing ones. They implement `Callable`, so the exit codes align with `CliRunner`'s `ExitCodeGenerator` contract: 0 on success, 1 on build failure, 2 on usage/config errors (picocli's own `USAGE` code for invalid options matches). - `status [--history]` reads `BuildResultRepository` directly and prints an aligned table (branch, status, commit, time, duration); it reuses `UiFormats`, so the console shows the same timestamp/duration formats as the web UI. @@ -56,7 +56,7 @@ Deviations and decisions: - A failed fetch only warns and the commands continue from the last-known origin state, so they work offline. - `retry` only retries FAILED builds (legacy `branch_has_failed_build` checked exactly `failed`); interrupted/pending builds are the watcher's startup-recovery job. - Exit code 130 for cancelled builds was not ported; a cancelled/interrupted build exits 1 like any non-success. -- Found while smoke testing: `.gitignore`'s `*.jar` rule excluded `gradle/wrapper/gradle-wrapper.jar`, so builds in fresh checkouts — including every GitTally worktree — failed with `ClassNotFoundException: GradleWrapperMain`. +- Found while smoke testing: `.gitignore`'s `*.jar` rule excluded `gradle/wrapper/gradle-wrapper.jar`, so builds in fresh checkouts — including every werkator worktree — failed with `ClassNotFoundException: GradleWrapperMain`. Fixed with a `!gradle/wrapper/gradle-wrapper.jar` exception and by adding the jar (same class of defect as the `build/` rule fixed in step 04). Manual smoke test (2026-07-07, in this repository): diff --git a/docs/plan/11-docker-build-runtime.md b/docs/plan/11-docker-build-runtime.md index 8f87a51..cf03604 100644 --- a/docs/plan/11-docker-build-runtime.md +++ b/docs/plan/11-docker-build-runtime.md @@ -14,18 +14,18 @@ Implement a `DockerBuildRunner` for the `BuildRunner` interface from step 04, sh Port from legacy (see analysis, lines ~4400+): -- Ensure image: build from configured Dockerfile/context when missing or stale; track staleness via an image label holding the SHA-256 of Dockerfile + context (legacy `org.gittally.build-inputs-sha256`). -- Gradle cache volume per repository (`gittally-gradle-`), mounted and chowned to the host UID/GID. +- Ensure image: build from configured Dockerfile/context when missing or stale; track staleness via an image label holding the SHA-256 of Dockerfile + context (legacy `org.werkator.build-inputs-sha256`). +- Gradle cache volume per repository (`werkator-gradle-`), mounted and chowned to the host UID/GID. - Run the build container: workspace mount, branch env var, configured extra env, network mode, docker socket mount for Testcontainers-based builds. - Post-build ownership repair of the workspace (legacy `repair_docker_workspace_ownership`). -- Label all containers (`org.hoennig.gittally=true`, repository, role) and clean up stale ones on startup. +- Label all containers (`org.hoennig.werkator=true`, repository, role) and clean up stale ones on startup. Decide during implementation whether the hsadmin-ng-specific legacy options (preflight command, `JAVA_TOOL_OPTIONS` injection) are needed; default to NOT porting them (see orphaned-config list in the analysis). ## Config New `branches..docker` section: `enabled`, `image`, `dockerfile`, `context`, `network`, `env`. -Update `GitTallyConfig`, `InitCommand` templates, and `docs/configuration.md` together. +Update `WerkatorConfig`, `InitCommand` templates, and `docs/configuration.md` together. ## Tests @@ -46,19 +46,19 @@ No test needs Docker; the main `docker run` argv is asserted exactly through an Ported from legacy: -- Image ensure (`ensure_docker_build_image`): rebuild when the image is missing or the `org.gittally.build-inputs-sha256` label no longer matches; all four `org.gittally.*` labels are set. +- Image ensure (`ensure_docker_build_image`): rebuild when the image is missing or the `org.werkator.build-inputs-sha256` label no longer matches; all four `org.werkator.*` labels are set. Without a configured `dockerfile`, the image is used as-is and pulled by `docker run` on demand. -- Gradle cache volume `gittally-gradle-`, created and chowned to the host uid/gid with the legacy container script. +- Gradle cache volume `werkator-gradle-`, created and chowned to the host uid/gid with the legacy container script. - Build container: workspace bind mount, `branch` env var, configured extra env, network mode, docker socket mount with `DOCKER_HOST`/`TESTCONTAINERS_*` for Testcontainers-based builds, `--add-host host.docker.internal:host-gateway` off host network. - Ownership repair of `build/` and `.gradle/` (`repair_docker_workspace_ownership`). -- `org.hoennig.gittally` labels (role `build`) and stale-container cleanup. +- `org.hoennig.werkator` labels (role `build`) and stale-container cleanup. Deviations and decisions: - The `BuildRunner` interface gained `repoDir` and `branchConfig` parameters (with defaults), because runner selection and Docker settings are per branch and the per-repo volume/container names need the repository path — a worktree cannot resolve the uncommitted config layer. - The hsadmin-ng-specific legacy options were not ported, as the step suggests: no preflight command, no `JAVA_TOOL_OPTIONS` injection, no `.testcontainers.properties` generation, no `HSADMINNG_*` env passthrough — `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`/`TESTCONTAINERS_HOST_OVERRIDE`/`DOCKER_HOST` cover modern Testcontainers; anything else fits `docker.env`. - Ownership repair runs inside the same build container (wrapped around the command, preserving its exit code) instead of a follow-up root container; the separate `prepare_docker_workspace_build_dir` step became unnecessary because the clean command already runs in the container. -- Container names are per branch (`gittally-build--`), not per repository, because builds of different branches may run concurrently. +- Container names are per branch (`werkator-build--`), not per repository, because builds of different branches may run concurrently. - Containers run with `--init`, so termination signals from build cancellation reach the build process inside the container. - Stale labelled containers are removed before the first Docker build of the process, not at daemon startup, so installations that never build in Docker never invoke docker. - The Gradle volume is prepared once per process and image, not before every build. @@ -68,8 +68,8 @@ Deviations and decisions: Manual smoke test (2026-07-07, scratch repo, Rancher Desktop 27.3.1): -- A scratch repo with `docker.enabled`, a two-line Dockerfile, and a build command writing `id -u` into `build/who.txt`: `gittally build` built the image with all four labels, created the `gittally-gradle-` volume, streamed the container output live, and exited 0 (`success after 0:14`). +- A scratch repo with `docker.enabled`, a two-line Dockerfile, and a build command writing `id -u` into `build/who.txt`: `werkator build` built the image with all four labels, created the `werkator-gradle-` volume, streamed the container output live, and exited 0 (`success after 0:14`). - A second run reused the image (inputs label matched, no rebuild; `success after 0:04`). - The command ran as uid 0 inside the container while `build/who.txt` ended up owned by the host user — the in-container ownership repair works. - No labelled containers were left behind after the builds. -- Caveat found while testing (environmental, not GitTally): with a VM-based Docker (Rancher Desktop/Lima), workspace bind mounts only work for paths shared into the VM (e.g. `$HOME`); a repo under an unshared `/tmp` builds against an empty VM-side directory. +- Caveat found while testing (environmental, not werkator): with a VM-based Docker (Rancher Desktop/Lima), workspace bind mounts only work for paths shared into the VM (e.g. `$HOME`); a repo under an unshared `/tmp` builds against an empty VM-side directory. diff --git a/docs/plan/12-deployment.md b/docs/plan/12-deployment.md index 3dd9d99..5c89bd6 100644 --- a/docs/plan/12-deployment.md +++ b/docs/plan/12-deployment.md @@ -5,13 +5,13 @@ Read `README.md` and `00-legacy-analysis.md` first. ## Goal -Make the new GitTally deployable as a service and retire the legacy script. +Make the new werkator deployable as a service and retire the legacy script. ## Design Deployment (documentation plus a small generator, no self-install): -- Extend `init` (or add `init --systemd`) to generate a systemd user unit running `java -jar gittally.jar server` with `WorkingDirectory` set to the repo, `Restart=always`, and an `EnvironmentFile` for overrides — port the shape of the legacy unit, drop the self-copy/update machinery. +- Extend `init` (or add `init --systemd`) to generate a systemd user unit running `java -jar werkator.jar server` with `WorkingDirectory` set to the repo, `Restart=always`, and an `EnvironmentFile` for overrides — port the shape of the legacy unit, drop the self-copy/update machinery. - Write `docs/deployment.md`: JRE requirement, jar location convention, systemd enable/start/log commands, and reverse-proxy guidance (example nginx `server` block proxying to `server.port`; TLS via the host's existing certbot — replaces the legacy managed nginx container). Migration: @@ -21,8 +21,8 @@ Migration: Housekeeping: -- Mark `legacy/gitTally` as deprecated in its header comment and in `README.md`. -- Review `docs/GitTally-Konzept.md` against what was actually built; update or note deviations. +- Mark `legacy/werkator` as deprecated in its header comment and in `README.md`. +- Review `../Werkator-Konzept.md` against what was actually built; update or note deviations. - Add an ADR summarizing the architecture decisions that emerged during the rewrite (persistence choice, polling UI, no nginx management). ## Tests @@ -37,25 +37,25 @@ Housekeeping: ## Implementation Notes (2026-07-07) -Implemented as designed: `init --systemd` (an option on `init`, not a separate subcommand) generates the unit and its `EnvironmentFile` under `.git/gittally/`, prints the install commands, and never touches `~/.config/systemd` itself (no self-install). +Implemented as designed: `init --systemd` (an option on `init`, not a separate subcommand) generates the unit and its `EnvironmentFile` under `.git/werkator/`, prints the install commands, and never touches `~/.config/systemd` itself (no self-install). `SystemdServiceFiles` builds the file contents and is unit-tested by content assertions, including the legacy `%` escaping and `ExecStart` quoting. -`docs/deployment.md` and `docs/migration-from-legacy.md` were written; `README.md`, `docs/bootstrapping.md`, `docs/GitTally-Konzept.md`, and `CLAUDE.md` were updated to reference them. +`docs/deployment.md` and `docs/migration-from-legacy.md` were written; `README.md`, `docs/bootstrapping.md`, `../Werkator-Konzept.md`, and `CLAUDE.md` were updated to reference them. Deviations and decisions: -- The unit is named per repository (`gittally-.service`) instead of the global legacy `gitTally.service`, because one instance serves one repository and several repositories can share a host. +- The unit is named per repository (`werkator-.service`) instead of the global legacy `werkator.service`, because one instance serves one repository and several repositories can share a host. - `ExecStart` uses the `java` binary and the jar path of the JVM that ran `init --systemd`, so the unit points at the jar in place (legacy copied the script to an install dir); systemd expands `$JAVA_OPTS` from the `EnvironmentFile` into the command line. When not started via `java -jar` (e.g. from Gradle), `init --systemd` prints an error instead of generating a broken unit. -- The `EnvironmentFile` only tunes the JVM (`JAVA_OPTS`); the legacy env file carried username/token, which now live in `.git/gittally/.gittally.yml`. - An existing `gittally.env` is kept; the unit file is regenerated on every run (same as legacy). +- The `EnvironmentFile` only tunes the JVM (`JAVA_OPTS`); the legacy env file carried username/token, which now live in `.git/werkator/.werkator.yml`. + An existing `werkator.env` is kept; the unit file is regenerated on every run (same as legacy). - The legacy `--nginx --docker` `ExecStart` flags were dropped (runtime selection is per-branch config now); the `After=… docker.service` ordering was kept. - Legacy build history (`build-results.tsv`) is not imported — decided and documented in `docs/migration-from-legacy.md` (formats differ substantially; retention would prune imported rows quickly). - ADR 0004 records the rewrite architecture decisions (JSON-file persistence behind a repository interface, polling UI, no managed nginx). -- `docs/GitTally-Konzept.md` review: only one real deviation found — "Builds laufen in Docker" became "nativ oder optional in Docker (pro Branch konfigurierbar)"; CLI capability lists gained build/retry; deployment links added. +- `../Werkator-Konzept.md` review: only one real deviation found — "Builds laufen in Docker" became "nativ oder optional in Docker (pro Branch konfigurierbar)"; CLI capability lists gained build/retry; deployment links added. Manual walkthrough (2026-07-07, fresh clone under `~/.cache`): - Followed `docs/deployment.md` end to end: built the jar, copied it to a stable path, cloned the repository freshly, ran `init` and `init --systemd`, linked the generated unit, `daemon-reload`, started the service. -- The clone already contained the committed `.gittally.yml`, so only the machine config was created; `server.port` was overridden to a free port via `.git/gittally/.gittally.yml` to avoid clashing with a locally running instance. +- The clone already contained the committed `.werkator.yml`, so only the machine config was created; `server.port` was overridden to a free port via `.git/werkator/.werkator.yml` to avoid clashing with a locally running instance. - Result: unit `active (running)`, `GET /` returned 200, `/api/watcher` showed a successful poll, `journalctl --user -u …` showed the startup log, `restart` and `stop` worked; the unit link and the scratch clone were removed afterwards. - Not machine-verified: `systemctl --user enable` and `loginctl enable-linger` (the walkthrough used a transient `start` to leave no persistent service behind) and the nginx/certbot section (no public host available); those commands were reviewed against the systemd/certbot documentation instead. diff --git a/docs/plan/13-nginx-tls.md b/docs/plan/13-nginx-tls.md index 6dda211..9b89abf 100644 --- a/docs/plan/13-nginx-tls.md +++ b/docs/plan/13-nginx-tls.md @@ -2,22 +2,22 @@ Prerequisites: steps 07, 11, 12. Read `README.md`, `00-legacy-analysis.md`, and ADR 0005 first. -Consult `legacy/gitTally` for the functions referenced below. +Consult `legacy/werkator` for the functions referenced below. ## Goal -Serve GitTally over HTTPS on hosts that provide Docker but no host reverse proxy (e.g. Hostsharing managed container environments). -GitTally optionally manages an nginx Docker container with Let's Encrypt certificates, ported from the legacy subsystem. +Serve werkator over HTTPS on hosts that provide Docker but no host reverse proxy (e.g. Hostsharing managed container environments). +werkator optionally manages an nginx Docker container with Let's Encrypt certificates, ported from the legacy subsystem. This is opt-in; the reverse-proxy deployment from step 12 stays the default (ADR 0005). ## Design Port the legacy nginx subsystem (functions `configure_artifact_nginx_defaults` ~1545, `artifact_nginx_write_ssl_options` ~4051, `artifact_nginx_write_config` ~4077, `artifact_nginx_ports_free` ~4242, `cleanup_stale_artifact_nginx_containers` ~4274, `artifact_nginx_run_container` ~4281, `artifact_nginx_obtain_or_renew_certificate` ~4310, `start_artifact_nginx` ~4342, shutdown cleanup ~723): -- Config under `server.nginx.*`: `enabled` (default false), `serverName`, `httpPort` (8080), `httpsPort` (8443), `upstreamHost` (default: `serverName`), `containerName` (default: `gittally-nginx-`), `stateDir` (default: `${XDG_STATE_HOME:-~/.local/state}/gittally/nginx/`), `letsencryptEmail`. - Update all three config places (`GitTallyConfig`, `init` templates, `docs/configuration.md`). +- Config under `server.nginx.*`: `enabled` (default false), `serverName`, `httpPort` (8080), `httpsPort` (8443), `upstreamHost` (default: `serverName`), `containerName` (default: `werkator-nginx-`), `stateDir` (default: `${XDG_STATE_HOME:-~/.local/state}/werkator/nginx/`), `letsencryptEmail`. + Update all three config places (`WerkatorConfig`, `init` templates, `docs/configuration.md`). - When `server.publicBaseUrl` is empty and `serverName` is set, default it to `https:///` (legacy line ~541). -- Shell out to the `docker` CLI like `DockerBuildRunner` (no SDK); label the container `org.hoennig.gittally` for stale-container cleanup. +- Shell out to the `docker` CLI like `DockerBuildRunner` (no SDK); label the container `org.hoennig.werkator` for stale-container cleanup. - Lifecycle as a server-profile component (like `ServerWatcherLifecycle`/`ServerMetricsLifecycle`): start after the web server is up, stop and remove the container on shutdown. Nothing runs in CLI mode or tests. - Two-phase startup, ported from legacy: write an HTTP-only nginx config for the ACME webroot challenge, run the container, obtain the certificate via a certbot container (webroot mode), then rewrite the full HTTPS config and restart nginx. @@ -36,9 +36,9 @@ Port the legacy nginx subsystem (functions `configure_artifact_nginx_defaults` ~ - `./gradlew ktlintFormat` then `./gradlew build` is green. - With `server.nginx.enabled: false` (default) nothing changes; no container is touched. -- Manual walkthrough on a Docker host: nginx container starts with the init config and proxies HTTP to GitTally. +- Manual walkthrough on a Docker host: nginx container starts with the init config and proxies HTTP to werkator. Full ACME issuance needs a public DNS name; if none is available, verify the certbot argv and the full-config path against the legacy script and document that in this file. -- `docs/deployment.md` gains a section for hosts without a reverse proxy; `docs/migration-from-legacy.md` maps the `GITTALLY_ARTIFACT_NGINX_*`/`GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` variables. +- `docs/deployment.md` gains a section for hosts without a reverse proxy; `docs/migration-from-legacy.md` maps the `werkator_ARTIFACT_NGINX_*`/`werkator_ARTIFACT_LETSENCRYPT_EMAIL` variables. ## Result (2026-07-08) @@ -50,7 +50,7 @@ Deviations from the design above and from legacy: - Legacy auto-moved the artifact server port on a collision with the nginx ports; the rewrite refuses to start the proxy with a warning instead — the Spring port cannot move after startup. - Legacy derived a missing `serverName` from the public base URL host; the rewrite requires `serverName` explicitly (the config default direction is only publicBaseUrl ← serverName). - `serverName` and `upstreamHost` are validated against a host-name pattern instead of substituting raw values, so no nginx directives can be injected via config. -- The container label namespace is `org.hoennig.gittally` (like the Docker build runner), not `org.hostsharing.gittally`; port cleanup still also matches legacy-named containers. +- The container label namespace is `org.hoennig.werkator` (like the Docker build runner), not `org.hostsharing.werkator`; port cleanup still also matches legacy-named containers. - The legacy `--nginx` CLI flag is not ported; enablement is `server.nginx.enabled` only. - `ssl-dhparams.pem` is downloaded via the Java HTTP client instead of `curl` (replaceable seam for tests). diff --git a/docs/plan/14-build-phase-timing-and-overhead.md b/docs/plan/14-build-phase-timing-and-overhead.md index a3d1509..68d0c99 100644 --- a/docs/plan/14-build-phase-timing-and-overhead.md +++ b/docs/plan/14-build-phase-timing-and-overhead.md @@ -1,7 +1,7 @@ # Step 14: Build-Phase Timing and Orchestration Overhead Prerequisites: steps 04 (build executor), 05 (artifact store), 07 (API), 08 (web UI), 11 (Docker runtime). -Read `README.md` first; read the referenced `legacy/gitTally` functions only where this step points at them. +Read `README.md` first; read the referenced `legacy/werkator` functions only where this step points at them. ## Goal diff --git a/docs/plan/15-runtime-bundle-distribution.md b/docs/plan/15-runtime-bundle-distribution.md index e69a540..514f926 100644 --- a/docs/plan/15-runtime-bundle-distribution.md +++ b/docs/plan/15-runtime-bundle-distribution.md @@ -6,8 +6,8 @@ This step revises the "Future: Docker-based Deployment" section of `docs/bootstr ## Goal -Deploy GitTally on hosts that provide Docker and git but no Java runtime (Hostsharing container servers, e.g. `tallyman@vm4006`). -GitTally is distributed as a self-contained runtime bundle: a jlink-trimmed JRE plus `gittally.jar` plus a launcher script, packed as one tarball. +Deploy werkator on hosts that provide Docker and git but no Java runtime (Hostsharing container servers, e.g. `tallyman@vm4006`). +werkator is distributed as a self-contained runtime bundle: a jlink-trimmed JRE plus `werkator.jar` plus a launcher script, packed as one tarball. The JAR stays the primary artifact for development and for hosts that already have a JRE. ## Distribution Format Decision (ADR 0006) @@ -17,9 +17,9 @@ Three formats were considered; write ADR 0006 recording the decision and this ra - **jlink runtime bundle (chosen)** — no production-code changes, plain JVM semantics, one tarball to `scp`. git and docker CLIs are used from the host, worktree paths stay host paths, and the `init --systemd` unit works unchanged because `java.home` and the running-jar path resolve into the bundle. - **GraalVM native image (rejected)** — Spring AOT evaluates bean conditions at build time. - GitTally's dual-context design (CLI context without web, second `SpringApplication` with the `server` profile and `WebApplicationType.SERVLET`, `@Profile("!server")` `CliRunner`, `@Profile("server")` lifecycles) cannot be represented in a single AOT arrangement. + werkator's dual-context design (CLI context without web, second `SpringApplication` with the `server` profile and `WebApplicationType.SERVLET`, `@Profile("!server")` `CliRunner`, `@Profile("server")` lifecycles) cannot be represented in a single AOT arrangement. Supporting it would require replacing the profile wiring with runtime guards and collapsing the two context shapes — an invasive rewrite with regression risk for the JVM path. -- **Containerized GitTally runtime (rejected, was the `docs/bootstrapping.md` sketch)** — needs git and docker CLIs inside the image, a same-path `$HOME` mount plus docker-socket mount and uid/gid mapping so that `DockerBuildRunner`'s `--volume $workspace:$workspace` sibling mounts keep working, and a hand-edited systemd unit. +- **Containerized werkator runtime (rejected, was the `docs/bootstrapping.md` sketch)** — needs git and docker CLIs inside the image, a same-path `$HOME` mount plus docker-socket mount and uid/gid mapping so that `DockerBuildRunner`'s `--volume $workspace:$workspace` sibling mounts keep working, and a hand-edited systemd unit. Kept as the documented fallback if the bundle approach ever becomes unworkable. ## Target Host Facts (verified 2026-08-10) @@ -35,15 +35,15 @@ Gradle: - Add a `runtimeBundle` task (depends on `bootJar`); the normal `./gradlew build` stays unchanged. - The task runs `jlink` from the configured Java toolchain (every JDK 21 ships jlink; no new toolchain requirement). - The JDK module list is pinned in the build script, computed once via `jdeps` on the exploded boot jar and its `BOOT-INF/lib`; document the jdeps command next to the list and re-check it when dependencies change. -- Bundle layout: `gittally/jre/` (jlink image), `gittally/lib/gittally.jar`, `gittally/bin/gittally` (sh launcher: `exec "$DIR/../jre/bin/java" $JAVA_OPTS -jar "$DIR/../lib/gittally.jar" "$@"`). -- Output: `build/distributions/gittally-runtime-linux-x64.tar.gz` with preserved execute permissions. +- Bundle layout: `werkator/jre/` (jlink image), `werkator/lib/werkator.jar`, `werkator/bin/werkator` (sh launcher: `exec "$DIR/../jre/bin/java" $JAVA_OPTS -jar "$DIR/../lib/werkator.jar" "$@"`). +- Output: `build/distributions/werkator-runtime-linux-x64.tar.gz` with preserved execute permissions. Deployment (no code changes expected): -- Unpack to `~/opt/gittally/` on the target host; run everything via `~/opt/gittally/bin/gittally`. -- `init --systemd` already generates `ExecStart= $JAVA_OPTS -jar server` from `java.home` and the running jar path — from the bundle both resolve into `~/opt/gittally/`, so the unit points at the bundle without changes. +- Unpack to `~/opt/werkator/` on the target host; run everything via `~/opt/werkator/bin/werkator`. +- `init --systemd` already generates `ExecStart= $JAVA_OPTS -jar server` from `java.home` and the running jar path — from the bundle both resolve into `~/opt/werkator/`, so the unit points at the bundle without changes. Verify this instead of adapting code; adapt only if the resolution fails. -- Updating GitTally = unpack a new bundle over `~/opt/gittally/` (or switch a symlink) and restart the service. +- Updating werkator = unpack a new bundle over `~/opt/werkator/` (or switch a symlink) and restart the service. Documentation: @@ -55,13 +55,13 @@ Documentation: ## Tests - No production code changes are expected, so no new unit tests; existing tests must stay green. -- Smoke-verify the bundle manually: `bin/gittally --help`, `init` in a scratch repo, `config:print --full`, a short `server` run, and `init --systemd` unit content pointing into the bundle; document the results in this file. -- Verify on vm4006 (which has no Java): copy the bundle, run `bin/gittally --help` and `config:print`; document the results in this file. +- Smoke-verify the bundle manually: `bin/werkator --help`, `init` in a scratch repo, `config:print --full`, a short `server` run, and `init --systemd` unit content pointing into the bundle; document the results in this file. +- Verify on vm4006 (which has no Java): copy the bundle, run `bin/werkator --help` and `config:print`; document the results in this file. ## Acceptance Criteria - `./gradlew ktlintFormat` then `./gradlew build` is green, with unchanged toolchain requirements. -- `./gradlew runtimeBundle` produces a tarball whose `bin/gittally` runs `--help`, `init`, and `server` on a machine without any Java runtime. +- `./gradlew runtimeBundle` produces a tarball whose `bin/werkator` runs `--help`, `init`, and `server` on a machine without any Java runtime. - A fresh deployment to vm4006 following `docs/deployment.md` and `docs/migration-from-legacy.md` reaches a running service: web UI reachable, a Docker build succeeds, commit status arrives in Gitea, managed nginx/TLS works (`server.nginx.enabled: true`, DNS for `serverName` pointing at vm4006). - vm2176 (legacy) keeps running in parallel during the migration; the legacy service is only retired after vm4006 is verified. - Docs and ADR 0006 written as described; document deviations in this file. @@ -71,15 +71,15 @@ Documentation: Implemented as designed; no production-code change was needed. The step was originally drafted for a GraalVM native image; it was re-planned to the jlink bundle after the Spring-AOT build-time condition evaluation turned out to be incompatible with the dual-context CLI/server wiring (see ADR 0006). -- `runtimeBundle` task in `build.gradle.kts` with the pinned module list (jdeps output plus java.logging, jdk.crypto.ec, jdk.management, jdk.zipfs); launcher script in `packaging/gittally`; tarball ~66 MB. +- `runtimeBundle` task in `build.gradle.kts` with the pinned module list (jdeps output plus java.logging, jdk.crypto.ec, jdk.management, jdk.zipfs); launcher script in `packaging/werkator`; tarball ~66 MB. - Smoke tests on the dev machine (with `JAVA_HOME` unset and a stripped `PATH`): `--help`, `init --systemd` in a scratch repo, `config:print --full`, and a `server` run all passed; `/` served HTTP 200 and `/api/branches` returned JSON. -- The `init --systemd` unit generated from the bundle points at `/jre/bin/java` and `/lib/gittally.jar` as predicted — no detection code needed. -- Verified on vm4006 (no Java installed): bundle unpacked to `~/opt/gittally`, `--version`, `--help`, and `status` in a scratch repo (host git via `GitCommandRunner`) all worked. +- The `init --systemd` unit generated from the bundle points at `/jre/bin/java` and `/lib/werkator.jar` as predicted — no detection code needed. +- Verified on vm4006 (no Java installed): bundle unpacked to `~/opt/werkator`, `--version`, `--help`, and `status` in a scratch repo (host git via `GitCommandRunner`) all worked. Production deployment to vm4006 (2026-08-10, same session): -- `hs.hsadmin.ng` cloned to `~/hs.hsadmin.ng` on vm4006; legacy configuration from vm2176 (repo `.gitTally` + `gitTally.env`) migrated to `.gittally.yml` per `docs/migration-from-legacy.md`; Gitea token moved (the token in vm2176's `gitTally.env` file was stale — the valid one came from the running legacy process environment). -- `statusContext: GitTally@vm4006` for the parallel phase; rename to `GitTally` after vm2176 is retired. +- `hs.hsadmin.ng` cloned to `~/hs.hsadmin.ng` on vm4006; legacy configuration from vm2176 (repo `.werkator` + `werkator.env`) migrated to `.werkator.yml` per `docs/migration-from-legacy.md`; Gitea token moved (the token in vm2176's `werkator.env` file was stale — the valid one came from the running legacy process environment). +- `statusContext: werkator@vm4006` for the parallel phase; rename to `werkator` after vm2176 is retired. - systemd user service installed via `init --systemd` from the bundle and running; watcher fetches origin branches with the migrated credentials. - Managed nginx/TLS live: Let's Encrypt certificate for `vm4006.hostsharing.net` obtained, `https://vm4006.hostsharing.net/` serves the UI with a valid chain, HTTP 301s to HTTPS (Hostsharing routes public 80/443 to `httpPort`/`httpsPort`, same as on vm2176). - Fix discovered during rollout: certbot removed `ssl-dhparams.pem` from its repository, so the first nginx start failed with HTTP 404. @@ -99,83 +99,83 @@ Fix: `HSADMINNG_POSTGRES_ADMIN_USERNAME=admin` and `HSADMINNG_POSTGRES_RESTRICTE Verified by running both test classes in the build container with the variables set: green. Open oddity: the same commit passed on vm2176 although neither its daemon environment, build image, Gradle volume, nor any build-script mechanism supplies these variables there (an unused git-ignored `.environment` file exists in its primary checkout, but nothing in the build reads it); the loading path on vm2176 remains unidentified. -Fourth finding (GitTally limitation, worked around in config): with all tests green, the build then failed in hsadmin-ng's `:prQuickCheck` — "fatal: not a git repository". -GitTally builds in a git worktree whose `.git` is a pointer file into the primary repository's `.git/worktrees/…`, and the Docker build container (deliberately, credentials live under `.git/gittally/`) only mounts the worktree — so build steps that call git fail; the legacy script avoided this by building in the primary checkout. +Fourth finding (werkator limitation, worked around in config): with all tests green, the build then failed in hsadmin-ng's `:prQuickCheck` — "fatal: not a git repository". +werkator builds in a git worktree whose `.git` is a pointer file into the primary repository's `.git/worktrees/…`, and the Docker build container (deliberately, credentials live under `.git/werkator/`) only mounts the worktree — so build steps that call git fail; the legacy script avoided this by building in the primary checkout. Workaround: `prQuickCheck` removed from the vm4006 build command — it is a PR quality gate against a base branch and has no meaning in a post-merge master build (on vm2176 it only passed as an accidental no-op). -The underlying question (safe git availability inside Docker build containers without exposing `.git/gittally/` secrets) is left as a follow-up design task. +The underlying question (safe git availability inside Docker build containers without exposing `.git/werkator/` secrets) is left as a follow-up design task. Cutover completed (2026-08-10, same day): after three green master builds and verified Gitea statuses from vm4006, the legacy service on vm2176 was disabled and removed from systemd. -vm4006's `statusContext` was switched to the canonical `GitTally` (effective without a restart — the Gitea client loads the config per call), and the branches still carrying red statuses from the buggy first hours were re-queued. -vm2176 now runs only a redirect nginx container (`gittally-redirect`, ports 8080/8443 like before): HTTP and HTTPS answer 301 to `https://vm4006.hostsharing.net$request_uri`, the ACME webroot keeps serving so the `nginx-letsencrypt-renew.timer` continues to renew the old host's certificate (the renew unit gained an `ExecStartPost` nginx reload). -GitTally answers the legacy static page names (`/index.html`, `/branches.html`, `/history.html`, `/system.html`, `/about.html`, `/license.html`) with permanent redirects to the new routes, so pre-rewrite links survive the host redirect. +vm4006's `statusContext` was switched to the canonical `werkator` (effective without a restart — the Gitea client loads the config per call), and the branches still carrying red statuses from the buggy first hours were re-queued. +vm2176 now runs only a redirect nginx container (`werkator-redirect`, ports 8080/8443 like before): HTTP and HTTPS answer 301 to `https://vm4006.hostsharing.net$request_uri`, the ACME webroot keeps serving so the `nginx-letsencrypt-renew.timer` continues to renew the old host's certificate (the renew unit gained an `ExecStartPost` nginx reload). +werkator answers the legacy static page names (`/index.html`, `/branches.html`, `/history.html`, `/system.html`, `/about.html`, `/license.html`) with permanent redirects to the new routes, so pre-rewrite links survive the host redirect. -Update to v0.9.8 (2026-08-10): the running build was awaited first (a restart would have killed it), then service stopped, `~/opt/gittally` backed up to `~/opt/gittally.v0.9.7.bak` and the new bundle unpacked over it, service started. +Update to v0.9.8 (2026-08-10): the running build was awaited first (a restart would have killed it), then service stopped, `~/opt/werkator` backed up to `~/opt/werkator.v0.9.7.bak` and the new bundle unpacked over it, service started. Verified live: `/` reports v0.9.8, the nav has no `Current` entry, the permanent `🔗` link appears only on branches whose latest build is their latest green one, and the newly linked reports answer 200 — including the stable `/branches//reports/profile/`. -Master has no profile report yet because its `.gittally.yml` still carries the pre-PR#282 build command; it appears once that PR merges. +Master has no profile report yet because its `.werkator.yml` still carries the pre-PR#282 build command; it appears once that PR merges. -Update to v0.9.9 (2026-08-11): no build was running, service stopped, `~/opt/gittally` backed up to `~/opt/gittally.v0.9.8.bak` and replaced by the new bundle, service started. -The changed `server.bindAddress` default was harmless here because vm4006 sets `0.0.0.0` explicitly in `.git/gittally/.gittally.yml` — which the managed nginx container needs. -Found and fixed on the host: `.git/gittally/.gittally.yml` (the Gitea token) was still `0644` and its directory `0755` from the pre-0.9.9 `init`; both were tightened to `0600`/`0700` manually, as the new code only sets the mode for files it creates. +Update to v0.9.9 (2026-08-11): no build was running, service stopped, `~/opt/werkator` backed up to `~/opt/werkator.v0.9.8.bak` and replaced by the new bundle, service started. +The changed `server.bindAddress` default was harmless here because vm4006 sets `0.0.0.0` explicitly in `.git/werkator/.werkator.yml` — which the managed nginx container needs. +Found and fixed on the host: `.git/werkator/.werkator.yml` (the Gitea token) was still `0644` and its directory `0755` from the pre-0.9.9 `init`; both were tightened to `0600`/`0700` manually, as the new code only sets the mode for files it creates. Verified live: `/` reports v0.9.9, HTTP 301s to HTTPS with a valid certificate, `/api/builds/latest` answers, a control token in the query string is rejected with 403, `config:print` masks `git.token`, and the mobile header stacks title over repository name. -Update to v0.9.10 (2026-08-11): same procedure, `~/opt/gittally.v0.9.9.bak` as the rollback copy. -Verified live: `/` reports v0.9.10, the pages no longer contain `gittally-control-token`, `/api/builds/latest` and `/branches` still answer 200 without any credential, and a mutation without the token is rejected with 403. -The operator has to paste the token from `~/hs.hsadmin.ng/.git/gittally/control-token` once per browser now. +Update to v0.9.10 (2026-08-11): same procedure, `~/opt/werkator.v0.9.9.bak` as the rollback copy. +Verified live: `/` reports v0.9.10, the pages no longer contain `werkator-control-token`, `/api/builds/latest` and `/branches` still answer 200 without any credential, and a mutation without the token is rejected with 403. +The operator has to paste the token from `~/hs.hsadmin.ng/.git/werkator/control-token` once per browser now. -Update to v0.9.11 (2026-08-14): same procedure, no build was running, `~/opt/gittally.0.9.10.bak` as the rollback copy. -Verified live: `bin/gittally --version` reports v0.9.11 before the start, the service is `active`, `/` answers 200 with v0.9.11 in the footer, `/releases` shows the v0.9.11 entry, and `/api/builds/current` is empty. +Update to v0.9.11 (2026-08-14): same procedure, no build was running, `~/opt/werkator.0.9.10.bak` as the rollback copy. +Verified live: `bin/werkator --version` reports v0.9.11 before the start, the service is `active`, `/` answers 200 with v0.9.11 in the footer, `/releases` shows the v0.9.11 entry, and `/api/builds/current` is empty. The watcher's new local-ref fast-forward logged nothing, because `~/hs.hsadmin.ng` had already been reset to `origin/master` by hand — it only acts on a branch that actually lags behind. -Update to v0.9.12 (2026-08-26): same procedure, `~/opt/gittally.0.9.11.bak` as the rollback copy; a build was running — interrupted and re-enqueued by the startup recovery as designed. -Verified live: `bin/gittally --version` reports v0.9.12 before the start, the service is `active`, `/` shows v0.9.12, and the recovery re-enqueued exactly one build per affected branch (the pre-fix duplicate queue entries were collapsed by `markStaleRunningAsInterrupted` + `latestPerBranch`). +Update to v0.9.12 (2026-08-26): same procedure, `~/opt/werkator.0.9.11.bak` as the rollback copy; a build was running — interrupted and re-enqueued by the startup recovery as designed. +Verified live: `bin/werkator --version` reports v0.9.12 before the start, the service is `active`, `/` shows v0.9.12, and the recovery re-enqueued exactly one build per affected branch (the pre-fix duplicate queue entries were collapsed by `markStaleRunningAsInterrupted` + `latestPerBranch`). Shipped fixes: prune never removes queued/running results (a branch deleted mid-build stays visible), and manual triggers dedup against an already active build of the same branch and commit. -Update to v0.9.13 (2026-08-28): same procedure, no build was running, `~/opt/gittally.0.9.12.bak` as the rollback copy. -Verified live: `bin/gittally --version` reports v0.9.13 before the start, the service is `active`, `/` answers 200 with v0.9.13 in the footer, `/api/builds/latest` carries the new `name` field, and the watcher polls without errors. +Update to v0.9.13 (2026-08-28): same procedure, no build was running, `~/opt/werkator.0.9.12.bak` as the rollback copy. +Verified live: `bin/werkator --version` reports v0.9.13 before the start, the service is `active`, `/` answers 200 with v0.9.13 in the footer, `/api/builds/latest` carries the new `name` field, and the watcher polls without errors. Shipped feature: an `autoBuild.times` entry can carry its own `buildCommand` and `name` — a named nightly slot (e.g. `master@nightly`) gets its own branches-view row, retention pool, and permanent latest-green link; restart/retry/recovery repeat a build with its original command and name. -Update to v0.9.14 (2026-08-28): same procedure, `~/opt/gittally.0.9.13.bak` as the rollback copy; a build was running — interrupted and re-enqueued by the startup recovery as designed (now under its recorded build definition `default`). -Verified live: `bin/gittally --version` reports v0.9.14 before the start, the service is `active`, `/` answers 200 with v0.9.14 in the footer, the watcher polls without errors, and the deprecation warning for `branches.master.autoBuild` appears once in the log. +Update to v0.9.14 (2026-08-28): same procedure, `~/opt/werkator.0.9.13.bak` as the rollback copy; a build was running — interrupted and re-enqueued by the startup recovery as designed (now under its recorded build definition `default`). +Verified live: `bin/werkator --version` reports v0.9.14 before the start, the service is `active`, `/` answers 200 with v0.9.14 in the footer, the watcher polls without errors, and the deprecation warning for `branches.master.autoBuild` appears once in the log. Shipped feature: named build definitions (ADR 0007) — the `builds` section defines jobs with `onPush`/`atTimes` triggers, branch selectors (globs, `activeWithin`), and setting overrides; named builds record under `@` pools; the v0.9.13 per-slot syntax was removed again. -Update to v0.9.15 (2026-08-29): same procedure, `~/opt/gittally.0.9.14.bak` as the rollback copy, no build was running. -Verified live: `bin/gittally --version` reports v0.9.15 before the start, the service is `active`, the API answers, and the watcher polls without errors. +Update to v0.9.15 (2026-08-29): same procedure, `~/opt/werkator.0.9.14.bak` as the rollback copy, no build was running. +Verified live: `bin/werkator --version` reports v0.9.15 before the start, the service is `active`, the API answers, and the watcher polls without errors. The leftover `builds.maxConcurrent` in the repository's committed `master` config is ignored with exactly one warning per context instead of failing the configuration — the reason it is tolerated rather than rejected: that config needs a colleague's approval to change, and the installation must not be stuck on a key it is meant to forget. -Shipped feature: the `.gittally.yml` committed on a branch takes precedence for its `builds` section as well, and the watcher reads it per origin branch (`git show`, cached by head commit) to decide which of that branch's builds are due. +Shipped feature: the `.werkator.yml` committed on a branch takes precedence for its `builds` section as well, and the watcher reads it per origin branch (`git show`, cached by head commit) to decide which of that branch's builds are due. Verified live within seconds of the start: the build definition `reactivate-pi-test-with-full-pitest`, which exists only on the branch `mihoe/reactivate-pi-test` and not in the `master` config, fired its due 05:00 UTC slot, recorded under the pool `mihoe/reactivate-pi-test@reactivate-pi-test-with-full-pitest`, and ran the branch's `pitestFull` command in the build container inherited from the `master` config. -Update to v0.9.16 (2026-08-29): same procedure, `~/opt/gittally.0.9.15.bak` as the rollback copy. +Update to v0.9.16 (2026-08-29): same procedure, `~/opt/werkator.0.9.15.bak` as the rollback copy. A build was running at the first attempt, so the deployment aborted itself before stopping anything; the service was then stopped in the first idle window (waiting rather than interrupting was the operator's call). -Verified live: `bin/gittally --version` reports v0.9.16 before the start, the service is `active`, `/releases` lists v0.9.16, and the watcher polls without errors. +Verified live: `bin/werkator --version` reports v0.9.16 before the start, the service is `active`, `/releases` lists v0.9.16, and the watcher polls without errors. Shipped features: hourly scheduled builds (`atTimes: ["??:05"]`) and the artifact page showing the command a build actually runs. Verified live: the branch's `atTimes: ["??:00"]` — rejected by v0.9.15 with a warning on every poll cycle — is accepted, and its 06:00 UTC slot fired right after the restart. -Update to v0.9.17 (2026-08-29): same procedure, `~/opt/gittally.0.9.16.bak` as the rollback copy. +Update to v0.9.17 (2026-08-29): same procedure, `~/opt/werkator.0.9.16.bak` as the rollback copy. Deployed under the operator's condition "only if no build is running": the deploy script re-checks `/api/builds/current` immediately before the stop and exits without touching anything when a build is executing. -Verified live: `bin/gittally --version` reports v0.9.17 before the start, the service is `active`, `/releases` lists v0.9.17, the served `gittally.js` carries the resume listeners, and no errors in the log. +Verified live: `bin/werkator --version` reports v0.9.17 before the start, the service is `active`, `/releases` lists v0.9.17, the served `werkator.js` carries the resume listeners, and no errors in the log. Shipped fix: a page returning from the background fetches the current state immediately instead of showing (and ticking) the state it was left in. -Update to v0.9.18 (2026-08-29): same procedure, `~/opt/gittally.0.9.17.bak` as the rollback copy, no build was running. -Verified live: `bin/gittally --version` reports v0.9.18 before the start, the service is `active`, `/releases` lists v0.9.18, the watcher polls without fetch or poll errors, and the only warnings are the two known `builds.maxConcurrent` lines from the repository's committed config. -Shipped feature: a configuration file can declare the GitTally it is written for (`gitTally.version.since`/`below`), so an incompatibility is named instead of silently ignored. +Update to v0.9.18 (2026-08-29): same procedure, `~/opt/werkator.0.9.17.bak` as the rollback copy, no build was running. +Verified live: `bin/werkator --version` reports v0.9.18 before the start, the service is `active`, `/releases` lists v0.9.18, the watcher polls without fetch or poll errors, and the only warnings are the two known `builds.maxConcurrent` lines from the repository's committed config. +Shipped feature: a configuration file can declare the werkator it is written for (`werkator.version.since`/`below`), so an incompatibility is named instead of silently ignored. The configs of the watched repository declare nothing yet and are unaffected — a missing declaration is never an error. -Update to v0.9.19 (2026-08-29): same procedure, `~/opt/gittally.0.9.18.bak` as the rollback copy, no build was running. +Update to v0.9.19 (2026-08-29): same procedure, `~/opt/werkator.0.9.18.bak` as the rollback copy, no build was running. Shipped feature: a build definition describes its build completely (`requirePullRequest`, the whole `docker` section), `builds.default` is the settings base of every other definition, and the per-branch `branches` section is superseded — read only while nothing defines a build at all. -The machine config `.git/gittally/.gittally.yml` was rewritten to the new shape beforehand (backup `.gittally.yml.20260829T085959Z.bak`), in a form valid under both versions: `builds.default` plus `builds.master` for the new one, a reduced `branches` block for v0.9.18, which has no sandbox policy inside a definition. +The machine config `.git/werkator/.werkator.yml` was rewritten to the new shape beforehand (backup `.werkator.yml.20260829T085959Z.bak`), in a form valid under both versions: `builds.default` plus `builds.master` for the new one, a reduced `branches` block for v0.9.18, which has no sandbox policy inside a definition. It also drops the machine-local `--no-build-cache` command that had shadowed master's own `buildCommand` for every on-push build; the commands now come from `origin/master`'s committed config. -Verified live: `bin/gittally --version` reports v0.9.19 before the start, the service is `active`, `/releases` lists v0.9.19, and the log carries the expected "ignoring the branches section" warning next to the known `builds.maxConcurrent` one. +Verified live: `bin/werkator --version` reports v0.9.19 before the start, the service is `active`, `/releases` lists v0.9.19, and the log carries the expected "ignoring the branches section" warning next to the known `builds.maxConcurrent` one. The new `master@master` job fired on the first poll after the restart — its 01:00 slot was due and unmarked for that pool — and runs in `hsadmin-ng-build-env:latest` with `bootJarWithDocumentation`: the pinned `docker.enabled`/`network` reached it through the inheritance from `builds.default`, while its own command won. The legacy `branches` block stays in the machine config for the transition week as the rollback path to v0.9.18; it is inert under v0.9.19. -Update to v0.9.20 (2026-08-29): same procedure, `~/opt/gittally.0.9.19.bak` as the rollback copy, no build was running. +Update to v0.9.20 (2026-08-29): same procedure, `~/opt/werkator.0.9.19.bak` as the rollback copy, no build was running. Shipped feature: a definition's `trigger` block, `!` exclusion patterns in `trigger.branches`, and a per-build Gitea `statusContext`. -The machine config had to be migrated in the same window (backup `.gittally.yml.20260829T100842Z.bak`): v0.9.19 drops an unknown `trigger` block silently — which would leave every definition without a trigger — and v0.9.20 refuses the flat keys, so the file is valid for exactly one of the two versions and had to be swapped while the service was down. +The machine config had to be migrated in the same window (backup `.werkator.yml.20260829T100842Z.bak`): v0.9.19 drops an unknown `trigger` block silently — which would leave every definition without a trigger — and v0.9.20 refuses the flat keys, so the file is valid for exactly one of the two versions and had to be swapped while the service was down. The new configuration was validated with the new binary (`config:print --full`) after the swap and before the start. Verified live: `--version` reports v0.9.20, the service is `active`, `/releases` lists v0.9.20, the watcher polls without errors, and the two expected warnings (`builds.maxConcurrent`, the ignored `branches` section) are the only ones from the host configuration. The branch-scoped refusal showed itself in production immediately: `mihoe/reactivate-pi-test` still commits its triggers flat, so its committed config is refused with a message naming the branch, the commit, and each definition's offending keys — the watcher falls back to the host's definitions for scheduling, and builds of that branch fail until the file is migrated. Every other branch is unaffected, which is the whole point of the per-file scoping. -Update to v0.9.21 (2026-08-30): same procedure, `~/opt/gittally.0.9.20.bak` as the rollback copy, no build was running; the machine config needed no change this time. +Update to v0.9.21 (2026-08-30): same procedure, `~/opt/werkator.0.9.20.bak` as the rollback copy, no build was running; the machine config needed no change this time. Shipped feature: an unreachable origin is shown in the web UI (step 19), and a lasting fetch failure is logged once per message instead of once per poll. -The occasion was an outage the same morning: the `git.token` in the machine config had been overwritten with a placeholder string, GitTally failed every fetch for 57 minutes, and the branches view kept showing its last known list as if nothing were wrong. -Verified live: `--version` reports v0.9.21, the service is `active`, `/` answers 200 with v0.9.21 in the footer, `/api/watcher` reports `lastFetchError: null`, the served `gittally.js` carries `refreshWatcherBanner`, `/branches` carries the banner element, and the only warnings are the two expected ones from the repository's committed config (`builds.maxConcurrent`, the ignored `branches` section). +The occasion was an outage the same morning: the `git.token` in the machine config had been overwritten with a placeholder string, werkator failed every fetch for 57 minutes, and the branches view kept showing its last known list as if nothing were wrong. +Verified live: `--version` reports v0.9.21, the service is `active`, `/` answers 200 with v0.9.21 in the footer, `/api/watcher` reports `lastFetchError: null`, the served `werkator.js` carries `refreshWatcherBanner`, `/branches` carries the banner element, and the only warnings are the two expected ones from the repository's committed config (`builds.maxConcurrent`, the ignored `branches` section). diff --git a/docs/plan/16-git-in-docker-builds.md b/docs/plan/16-git-in-docker-builds.md index 00da2e9..e073530 100644 --- a/docs/plan/16-git-in-docker-builds.md +++ b/docs/plan/16-git-in-docker-builds.md @@ -6,14 +6,14 @@ Motivated by the vm4006 rollout (step 15, fourth finding): hs.hsadmin.ng's build ## Problem -Builds run in git worktrees under `.git/gittally/worktrees/`. +Builds run in git worktrees under `.git/werkator/worktrees/`. A worktree's `.git` is a pointer file into the primary repository's `.git/worktrees/`, and `DockerBuildRunner` bind-mounts only the worktree — so every git call inside the build container fails. The legacy script did not have this problem because it built in the primary checkout with the real `.git` present (read-write, including all secrets stored next to it — full exposure). -Hard invariant to preserve: a branch build must never be able to reach credentials; `.git/gittally/.gittally.yml` (`git.token`) and the control token live under `.git`. +Hard invariant to preserve: a branch build must never be able to reach credentials; `.git/werkator/.werkator.yml` (`git.token`) and the control token live under `.git`. ## Considered Options -- **Read-only `.git` mount with `.git/gittally/` masked (chosen)** — three layered mounts, no config key, no workspace mutation; strictly less privileged than legacy. +- **Read-only `.git` mount with `.git/werkator/` masked (chosen)** — three layered mounts, no config key, no workspace mutation; strictly less privileged than legacy. - Copy minimal git metadata into the workspace (admin dir plus `objects/info/alternates`) — mutates the workspace, still needs the object database mounted, more moving parts. - Document the limitation and require git-free build commands — pushes the problem onto every watched project; hsadmin-ng shows real builds do call git. @@ -22,11 +22,11 @@ Hard invariant to preserve: a branch build must never be able to reach credentia `DockerBuildRunner.gitMetadataMounts(workspace, repoDir)` adds three mounts when (and only when) the workspace is a worktree of `repoDir` (detected via the `gitdir:` pointer file, which must resolve into `repoDir/.git`): 1. `repoDir/.git` → same path, **read-only**: objects, refs, and the worktree admin metadata become resolvable; object and ref writes stay impossible. -2. An empty **tmpfs over `repoDir/.git/gittally`**: masks the machine config (`git.token`), the control token, and all GitTally state; the workspace bind (deeper path, Docker nests mounts by target depth) resurfaces only this build's own worktree inside the masked directory. +2. An empty **tmpfs over `repoDir/.git/werkator`**: masks the machine config (`git.token`), the control token, and all werkator state; the workspace bind (deeper path, Docker nests mounts by target depth) resurfaces only this build's own worktree inside the masked directory. 3. `repoDir/.git/worktrees/` → same path, **read-write**: the worktree's admin dir (HEAD, index), so index-refreshing commands like `git status` work. No configuration key: the exposure is strictly smaller than the legacy baseline, and a knob would join the pinned sandbox-policy set without a known use case. -Remaining, documented exposure: the rest of `.git` — including `.git/config` — is readable by builds; GitTally never stores credentials there (fetch auth uses a secret-free `GIT_ASKPASS` with env-passed credentials). +Remaining, documented exposure: the rest of `.git` — including `.git/config` — is readable by builds; werkator never stores credentials there (fetch auth uses a secret-free `GIT_ASKPASS` with env-passed credentials). ## Tests @@ -35,10 +35,10 @@ Remaining, documented exposure: the rest of `.git` — including `.git/config` ## Acceptance Criteria - `./gradlew ktlintFormat` then `./gradlew build` is green. -- In a real Docker build worktree: `git log`/`git status` succeed inside the container, `.git/gittally/.gittally.yml` and `control-token` are not readable, and a `git push`/ref write fails. +- In a real Docker build worktree: `git log`/`git status` succeed inside the container, `.git/werkator/.werkator.yml` and `control-token` are not readable, and a `git push`/ref write fails. - `docs/configuration.md` (docker notes) and the architecture skill describe the mounts. ## Result (2026-08-10) Implemented as designed; verified on vm4006 (see below) and in unit tests. -`sh -c 'git log -1 && git status --short && cat .../.git/gittally/.gittally.yml'` inside a build container of the hs.hsadmin.ng worktree: git commands succeed, the machine config read fails with "No such file or directory", `git update-ref` fails on the read-only filesystem. +`sh -c 'git log -1 && git status --short && cat .../.git/werkator/.werkator.yml'` inside a build container of the hs.hsadmin.ng worktree: git commands succeed, the machine config read fails with "No such file or directory", `git update-ref` fails on the read-only filesystem. diff --git a/docs/plan/17-bwrap-build-runtime.md b/docs/plan/17-bwrap-build-runtime.md index 1bf03df..9a0ca9d 100644 --- a/docs/plan/17-bwrap-build-runtime.md +++ b/docs/plan/17-bwrap-build-runtime.md @@ -1,14 +1,14 @@ -# Step 17: Running GitTally on a Managed Webspace (bubblewrap builds + web access) +# Step 17: Running werkator on a Managed Webspace (bubblewrap builds + web access) Prerequisites: steps 11, 15, 16. Read `README.md` first. -Motivated by running GitTally on Hostsharing **Managed Webspaces**: no root, no Docker daemon, but `bwrap` (bubblewrap) is available and unprivileged user namespaces are allowed. -Target use case: GitTally builds GitTally itself on a Managed Webspace; builds needing special dependencies get them from a prepared root filesystem instead of the host. +Motivated by running werkator on Hostsharing **Managed Webspaces**: no root, no Docker daemon, but `bwrap` (bubblewrap) is available and unprivileged user namespaces are allowed. +Target use case: werkator builds werkator itself on a Managed Webspace; builds needing special dependencies get them from a prepared root filesystem instead of the host. Projects that need Docker for their own tests (hs.hsadmin.ng with Testcontainers) stay on a container host like vm4006 — the webspace is for Docker-free builds only. The step covers two halves of the same deployment and is deliberately not split: the build sandbox (most of this document) and the web access under a domain (last section). -Without the second half the first one only proves that sandboxed builds work somewhere; without the first one GitTally on a webspace would run builds unsandboxed on the host. +Without the second half the first one only proves that sandboxed builds work somewhere; without the first one werkator on a webspace would run builds unsandboxed on the host. ## Precondition Check (run on the target webspace first) @@ -46,7 +46,7 @@ What 0.8.0 lacks is overlayfs (`--overlay`, added in 0.9.0): a future "throwaway **The runtime bundle runs there — checked, not assumed.** The webspace has glibc 2.36 (Debian 12), below the dev machine's 2.39, which by ADR 0006's original wording would have ruled the bundle out. That wording was wrong and has been corrected: the bundle's highest required symbol version is `GLIBC_2.15`, because `jlink` copies Temurin's prebuilt binaries rather than compiling anything. So no container build and no second build machine are needed for this platform. -The bundle's `java.desktop` module does carry X11, ALSA and freetype dependencies, but only in the AWT libraries, which a headless GitTally never loads — as on vm4006. +The bundle's `java.desktop` module does carry X11, ALSA and freetype dependencies, but only in the AWT libraries, which a headless werkator never loads — as on vm4006. ## Goal @@ -58,8 +58,8 @@ No root on the host, no Docker daemon, no changes to the native and Docker runti ### Prepared root filesystem `debootstrap`/`mmdebstrap` are not available on the webspace, so the rootfs is **not created on the target system**. -It is built once elsewhere (any machine with Docker or root, e.g. a container VM) and distributed as an archive, e.g. `gittally-buildenv-trixie-java21.tar.zst`, containing Debian plus all build dependencies (JDK 21, git, locales, project-specific tools). -GitTally unpacks it on demand (`tar --no-same-owner`) into `.git/gittally/buildenv//rootfs` — **not** into the working tree. +It is built once elsewhere (any machine with Docker or root, e.g. a container VM) and distributed as an archive, e.g. `werkator-buildenv-trixie-java21.tar.zst`, containing Debian plus all build dependencies (JDK 21, git, locales, project-specific tools). +werkator unpacks it on demand (`tar --no-same-owner`) into `.git/werkator/buildenv//rootfs` — **not** into the working tree. Like the Docker image and the Gradle cache volume, the environment is shared across all branch worktrees and survives worktree pruning; `` derives from a hash of the configured archive source, so an environment-version change unpacks a fresh rootfs and stale ones can be pruned. ### Configuration @@ -67,7 +67,7 @@ Like the Docker image and the Gradle cache volume, the environment is shared acr New `branches..bwrap` section: `enabled`, `rootfs` (path or URL of the archive), `env` (like `docker.env`). `bwrap.enabled` and `bwrap.rootfs` join the **pinned sandbox-policy set** (like `docker.enabled`/`docker.network`): a branch must not be able to switch off its sandbox or substitute a foreign rootfs via its committed config. `docker.enabled` and `bwrap.enabled` are mutually exclusive per branch — reject the config, do not pick silently. -Keep the three config places in sync: `GitTallyConfig`, the `InitCommand` templates, `docs/configuration.md`. +Keep the three config places in sync: `WerkatorConfig`, the `InitCommand` templates, `docs/configuration.md`. ### Invocation @@ -84,7 +84,7 @@ bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \ /bin/sh -c '' ``` -- The workspace is bound at its **host path**, not at `/workspace`: the worktree's `.git` pointer file contains absolute host paths, and the step-16 git metadata mounts (`--ro-bind` of the primary `.git`, `--tmpfs` over `.git/gittally`, `--bind` of `.git/worktrees/`) port 1:1 — reuse that logic, do not duplicate it. +- The workspace is bound at its **host path**, not at `/workspace`: the worktree's `.git` pointer file contains absolute host paths, and the step-16 git metadata mounts (`--ro-bind` of the primary `.git`, `--tmpfs` over `.git/werkator`, `--bind` of `.git/worktrees/`) port 1:1 — reuse that logic, do not duplicate it. - `/home` bound as `/root` gives Gradle a persistent `$HOME` (wrapper dists, `.gradle` caches) — the bwrap sibling of the Docker runner's Gradle cache volume. - `--die-with-parent` plus `--unshare-pid`: cancellation kills the returned `bwrap` process tree and nothing survives — same semantics as the other runtimes. - No ownership repair is needed: files created as uid 0 inside the namespace are owned by the webspace user on the host. @@ -93,7 +93,7 @@ bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \ - Network stays shared with the host (Gradle needs it); isolation is weaker than Docker's per-container network. - No Docker inside the sandbox, so no Testcontainers-based tests; build commands must select a Docker-free test subset. - For GitTally's own build this means `TestcontainersSmokeTest` must become conditional (`enabledIf` docker present) — that change is part of this step. + For werkator's own build this means `TestcontainersSmokeTest` must become conditional (`enabledIf` docker present) — that change is part of this step. ## Web Access under a Domain (no Docker, no managed nginx) @@ -103,19 +103,19 @@ Three platform-side prerequisites, none of them code: 1. **Book the "eigener Serverdienst" option** — a service user plus one reserved localhost port, requested from `service@hostsharing.net` stating the service user and the number of ports. Surcharged on Managed Webspaces (RAM contingent in 128 MB steps), included on Managed Servers. - The port number is **assigned by Hostsharing** (wiki examples use 34567, 38005/38006), so it goes into `server.port` — GitTally's 18080 is not available by choice. + The port number is **assigned by Hostsharing** (wiki examples use 34567, 38005/38006), so it goes into `server.port` — werkator's 18080 is not available by choice. Sources: [Individuelle Serverdienste](https://www.hostsharing.net/features/individuelle-serverdienste/), [Apache](https://www.hostsharing.net/features/apache/). 2. **Run the service as a systemd user unit** — mandatory on Managed Webspaces (no `nohup`, no supervisord); lingering needs a valid login shell configured in HSAdmin, and the account's RAM is capped by a slice (`systemctl status pacs-.slice`). - `gittally init --systemd` already generates the unit and the `gittally.env`, whose `JAVA_OPTS=-Xmx…` is what keeps the JVM inside the slice. + `werkator init --systemd` already generates the unit and the `werkator.env`, whose `JAVA_OPTS=-Xmx…` is what keeps the JVM inside the slice. Source: [Prozessmanagement mit systemd im Userspace](https://wiki.hostsharing.net/index.php/Prozessmanagement_mit_systemd_im_Userspace). 3. **Let's Encrypt** is a domain option ticked in HSAdmin (free, automatic, includes the wildcard subdomain; requires the domain's nameservers to be delegated to Hostsharing), so TLS terminates in the managed Apache. Source: [TLS](https://www.hostsharing.net/doc/managed-operations-platform/tls/). ### User model: a dedicated unix user, not the package admin -GitTally runs as its own unix user, e.g. `xyz00-gittally`, with the domain assigned to that same user (`domain.add({set:{name:'…',user:'xyz00-gittally'}})`), so the service, its repository checkout and `~/doms//htdocs-ssl/` share one home directory. +werkator runs as its own unix user, e.g. `xyz00-werkator`, with the domain assigned to that same user (`domain.add({set:{name:'…',user:'xyz00-werkator'}})`), so the service, its repository checkout and `~/doms//htdocs-ssl/` share one home directory. That is what every Hostsharing service guide does (`xyz00-chat` for Mattermost, `xyz00-tomcat`, `xyz00-cloud` for Nextcloud) and what their user documentation recommends: a domain *can* run under the package admin, but "aus Sicherheitsgründen empfiehlt es sich aber Domains auf separate Domain-Admins aufzuschalten", so a compromise stays inside one home instead of reaching the whole package. -Here the argument is stronger than usual, because GitTally checks out foreign commits and executes their build scripts — running that as the package admin would undo the sandbox rationale of this very step. +Here the argument is stronger than usual, because werkator checks out foreign commits and executes their build scripts — running that as the package admin would undo the sandbox rationale of this very step. The service user is named when ordering the daemon port anyway. Sources: [Benutzer](https://www.hostsharing.net/doc/managed-operations-platform/benutzer/), [HSAdmin domain](https://www.hostsharing.net/doc/managed-operations-platform/hsadmin/domain/). @@ -137,7 +137,7 @@ RewriteRule .* http://127.0.0.1:%{REQUEST_URI} [proxy] Sources: [Mattermost Installieren](https://wiki.hostsharing.net/index.php/Mattermost_Installieren), [Tomcat Installieren](https://wiki.hostsharing.net/index.php?title=Tomcat_Installieren). -The matching GitTally configuration: +The matching werkator configuration: ```yaml server: @@ -148,7 +148,7 @@ server: enabled: false # the managed nginx container is not used on a webspace ``` -**This half needs no code change.** GitTally never reconstructs absolute URLs from the request — everything external comes from `server.publicBaseUrl` and the UI links relatively — so the usual reverse-proxy fix `server.forward-headers-strategy` is not needed. +**This half needs no code change.** werkator never reconstructs absolute URLs from the request — everything external comes from `server.publicBaseUrl` and the UI links relatively — so the usual reverse-proxy fix `server.forward-headers-strategy` is not needed. Two claims could **not** be verified from a Hostsharing primary source; check them on the target webspace rather than relying on them: @@ -169,6 +169,6 @@ Write ADR 0007: bubblewrap user-namespace sandbox as the third build runtime (op - The precondition command line above passes on the target webspace; its output is recorded in this file. - `./gradlew ktlintFormat` then `./gradlew build` is green — also on a machine without Docker (Testcontainers smoke test skipped, not failed). -- On a Managed Webspace: GitTally (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/gittally/` is not readable from the build; a write to `/usr` fails. +- On a Managed Webspace: werkator (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/werkator/` is not readable from the build; a write to `/usr` fails. - On the same webspace: the UI answers over HTTPS under the domain through the Apache `.htaccess` proxy, the service survives a logout and a reboot (systemd lingering), and Gitea statuses carry `publicBaseUrl` links that resolve. - Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0007, and `docs/deployment.md` gains "Hostsharing Managed Webspace" as a third deployment variant — written only once the setup above is verified on a real webspace, not from this plan. diff --git a/docs/plan/18-remove-branches-section.md b/docs/plan/18-remove-branches-section.md index 89c213a..b29524b 100644 --- a/docs/plan/18-remove-branches-section.md +++ b/docs/plan/18-remove-branches-section.md @@ -11,12 +11,12 @@ The `trigger` block, originally planned here, shipped earlier — see the sectio ## Precondition Check (run first, do not skip) -The removal adds a *rejection by name*: a configuration file that still carries a `branches:` key is refused, because a silently ignored section is exactly the failure this step exists to prevent — and the `gitTally.version` check cannot catch it, since it only bites files that declare a version, which none of the hs.hsadmin.ng configs do. +The removal adds a *rejection by name*: a configuration file that still carries a `branches:` key is refused, because a silently ignored section is exactly the failure this step exists to prevent — and the `werkator.version` check cannot catch it, since it only bites files that declare a version, which none of the hs.hsadmin.ng configs do. So no configuration still in play may contain the section. On vm4006: ```bash -ssh tallyman@vm4006.hostsharing.net 'cd ~/hs.hsadmin.ng && grep -n "^branches:" .git/gittally/.gittally.yml; for b in $(git for-each-ref --format="%(refname:short)" refs/remotes/origin | sed "s|origin/||"); do if git cat-file -e origin/$b:.gittally.yml 2>/dev/null && git show origin/$b:.gittally.yml | grep -qE "^branches:"; then echo "still legacy: $b"; fi; done' +ssh tallyman@vm4006.hostsharing.net 'cd ~/hs.hsadmin.ng && grep -n "^branches:" .git/werkator/.werkator.yml; for b in $(git for-each-ref --format="%(refname:short)" refs/remotes/origin | sed "s|origin/||"); do if git cat-file -e origin/$b:.werkator.yml 2>/dev/null && git show origin/$b:.werkator.yml | grep -qE "^branches:"; then echo "still legacy: $b"; fi; done' ``` Expected output: nothing at all. @@ -24,13 +24,13 @@ Expected output: nothing at all. As of 2026-08-29 this listed `master`, five `mihoe/…` branches, and the machine config; the machine config was cleaned up on 2026-08-30, so only the committed ones are left. The plan was: merge `mihoe/reactivate-pi-test` (the first branch with the new shape) to master, rebase the other branches onto the new master, then run this step. -Branches without a committed `.gittally.yml` are fine — they build from the machine config. +Branches without a committed `.werkator.yml` are fine — they build from the machine config. Note that `branches:` also exists as the *selector* key **inside** a build definition (`builds..branches: ["master"]`). That one stays. Only the top-level section goes. The grep above anchors at the line start for exactly that reason. ## Code -- `config/GitTallyConfig.kt`: drop the `branches` property and `AutoBuildConfig`, and `BranchConfig.autoBuild` with it. +- `config/werkatorConfig.kt`: drop the `branches` property and `AutoBuildConfig`, and `BranchConfig.autoBuild` with it. Rename `BranchConfig` to `BuildSettings` — with the section gone it is no longer a schema type but the resolved answer to "what does this build run", which is all it is used for. `buildSettings(branch, build)` then no longer needs the branch lookup: `effectiveBuildDefinitions()[build]?.applyTo(BuildSettings()) ?: BuildSettings()`. Keep the `branch` parameter — the callers pass it and a later per-branch concern would need it back. @@ -39,9 +39,9 @@ Note that `branches:` also exists as the *selector* key **inside** a build defin Reuse `ConfigVersionException` or add a sibling; the message must name the file and say where the settings belong now. - `watcher/Watcher.kt`: delete `enqueueDeprecatedAutoBuilds`, its call in `enqueueDueBranches`, and the `warnedDeprecatedAutoBuild` flag. - `config/ConfigVersion.kt`: set `FORMAT_BROKE_IN` to this release's version and `FORMAT_BROKE_DESCRIPTION` to something like "the per-branch `branches` section was replaced by build definitions". - This is the first real use of that mechanism: a file declaring `gitTally.version.since` below this release is then refused with a message naming the change. + This is the first real use of that mechanism: a file declaring `werkator.version.since` below this release is then refused with a message naming the change. Note that it only bites files that declare a version — which is why the rejection by name above exists next to it, not instead of it. -- `commands/InitCommand.kt`: nothing — the generated template has been `builds`-only with a `trigger` block since v0.9.20. Verify with `gittally init` in a scratch repo. +- `commands/InitCommand.kt`: nothing — the generated template has been `builds`-only with a `trigger` block since v0.9.20. Verify with `werkator init` in a scratch repo. ## Already done: the `trigger` block @@ -75,13 +75,13 @@ Its legacy `branches` block is deleted, `builds.master` is now `builds.nightly`, The file holds `git`, `server`, `builds.default`, and `builds.nightly`; `config:print --full` resolves both definitions completely, with `nightly` inheriting the docker settings and all three artifact directories. Nothing about the section removal itself is left to do there. The file is 600 by design; a shell redirect creates 644, so check the mode after every edit. -Backups: `.gittally.yml.20260829T085959Z.bak` (the pre-v0.9.19 shape) and `.gittally.yml.20260829T100842Z.bak` (the last one carrying the legacy block). +Backups: `.werkator.yml.20260829T085959Z.bak` (the pre-v0.9.19 shape) and `.werkator.yml.20260829T100842Z.bak` (the last one carrying the legacy block). What remains is deleting the `builds` section from the machine config entirely, as soon as master carries its own. The file then holds `git` and `server` — the secrets and the host's addresses — and nothing that describes a build. That works because the pinning strips the *branch* layer only (`ConfigLoader.stripPinned`, applied in `withBranchLayer`). -Master's committed `.gittally.yml` is the project layer of the primary checkout and is merged unstripped, so its `docker.enabled: true` and `network: host` are what every branch's builds inherit — including a build a branch invents for itself, because the inheritance from `builds.default` runs after the layers are merged. +Master's committed `.werkator.yml` is the project layer of the primary checkout and is merged unstripped, so its `docker.enabled: true` and `network: host` are what every branch's builds inherit — including a build a branch invents for itself, because the inheritance from `builds.default` runs after the layers are merged. The sandbox policy thereby moves from the host to master, where changing it needs a review; it must therefore actually be in master's file before the host's section goes. `builds.nightly` goes with it: master's config defines its own `release` job, and the nightly rebuild belongs next to it rather than on the host. @@ -94,12 +94,12 @@ Deploy as usual (`docs/plan/15-runtime-bundle-distribution.md`), only while `/ap ## Verification -- `gittally config:print --full` on vm4006 before the restart: no `branches` in the output, every definition complete, and `docker.enabled: true` plus `network: host` on all of them. +- `werkator config:print --full` on vm4006 before the restart: no `branches` in the output, every definition complete, and `docker.enabled: true` plus `network: host` on all of them. Once the machine config's `builds` section is gone, that resolves entirely from master's committed file — which is exactly what the check is for. - After the restart: no warnings about a branches section, the watcher polls without errors, and a branch build starts in `hsadmin-ng-build-env:latest`. - Deliberately: point the running instance at a scratch repository whose config still has `branches:` and confirm the error names the file and the way out. ## Rollback -Keep the `~/opt/gittally.0.9.20.bak` that this step's deployment creates, plus a timestamped copy of the machine config. +Keep the `~/opt/werkator.0.9.20.bak` that this step's deployment creates, plus a timestamped copy of the machine config. Going back below v0.9.19 is not provided for: v0.9.18 reads its sandbox policy from the `branches` block that no configuration carries any more, and would build natively on the host. diff --git a/docs/plan/19-watcher-health-in-ui.md b/docs/plan/19-watcher-health-in-ui.md index e627bb8..ead4d13 100644 --- a/docs/plan/19-watcher-health-in-ui.md +++ b/docs/plan/19-watcher-health-in-ui.md @@ -6,7 +6,7 @@ Read `README.md` first. ## Why On 2026-08-30 the Gitea token in the machine config on vm4006 was replaced by a placeholder string. -For 57 minutes GitTally failed `git fetch --prune origin` every ten seconds and wrote 297 warnings to the journal. +For 57 minutes werkator failed `git fetch --prune origin` every ten seconds and wrote 297 warnings to the journal. The branches view showed a calm, ordinary list the whole time: every branch with its last build, nothing amiss. The failure was noticed only because an expected build did not start, and it took reading the journal to see why. @@ -27,11 +27,11 @@ The endpoint needs no change. ## Code -- `static/gittally.js`: fetch `/api/watcher` from the same polling cycle that refreshes the view, and show a banner while any of the three conditions holds. +- `static/werkator.js`: fetch `/api/watcher` from the same polling cycle that refreshes the view, and show a banner while any of the three conditions holds. Keep the existing discipline — a timeout on the fetch, and a failure of *this* request must never break the view's own refresh. - `templates/fragments.html`: add a `watcher-banner` fragment to the `nav(view)` row so every view inherits it; hidden unless the script fills it. - Wording says what is stale and since when, not just that something failed: the branch list is not updating, since `lastPollAt`, because ``. - Timestamps go through the shared formatting — `UiFormats` and `gittally.js` must produce identical formats (invariant in `AGENTS.md`). + Timestamps go through the shared formatting — `UiFormats` and `werkator.js` must produce identical formats (invariant in `AGENTS.md`). - Do not overload `live-indicator`: it reports whether the *browser* reaches the server. This banner reports whether the *server* reaches origin. Two independent failures, two independent signals. diff --git a/docs/plan/README.md b/docs/plan/README.md index 6db4e57..17e1f4f 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -1,6 +1,6 @@ -# GitTally Rewrite Plan +# werkator Rewrite Plan -This directory contains the step-by-step plan for rewriting `legacy/gitTally` (bash) as the Kotlin/Spring application in this repository. +This directory contains the step-by-step plan for rewriting `legacy/werkator` (bash) as the Kotlin/Spring application in this repository. Each step file is self-contained and sized for one focused Claude Code session. ## How to Execute a Step @@ -9,7 +9,7 @@ Start a fresh Claude Code session and prompt, for example: "Execute docs/plan/01 The executing session should: 1. Read this file, `00-legacy-analysis.md`, and the step file. -2. Read the referenced parts of `legacy/gitTally` only if the step file says so. +2. Read the referenced parts of `legacy/werkator` only if the step file says so. 3. Implement with tests, following `CLAUDE.md` conventions. 4. Run `./gradlew ktlintFormat` and then `./gradlew build` until green. 5. Update the step's checkbox below and note deviations inside the step file. @@ -22,16 +22,16 @@ The executing session should: - The web UI must never get stuck loading (JSON status endpoints with explicit error states instead of regex-rewritten HTML). - Do not port orphaned or half-implemented legacy config options (see `00-legacy-analysis.md`). - Every step leaves the build green and the application runnable. -- Config keys added by a step must be updated in three places: `GitTallyConfig`, the `InitCommand` templates, and `docs/configuration.md`. +- Config keys added by a step must be updated in three places: `WerkatorConfig`, the `InitCommand` templates, and `docs/configuration.md`. ## Proposed Architecture Decisions These are proposals baked into the steps. Revisit them in an ADR if a step uncovers problems. -- Build results are persisted as a JSON file under `.git/gittally/`, behind a `BuildResultRepository` interface (no database, but replaceable). +- Build results are persisted as a JSON file under `.git/werkator/`, behind a `BuildResultRepository` interface (no database, but replaceable). - Builds run concurrently up to `builds.maxConcurrent` (default 1), but never more than one build per branch at a time. - Each branch builds in its own reusable git worktree under `.git/gittally/worktrees/`, checked out detached at the requested commit — never in the primary checkout. + Each branch builds in its own reusable git worktree under `.git/werkator/worktrees/`, checked out detached at the requested commit — never in the primary checkout. A later step must decide (possibly per config) whether a new commit on a branch cancels that branch's running build or waits for it; for now new builds queue behind the running one. - Artifacts stay on the filesystem, served by the Spring server. - The web UI is server-rendered HTML plus small JavaScript polling JSON endpoints (no SPA framework). @@ -76,7 +76,7 @@ Added after the 2026-08-10 overhead measurements on vm2176: Added for the vm2176 → vm4006 migration (2026-08-10): - [x] `15-runtime-bundle-distribution.md` — self-contained runtime bundle (jlink JRE + jar) for hosts without a Java runtime -- [x] `16-git-in-docker-builds.md` — read-only git metadata inside Docker build containers, with `.git/gittally/` masked +- [x] `16-git-in-docker-builds.md` — read-only git metadata inside Docker build containers, with `.git/werkator/` masked Added after v0.9.19 replaced the per-branch settings with build definitions (2026-08-29): @@ -86,9 +86,9 @@ Added after a silent 57-minute fetch outage on vm4006 (2026-08-30): - [x] `19-watcher-health-in-ui.md` — show an unreachable origin in the web UI instead of only in the journal -Added for running GitTally on Hostsharing Managed Webspaces (2026-08-10): +Added for running werkator on Hostsharing Managed Webspaces (2026-08-10): -- [ ] `17-bwrap-build-runtime.md` — GitTally on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt +- [ ] `17-bwrap-build-runtime.md` — werkator on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt Steps 01–03 are independent of each other. Steps 04–06 depend on 01–03. diff --git a/legacy/.gitTally b/legacy/.gitTally deleted file mode 100644 index af85aeb..0000000 --- a/legacy/.gitTally +++ /dev/null @@ -1,176 +0,0 @@ -# Environment for GitTally -# -# Save and source this output before starting the script, for example: -# gitTally --env > .gittally.env -# . .gittally.env - -# ================================================================================ -# Installation -# -------------------------------------------------------------------------------- - -# Target directory used by --install. -# default GITTALLY_INSTALL_DIR=$HOME/bin -export GITTALLY_INSTALL_DIR=$HOME/gitTally - -# ================================================================================ -# Build command -# -------------------------------------------------------------------------------- - -# Shell command executed for each build. The branch name is available as $branch. -# default GITTALLY_BUILD_COMMAND='./gradlew --console=plain --no-daemon --no-build-cache --rerun-tasks test' -export GITTALLY_BUILD_COMMAND='./gradlew --console=plain --no-daemon --no-build-cache --rerun-tasks migrationTest -x unitTest -x test -x check -x pitest -x dependencyCheckAnalyze' - -# Shell command executed before a non-Docker build and before preparing a Docker workspace. -# default GITTALLY_BUILD_CLEAN_COMMAND='rm -rf build' -export GITTALLY_BUILD_CLEAN_COMMAND='rm -rf build' - -# Report directories copied into artifacts. Separate multiple paths with ';'. -# default GITTALLY_BUILD_ARTEFACT_DIRS='build/reports' -export GITTALLY_BUILD_ARTEFACT_DIRS='build/reports;build/doc' - -# Artifact filename for captured build stdout. -# default GITTALLY_BUILD_STDOUT_LOG='build.stdout.log' -export GITTALLY_BUILD_STDOUT_LOG='build.stdout.log' - -# Artifact filename for captured build stderr. -# default GITTALLY_BUILD_STDERR_LOG='build.stderr.log' -export GITTALLY_BUILD_STDERR_LOG='build.stderr.log' - -# Maximum age for the latest commit on new origin branches. Use h/d suffix. -# default GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE='3d' -# export GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE='3d' - -# ================================================================================ -# Docker build runtime -# -------------------------------------------------------------------------------- - -# Docker image used when --docker is enabled. -# default GITTALLY_BUILD_DOCKER_IMAGE='hsadmin-ng-build-env:latest' -export GITTALLY_BUILD_DOCKER_IMAGE='hsadmin-ng-build-env:latest' - -# Dockerfile used to build the image when it does not exist locally. -# default GITTALLY_BUILD_DOCKERFILE='Jenkins/jenkins-agent/Dockerfile' -export GITTALLY_BUILD_DOCKERFILE='Jenkins/jenkins-agent/Dockerfile' - -# Docker build context used with GITTALLY_BUILD_DOCKERFILE. -# default GITTALLY_BUILD_DOCKER_CONTEXT='Jenkins/jenkins-agent' -export GITTALLY_BUILD_DOCKER_CONTEXT='Jenkins/jenkins-agent' - -# Docker network mode for build containers. -# default GITTALLY_BUILD_DOCKER_NETWORK='host' -export GITTALLY_BUILD_DOCKER_NETWORK='host' - -# Command run inside the build container to verify Docker access. -# default GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND='docker version' -export GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND='docker version' - -# Additional environment assignments passed to the build container, separated by spaces. -# default GITTALLY_BUILD_DOCKER_ENV='TESTCONTAINERS_RYUK_DISABLED=true' -export GITTALLY_BUILD_DOCKER_ENV='TESTCONTAINERS_RYUK_DISABLED=true' - -# Java tool options added for Docker and Testcontainers defaults. -# default GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS='-Ddocker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy -Dtestcontainers.docker.socket.override=/var/run/docker.sock' -export GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS='-Ddocker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy -Dtestcontainers.docker.socket.override=/var/run/docker.sock' - -# ================================================================================ -# Artifact server -# -------------------------------------------------------------------------------- - -# Preferred HTTP port for serving archived build artifacts. -# default GITTALLY_ARTIFACT_SERVER_PORT='18080' -export GITTALLY_ARTIFACT_SERVER_PORT='18080' - -# Bind address for the artifact HTTP server. -# default GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS='0.0.0.0' -export GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS='0.0.0.0' - - -# Public base URL used for artifact links and Gitea status target URLs. -# default GITTALLY_ARTIFACT_PUBLIC_BASE_URL='https://ci.example.org/' -export GITTALLY_ARTIFACT_PUBLIC_BASE_URL='https://vm2176.hostsharing.net/' - -# Retained builds per branch. Use a count, or h/d suffix for age based retention. -# default GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH='3' -export GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH='3' - -# ================================================================================ -# Nginx and certificates -# -------------------------------------------------------------------------------- - -# Public server name for the nginx and certificate setup. -# default GITTALLY_ARTIFACT_NGINX_SERVER_NAME='ci.example.org' -export GITTALLY_ARTIFACT_NGINX_SERVER_NAME='vm2176.hostsharing.net' - -# Host HTTP port published by the nginx container. -# default GITTALLY_ARTIFACT_NGINX_HTTP_PORT='8080' -export GITTALLY_ARTIFACT_NGINX_HTTP_PORT='8080' - -# Host HTTPS port published by the nginx container. -# default GITTALLY_ARTIFACT_NGINX_HTTPS_PORT='8443' -export GITTALLY_ARTIFACT_NGINX_HTTPS_PORT='8443' - -# Host name nginx uses to reach the artifact HTTP server. -# default GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST='ci.example.org' -export GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST='vm2176.hostsharing.net' - -# Docker container name for the nginx reverse proxy. -# default GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME='gittally-nginx-example-repo' -export GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME='gittally-nginx-hsadmin-ng' - -# Persistent state directory for nginx config, logs, and certificate data. -# default GITTALLY_ARTIFACT_NGINX_STATE_DIR=${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/ -# export GITTALLY_ARTIFACT_NGINX_STATE_DIR='' - -# Email address used when registering Lets Encrypt certificates. -# default GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL='admin@example.org' -export GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL='' - - -# ================================================================================ -# Legal -# -------------------------------------------------------------------------------- - -# URL for the Impressum (Legal Disclosure) link in the footer. -# default GITTALLY_IMPRESSUM_URL='https://example.org/imprint.html' -export GITTALLY_IMPRESSUM_URL='https://michael.hoennig.de/imprint.html' - -# ================================================================================ -# Auto builds -# -------------------------------------------------------------------------------- - -# Colon-separated list of branches to rebuild automatically. Leave empty to disable auto builds. -# default GITTALLY_AUTO_BUILD_BRANCHES='' -export GITTALLY_AUTO_BUILD_BRANCHES='master;main;mihoe/introduce-gitTally-ci' - -# Semicolon-separated list of UTC times (HH:MM) at which auto builds are triggered, e.g. 02:00:08:00:14:00:20:00. -# default GITTALLY_AUTO_BUILD_TIMES='02:00' -export GITTALLY_AUTO_BUILD_TIMES='03:30' - -# ================================================================================ -# Gitea -# -------------------------------------------------------------------------------- - -# Base URL of the Gitea instance. -# default GITTALLY_GITEA_BASE_URL='https://git.example.org' -export GITTALLY_GITEA_BASE_URL='https://dev.hostsharing.net' - -# Gitea repository owner. -# default GITTALLY_GITEA_OWNER='example-owner' -export GITTALLY_GITEA_OWNER='hostsharing' - -# Gitea repository name. -# default GITTALLY_GITEA_REPO='example-repo' -export GITTALLY_GITEA_REPO='hs.hsadmin.ng' - -# Required HTTPS git username used with the Gitea token. -# default GITTALLY_GITEA_GIT_USERNAME='example-user' -# export GITTALLY_GITEA_GIT_USERNAME='' - -# Required token used for Gitea commit statuses and HTTPS git authentication. -# default GITTALLY_GITEA_TOKEN='' -# export GITTALLY_GITEA_TOKEN='' - -# Gitea commit status context published by GitTally. -# default GITTALLY_GITEA_STATUS_CONTEXT='GitTally' -export GITTALLY_GITEA_STATUS_CONTEXT='GitTally' - diff --git a/legacy/gitTally b/legacy/gitTally deleted file mode 100755 index b155c80..0000000 --- a/legacy/gitTally +++ /dev/null @@ -1,5970 +0,0 @@ -#!/usr/bin/env bash -# DEPRECATED: This script has been replaced by the Kotlin/Spring application in this repository. -# See docs/migration-from-legacy.md for the migration guide; the script is kept only as a behavioral reference. -# -# A small and opinionated continuous integration tool for projects that do not have the hardware budget or operational staff for large CI/CD systems. -# Made to run locally or in Designed for Hostsharing Container Server environments with Docker (Podman not tested yet). -# -# Create the config file with (--env), edit and source, -# then run the script in the root of a git working tree, e.g. with --install. -# The script waits for new commits on any branch on origin, then checks it out, and runs a command to build+test the branch. -# Call with -h or --help for more details. -# -# This script was mostly vibe-coded to replace the clumsy configuration by code of Jenkins. -# -# MIT License -# -# Copyright (c) 2026 Michael Hönnig -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -script_version="0.7.8" -script_path=$(realpath "${BASH_SOURCE[0]}") -script_name=$(basename "${BASH_SOURCE[0]}") -tool_name="GitTally" -installed_script_path= - -# Ensure consistent output from system tools (e.g., date, df, awk, sort) across different environments. -export LC_ALL=C -repo_root= -systemd_unit_name="gitTally.service" -monitor_generation=$(date +%s) - -default_build_command='./gradlew --console=plain --no-daemon --no-build-cache --rerun-tasks test' -default_build_clean_command='rm -rf build' -default_build_artefact_dirs='build/reports' -default_build_stdout_log='build.stdout.log' -default_build_stderr_log='build.stderr.log' -default_build_docker_image='hsadmin-ng-build-env:latest' -default_build_dockerfile='Jenkins/jenkins-agent/Dockerfile' -default_build_docker_context='Jenkins/jenkins-agent' -default_build_docker_network='host' -default_build_docker_preflight_command='docker version' -default_build_docker_env='TESTCONTAINERS_RYUK_DISABLED=false' -default_build_docker_java_tool_options='-Ddocker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy -Dtestcontainers.docker.socket.override=/var/run/docker.sock' -default_new_branch_commit_max_age='5d' -default_auto_build_times='02:00' -default_gitea_status_context='GitTally' -default_gitea_base_url='https://git.example.org' -default_gitea_owner='example-owner' -default_gitea_repo='example-repo' -default_gitea_git_username='example-user' -default_artifact_public_base_url='https://ci.example.org/' -default_impressum_url='https://example.org/imprint.html' -default_artifact_nginx_server_name='ci.example.org' -default_artifact_nginx_upstream_host='ci.example.org' -default_artifact_nginx_container_name='gittally-nginx-example-repo' -default_artifact_letsencrypt_email='admin@example.org' - -config_file_var() { - local name="$1" - - printf 'GITTALLY_CONFIG_%s' "$name" -} - -load_repo_config() { - local config_file - local before_vars - local after_vars - local env_name - local config_name - local backup_name - - repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [ -z "$repo_root" ]; then - return 0 - fi - - config_file="$repo_root/.gitTally" - if [ ! -f "$config_file" ]; then - return 0 - fi - - before_vars=$(compgen -v GITTALLY_ | sort) - while IFS= read -r env_name; do - if [ -z "$env_name" ]; then - continue - fi - backup_name="__gittally_env_backup_$env_name" - printf -v "$backup_name" '%s' "${!env_name}" - done <<<"$before_vars" - - set -a - # shellcheck source=/dev/null - . "$config_file" - set +a - after_vars=$(compgen -v GITTALLY_ | sort) - - while IFS= read -r env_name; do - if [ -z "$env_name" ]; then - continue - fi - config_name=$(config_file_var "$env_name") - printf -v "$config_name" '%s' "${!env_name}" - if grep -Fxq "$env_name" <<<"$before_vars"; then - backup_name="__gittally_env_backup_$env_name" - printf -v "$env_name" '%s' "${!backup_name}" - unset "$backup_name" - else - unset "$env_name" - fi - done <<<"$after_vars" -} - -config_value() { - local primary_name="$1" - local fallback_name="$2" - local default_value="$3" - local config_name - - if [ -n "$primary_name" ] && [ -n "${!primary_name+x}" ]; then - printf '%s' "${!primary_name}" - elif [ -n "$fallback_name" ] && [ -n "${!fallback_name+x}" ]; then - printf '%s' "${!fallback_name}" - elif [ -n "$primary_name" ]; then - config_name=$(config_file_var "$primary_name") - if [ -n "${!config_name+x}" ]; then - printf '%s' "${!config_name}" - else - printf '%s' "$default_value" - fi - else - printf '%s' "$default_value" - fi -} - -branch_config_value() { - local primary_name="$1" - local fallback_name="$2" - local checkout_repo_root - local config_file - local value_file - local status - - checkout_repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1 - config_file="$checkout_repo_root/.gitTally" - if [ ! -f "$config_file" ]; then - return 1 - fi - - value_file=$(mktemp "${TMPDIR:-/tmp}/gittally-config-value.XXXXXX") || return 1 - ( - unset "$primary_name" - if [ -n "$fallback_name" ]; then - unset "$fallback_name" - fi - set -a - # shellcheck source=/dev/null - . "$config_file" >/dev/null - set +a - if [ -n "${!primary_name+x}" ]; then - printf '%s' "${!primary_name}" >"$value_file" - elif [ -n "$fallback_name" ] && [ -n "${!fallback_name+x}" ]; then - printf '%s' "${!fallback_name}" >"$value_file" - else - exit 1 - fi - ) - status=$? - - if [ "$status" -eq 0 ]; then - cat "$value_file" - fi - rm -f "$value_file" - return "$status" -} - -install_target_dir() { - local target_dir - - target_dir=$(config_value GITTALLY_INSTALL_DIR "" "$HOME/bin") - if [ -z "$target_dir" ]; then - echo "ERROR: GITTALLY_INSTALL_DIR must not be empty." >&2 - return 1 - fi - - printf '%s' "$target_dir" -} - -systemd_quote() { - local value="$1" - - value="${value//\\/\\\\}" - value="${value//\"/\\\"}" - value="${value//%/%%}" - printf '"%s"' "$value" -} - -systemd_path() { - local value="$1" - - value="${value//%/%%}" - printf '%s' "$value" -} - -generate_systemd_config() { - local target_dir="$1" - local target_path="$2" - local service_path="$target_dir/$systemd_unit_name" - local env_path="$target_dir/gitTally.env" - local working_dir="${repo_root:-$(pwd)}" - local service_description="GitTally CI for $(basename "$working_dir")" - - cat >"$service_path" <"$env_path" </dev/null | \ - while IFS= read -r line; do - echo " | $line" - [[ "$line" == *"$tool_name version"*": service ready"* ]] && break - done || true -} - -run_systemd_action() { - local action="$1" - - case "$action" in - start|stop|status|enable|disable) - systemctl --user "$action" "$systemd_unit_name" - ;; - reload) - systemctl --user daemon-reload - systemctl --user restart "$systemd_unit_name" - ;; - log) - journalctl --user -u "$systemd_unit_name" -b --no-pager - ;; - watch) - journalctl --user -u "$systemd_unit_name" -f - ;; - *) - echo "ERROR: unknown systemd action: $action" >&2 - echo "Supported actions: start, stop, reload, status, log, watch, enable, disable." >&2 - return 1 - ;; - esac -} - -generate_update_script() { - local target_dir="$1" - local target_path="$2" - local install_branch="$3" - local update_script_path="$target_dir/gitTally-update" - local env_path="$target_dir/gitTally.env" - local working_dir="${repo_root:-$(pwd)}" - - cat >"$update_script_path" <&2 - exit 1 -fi - -cd "\$repo_root" -git switch "\$install_branch" -tools/gitTally --pull --install --systemd -EOF - chmod 700 "$update_script_path" - echo "generated $update_script_path" -} - -install_to_bin() { - local target_dir - local target_path - local install_branch - - target_dir=$(install_target_dir) || exit 1 - mkdir -p "$target_dir" - target_dir=$(realpath "$target_dir") - target_path="$target_dir/$script_name" - cp "$script_path" "$target_path" - chmod +x "$target_path" - install_branch=$(git -C "$(dirname "$script_path")" branch --show-current 2>/dev/null) - if [ -z "$install_branch" ]; then - install_branch="detached HEAD" - fi - generate_systemd_config "$target_dir" "$target_path" - generate_update_script "$target_dir" "$target_path" "$install_branch" - echo "installed $target_path from branch: $install_branch" - installed_script_path="$target_path" -} - -shell_quote() { - printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" -} - -origin_url() { - git remote get-url origin 2>/dev/null || true -} - -detect_git_username_from_origin_url() { - local origin_url - local authority - - origin_url=$(origin_url) - authority="${origin_url#*://}" - authority="${authority%%/*}" - if [[ "$authority" == *@* ]]; then - echo "${authority%@*}" - fi -} - -detect_gitea_repo_from_origin_url() { - local origin_url - local path - - origin_url=$(origin_url) - if [ -z "$origin_url" ]; then - return 0 - fi - - if [[ "$origin_url" =~ ^https?:// ]]; then - if [ -z "$gitea_base_url" ]; then - gitea_base_url=$(printf '%s' "$origin_url" | sed -E 's#^(https?://)([^/@]+@)?([^/]+)/.*#\1\3#') - fi - path=$(printf '%s' "$origin_url" | sed -E 's#^https?://[^/]+/##') - elif [[ "$origin_url" == git@*:* ]]; then - if [ -z "$gitea_base_url" ]; then - gitea_base_url="https://${origin_url#git@}" - gitea_base_url="${gitea_base_url%%:*}" - fi - path="${origin_url#*:}" - else - return 0 - fi - - path="${path%.git}" - if [ -z "$gitea_owner" ]; then - gitea_owner="${path%%/*}" - fi - if [ -z "$gitea_repo" ] && [[ "$path" == */* ]]; then - gitea_repo="${path#*/}" - fi -} - -print_env_section() { - local title="$1" - - printf '\n' - printf '# %s\n' "================================================================================" - printf '# %s\n' "$title" - printf '# %s\n' "--------------------------------------------------------------------------------" -} - -is_sensitive_var_name() { - case "$1" in - *_TOKEN*|*_SECRET*|*_PASSWORD*|*_APIKEY*|*_PASSKEY*|*_USERNAME*) return 0 ;; - esac - return 1 -} - -print_env_var() { - local name="$1" - local value="$2" - local comment="$3" - local default_value="${4:-}" - local display_value="$value" - - if is_sensitive_var_name "$name" && [ -n "$value" ]; then - display_value='' - fi - - printf '\n' - printf '# %s\n' "$comment" - printf '# default %s=%s\n' "$name" "$(shell_quote "$default_value")" - printf 'export %s=%s\n' "$name" "$(shell_quote "$display_value")" -} - -print_env_optional_var() { - local name="$1" - local value="$2" - local comment="$3" - local default_value="${4:-}" - local display_value="$value" - - if is_sensitive_var_name "$name" && [ -n "$value" ]; then - display_value='secret-value-hidden' - fi - - printf '\n' - printf '# %s\n' "$comment" - printf '# default %s=%s\n' "$name" "$(shell_quote "$default_value")" - if [ -n "$value" ]; then - printf '# resolved %s=%s\n' "$name" "$(shell_quote "$display_value")" - fi - printf '# export %s=%s\n' "$name" "$(shell_quote "")" -} - -print_env() { - local install_dir - local build_command - local build_clean_command - local build_artefact_dirs - local build_stdout_log - local build_stderr_log - local build_docker_image - local build_dockerfile - local build_docker_context - local build_docker_network - local build_docker_preflight_command - local build_docker_env - local build_docker_java_tool_options - local new_branch_commit_max_age - local artifact_server_port - local artifact_server_bind_address - local artifact_public_base_url - local artifact_build_retention_per_branch - local artifact_nginx_server_name - local artifact_nginx_http_port - local artifact_nginx_https_port - local artifact_nginx_upstream_host - local artifact_nginx_container_name - local artifact_nginx_state_dir - local artifact_letsencrypt_email - local impressum_url - local gitea_token - local gitea_status_context - local default_artifact_nginx_state_dir - local default_repository_key - local default_repository_simple_name - - install_dir=$(config_value GITTALLY_INSTALL_DIR "" "$HOME/bin") - build_command=$(config_value GITTALLY_BUILD_COMMAND "" "$default_build_command") - build_clean_command=$(config_value GITTALLY_BUILD_CLEAN_COMMAND "" "$default_build_clean_command") - build_artefact_dirs=$(config_value GITTALLY_BUILD_ARTEFACT_DIRS "" "$default_build_artefact_dirs") - build_stdout_log=$(config_value GITTALLY_BUILD_STDOUT_LOG "" "$default_build_stdout_log") - build_stderr_log=$(config_value GITTALLY_BUILD_STDERR_LOG "" "$default_build_stderr_log") - build_docker_image=$(config_value GITTALLY_BUILD_DOCKER_IMAGE HSADMIN_NG_BUILD_IMAGE "$default_build_docker_image") - build_dockerfile=$(config_value GITTALLY_BUILD_DOCKERFILE "" "$default_build_dockerfile") - build_docker_context=$(config_value GITTALLY_BUILD_DOCKER_CONTEXT "" "$default_build_docker_context") - build_docker_network=$(config_value GITTALLY_BUILD_DOCKER_NETWORK HSADMIN_NG_BUILD_NETWORK "$default_build_docker_network") - build_docker_preflight_command=$(config_value GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "" "$default_build_docker_preflight_command") - build_docker_env=$(config_value GITTALLY_BUILD_DOCKER_ENV "" "$default_build_docker_env") - build_docker_java_tool_options=$(config_value GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "" "$default_build_docker_java_tool_options") - new_branch_commit_max_age=$(config_value GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE "" "$default_new_branch_commit_max_age") - auto_build_branches=$(config_value GITTALLY_AUTO_BUILD_BRANCHES "" "") - auto_build_times=$(config_value GITTALLY_AUTO_BUILD_TIMES "" "$default_auto_build_times") - - artifact_server_port=$(config_value GITTALLY_ARTIFACT_SERVER_PORT HSADMIN_NG_ARTIFACT_SERVER_PORT 18080) - artifact_server_bind_address=$(config_value GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS HSADMIN_NG_ARTIFACT_SERVER_BIND_ADDRESS 0.0.0.0) - artifact_http_server_port="$artifact_server_port" - artifact_http_server_bind_address="$artifact_server_bind_address" - artifact_nginx_server_name=$(config_value GITTALLY_ARTIFACT_NGINX_SERVER_NAME HSADMIN_NG_ARTIFACT_NGINX_SERVER_NAME "") - artifact_public_base_url=$(config_value GITTALLY_ARTIFACT_PUBLIC_BASE_URL HSADMIN_NG_ARTIFACT_PUBLIC_BASE_URL "") - artifact_build_retention_per_branch=$(config_value GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH HSADMIN_NG_ARTIFACT_BUILD_RETENTION_PER_BRANCH 3) - artifact_nginx_http_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTP_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTP_PORT 8080) - artifact_nginx_https_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTPS_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTPS_PORT 8443) - artifact_nginx_upstream_host=$(config_value GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST HSADMIN_NG_ARTIFACT_NGINX_UPSTREAM_HOST "") - artifact_nginx_container_name=$(config_value GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME HSADMIN_NG_ARTIFACT_NGINX_CONTAINER_NAME "") - artifact_nginx_state_dir=$(config_value GITTALLY_ARTIFACT_NGINX_STATE_DIR HSADMIN_NG_ARTIFACT_NGINX_STATE_DIR "") - artifact_letsencrypt_email=$(config_value GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL HSADMIN_NG_ARTIFACT_LETSENCRYPT_EMAIL "") - impressum_url=$(config_value GITTALLY_IMPRESSUM_URL "" "$default_impressum_url") - if [ -n "$artifact_public_base_url" ]; then - : - elif [ -n "$artifact_nginx_server_name" ]; then - artifact_public_base_url="https://$artifact_nginx_server_name/" - else - artifact_public_base_url="http://$artifact_server_bind_address:$artifact_server_port/" - fi - if [[ "$artifact_public_base_url" != */ ]]; then - artifact_public_base_url="$artifact_public_base_url/" - fi - - gitea_base_url=$(config_value GITTALLY_GITEA_BASE_URL HSADMIN_NG_GITEA_BASE_URL "") - gitea_owner=$(config_value GITTALLY_GITEA_OWNER HSADMIN_NG_GITEA_OWNER "") - gitea_repo=$(config_value GITTALLY_GITEA_REPO HSADMIN_NG_GITEA_REPO "") - gitea_git_username=$(config_value GITTALLY_GITEA_GIT_USERNAME HSADMIN_NG_GITEA_GIT_USERNAME "") - gitea_token=$(config_value GITTALLY_GITEA_TOKEN HSADMIN_NG_GITEA_TOKEN "") - gitea_status_context=$(config_value GITTALLY_GITEA_STATUS_CONTEXT HSADMIN_NG_GITEA_STATUS_CONTEXT "$default_gitea_status_context") - detect_gitea_repo_from_origin_url - if [ -z "$gitea_git_username" ]; then - gitea_git_username=$(detect_git_username_from_origin_url) - fi - default_repository_key=$(printf '%s' "${repo_root:-$(git rev-parse --show-toplevel)}" | sed 's#[^[:alnum:]._-]#_#g') - default_repository_simple_name=$(basename "${repo_root:-$(git rev-parse --show-toplevel)}") - default_artifact_nginx_state_dir='${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/' - if [ -z "$artifact_nginx_upstream_host" ] && [ -n "$artifact_nginx_server_name" ]; then - artifact_nginx_upstream_host="$artifact_nginx_server_name" - fi - if [ -z "$artifact_nginx_container_name" ]; then - artifact_nginx_container_name="gittally-nginx-$(printf '%s' "$default_repository_simple_name" | sed 's#[^[:alnum:]_.-]#-#g')" - fi - if [ -z "$artifact_nginx_state_dir" ]; then - artifact_nginx_state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/$default_repository_key" - fi - - printf '# Environment for %s version %s\n' "$tool_name" "$script_version" - printf '# Save and source this output before starting the script, for example:\n' - printf '# %s --env > .gittally.env\n' "$script_name" - printf '# . .gittally.env\n' - - print_env_section "Installation" - print_env_var GITTALLY_INSTALL_DIR "$install_dir" 'Target directory used by --install.' "$HOME/bin" - - print_env_section "Build command" - print_env_var GITTALLY_BUILD_COMMAND "$build_command" 'Fallback shell command used if the checked-out branch .gitTally does not define it. The branch name is available as $branch.' "$default_build_command" - print_env_var GITTALLY_BUILD_CLEAN_COMMAND "$build_clean_command" 'Shell command executed before a non-Docker build and before preparing a Docker workspace.' "$default_build_clean_command" - print_env_var GITTALLY_BUILD_ARTEFACT_DIRS "$build_artefact_dirs" "Report directories copied into artifacts. Separate multiple paths with ';'." "$default_build_artefact_dirs" - print_env_var GITTALLY_BUILD_STDOUT_LOG "$build_stdout_log" 'Artifact filename for captured build stdout.' "$default_build_stdout_log" - print_env_var GITTALLY_BUILD_STDERR_LOG "$build_stderr_log" 'Artifact filename for captured build stderr.' "$default_build_stderr_log" - print_env_var GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE "$new_branch_commit_max_age" 'Maximum age for the latest commit on new origin branches. Use h/d suffix.' "$default_new_branch_commit_max_age" - - print_env_section "Docker build runtime" - print_env_var GITTALLY_BUILD_DOCKER_IMAGE "$build_docker_image" 'Docker image used when --docker is enabled.' "$default_build_docker_image" - print_env_var GITTALLY_BUILD_DOCKERFILE "$build_dockerfile" 'Dockerfile used to build the image when it does not exist locally.' "$default_build_dockerfile" - print_env_var GITTALLY_BUILD_DOCKER_CONTEXT "$build_docker_context" 'Docker build context used with GITTALLY_BUILD_DOCKERFILE.' "$default_build_docker_context" - print_env_var GITTALLY_BUILD_DOCKER_NETWORK "$build_docker_network" 'Docker network mode for build containers.' "$default_build_docker_network" - print_env_var GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "$build_docker_preflight_command" 'Command run inside the build container to verify Docker access.' "$default_build_docker_preflight_command" - print_env_var GITTALLY_BUILD_DOCKER_ENV "$build_docker_env" 'Additional environment assignments passed to the build container, separated by spaces.' "$default_build_docker_env" - print_env_var GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "$build_docker_java_tool_options" 'Java tool options added for Docker and Testcontainers defaults.' "$default_build_docker_java_tool_options" - - print_env_section "Artifact server" - print_env_var GITTALLY_ARTIFACT_SERVER_PORT "$artifact_server_port" 'Preferred HTTP port for serving archived build artifacts.' 18080 - print_env_var GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS "$artifact_server_bind_address" 'Bind address for the artifact HTTP server.' 0.0.0.0 - print_env_var GITTALLY_ARTIFACT_PUBLIC_BASE_URL "$artifact_public_base_url" 'Public base URL used for artifact links and Gitea status target URLs.' "$default_artifact_public_base_url" - print_env_var GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH "$artifact_build_retention_per_branch" 'Retained builds per branch. Use a count, or h/d suffix for age based retention.' 3 - - print_env_section "Nginx and certificates" - print_env_var GITTALLY_ARTIFACT_NGINX_SERVER_NAME "$artifact_nginx_server_name" 'Public server name for the nginx and certificate setup.' "$default_artifact_nginx_server_name" - print_env_var GITTALLY_ARTIFACT_NGINX_HTTP_PORT "$artifact_nginx_http_port" 'Host HTTP port published by the nginx container.' 8080 - print_env_var GITTALLY_ARTIFACT_NGINX_HTTPS_PORT "$artifact_nginx_https_port" 'Host HTTPS port published by the nginx container.' 8443 - print_env_var GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST "$artifact_nginx_upstream_host" 'Host name nginx uses to reach the artifact HTTP server.' "$default_artifact_nginx_upstream_host" - print_env_var GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME "$artifact_nginx_container_name" 'Docker container name for the nginx reverse proxy.' "$default_artifact_nginx_container_name" - print_env_optional_var GITTALLY_ARTIFACT_NGINX_STATE_DIR "$artifact_nginx_state_dir" 'Persistent state directory for nginx config, logs, and certificate data.' "$default_artifact_nginx_state_dir" - print_env_var GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL "$artifact_letsencrypt_email" 'Email address used when registering Lets Encrypt certificates.' "$default_artifact_letsencrypt_email" - - print_env_section "Legal" - print_env_var GITTALLY_IMPRESSUM_URL "$impressum_url" 'URL for the Impressum (Legal Disclosure) link in the footer.' "$default_impressum_url" - - print_env_section "Auto builds" - print_env_var GITTALLY_AUTO_BUILD_BRANCHES "$auto_build_branches" "Semicolon-separated list of branches to rebuild automatically. Leave empty to disable auto builds." "" - print_env_var GITTALLY_AUTO_BUILD_TIMES "$auto_build_times" 'Semicolon-separated list of UTC times (HH:MM) at which auto builds are triggered, e.g. 02:00;08:00;14:00;20:00.' "$default_auto_build_times" - - print_env_section "Gitea" - print_env_var GITTALLY_GITEA_BASE_URL "$gitea_base_url" 'Base URL of the Gitea instance.' "$default_gitea_base_url" - print_env_var GITTALLY_GITEA_OWNER "$gitea_owner" 'Gitea repository owner.' "$default_gitea_owner" - print_env_var GITTALLY_GITEA_REPO "$gitea_repo" 'Gitea repository name.' "$default_gitea_repo" - print_env_var GITTALLY_GITEA_GIT_USERNAME "$gitea_git_username" 'HTTPS git username used with the Gitea token. (required)' "$default_gitea_git_username" - print_env_var GITTALLY_GITEA_TOKEN "$gitea_token" 'Token used for Gitea commit statuses and HTTPS git authentication. (required)' "" - print_env_var GITTALLY_GITEA_STATUS_CONTEXT "$gitea_status_context" 'Gitea commit status context published by GitTally.' "$default_gitea_status_context" -} - -if [ "$1" = "--env" ]; then - load_repo_config - print_env - exit 0 -fi - -has_arg() { - local wanted="$1" - shift - local arg - - for arg in "$@"; do - if [ "$arg" = "$wanted" ]; then - return 0 - fi - done - return 1 -} - -is_repo_safe_command() { - local arg - - if has_arg --install "$@" || has_arg --pull "$@" || has_arg --help "$@" || has_arg -h "$@"; then - return 0 - fi - for arg in "$@"; do - case "$arg" in - --systemd|--systemd:*) - return 0 - ;; - esac - done - return 1 -} - -script_repo_root=$(git -C "$(dirname "$script_path")" rev-parse --show-toplevel 2>/dev/null || true) -if [ -n "$script_repo_root" ] && - [ "${GITTALLY_BIN_FORWARD:-${HSADMIN_NG_GIT_WATCH_ORIGIN_AND_TEST_BIN_FORWARD:-}}" != true ] && - ! is_repo_safe_command "$@"; then - echo "ERROR: $tool_name must not be started from within its repository." >&2 - echo "Only --pull and --install are allowed from within the repository." >&2 - echo "Install it first: $script_path --install" >&2 - echo "Then start it from: ${GITTALLY_INSTALL_DIR:-$HOME/bin}/$script_name" >&2 - exit 1 -fi -unset HSADMIN_NG_GIT_WATCH_ORIGIN_AND_TEST_BIN_FORWARD -unset GITTALLY_BIN_FORWARD - -reported_skipped_new_branches=$(mktemp "${TMPDIR:-/tmp}/gittally-skipped.XXXXXX") -active_build_branch= -active_build_artifact_key= -active_build_started_at= -active_build_pid= -active_build_cancelled=false -git_askpass_file= -artifact_nginx_container_started=false -artifact_nginx_container_id= - -cleanup() { - local ended_at - local ended_timestamp - local build_duration - - if [ -n "${active_build_pid:-}" ]; then - echo "stopping active build process: $active_build_pid" - terminate_process_tree "$active_build_pid" TERM - sleep 2 - if kill -0 "$active_build_pid" >/dev/null 2>&1; then - terminate_process_tree "$active_build_pid" KILL - fi - wait "$active_build_pid" 2>/dev/null || true - active_build_pid= - cleanup_stale_build_runtime || true - fi - clear_build_cancel_request || true - if [ -n "${active_build_branch:-}" ]; then - ended_at=$(date +%s) - ended_timestamp=$(date -Iseconds) - build_duration=$(format_build_duration "$((ended_at - active_build_started_at))") - echo "marking interrupted build: $active_build_branch" - record_build_result "$active_build_branch" interrupted "$build_duration" "$ended_timestamp" "$active_build_artifact_key" || true - write_current_build_page "$active_build_branch" interrupted "$ended_timestamp" || true - active_build_branch= - active_build_artifact_key= - active_build_started_at= - fi - if [ -n "$git_askpass_file" ]; then - rm -f "$git_askpass_file" - fi - rm -f "$reported_skipped_new_branches" - if [ -n "$artifact_http_server_pid" ]; then - kill "$artifact_http_server_pid" >/dev/null 2>&1 || true - wait "$artifact_http_server_pid" 2>/dev/null || true - fi - if [ "${artifact_nginx_container_started:-false}" = true ] && [ -n "${artifact_nginx_container_id:-}" ]; then - docker rm -f "$artifact_nginx_container_id" >/dev/null 2>&1 || true - artifact_nginx_container_started=false - artifact_nginx_container_id= - fi -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM -trap 'exit 129' HUP - -. .aliases - -load_repo_config - -use_docker_build=false -use_artifact_http_server=false -use_artifact_nginx=false -open_artifact_frontend=false -pull_current_branch=false -install_after_pull=false -install_systemd_after_install=false -systemd_command_given=false -systemd_action= -retry_failed_builds_requested=false -stay_on_current_branch=false -environment_build_command=${GITTALLY_BUILD_COMMAND-"$default_build_command"} -build_command=$(config_value GITTALLY_BUILD_COMMAND "" "$default_build_command") -build_clean_command=$(config_value GITTALLY_BUILD_CLEAN_COMMAND "" "$default_build_clean_command") -build_artefact_dirs=$(config_value GITTALLY_BUILD_ARTEFACT_DIRS "" "$default_build_artefact_dirs") -build_stdout_log=$(config_value GITTALLY_BUILD_STDOUT_LOG "" "$default_build_stdout_log") -build_stderr_log=$(config_value GITTALLY_BUILD_STDERR_LOG "" "$default_build_stderr_log") -new_branch_commit_max_age=$(config_value GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE "" "$default_new_branch_commit_max_age") -auto_build_branches=$(config_value GITTALLY_AUTO_BUILD_BRANCHES "" "") -auto_build_times=$(config_value GITTALLY_AUTO_BUILD_TIMES "" "$default_auto_build_times") -docker_build_image=$(config_value GITTALLY_BUILD_DOCKER_IMAGE HSADMIN_NG_BUILD_IMAGE "$default_build_docker_image") -docker_build_dockerfile=$(config_value GITTALLY_BUILD_DOCKERFILE "" "$default_build_dockerfile") -docker_build_context=$(config_value GITTALLY_BUILD_DOCKER_CONTEXT "" "$default_build_docker_context") -docker_build_network=$(config_value GITTALLY_BUILD_DOCKER_NETWORK HSADMIN_NG_BUILD_NETWORK "$default_build_docker_network") -docker_build_preflight_command=$(config_value GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "" "$default_build_docker_preflight_command") -docker_build_env=$(config_value GITTALLY_BUILD_DOCKER_ENV "" "$default_build_docker_env") -docker_build_java_tool_options=$(config_value GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "" "$default_build_docker_java_tool_options") -bootstrap_docker_build_image="$docker_build_image" -bootstrap_docker_build_dockerfile="$docker_build_dockerfile" -bootstrap_docker_build_context="$docker_build_context" -bootstrap_docker_build_network="$docker_build_network" -bootstrap_docker_build_preflight_command="$docker_build_preflight_command" -bootstrap_docker_build_env="$docker_build_env" -bootstrap_docker_build_java_tool_options="$docker_build_java_tool_options" -artifact_http_server_port=$(config_value GITTALLY_ARTIFACT_SERVER_PORT HSADMIN_NG_ARTIFACT_SERVER_PORT 18080) -artifact_http_server_bind_address=$(config_value GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS HSADMIN_NG_ARTIFACT_SERVER_BIND_ADDRESS 0.0.0.0) -artifact_public_base_url=$(config_value GITTALLY_ARTIFACT_PUBLIC_BASE_URL HSADMIN_NG_ARTIFACT_PUBLIC_BASE_URL "") -artifact_build_retention_per_branch=$(config_value GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH HSADMIN_NG_ARTIFACT_BUILD_RETENTION_PER_BRANCH 3) -artifact_nginx_server_name=$(config_value GITTALLY_ARTIFACT_NGINX_SERVER_NAME HSADMIN_NG_ARTIFACT_NGINX_SERVER_NAME "") -artifact_nginx_http_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTP_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTP_PORT 8080) -artifact_nginx_https_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTPS_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTPS_PORT 8443) -artifact_nginx_upstream_host=$(config_value GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST HSADMIN_NG_ARTIFACT_NGINX_UPSTREAM_HOST "") -artifact_nginx_container_name=$(config_value GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME HSADMIN_NG_ARTIFACT_NGINX_CONTAINER_NAME "") -artifact_nginx_state_dir=$(config_value GITTALLY_ARTIFACT_NGINX_STATE_DIR HSADMIN_NG_ARTIFACT_NGINX_STATE_DIR "") -artifact_letsencrypt_email=$(config_value GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL HSADMIN_NG_ARTIFACT_LETSENCRYPT_EMAIL "") -impressum_url=$(config_value GITTALLY_IMPRESSUM_URL "" "$default_impressum_url") -artifact_http_server_pid= -artifact_http_server_local_url= -artifact_http_server_url= -gitea_base_url=$(config_value GITTALLY_GITEA_BASE_URL HSADMIN_NG_GITEA_BASE_URL "") -gitea_owner=$(config_value GITTALLY_GITEA_OWNER HSADMIN_NG_GITEA_OWNER "") -gitea_repo=$(config_value GITTALLY_GITEA_REPO HSADMIN_NG_GITEA_REPO "") -gitea_git_username=$(config_value GITTALLY_GITEA_GIT_USERNAME HSADMIN_NG_GITEA_GIT_USERNAME "") -gitea_token=$(config_value GITTALLY_GITEA_TOKEN HSADMIN_NG_GITEA_TOKEN "") -gitea_status_context=$(config_value GITTALLY_GITEA_STATUS_CONTEXT HSADMIN_NG_GITEA_STATUS_CONTEXT "$default_gitea_status_context") -branches_to_build=() - -usage() { - echo "$tool_name - a simple Gitea branch CI tally." - echo "Checks for branches on origin, pulls, builds, and records their states, logs, reports, and artifacts." - echo "Usage: $0 [--install] [--systemd] [--env] [--pull] [--docker] [--http] [--nginx] [--retry] [--stay] [--open] [branch ...]" - echo - echo "With --install, this script and systemd config files are installed to GITTALLY_INSTALL_DIR," - echo " defaulting to the current user's ~/bin directory." - echo " It also installs gitTally-update to pull, reinstall, restart, and watch the service." - echo "With --systemd, --install also installs/reloads the generated systemd user service." - echo "Systemd actions: --systemd:start, --systemd:stop, --systemd:reload, --systemd:status," - echo " --systemd:log, --systemd:watch, --systemd:enable, --systemd:disable." - echo "With --env, supported environment variables with detected defaults are printed." - echo "With --pull, the current branch is pulled from origin using the configured Gitea token." - echo "If a .gitTally file exists in the repository root, its non-secret config values are loaded." - echo "Shell environment variables override values from .gitTally." - echo "When combined, --pull runs before --install." - echo "Existing local branches and recent new origin branches are watched." - echo "New origin branches are watched if they do not exist locally yet" - echo " and whose latest origin commit is not older than GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE." - echo "With --docker, the build command runs in the configured Docker image." - echo "With --http, an HTTP server for archived build artifacts is started;" - echo " failed builds continue without asking to open the artifact index." - echo "With --nginx, --http is implied and a Docker nginx reverse proxy with Let's Encrypt is configured and started." - echo "With --open, --http is implied and the frontend is opened in the local browser once the server is running." - echo "With --retry, branches whose latest build failed are built again even without new commits." - echo "With --stay, only the currently checked-out branch is built; other branches stay pending." - echo - echo "Set GITTALLY_BUILD_COMMAND as fallback build command if the checked-out branch .gitTally does not define it;" - echo " branch is exported for shell expansion." - echo " Default: $default_build_command" - echo "Set GITTALLY_BUILD_CLEAN_COMMAND to override the pre-build clean command; default: $default_build_clean_command" - echo "Set GITTALLY_BUILD_ARTEFACT_DIRS to override report directories copied into artifacts; separate paths with ';'." - echo "Set GITTALLY_BUILD_STDOUT_LOG and GITTALLY_BUILD_STDERR_LOG to override persisted build log names." - echo "Set GITTALLY_BUILD_DOCKER_IMAGE to override the Docker image name." - echo "Set GITTALLY_BUILD_DOCKERFILE and GITTALLY_BUILD_DOCKER_CONTEXT to override image build inputs." - echo "Set GITTALLY_BUILD_DOCKER_NETWORK to override the Docker network mode; default: host." - echo "Set GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND to override the container Docker access check." - echo "Set GITTALLY_BUILD_DOCKER_ENV for additional Docker build-container env assignments, separated by spaces." - echo "Set GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS to override Testcontainers Java defaults." - echo "Set GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE to override how long new origin branches are considered recent;" - echo " use a value ending in h/d; default: $default_new_branch_commit_max_age." - echo "Legacy HSADMIN_NG_BUILD_IMAGE and HSADMIN_NG_BUILD_NETWORK are still accepted as fallbacks." - echo - echo "Set GITTALLY_ARTIFACT_SERVER_PORT to override the preferred artifact server port; default: 18080." - echo "Set GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS to override the artifact server bind address; default: 0.0.0.0." - echo "Set GITTALLY_ARTIFACT_PUBLIC_BASE_URL to override public artifact URLs, for example behind nginx." - echo "Set GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH to override retained builds per branch;" - echo " use a number for count, or a value ending in h/d for age; default: 3." - echo "Set GITTALLY_ARTIFACT_NGINX_SERVER_NAME to configure the nginx/Let's Encrypt hostname." - echo "Set GITTALLY_ARTIFACT_NGINX_HTTP_PORT and GITTALLY_ARTIFACT_NGINX_HTTPS_PORT to override nginx host ports; defaults: 8080/8443." - echo "Set GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST to override the host nginx uses for the artifact HTTP server." - echo "Set GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME to override the Docker container name." - echo "Set GITTALLY_ARTIFACT_NGINX_STATE_DIR to override the persistent nginx/certbot state directory." - echo "Set GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL to register the certificate with an email address." - echo "Legacy HSADMIN_NG_ARTIFACT_* variables are still accepted as fallbacks." - echo - echo "Set GITTALLY_GITEA_TOKEN to publish/read build statuses and authenticate HTTPS git commands via Gitea; required for startup." - echo "Set GITTALLY_GITEA_GIT_USERNAME to authenticate HTTPS git commands with GITTALLY_GITEA_TOKEN; required for startup." - echo "Set GITTALLY_GITEA_BASE_URL, GITTALLY_GITEA_OWNER, and GITTALLY_GITEA_REPO to override origin-based detection." - echo "Set GITTALLY_GITEA_STATUS_CONTEXT to override the status context; default: GitTally." - echo "Legacy HSADMIN_NG_GITEA_* variables are still accepted as fallbacks." - echo - echo "Branch arguments may be full names or unique name parts matching local or origin branches." - echo "If multiple branches match, the script asks which one to use." -} - -normalize_branch_name() { - local branch="$1" - - branch="${branch#"${branch%%[![:space:]]*}"}" - branch="${branch%"${branch##*[![:space:]]}"}" - branch="${branch#\* }" - branch="${branch#remotes/}" - branch="${branch#remote/}" - branch="${branch#origin/}" - - echo "$branch" -} - -branch_candidates() { - { - git for-each-ref --format='%(refname:strip=2)' refs/heads - git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin | grep -v '^HEAD$' - } | awk '!seen[$0]++' -} - -resolve_branch_name() { - local branch_part="$1" - local choice - local index - local selected - local matches=() - - mapfile -t matches < <(branch_candidates | grep -F -- "$branch_part") - - if [ "${#matches[@]}" -eq 0 ]; then - echo "ERROR: no local or origin branch matches '$branch_part'." >&2 - return 1 - fi - - if [ "${#matches[@]}" -eq 1 ]; then - echo "${matches[0]}" - return 0 - fi - - echo "Multiple branches match '$branch_part':" >&2 - index=1 - for selected in "${matches[@]}"; do - echo " $index) $selected" >&2 - index=$((index + 1)) - done - - while true; do - echo -n "Select branch [1-${#matches[@]}]: " >&2 - if ! read -r choice; then - echo "ERROR: no branch selected." >&2 - return 1 - fi - if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#matches[@]}" ]; then - echo "${matches[$((choice - 1))]}" - return 0 - fi - echo "ERROR: invalid selection: $choice" >&2 - done -} - -switch_to_branch() { - local branch="$1" - - echo "checking out branch: $branch" - if git show-ref --quiet --verify "refs/heads/$branch"; then - echo "Branch $branch already exists. Checking it out." - git switch "$branch" || return 1 - else - echo "Creating and checking out new branch: $branch" - git switch --track -c "$branch" "refs/remotes/origin/$branch" || return 1 - fi -} - -pull_branch_if_possible() { - local branch="$1" - - if git show-ref --quiet --verify "refs/remotes/origin/$branch"; then - git_with_gitea_token fetch origin "$branch" || return 1 - git reset --hard "origin/$branch" || return 1 - fi -} - -pull_current_branch_from_origin() { - local branch - - branch=$(git branch --show-current) - if [ -z "$branch" ]; then - echo "ERROR: --pull requires a checked out branch, but HEAD is detached." >&2 - return 1 - fi - - echo "pulling current branch from origin: $branch" - git_with_gitea_token fetch origin "$branch" || return 1 - git reset --hard "origin/$branch" -} - -branch_exists_on_origin() { - local branch="$1" - - git show-ref --quiet --verify "refs/remotes/origin/$branch" -} - -branch_matches_current_worktree_branch() { - local branch="$1" - local current_branch - - if [ "$stay_on_current_branch" != true ]; then - return 0 - fi - - current_branch=$(git branch --show-current) || return 1 - [ -n "$current_branch" ] && [ "$branch" = "$current_branch" ] -} - -validate_stay_on_current_branch() { - local current_branch - - if [ "$stay_on_current_branch" != true ]; then - return 0 - fi - - current_branch=$(git branch --show-current) || return 1 - if [ -z "$current_branch" ]; then - echo "ERROR: --stay requires a checked out branch, but HEAD is detached." >&2 - return 1 - fi - - echo "staying on current branch: $current_branch" -} - -checkout_and_build() { - local branch="$1" - - if ! branch_exists_on_origin "$branch"; then - echo "Branch $branch no longer exists on origin. Skipping build." - return 0 - fi - - if branch_matches_current_worktree_branch "$branch"; then - if [ "$stay_on_current_branch" = true ]; then - echo "staying on current branch: $branch" - else - switch_to_branch "$branch" || return 1 - fi - else - echo "Branch $branch is pending, but --stay keeps this worktree on the current branch." - return 0 - fi - pull_branch_if_possible "$branch" || return 1 - - build_current_checkout -} - -branch_has_new_commits() { - local branch="$1" - local upstream - - if ! git show-ref --quiet --verify "refs/heads/$branch"; then - git show-ref --quiet --verify "refs/remotes/origin/$branch" - return - fi - - upstream=$(git for-each-ref --format='%(upstream)' "refs/heads/$branch") || return 2 - if [ -n "$upstream" ]; then - has_new_commits "refs/heads/$branch" "$upstream" - elif git show-ref --quiet --verify "refs/remotes/origin/$branch"; then - has_new_commits "refs/heads/$branch" "refs/remotes/origin/$branch" - else - return 1 - fi -} - -checkout_requested_branch() { - local branch_arg="$1" - local branch - local branch_has_new_commits_status - local has_remote_updates=false - - branch=$(resolve_branch_name "$branch_arg") || return 1 - if ! branch_exists_on_origin "$branch"; then - echo "Branch $branch no longer exists on origin. Skipping build." - return 0 - fi - - while true; do - if branch_has_new_commits "$branch"; then - has_remote_updates=true - break - fi - branch_has_new_commits_status=$? - if [ "$branch_has_new_commits_status" -eq 1 ]; then - break - fi - echo "checking branch $branch for new commits failed; retrying in 10s ..." >&2 - sleep 10 - retry_fetch_origin - done - - if branch_matches_current_worktree_branch "$branch"; then - if [ "$stay_on_current_branch" = true ]; then - echo "staying on current branch: $branch" - else - switch_to_branch "$branch" || return 1 - fi - else - echo "Branch $branch is pending, but --stay keeps this worktree on the current branch." - return 0 - fi - - if [ "$has_remote_updates" = true ]; then - pull_branch_if_possible "$branch" || return 1 - fi - - if [ "$has_remote_updates" = true ]; then - build_current_checkout - elif branch_has_restartable_build "$branch"; then - echo "Restarting pending, interrupted, or stale running build: $branch" - build_current_checkout - elif [ "$retry_failed_builds_requested" = true ] && branch_has_failed_build "$branch"; then - echo "Retrying failed build: $branch" - build_current_checkout - else - echo "Branch $branch has no new commits. Skipping initial build." - fi -} - -print_build_banner() { - local title="$1" - - echo - printf '%*s\n' 80 '' | tr ' ' '=' - echo "$title" - printf '%*s\n' 80 '' | tr ' ' '-' -} - -open_in_local_browser() { - local label="$1" - local target="$2" - - if command -v xdg-open >/dev/null 2>&1; then - xdg-open "$target" >/dev/null 2>&1 & - elif command -v open >/dev/null 2>&1; then - open "$target" >/dev/null 2>&1 & - elif command -v sensible-browser >/dev/null 2>&1; then - sensible-browser "$target" >/dev/null 2>&1 & - else - echo "Cannot open $label automatically: no browser opener found." - echo "$label: $target" - return 0 - fi - - echo "Opened $label: $target" -} - -open_artifact_index() { - local branch="$1" - local artifact_key="${2:-}" - local artifact_dir - local artifact_index - local artifact_content_index - local artifact_url - local artifact_target - local artifact_display_target - - if [ -z "$artifact_key" ]; then - artifact_key=$(build_artifact_branch_key "$branch") - fi - - artifact_dir=$(build_artifact_dir "$branch" "$artifact_key") - artifact_index="$artifact_dir/index.html" - artifact_content_index=$(build_artifact_index_content_file "$artifact_dir") - if [ ! -f "$artifact_index" ] && [ ! -f "$artifact_content_index" ]; then - echo "Artifact index not found: $artifact_index" - return 0 - fi - if [ -n "$artifact_http_server_url" ]; then - artifact_url="${artifact_http_server_url}branches/$artifact_key/index.html" - artifact_target="$artifact_url" - artifact_display_target="$artifact_url" - elif [ -f "$artifact_index" ]; then - artifact_target="$artifact_index" - artifact_display_target="file://$artifact_target" - else - artifact_target="$artifact_content_index" - artifact_display_target="file://$artifact_target" - fi - - open_in_local_browser "artifact index" "$artifact_display_target" -} - -handle_build_failure_prompt() { - local branch="$1" - local artifact_key="${2:-}" - local choice - - if [ "$use_artifact_http_server" = true ]; then - return 0 - fi - - while true; do - echo - printf "Build failed on branch: %s\n[o]pen artifact index, [ENTER/c]ontinue, e[x]it: " "$branch" - if ! IFS= read -r -n 1 choice; then - echo - return 0 - fi - echo - - case "$choice" in - o|O) - open_artifact_index "$branch" "$artifact_key" - return 0 - ;; - ""|c|C) - return 0 - ;; - x|X|q|Q) - echo "Exiting." - exit 1 - ;; - *) - echo "Please press o, c, or x." - ;; - esac - done -} - -build_results_file() { - git rev-parse --git-path git-watch-origin-and-test/build-results.tsv -} - -auto_builds_state_file() { - git rev-parse --git-path git-watch-origin-and-test/auto-builds.tsv -} - -build_lock_file() { - git rev-parse --git-path git-watch-origin-and-test/build.lock -} - -build_cancel_request_file() { - git rev-parse --git-path git-watch-origin-and-test/cancel-request 2>/dev/null -} - -build_cancel_token_file() { - git rev-parse --git-path git-watch-origin-and-test/cancel-token 2>/dev/null -} - -build_cancel_accepted_file() { - git rev-parse --git-path git-watch-origin-and-test/cancel-accepted 2>/dev/null -} - -new_build_cancel_token() { - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex 24 - elif [ -r /proc/sys/kernel/random/uuid ]; then - sed -n '1p' /proc/sys/kernel/random/uuid - else - printf '%s-%s-%s' "$$" "${RANDOM:-0}" "$(date +%s)" - fi -} - -write_build_cancel_token() { - local token_file - local token_dir - - token_file=$(build_cancel_token_file) - token_dir=$(dirname "$token_file") - mkdir -p "$token_dir" || return 1 - new_build_cancel_token >"$token_file" - chmod 600 "$token_file" 2>/dev/null || true -} - -read_build_cancel_token() { - local token_file - - token_file=$(build_cancel_token_file) - if [ -r "$token_file" ]; then - sed -n '1p' "$token_file" - fi -} - -clear_build_cancel_request() { - local request_file - local token_file - local accepted_file - - request_file=$(build_cancel_request_file) - token_file=$(build_cancel_token_file) - accepted_file=$(build_cancel_accepted_file) - if [ -n "$request_file" ]; then - rm -f "$request_file" - fi - if [ -n "$token_file" ]; then - rm -f "$token_file" - fi - if [ -n "$accepted_file" ]; then - rm -f "$accepted_file" - fi -} - -build_cancel_requested() { - [ -f "$(build_cancel_request_file)" ] -} - -process_is_zombie() { - local pid="$1" - local state - - state=$(ps -p "$pid" -o stat= 2>/dev/null || true) - [[ "$state" == Z* ]] -} - -monitor_build_cancel_request() { - local pid="$1" - local accepted_file="$2" - - while kill -0 "$pid" >/dev/null 2>&1; do - if build_cancel_requested; then - if process_is_zombie "$pid"; then - return 0 - fi - echo cancel >"$accepted_file" - echo "build cancellation requested" - terminate_process_tree "$pid" TERM - sleep 2 - if kill -0 "$pid" >/dev/null 2>&1; then - terminate_process_tree "$pid" KILL - fi - return 130 - fi - sleep 1 - done -} - -wait_for_active_build() { - local pid="$1" - local monitor_pid= - local accepted_file - local exit_code - - active_build_cancelled=false - accepted_file=$(build_cancel_accepted_file) - if [ -n "$accepted_file" ]; then - rm -f "$accepted_file" - monitor_build_cancel_request "$pid" "$accepted_file" & - monitor_pid=$! - fi - - wait "$pid" - exit_code=$? - if [ -n "$monitor_pid" ]; then - kill "$monitor_pid" >/dev/null 2>&1 || true - wait "$monitor_pid" 2>/dev/null || true - fi - if [ -n "$accepted_file" ] && [ -f "$accepted_file" ]; then - active_build_cancelled=true - return 130 - fi - return "$exit_code" -} - -process_command_line() { - local pid="$1" - - if [ -r "/proc/$pid/cmdline" ]; then - tr '\0' ' ' <"/proc/$pid/cmdline" | sed 's/[[:space:]]*$//' - else - ps -p "$pid" -o args= 2>/dev/null || true - fi -} - -process_working_directory() { - local pid="$1" - - readlink -f "/proc/$pid/cwd" 2>/dev/null || true -} - -process_is_current_shell() { - local pid="$1" - - [ "$pid" = "$$" ] || [ "$pid" = "${BASHPID:-}" ] || [ "$pid" = "${PPID:-}" ] -} - -terminate_process_tree() { - local pid="$1" - local signal="${2:-TERM}" - local child_pid - - if ! kill -0 "$pid" >/dev/null 2>&1; then - return 0 - fi - if command -v pgrep >/dev/null 2>&1; then - while IFS= read -r child_pid; do - if [ -n "$child_pid" ]; then - terminate_process_tree "$child_pid" "$signal" - fi - done < <(pgrep -P "$pid" 2>/dev/null || true) - fi - kill "-$signal" "$pid" >/dev/null 2>&1 || true -} - -build_lock_holder_pids() { - local lock_path="$1" - - if command -v fuser >/dev/null 2>&1; then - fuser "$lock_path" 2>/dev/null | tr -cs '0-9' '\n' | sed '/^$/d' | sort -u - elif command -v lsof >/dev/null 2>&1; then - lsof -t -- "$lock_path" 2>/dev/null | sort -u - fi -} - -terminate_stale_build_lock_holders() { - local lock_path="$1" - local repo_dir - local pid - local process_dir - local process_command - local -a stale_pids=() - - repo_dir=$(realpath "$PWD") - while IFS= read -r pid; do - if [ -z "$pid" ] || process_is_current_shell "$pid"; then - continue - fi - process_dir=$(process_working_directory "$pid") - process_command=$(process_command_line "$pid") - if [ "$process_dir" = "$repo_dir" ]; then - echo "Terminating stale build lock holder: pid $pid ($process_command)" - stale_pids+=("$pid") - else - echo "Build lock is held by pid $pid outside this repository: $process_command" >&2 - fi - done < <(build_lock_holder_pids "$lock_path") - - if [ "${#stale_pids[@]}" -eq 0 ]; then - return 1 - fi - - for pid in "${stale_pids[@]}"; do - terminate_process_tree "$pid" TERM - done - sleep 2 - for pid in "${stale_pids[@]}"; do - if kill -0 "$pid" >/dev/null 2>&1; then - echo "Force killing stale build lock holder: pid $pid" - terminate_process_tree "$pid" KILL - fi - done -} - -build_artifacts_root() { - echo "${TMPDIR:-/tmp}/git-watch-origin-and-test/$(repository_key)" -} - -repository_simple_name() { - basename "${repo_root:-$(git rev-parse --show-toplevel)}" -} - -repository_key() { - local current_repo_root - - current_repo_root="${repo_root:-$(git rev-parse --show-toplevel)}" - printf '%s' "$current_repo_root" | sed 's#[^[:alnum:]._-]#_#g' -} - -build_artifact_branch_key() { - local branch="$1" - local branch_key - local branch_hash - - branch_key=$(printf '%s' "$branch" | sed 's#[^[:alnum:]._-]#_#g') - branch_hash=$(printf '%s' "$branch" | sha256sum | awk '{print substr($1, 1, 12)}') - - echo "$branch_key-$branch_hash" -} - -build_artifact_key() { - local branch="$1" - local timestamp="$2" - local timestamp_key - local build_hash - - timestamp_key=$(printf '%s' "$timestamp" | sed 's#[^[:alnum:]._-]#_#g') - build_hash=$(printf '%s\t%s' "$branch" "$timestamp" | sha256sum | awk '{print substr($1, 1, 12)}') - - echo "$(build_artifact_branch_key "$branch")-$timestamp_key-$build_hash" -} - -build_artifact_dir() { - local branch="$1" - local artifact_key="${2:-}" - - if [ -z "$artifact_key" ]; then - artifact_key=$(build_artifact_branch_key "$branch") - fi - - echo "$(build_artifacts_root)/branches/$artifact_key" -} - -build_artifact_index_content_file() { - local artifact_dir="$1" - - echo "$artifact_dir/artifact-index-content.html" -} - -format_build_duration() { - local duration_seconds="$1" - - printf '%02d:%02d' "$((duration_seconds / 60))" "$((duration_seconds % 60))" -} - -normalize_build_result_fields() { - if [ -z "$artifact_key" ] && [ -n "$duration" ] && ! [[ "$duration" =~ ^[0-9]+:[0-5][0-9]$ ]]; then - artifact_key="$duration" - duration= - fi - - if [ -n "$duration" ] && ! [[ "$duration" =~ ^[0-9]+:[0-5][0-9]$ ]]; then - duration= - fi - - case "$status" in - passed) - status=success - ;; - esac - - if [ -z "$artifact_key" ]; then - artifact_key=$(build_artifact_branch_key "$branch") - fi -} - -display_build_timestamp() { - local timestamp="$1" - - echo "${timestamp/T/ }" -} - -commit_timestamp() { - local commit="$1" - - if [[ "$commit" =~ ^[0-9a-fA-F]{7,40}$ ]] && git cat-file -e "$commit^{commit}" 2>/dev/null; then - git show -s --format=%cI "$commit" 2>/dev/null || true - fi -} - -normalize_base_url() { - local base_url="$1" - - if [ -z "$base_url" ]; then - return 0 - fi - if [[ "$base_url" == */ ]]; then - echo "$base_url" - else - echo "$base_url/" - fi -} - -base_url_host() { - local base_url="$1" - - if [[ "$base_url" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then - printf '%s' "$base_url" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/@:]+@)?([^/:]+).*$#\2#' - fi -} - -safe_container_name_part() { - printf '%s' "$1" | sed 's#[^[:alnum:]_.-]#-#g' -} - -gittally_docker_label_args() { - local role="$1" - - printf '%s\n' \ - --label "org.hostsharing.gittally=true" \ - --label "org.hostsharing.gittally.repository=$(repository_key)" \ - --label "org.hostsharing.gittally.role=$role" -} - -artifact_nginx_default_state_dir() { - echo "${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/$(repository_key)" -} - -configure_artifact_nginx_defaults() { - local public_base_url_host - - if [ "$use_artifact_nginx" != true ]; then - return 0 - fi - - use_artifact_http_server=true - - if [ -z "$artifact_nginx_server_name" ]; then - public_base_url_host=$(base_url_host "$artifact_public_base_url") - if [ -n "$public_base_url_host" ]; then - artifact_nginx_server_name="$public_base_url_host" - fi - fi - - if [ -z "$artifact_public_base_url" ] && [ -n "$artifact_nginx_server_name" ]; then - artifact_public_base_url="https://$artifact_nginx_server_name/" - fi - - if [ "$artifact_http_server_port" = "$artifact_nginx_http_port" ] || - [ "$artifact_http_server_port" = "$artifact_nginx_https_port" ]; then - echo "WARNING: moving artifact HTTP server from port $artifact_http_server_port to 18080 because nginx uses port $artifact_http_server_port." >&2 - artifact_http_server_port=18080 - if [ "$artifact_http_server_port" = "$artifact_nginx_http_port" ] || - [ "$artifact_http_server_port" = "$artifact_nginx_https_port" ]; then - artifact_http_server_port=18081 - fi - fi - - if [ -z "$artifact_nginx_upstream_host" ]; then - artifact_nginx_upstream_host="$artifact_nginx_server_name" - fi - - if [ -z "$artifact_nginx_container_name" ]; then - artifact_nginx_container_name="gittally-nginx-$(safe_container_name_part "$(repository_simple_name)")" - fi - - if [ -z "$artifact_nginx_state_dir" ]; then - artifact_nginx_state_dir=$(artifact_nginx_default_state_dir) - fi - -} - -validate_artifact_build_retention_per_branch() { - if ! [[ "$artifact_build_retention_per_branch" =~ ^[1-9][0-9]*([dh])?$ ]]; then - echo "WARNING: invalid GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH: $artifact_build_retention_per_branch; using 3." >&2 - artifact_build_retention_per_branch=3 - fi -} - -validate_new_branch_commit_max_age() { - if ! [[ "$new_branch_commit_max_age" =~ ^[1-9][0-9]*[dh]$ ]]; then - echo "WARNING: invalid GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE: $new_branch_commit_max_age; using $default_new_branch_commit_max_age." >&2 - new_branch_commit_max_age="$default_new_branch_commit_max_age" - fi -} - -validate_auto_build_times() { - [ -n "$auto_build_branches" ] || return 0 - local valid_times="" IFS=';' slot - for slot in $auto_build_times; do - if [[ "$slot" =~ ^[0-2][0-9]:[0-5][0-9]$ ]]; then - valid_times="${valid_times:+$valid_times;}$slot" - else - echo "WARNING: invalid time in GITTALLY_AUTO_BUILD_TIMES: '$slot'; expected HH:MM (semicolon-separated)." >&2 - fi - done - if [ -z "$valid_times" ] && [ -n "$auto_build_times" ]; then - echo "WARNING: no valid times in GITTALLY_AUTO_BUILD_TIMES; auto builds disabled." >&2 - fi - auto_build_times="$valid_times" -} - -artifact_build_retention_is_count() { - [[ "$artifact_build_retention_per_branch" =~ ^[1-9][0-9]*$ ]] -} - -artifact_build_retention_cutoff_epoch() { - local amount - - amount="${artifact_build_retention_per_branch%[dh]}" - case "$artifact_build_retention_per_branch" in - *h) - echo "$(($(date +%s) - amount * 3600))" - ;; - *d) - echo "$(($(date +%s) - amount * 86400))" - ;; - esac -} - -new_branch_commit_max_age_cutoff_epoch() { - local amount - - amount="${new_branch_commit_max_age%[dh]}" - case "$new_branch_commit_max_age" in - *h) - echo "$(($(date +%s) - amount * 3600))" - ;; - *d) - echo "$(($(date +%s) - amount * 86400))" - ;; - esac -} - -detect_gitea_repo() { - detect_gitea_repo_from_origin_url -} - -gitea_status_enabled() { - [ -n "$gitea_token" ] && - [ -n "$gitea_base_url" ] && - [ -n "$gitea_owner" ] && - [ -n "$gitea_repo" ] && - command -v curl >/dev/null 2>&1 && - command -v python3 >/dev/null 2>&1 -} - -validate_gitea_git_credentials() { - if [ -n "$gitea_git_username" ] && [ -n "$gitea_token" ]; then - return 0 - fi - - if [ -z "$gitea_git_username" ]; then - echo "ERROR: GITTALLY_GITEA_GIT_USERNAME must be set before starting $tool_name." >&2 - fi - if [ -z "$gitea_token" ]; then - echo "ERROR: GITTALLY_GITEA_TOKEN must be set before starting $tool_name." >&2 - fi - echo "Git commands cannot run without both Gitea credentials." >&2 - return 1 -} - -json_string() { - python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$1" -} - -origin_uses_https() { - local origin_url - - origin_url=$(origin_url) - [[ "$origin_url" =~ ^https?:// ]] -} - -origin_url_username() { - detect_git_username_from_origin_url -} - -gitea_token_username() { - local user_json - - gitea_status_enabled || return 1 - - user_json=$(curl -fsS \ - -H "Authorization: token $gitea_token" \ - "${gitea_base_url%/}/api/v1/user" 2>/dev/null) || return 1 - - printf '%s' "$user_json" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("login", ""))' 2>/dev/null -} - -ensure_git_askpass_file() { - if [ -n "$git_askpass_file" ]; then - return 0 - fi - - git_askpass_file=$(mktemp "${TMPDIR:-/tmp}/git-watch-origin-and-test-askpass.XXXXXX") || return 1 - cat >"$git_askpass_file" <<'EOF' -#!/bin/sh -case "$1" in - *Username*|*username*) - printf '%s\n' "$GITTALLY_GITEA_GIT_USERNAME" - ;; - *) - printf '%s\n' "$GITTALLY_GITEA_TOKEN" - ;; -esac -EOF - chmod 700 "$git_askpass_file" -} - -git_with_gitea_token() { - local git_username - - if [ -z "$gitea_token" ] || ! origin_uses_https; then - git "$@" - return - fi - - ensure_git_askpass_file || return 1 - git_username="${gitea_git_username:-}" - if [ -z "$git_username" ]; then - git_username=$(origin_url_username) - fi - if [ -z "$git_username" ]; then - git_username=$(gitea_token_username || true) - fi - if [ -z "$git_username" ]; then - echo "WARNING: GITTALLY_GITEA_TOKEN is set, but no HTTPS git username could be determined." >&2 - echo "Set GITTALLY_GITEA_GIT_USERNAME to use the token for git fetch/pull." >&2 - git "$@" - return - fi - - GITTALLY_GITEA_GIT_USERNAME="$git_username" \ - GITTALLY_GITEA_TOKEN="$gitea_token" \ - GIT_ASKPASS="$git_askpass_file" \ - GIT_TERMINAL_PROMPT=0 \ - git "$@" -} - -gitea_status_state_for_build_status() { - case "$1" in - success|passed) - echo success - ;; - failed|interrupted|cancelled) - echo failure - ;; - pending|running) - echo pending - ;; - *) - echo error - ;; - esac -} - -gitea_deleted_status_description() { - echo "Build status deleted" -} - -build_status_for_gitea_status_state() { - case "$1" in - success) - echo success - ;; - failure|error|warning) - echo failed - ;; - pending) - echo running - ;; - *) - return 1 - ;; - esac -} - -gitea_status_api_url() { - local sha="$1" - - printf '%s/api/v1/repos/%s/%s/statuses/%s' \ - "${gitea_base_url%/}" \ - "$gitea_owner" \ - "$gitea_repo" \ - "$sha" -} - -gitea_commit_status_api_url() { - local sha="$1" - - printf '%s/api/v1/repos/%s/%s/commits/%s/statuses?sort=recentupdate' \ - "${gitea_base_url%/}" \ - "$gitea_owner" \ - "$gitea_repo" \ - "$sha" -} - -gitea_status_target_url_for_branch() { - local branch="$1" - local artifact_key="${2:-}" - - if [ -n "$artifact_http_server_url" ]; then - if [ -z "$artifact_key" ]; then - artifact_key=$(build_artifact_branch_key "$branch") - fi - echo "${artifact_http_server_url}branches/$artifact_key/index.html" - fi -} - -publish_gitea_build_status() { - local sha="$1" - local build_status="$2" - local branch="$3" - local artifact_key="${4:-}" - local state - local description - local target_url - local payload - local status_url - - gitea_status_enabled || return 0 - - state=$(gitea_status_state_for_build_status "$build_status") - target_url=$(gitea_status_target_url_for_branch "$branch" "$artifact_key") - case "$build_status" in - success|passed) - description="Build succeeded" - ;; - failed) - description="Build failed" - ;; - cancelled) - description="Build cancelled" - ;; - interrupted) - description="Build interrupted" - ;; - pending|running) - description="Build running" - ;; - *) - description="Build status unknown" - ;; - esac - - payload=$( - printf '{"state":%s,"context":%s,"description":%s' \ - "$(json_string "$state")" \ - "$(json_string "$gitea_status_context")" \ - "$(json_string "$description")" - if [ -n "$target_url" ]; then - printf ',"target_url":%s' "$(json_string "$target_url")" - fi - printf '}' - ) - status_url=$(gitea_status_api_url "$sha") - - if ! curl -fsS \ - -H "Authorization: token $gitea_token" \ - -H "Content-Type: application/json" \ - -X POST \ - -d "$payload" \ - "$status_url" >/dev/null; then - echo "WARNING: could not publish Gitea build status for $sha." >&2 - return 1 - fi -} - -read_gitea_build_status() { - local sha="$1" - local statuses_json - local state - local response_file - local http_status - - gitea_status_enabled || return 1 - - response_file=$(mktemp "${TMPDIR:-/tmp}/gittally-gitea-status.XXXXXX") || return 1 - http_status=$(curl -sS \ - -w '%{http_code}' \ - -o "$response_file" \ - -H "Authorization: token $gitea_token" \ - "$(gitea_commit_status_api_url "$sha")") || { - rm -f -- "$response_file" - return 1 - } - statuses_json=$(cat "$response_file") - rm -f -- "$response_file" - if [ "$http_status" = 404 ]; then - return 1 - fi - if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then - echo "WARNING: could not read Gitea build status for $sha: HTTP $http_status." >&2 - return 1 - fi - - state=$( - GITEA_STATUS_CONTEXT="$gitea_status_context" python3 -c ' -import json -import os -import sys - -context = os.environ["GITEA_STATUS_CONTEXT"] -statuses = json.load(sys.stdin) -for status in statuses: - if status.get("context") == context: - if status.get("description") == "Build status deleted": - print("deleted") - else: - print(status.get("state", "")) - break -' <<<"$statuses_json" - ) - - build_status_for_gitea_status_state "$state" -} - -effective_build_status() { - local commit="$1" - local status="$2" - - case "$status" in - pending|interrupted|cancelled) - echo "$status" - ;; - *) - if gitea_status_enabled; then - read_gitea_build_status "$commit" || echo "$status" - else - echo "$status" - fi - ;; - esac -} - -html_escape() { - sed \ - -e 's/&/\&/g' \ - -e 's//\>/g' \ - -e 's/"/\"/g' -} - -url_path_escape() { - local value="$1" - local safe="${2:-}" - - if command -v python3 >/dev/null 2>&1; then - python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=sys.argv[2]))' "$value" "$safe" - else - printf '%s' "$value" - fi -} - -gitea_repo_web_url() { - if [ -n "$gitea_base_url" ] && [ -n "$gitea_owner" ] && [ -n "$gitea_repo" ]; then - printf '%s/%s/%s' "${gitea_base_url%/}" "$gitea_owner" "$gitea_repo" - fi -} - -gitea_branch_web_url() { - local branch="$1" - local repo_url - - repo_url=$(gitea_repo_web_url) - if [ -n "$repo_url" ]; then - printf '%s/src/branch/%s' "$repo_url" "$(url_path_escape "$branch" '/')" - fi -} - -gitea_commit_web_url() { - local commit="$1" - local repo_url - - repo_url=$(gitea_repo_web_url) - if [ -n "$repo_url" ]; then - printf '%s/commit/%s' "$repo_url" "$(url_path_escape "$commit")" - fi -} - -write_html_link() { - local index_file="$1" - local href="$2" - local label="$3" - - printf '

  • %s
  • \n' \ - "$(printf '%s' "$href" | html_escape)" \ - "$(printf '%s' "$label" | html_escape)" \ - >>"$index_file" -} - -html_copy_button() { - local value="$1" - local label="$2" - - printf '' \ - "$(printf '%s' "$value" | html_escape)" \ - "$(printf '%s' "$label" | html_escape)" \ - "$(printf '%s' "$label" | html_escape)" -} - -write_html_favicon_links() { - local index_file="$1" - local href="${2:-favicon.svg}" - - { - printf ' \n' "$(printf '%s' "$href" | html_escape)" - printf ' \n' "$(printf '%s' "$href" | html_escape)" - } >>"$index_file" -} - -write_html_favicon() { - local artifacts_root="$1" - local icon_file="$artifacts_root/favicon.svg" - - mkdir -p "$artifacts_root" || return 1 - cat >"$icon_file" <<-'EOF' - - - - - - - - - EOF -} - -write_script_download() { - local artifacts_root="$1" - local download_file="$artifacts_root/gitTally.sh" - - mkdir -p "$artifacts_root" || return 1 - cp "$script_path" "$download_file" || return 1 - chmod 644 "$download_file" -} - -write_html_footer() { - local index_file="$1" - local license_href="${2:-license.html}" - local about_href="${3:-about.html}" - - { - printf '
    ' - printf 'gitTally v%s (env) ' \ - "$(printf '%s' "$about_href" | html_escape)" \ - "$(printf '%s' "$script_version" | html_escape)" - printf -- '- (c) Michael Hönnig, 2026 ' - printf -- '- Licensed under the MIT License ' "$(printf '%s' "$license_href" | html_escape)" - printf -- '- Impressum (Legal Disclosure)' "$(printf '%s' "$impressum_url" | html_escape)" - printf '
    \n' - } >>"$index_file" -} - -write_html_about_page() { - local artifacts_root="$1" - local index_file="$artifacts_root/about.html" - - mkdir -p "$artifacts_root" || return 1 - cat >"$index_file" <<-EOF - - - - - - gitTally - About - - - - - -
    -

    About gitTally

    - -
    -

    gitTally is a deliberately small and opinionated CI and deployment tool - for projects that do not have the hardware budget or operational staff for large CI/CD systems.

    -

    It is a Hostsharing community project, - not an official project of Hostsharing eG.

    - -

    Intention

    -
      -
    • Configuration is environment-driven, with no UI settings, so installations remain easy to bootstrap and repeatable.
    • -
    • It can be started right within any git working tree, even locally on the developers computer or on a spare computer.
    • -
    • Designed for Hostsharing Container Server environments with Docker or Podman.
    • -
    - -

    Operating Model

    -

    gitTally watches branches, checks out new commits, runs a configurable build command, - then archives build output for later inspection. - GitEA integration can publish commit status and protect the artifact website through OAuth2 login.

    -
      -
    • Build run directly in the environment or optionally in a Docker container.
    • -
    • The build command is configurable, so gitTally is build-system agnostic.
    • -
    • Build-status for the branches are kept locally and are pushed to a GitEA instance.
    • -
    - -

    Runtime Environment

    -

    The goal is low-cost operation without a dedicated VM per project. - gitTally is meant to run as a normal Linux user without root privileges.

    -
      -
    • Can also run on a local computer for small projects or personal workflows.
    • -
    • Supports systemd user services for unattended operation.
    • -
    • Provides optional nginx reverse-proxy support with Let's Encrypt certificates.
    • -
    - -

    Web Interface

    -

    The web interface exposes the information needed to inspect current and past builds, while staying simple and static.

    -
      -
    • Latest, branches, builds, and current-build views.
    • -
    • Archived stdout, stderr, and reports for each build.
    • -
    • Optional cancellation of the currently running build.
    • -
    • Static HTML served by the built-in artifact HTTP server or through nginx.
    • -
    - -

    Roadmap

    -

    gitTally is currently a bash script, developed (mostly vibe-coded) with - IntelliJ IDEA AI Chat, - mainly powered by Codex - and GPT-5.5 - It may later get refactored to maintainable code in Kotlin or Python.

    -

    Planned features: Support for ...

    -
      -
    • Separate the builder from the watcher, so that new branches can get detected during a build.
    • -
    • GitEA PRs including green build as quality-gate for merging to master/main,
    • -
    • separate build-command for special branches like master/main.,
    • -
    • Docker-based deployments for branches,
    • -
    • rootles Podman environments.
    • -
    -

    Download the current gitTally script.

    -
    -
    - EOF - write_html_footer "$index_file" - { - printf '\n' - printf '\n' - } >>"$index_file" -} - -write_html_license_page() { - local artifacts_root="$1" - local index_file="$artifacts_root/license.html" - - mkdir -p "$artifacts_root" || return 1 - { - printf '\n' - printf '\n' - printf '\n' - printf ' \n' - printf ' \n' - printf ' GitTally - MIT License\n' - } >"$index_file" - write_html_favicon_links "$index_file" - { - printf ' \n' - printf '\n' - printf '\n' - printf '
    \n' - printf '

    The MIT License

    \n' - printf ' \n' - printf '
    \n' - printf '

    Copyright 2026 Michael Hönnig

    \n' - printf '

    Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

    \n' - printf '

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

    \n' - printf '

    THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

    \n' - printf '
    \n' - printf '
    \n' - } >>"$index_file" - write_html_footer "$index_file" - { - printf '\n' - printf '\n' - } >>"$index_file" -} - -write_build_artifact_view_toggle() { - local index_file="$1" - local current_view_label="$2" - local right_html="${3:-}" - - printf '
    \n' >>"$index_file" - printf ' \n' >>"$index_file" - if [ -n "$right_html" ]; then - printf '
    %s
    \n' "$right_html" >>"$index_file" - fi - printf '
    \n' >>"$index_file" -} - -write_artifacts_root_index_page() { - local view="$1" - local artifacts_root - local index_file - local results_file - local branch - local commit - local status - local display_status - local status_class - local timestamp - local commit_timestamp_value - local display_commit_timestamp - local display_status_timestamp - local duration - local artifact_key - local commit_abbrev - local branch_cell - local branch_copy_button - local branch_url - local commit_cell - local commit_copy_button - local commit_url - local page_title - local current_view_label - local reload_action_html - local return_to - local load_statuses_in_browser=false - local row_class - local local_status - local loading_status=false - local result_line - local has_results=false - local row_index=0 - - artifacts_root=$(build_artifacts_root) - results_file=$(build_results_file) - if [ "$use_artifact_http_server" = true ] && gitea_status_enabled; then - load_statuses_in_browser=true - fi - write_html_favicon "$artifacts_root" || return 1 - write_script_download "$artifacts_root" || return 1 - write_html_about_page "$artifacts_root" || return 1 - write_html_license_page "$artifacts_root" || return 1 - case "$view" in - latest) - index_file="$artifacts_root/index.html" - page_title="GitTally [$(repository_simple_name)] - Latest Branch Builds" - current_view_label="Latest" - ;; - branches) - index_file="$artifacts_root/branches.html" - page_title="GitTally [$(repository_simple_name)] - Branches" - current_view_label="Branches" - ;; - history|*) - index_file="$artifacts_root/history.html" - page_title="GitTally [$(repository_simple_name)] - Builds" - current_view_label="Builds" - ;; - esac - reload_action_html=$(printf '
    ' "$(basename "$index_file")") - - mkdir -p "$artifacts_root" || return 1 - - { - printf '\n' - printf '\n' - printf '\n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" - } >"$index_file" - write_html_favicon_links "$index_file" - { - printf ' \n' - printf '\n' - printf '\n' - printf '
    \n' - printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" - } >>"$index_file" - write_build_artifact_view_toggle "$index_file" "$current_view_label" "$reload_action_html" - { - printf '
    \n' - printf ' \n' - printf ' \n' - printf ' \n' - } >>"$index_file" - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - if [ -z "$branch" ]; then - continue - fi - has_results=true - normalize_build_result_fields - if [ "$status" = unknown ]; then - artifact_key= - fi - commit_abbrev=${commit:0:12} - local_status="$status" - loading_status=false - if [ "$load_statuses_in_browser" = true ]; then - case "$local_status" in - pending|interrupted|cancelled) - display_status="$local_status" - ;; - *) - display_status=loading - loading_status=true - ;; - esac - else - display_status=$(effective_build_status "$commit" "$local_status") - fi - status_class="status-$display_status" - row_class="$status_class" - if [ "$loading_status" = true ]; then - row_class="status-loading" - fi - commit_timestamp_value=$(commit_timestamp "$commit") - display_commit_timestamp=$(display_build_timestamp "$commit_timestamp_value") - display_status_timestamp=$(display_build_timestamp "$timestamp") - branch_cell=$(printf '%s' "$branch" | html_escape) - branch_copy_button= - branch_url=$(gitea_branch_web_url "$branch") - if [ -n "$branch_url" ]; then - branch_copy_button=$(html_copy_button "$branch" "branch name") - branch_cell=$(printf '%s%s' \ - "$(printf '%s' "$branch_url" | html_escape)" \ - "$branch_cell" \ - "$branch_copy_button") - fi - commit_cell=$(printf '%s' "$commit_abbrev" | html_escape) - commit_copy_button= - commit_url= - if [[ "$commit" =~ ^[0-9a-fA-F]{7,40}$ ]]; then - commit_url=$(gitea_commit_web_url "$commit") - fi - if [ -n "$commit_url" ]; then - commit_copy_button=$(html_copy_button "$commit" "full commit ID") - commit_cell=$(printf '%s' \ - "$(printf '%s' "$commit_url" | html_escape)" \ - "$commit_cell") - fi - printf ' \n' >>"$index_file" - done < <( - if [ "$view" = "latest" ]; then - if [ -f "$results_file" ]; then - sort -t $'\t' -k4,4r "$results_file" | awk -F '\t' '!seen[$1]++' - fi - elif [ "$view" = "branches" ]; then - git for-each-ref --format='%(refname:strip=2)%09%(objectname)' refs/heads | - awk -F '\t' '{ - sort_group = 2 - if ($1 == "main" || $1 == "master") { - sort_group = 0 - } else if (index($1, "/") == 0) { - sort_group = 1 - } - printf "%d\t%s\t%s\n", sort_group, $1, $0 - }' | - sort -t $'\t' -k1,1n -k2,2 | - cut -f3- | - while IFS=$'\t' read -r branch commit; do - result_line= - if [ -f "$results_file" ]; then - result_line=$(sort -t $'\t' -k4,4r "$results_file" | awk -F '\t' -v wanted="$branch" '$1 == wanted { print; exit }') - fi - if [ -n "$result_line" ]; then - printf '%s\n' "$result_line" - else - printf '%s\t%s\tunknown\t\t\t\n' "$branch" "$commit" - fi - done - elif [ -f "$results_file" ]; then - sort -t $'\t' -k4,4r "$results_file" - fi - ) - - if [ "$has_results" = false ]; then - if [ "$view" = "latest" ]; then - printf ' \n' >>"$index_file" - elif [ "$view" = "branches" ]; then - printf ' \n' >>"$index_file" - else - printf ' \n' >>"$index_file" - fi - fi - - { - printf ' \n' - printf '
    StatusCommitDurationArtifactsActions
    %s%s%s%s%s%s%s' \ - "$(printf '%s' "$row_class" | html_escape)" \ - "$row_index" \ - "$(printf '%s' "$branch" | html_escape)" \ - "$(printf '%s' "$commit_timestamp_value" | html_escape)" \ - "$(printf '%s' "$timestamp" | html_escape)" \ - "$(printf '%s' "$artifact_key" | html_escape)" \ - "$(printf '%s' "$commit" | html_escape)" \ - "$(printf '%s' "$local_status" | html_escape)" \ - "$(printf '%s' "$status_class" | html_escape)" \ - "$(printf '%s' "$display_status" | html_escape)" \ - "$branch_cell" \ - "$commit_cell" \ - "$commit_copy_button" \ - "$(printf '%s' "$display_commit_timestamp" | html_escape)" \ - "$(printf '%s' "$display_status_timestamp" | html_escape)" \ - "$(printf '%s' "$duration" | html_escape)" \ - "$(printf '%s' "$duration" | html_escape)" \ - >>"$index_file" - row_index=$((row_index + 1)) - if [ -n "$artifact_key" ] && - { [ -f "$artifacts_root/branches/$artifact_key/index.html" ] || - [ -f "$(build_artifact_index_content_file "$artifacts_root/branches/$artifact_key")" ]; }; then - printf '' \ - "$(printf '%s' "$artifact_key" | html_escape)" \ - >>"$index_file" - else - printf 'n/a' >>"$index_file" - fi - printf '
    ' >>"$index_file" - if [ "$use_artifact_http_server" = true ]; then - if [ "$view" = "history" ]; then - return_to=history.html - elif [ "$view" = "branches" ]; then - return_to=branches.html - else - return_to=index.html - fi - if [ "$view" != "history" ]; then - printf '
    ' \ - "$(printf '%s' "$branch" | html_escape)" \ - "$(printf '%s' "$commit" | html_escape)" \ - "$return_to" \ - >>"$index_file" - fi - if [ -n "$artifact_key" ]; then - printf '
    ' \ - "$(printf '%s' "$branch" | html_escape)" \ - "$(printf '%s' "$commit" | html_escape)" \ - "$(printf '%s' "$artifact_key" | html_escape)" \ - "$return_to" \ - >>"$index_file" - fi - fi - printf '
    No latest build results found.
    No local branches found.
    No builds archived yet.
    \n' - printf '
    \n' - printf '
    \n' - } >>"$index_file" - { - printf ' \n' - } >>"$index_file" - write_html_footer "$index_file" - { - printf '\n' - printf '\n' - } >>"$index_file" -} - -current_build_log_file() { - echo "$(build_artifacts_root)/current.log" -} - -write_current_build_page() { - local branch="${1:-}" - local status="${2:-idle}" - local timestamp="${3:-}" - local artifacts_root - local index_file - local page_title - local display_timestamp - local cancel_token - local cancel_action_html= - - artifacts_root=$(build_artifacts_root) - write_html_favicon "$artifacts_root" || return 1 - write_script_download "$artifacts_root" || return 1 - write_html_about_page "$artifacts_root" || return 1 - write_html_license_page "$artifacts_root" || return 1 - index_file="$artifacts_root/current.html" - page_title="GitTally [$(repository_simple_name)] - Current Branch Build" - display_timestamp=$(display_build_timestamp "$timestamp") - if [ "$status" = running ]; then - cancel_token=$(read_build_cancel_token) - if [ -n "$cancel_token" ]; then - cancel_action_html=$(printf '
    ' "$(printf '%s' "$cancel_token" | html_escape)") - fi - fi - mkdir -p "$artifacts_root" || return 1 - - { - printf '\n' - printf '\n' - printf '\n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" - } >"$index_file" - write_html_favicon_links "$index_file" - { - printf ' \n' - printf '\n' - printf '\n' - printf '
    \n' - printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" - } >>"$index_file" - write_build_artifact_view_toggle "$index_file" Current "$cancel_action_html" - { - if [ -n "$branch" ]; then - printf '

    Status: %s | Branch: %s' \ - "$(printf '%s' "$status" | html_escape)" \ - "$(printf '%s' "$branch" | html_escape)" - if [ -n "$display_timestamp" ]; then - printf ' | Started: %s' "$(printf '%s' "$display_timestamp" | html_escape)" - fi - printf '

    \n' - else - printf '

    No build is currently running.

    \n' - fi - printf '
    Loading current.log...
    \n' - printf '
    \n' - printf ' \n' - } >>"$index_file" - write_html_footer "$index_file" - { - printf '\n' - printf '\n' - } >>"$index_file" -} - -system_page_snapshot() { - local system_file="$1" - - python3 - "$system_file" <<'PY' -import datetime -import json -import math -import sys - -placeholder = "\u2014" -keys = [ - "cpu_used", "cpu_used_min", "cpu_used_max", "cpu_used_avg", - "cpu_idle", "cpu_idle_min", "cpu_idle_max", "cpu_idle_avg", - "ram_used_gib", "ram_used_gib_min", "ram_used_gib_max", "ram_used_gib_avg", - "ram_free_gib", "ram_free_gib_min", "ram_free_gib_max", "ram_free_gib_avg", - "disk_used_gib", "disk_used_gib_min", "disk_used_gib_max", "disk_used_gib_avg", - "disk_free_gib", "disk_free_gib_min", "disk_free_gib_max", "disk_free_gib_avg", - "repo_size_gib", "repo_size_gib_min", "repo_size_gib_max", "repo_size_gib_avg", -] - -def fmt(value): - try: - val = float(value) - if math.isfinite(val): - return f"{val:.2f}" - except (ValueError, TypeError): - pass - return placeholder - -try: - with open(sys.argv[1], encoding="utf-8") as input_file: - data = json.load(input_file) -except Exception: - data = {} - -values = [fmt(data.get(key)) for key in keys] -cpu_count = data.get("cpu_count") -values.append(f"{int(cpu_count)} cores" if cpu_count is not None and str(cpu_count).isdigit() else placeholder) -ram_total = data.get("ram_total_gib") -values.append(fmt(ram_total) + " GiB" if ram_total is not None else placeholder) -disk_total = data.get("disk_total_gib") -values.append(fmt(disk_total) + " GiB" if disk_total is not None else placeholder) -timestamp = data.get("timestamp") -if isinstance(timestamp, str) and timestamp: - try: - values.append(datetime.datetime.fromisoformat(timestamp).strftime("%H:%M:%S")) - except ValueError: - values.append(timestamp) -else: - values.append(placeholder) -print("\t".join(values)) -PY -} - -write_system_page() { - local artifacts_root - local index_file - local page_title - local cpu_used cpu_used_min cpu_used_max cpu_used_avg - local cpu_idle cpu_idle_min cpu_idle_max cpu_idle_avg - local ram_used ram_used_min ram_used_max ram_used_avg - local ram_free ram_free_min ram_free_max ram_free_avg - local disk_used disk_used_min disk_used_max disk_used_avg - local disk_free disk_free_min disk_free_max disk_free_avg - local repo_size repo_size_min repo_size_max repo_size_avg - local cpu_count ram_total disk_total updated - - artifacts_root=$(build_artifacts_root) - write_html_favicon "$artifacts_root" || return 1 - index_file="$artifacts_root/system.html" - page_title="GitTally [$(repository_simple_name)] - System" - reload_action_html=$(printf '
    ' "$(basename "$index_file")") - mkdir -p "$artifacts_root" || return 1 - IFS=$'\t' read -r \ - cpu_used cpu_used_min cpu_used_max cpu_used_avg \ - cpu_idle cpu_idle_min cpu_idle_max cpu_idle_avg \ - ram_used ram_used_min ram_used_max ram_used_avg \ - ram_free ram_free_min ram_free_max ram_free_avg \ - disk_used disk_used_min disk_used_max disk_used_avg \ - disk_free disk_free_min disk_free_max disk_free_avg \ - repo_size repo_size_min repo_size_max repo_size_avg \ - cpu_count ram_total disk_total updated < <(system_page_snapshot "$artifacts_root/system.json") - - { - printf '\n' - printf '\n' - printf '\n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" - } >"$index_file" - write_html_favicon_links "$index_file" - { - printf ' \n' - printf '\n' - printf '\n' - printf '
    \n' - printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" - } >>"$index_file" - write_build_artifact_view_toggle "$index_file" System "$reload_action_html" - { - printf '
    \n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' \n' \ - "$(printf '%s' "$cpu_used" | html_escape)" "$(printf '%s' "$cpu_used_min" | html_escape)" "$(printf '%s' "$cpu_used_max" | html_escape)" "$(printf '%s' "$cpu_used_avg" | html_escape)" - printf ' \n' \ - "$(printf '%s' "$cpu_idle" | html_escape)" "$(printf '%s' "$cpu_idle_min" | html_escape)" "$(printf '%s' "$cpu_idle_max" | html_escape)" "$(printf '%s' "$cpu_idle_avg" | html_escape)" - printf ' \n' \ - "$(printf '%s' "$ram_used" | html_escape)" "$(printf '%s' "$ram_used_min" | html_escape)" "$(printf '%s' "$ram_used_max" | html_escape)" "$(printf '%s' "$ram_used_avg" | html_escape)" - printf ' \n' \ - "$(printf '%s' "$ram_free" | html_escape)" "$(printf '%s' "$ram_free_min" | html_escape)" "$(printf '%s' "$ram_free_max" | html_escape)" "$(printf '%s' "$ram_free_avg" | html_escape)" - printf ' \n' \ - "$(printf '%s' "$disk_used" | html_escape)" "$(printf '%s' "$disk_used_min" | html_escape)" "$(printf '%s' "$disk_used_max" | html_escape)" "$(printf '%s' "$disk_used_avg" | html_escape)" - printf ' \n' \ - "$(printf '%s' "$disk_free" | html_escape)" "$(printf '%s' "$disk_free_min" | html_escape)" "$(printf '%s' "$disk_free_max" | html_escape)" "$(printf '%s' "$disk_free_avg" | html_escape)" - printf ' \n' \ - "$(printf '%s' "$repo_size" | html_escape)" "$(printf '%s' "$repo_size_min" | html_escape)" "$(printf '%s' "$repo_size_max" | html_escape)" "$(printf '%s' "$repo_size_avg" | html_escape)" - printf ' \n' - printf '
    MetricCurrentMinMaxAvg
    CPU used (cores)%s%s%s%s
    CPU idle (cores)%s%s%s%s
    RAM used (GiB)%s%s%s%s
    RAM free (GiB)%s%s%s%s
    Disk used (GiB)%s%s%s%s
    Disk free (GiB)%s%s%s%s
    Repo size (GiB)%s%s%s%s
    \n' - printf '
    \n' - printf '

    CPU total: %s  ·  RAM total: %s  ·  Disk total: %s  ·  Updated: %s (updated every 60s)(*: min/max/avg since script start)

    \n' \ - "$(printf '%s' "$cpu_count" | html_escape)" "$(printf '%s' "$ram_total" | html_escape)" "$(printf '%s' "$disk_total" | html_escape)" "$(printf '%s' "$updated" | html_escape)" - printf '
    \n' - printf ' \n' - } >>"$index_file" - write_html_footer "$index_file" - { - printf '\n' - printf '\n' - } >>"$index_file" -} - -write_env_page() { - local artifacts_root index_file page_title env_text - - artifacts_root=$(build_artifacts_root) - write_html_favicon "$artifacts_root" || return 1 - index_file="$artifacts_root/env.html" - page_title="GitTally [$(repository_simple_name)] - Env" - mkdir -p "$artifacts_root" || return 1 - - env_text=$(print_env) - - { - printf '\n' - printf '\n' - printf '\n' - printf ' \n' - printf ' \n' - printf ' \n' - printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" - } >"$index_file" - write_html_favicon_links "$index_file" - { - printf ' \n' - printf '\n' - printf '\n' - printf '
    \n' - printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" - } >>"$index_file" - write_build_artifact_view_toggle "$index_file" Env - { - printf '
    \n' - printf '
    %s
    \n' "$(printf '%s' "$env_text" | html_escape)" - printf '
    \n' - printf '
    \n' - } >>"$index_file" - write_html_footer "$index_file" - { - printf '\n' - printf '\n' - } >>"$index_file" -} - -start_resource_monitor() { - [ -f /proc/stat ] && [ -f /proc/meminfo ] || return 0 - - local artifacts_root pid_file old_pid - artifacts_root=$(build_artifacts_root) - pid_file="$artifacts_root/system_monitor.pid" - if [ -f "$pid_file" ]; then - old_pid=$(cat "$pid_file" 2>/dev/null) - if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then - kill "$old_pid" 2>/dev/null || true - fi - rm -f "$pid_file" - fi - rm -f "$artifacts_root/system_state.dat" "$artifacts_root/system.json" - - ( - local cpu_count prev_total prev_idle - cpu_count=$(nproc) - read -r prev_total prev_idle < <( - awk '/^cpu / {idle=$5; total=0; for(i=2;i<=NF;i++) total+=$i; print total, idle; exit}' /proc/stat - ) - while true; do - local total idle total_diff idle_diff cpu_used cpu_idle - local ram_total_gib ram_used_gib ram_free_gib timestamp - local disk_total_gib disk_used_gib disk_free_gib - local artifacts_root system_file state_file - read -r total idle < <( - awk '/^cpu / {idle=$5; total=0; for(i=2;i<=NF;i++) total+=$i; print total, idle; exit}' /proc/stat - ) - read -r ram_total_gib ram_used_gib ram_free_gib < <( - LC_ALL=C awk '/^MemTotal:/ {total=$2} /^MemAvailable:/ {avail=$2} - END { used=total-avail; printf "%.2f %.2f %.2f\n", total/1048576, used/1048576, avail/1048576 }' \ - /proc/meminfo - ) - read -r disk_total_gib disk_used_gib disk_free_gib < <( - LC_ALL=C df -Pk . | awk 'NR > 1 { t += $(NF-4); u += $(NF-3); a += $(NF-2) } END { printf "%.2f %.2f %.2f\n", t/1048576, u/1048576, a/1048576 }' - ) - read -r repo_size_gib < <( - du -sk . | awk '{printf "%.4f\n", $1/1048576}' - ) - total_diff=$((total - prev_total)) - idle_diff=$((idle - prev_idle)) - if [ "$total_diff" -gt 0 ]; then - cpu_used=$(awk "BEGIN {printf \"%.2f\", $cpu_count * ($total_diff - $idle_diff) / $total_diff}") - cpu_idle=$(awk "BEGIN {printf \"%.2f\", $cpu_count - $cpu_used}") - else - cpu_used="0.00" - cpu_idle=$(printf "%.2f" "$cpu_count") - fi - prev_total=$total - prev_idle=$idle - artifacts_root=$(build_artifacts_root) - system_file="$artifacts_root/system.json" - state_file="$artifacts_root/system_state.dat" - mkdir -p "$artifacts_root" || continue - timestamp=$(date -Iseconds) - LC_ALL=C awk -v ts="$timestamp" -v gen="$monitor_generation" -v cc="$cpu_count" -v rtg="$ram_total_gib" \ - -v cu="$cpu_used" -v ci="$cpu_idle" -v ru="$ram_used_gib" -v rf="$ram_free_gib" \ - -v dtg="$disk_total_gib" -v du="$disk_used_gib" -v df="$disk_free_gib" \ - -v rs="$repo_size_gib" -v sf="$state_file" \ - 'BEGIN { - n = 0 - cu_min = cu; cu_max = cu; cu_sum = 0 - ci_min = ci; ci_max = ci; ci_sum = 0 - ru_min = ru; ru_max = ru; ru_sum = 0 - rf_min = rf; rf_max = rf; rf_sum = 0 - du_min = du; du_max = du; du_sum = 0 - df_min = df; df_max = df; df_sum = 0 - rs_min = rs; rs_max = rs; rs_sum = 0 - if ((getline line < sf) > 0) { - nf = split(line, f, " ") - if (nf >= 22) { - n = f[1]+0 - cu_min = f[2]+0; cu_max = f[3]+0; cu_sum = f[4]+0 - ci_min = f[5]+0; ci_max = f[6]+0; ci_sum = f[7]+0 - ru_min = f[8]+0; ru_max = f[9]+0; ru_sum = f[10]+0 - rf_min = f[11]+0; rf_max = f[12]+0; rf_sum = f[13]+0 - du_min = f[14]+0; du_max = f[15]+0; du_sum = f[16]+0 - df_min = f[17]+0; df_max = f[18]+0; df_sum = f[19]+0 - rs_min = f[20]+0; rs_max = f[21]+0; rs_sum = f[22]+0 - } else if (nf >= 19) { - n = f[1]+0 - cu_min = f[2]+0; cu_max = f[3]+0; cu_sum = f[4]+0 - ci_min = f[5]+0; ci_max = f[6]+0; ci_sum = f[7]+0 - ru_min = f[8]+0; ru_max = f[9]+0; ru_sum = f[10]+0 - rf_min = f[11]+0; rf_max = f[12]+0; rf_sum = f[13]+0 - } - } - close(sf) - n++ - if (n == 1 || cu < cu_min) cu_min = cu - if (n == 1 || cu > cu_max) cu_max = cu - cu_sum += cu - if (n == 1 || ci < ci_min) ci_min = ci - if (n == 1 || ci > ci_max) ci_max = ci - ci_sum += ci - if (n == 1 || ru < ru_min) ru_min = ru - if (n == 1 || ru > ru_max) ru_max = ru - ru_sum += ru - if (n == 1 || rf < rf_min) rf_min = rf - if (n == 1 || rf > rf_max) rf_max = rf - rf_sum += rf - if (n == 1 || du < du_min) du_min = du - if (n == 1 || du > du_max) du_max = du - du_sum += du - if (n == 1 || df < df_min) df_min = df - if (n == 1 || df > df_max) df_max = df - df_sum += df - if (n == 1 || rs < rs_min) rs_min = rs - if (n == 1 || rs > rs_max) rs_max = rs - rs_sum += rs - printf "%d %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f\n", - n, cu_min, cu_max, cu_sum, ci_min, ci_max, ci_sum, - ru_min, ru_max, ru_sum, rf_min, rf_max, rf_sum, - du_min, du_max, du_sum, df_min, df_max, df_sum, - rs_min, rs_max, rs_sum > sf - close(sf) - printf "{\"timestamp\":\"%s\",\"generation\":\"%s\",\"cpu_count\":%d,\"sample_count\":%d,", ts, gen, cc, n - printf "\"cpu_used\":%.2f,\"cpu_used_min\":%.2f,\"cpu_used_max\":%.2f,\"cpu_used_avg\":%.2f,", cu, cu_min, cu_max, cu_sum/n - printf "\"cpu_idle\":%.2f,\"cpu_idle_min\":%.2f,\"cpu_idle_max\":%.2f,\"cpu_idle_avg\":%.2f,", ci, ci_min, ci_max, ci_sum/n - printf "\"ram_total_gib\":%.2f,", rtg - printf "\"ram_used_gib\":%.2f,\"ram_used_gib_min\":%.2f,\"ram_used_gib_max\":%.2f,\"ram_used_gib_avg\":%.2f,", ru, ru_min, ru_max, ru_sum/n - printf "\"ram_free_gib\":%.2f,\"ram_free_gib_min\":%.2f,\"ram_free_gib_max\":%.2f,\"ram_free_gib_avg\":%.2f,", rf, rf_min, rf_max, rf_sum/n - printf "\"disk_total_gib\":%.2f,", dtg - printf "\"disk_used_gib\":%.2f,\"disk_used_gib_min\":%.2f,\"disk_used_gib_max\":%.2f,\"disk_used_gib_avg\":%.2f,", du, du_min, du_max, du_sum/n - printf "\"disk_free_gib\":%.2f,\"disk_free_gib_min\":%.2f,\"disk_free_gib_max\":%.2f,\"disk_free_gib_avg\":%.2f,", df, df_min, df_max, df_sum/n - printf "\"repo_size_gib\":%.2f,\"repo_size_gib_min\":%.2f,\"repo_size_gib_max\":%.2f,\"repo_size_gib_avg\":%.2f}\n", rs, rs_min, rs_max, rs_sum/n - }' > "${system_file}.tmp" && mv "${system_file}.tmp" "${system_file}" && write_system_page || true - sleep 60 - done - ) & - echo $! > "$pid_file" -} - -write_artifacts_root_index() { - write_artifacts_root_index_page latest || return 1 - write_artifacts_root_index_page branches || return 1 - write_artifacts_root_index_page history || return 1 - if [ -z "${active_build_branch:-}" ]; then - write_current_build_page - fi - write_system_page || return 1 -} - -detect_artifact_http_server_host() { - local host - - if [ "$artifact_http_server_bind_address" != "0.0.0.0" ]; then - echo "$artifact_http_server_bind_address" - return 0 - fi - - if command -v ip >/dev/null 2>&1; then - host=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -n 1) - if [ -n "$host" ]; then - echo "$host" - return 0 - fi - fi - - if command -v hostname >/dev/null 2>&1; then - host=$(hostname -I 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^[0-9.]+$/) { print $i; exit } }') - if [ -n "$host" ]; then - echo "$host" - return 0 - fi - fi - - echo "127.0.0.1" -} - -default_artifact_public_base_url() { - local port="$1" - local url_host - - url_host=$(detect_artifact_http_server_host) - echo "http://$url_host:$port/" -} - -start_artifact_http_server_process() { - local port="$1" - local bind_address="$2" - local directory="$3" - local cancel_request_file="$4" - local cancel_token_file="$5" - local results_file="$6" - - GITTALLY_GITEA_BASE_URL="$gitea_base_url" \ - GITTALLY_GITEA_OWNER="$gitea_owner" \ - GITTALLY_GITEA_REPO="$gitea_repo" \ - GITTALLY_GITEA_TOKEN="$gitea_token" \ - GITTALLY_GITEA_STATUS_CONTEXT="$gitea_status_context" \ - GITTALLY_GITEA_DELETED_STATUS_DESCRIPTION="$(gitea_deleted_status_description)" \ - GITTALLY_SCRIPT_VERSION="$script_version" \ - GITTALLY_IMPRESSUM_URL="$impressum_url" \ - python3 -c ' -import functools -import datetime -import hashlib -import html -import http.server -import json -import os -import re -import sys -import urllib.parse -import urllib.request - -port = int(sys.argv[1]) -bind_address = sys.argv[2] -directory = sys.argv[3] -cancel_request_file = sys.argv[4] -cancel_token_file = sys.argv[5] -results_file = sys.argv[6] -gitea_base_url = os.environ.get("GITTALLY_GITEA_BASE_URL", "") -gitea_owner = os.environ.get("GITTALLY_GITEA_OWNER", "") -gitea_repo = os.environ.get("GITTALLY_GITEA_REPO", "") -gitea_token = os.environ.get("GITTALLY_GITEA_TOKEN", "") -gitea_status_context = os.environ.get("GITTALLY_GITEA_STATUS_CONTEXT", "") -gitea_deleted_status_description = os.environ.get("GITTALLY_GITEA_DELETED_STATUS_DESCRIPTION", "Build status deleted") -script_version = os.environ.get("GITTALLY_SCRIPT_VERSION", "") -impressum_url = os.environ.get("GITTALLY_IMPRESSUM_URL", "") - -class ArtifactRequestHandler(http.server.SimpleHTTPRequestHandler): - extensions_map = { - **http.server.SimpleHTTPRequestHandler.extensions_map, - ".log": "text/plain; charset=utf-8", - ".sh": "text/x-shellscript; charset=utf-8", - } - no_store_suffixes = (".html", ".json", ".log") - - def is_control_path(self, request_path, control_path): - request_path = request_path.rstrip("/") or "/" - return request_path == control_path or request_path.endswith(control_path) - - def end_headers(self): - request_path = urllib.parse.urlparse(self.path).path - if request_path.endswith(self.no_store_suffixes) or request_path.startswith("/control/"): - self.send_header("Cache-Control", "no-store, max-age=0") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") - super().end_headers() - - def do_GET(self): - request = urllib.parse.urlparse(self.path) - if self.is_control_path(request.path, "/control/status"): - self.handle_status(urllib.parse.parse_qs(request.query)) - return - if self.handle_artifact_index_request(request.path): - return - super().do_GET() - - def handle_artifact_index_request(self, request_path): - request_path = urllib.parse.unquote(request_path) - match = re.fullmatch(r"/?branches/([A-Za-z0-9._-]+)/index\.html", request_path) - if not match: - return False - artifact_key = match.group(1) - artifact_dir = os.path.join(directory, "branches", artifact_key) - content = self.read_artifact_index_content(artifact_dir) - if content is None: - self.send_error(404) - return True - branch = self.branch_for_artifact_key(artifact_key) - payload = self.render_artifact_index_page(branch, content).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - return True - - def read_artifact_index_content(self, artifact_dir): - content_file = os.path.join(artifact_dir, "artifact-index-content.html") - try: - with open(content_file, encoding="utf-8") as content_input: - return content_input.read() - except FileNotFoundError: - pass - old_index_file = os.path.join(artifact_dir, "index.html") - try: - with open(old_index_file, encoding="utf-8") as index_input: - old_index = index_input.read() - except FileNotFoundError: - return None - article_match = re.search(r"]*>.*?", old_index, re.IGNORECASE | re.DOTALL) - if article_match: - return article_match.group(0) - main_match = re.search(r"]*>(.*?)", old_index, re.IGNORECASE | re.DOTALL) - if main_match: - return re.sub( - r"\s*]*>.*?\s*", - "", - main_match.group(1), - count=1, - flags=re.IGNORECASE | re.DOTALL, - ).strip() - return "

    Could not extract artifact index content from the stored page.

    " - - def branch_for_artifact_key(self, artifact_key): - try: - with open(results_file, encoding="utf-8") as results_input: - for line in results_input: - fields = line.rstrip("\n").split("\t") - while len(fields) < 6: - fields.append("") - branch, _commit, _status, _timestamp, _duration, stored_artifact_key = fields[:6] - if stored_artifact_key == artifact_key: - return branch - except FileNotFoundError: - pass - return artifact_key - - def render_artifact_index_page(self, branch, content): - escaped_branch = html.escape(branch) - escaped_branch_title = html.escape(branch, quote=True) - branch_url = self.gitea_branch_web_url(branch) - if branch_url: - branch_title_html = f"""{escaped_branch}""" - else: - branch_title_html = f"""{escaped_branch}""" - escaped_version = html.escape(script_version) - escaped_impressum_url = html.escape(impressum_url, quote=True) - return f""" - - - - Build artifacts: {escaped_branch} - - - - - -
    -

    Build artifacts: {branch_title_html}

    - {content} -
    - - - - -""" - - def gitea_branch_web_url(self, branch): - if not (gitea_base_url and gitea_owner and gitea_repo): - return "" - return ( - gitea_base_url.rstrip("/") + "/" + - urllib.parse.quote(gitea_owner, safe="") + "/" + - urllib.parse.quote(gitea_repo, safe="") + "/src/branch/" + - urllib.parse.quote(branch, safe="/") - ) - - def do_POST(self): - request_path = urllib.parse.urlparse(self.path).path - if self.is_control_path(request_path, "/control/cancel"): - self.handle_cancel() - elif self.is_control_path(request_path, "/control/restart"): - body = self.read_form_body() - if body is None: - return - self.handle_restart(urllib.parse.parse_qs(body)) - elif self.is_control_path(request_path, "/control/delete"): - body = self.read_form_body() - if body is None: - return - self.handle_delete(urllib.parse.parse_qs(body)) - else: - self.send_error(404) - return - - def read_form_body(self): - content_length = int(self.headers.get("Content-Length", "0")) - if content_length > 4096: - self.send_error(413) - return None - return self.rfile.read(content_length).decode("utf-8") - - def handle_status(self, query): - commit = query.get("commit", [""])[0] - local_status = query.get("local_status", [""])[0] - if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit): - self.send_error(400) - return - if not re.fullmatch(r"[A-Za-z_-]+", local_status): - self.send_error(400) - return - status = self.read_gitea_build_status(commit) or local_status - payload = json.dumps({"status": status}).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def read_gitea_build_status(self, commit): - if not (gitea_base_url and gitea_owner and gitea_repo and gitea_token and gitea_status_context): - return None - owner = urllib.parse.quote(gitea_owner, safe="") - repo = urllib.parse.quote(gitea_repo, safe="") - status_url = ( - gitea_base_url.rstrip("/") + "/api/v1/repos/" + owner + "/" + repo + - "/commits/" + commit + "/statuses?sort=recentupdate" - ) - request = urllib.request.Request( - status_url, - headers={"Authorization": "token " + gitea_token}, - method="GET", - ) - try: - with urllib.request.urlopen(request, timeout=10) as response: - if response.status < 200 or response.status >= 300: - return None - statuses = json.loads(response.read().decode("utf-8")) - except Exception: - return None - for status in statuses: - if status.get("context") != gitea_status_context: - continue - if status.get("description") == gitea_deleted_status_description: - return None - state = status.get("state", "") - if state == "success": - return "success" - if state in ("failure", "error", "warning"): - return "failed" - if state == "pending": - return "running" - return None - return None - - def handle_cancel(self): - body = self.read_form_body() - if body is None: - return - submitted_token = urllib.parse.parse_qs(body).get("token", [""])[0] - try: - with open(cancel_token_file, encoding="utf-8") as token_input: - expected_token = token_input.readline().strip() - except FileNotFoundError: - expected_token = "" - if not expected_token or submitted_token != expected_token: - self.send_error(403) - return - with open(cancel_request_file, "w", encoding="utf-8") as request_output: - request_output.write("cancel\n") - self.send_response(202) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.end_headers() - self.wfile.write(b"Cancellation requested.\n") - - def handle_restart(self, form): - branch = form.get("branch", [""])[0] - commit = form.get("commit", [""])[0] - return_to = form.get("return_to", ["index.html"])[0] - if not branch or "\n" in branch or "\r" in branch: - self.send_error(400) - return - if not commit: - commit = "0000000" - elif not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit): - self.send_error(400) - return - if return_to not in ("index.html", "branches.html"): - return_to = "index.html" - timestamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds") - branch_key = re.sub(r"[^A-Za-z0-9._-]", "_", branch) - branch_hash = hashlib.sha256(branch.encode("utf-8")).hexdigest()[:12] - timestamp_key = re.sub(r"[^A-Za-z0-9._-]", "_", timestamp) - artifact_key = branch_key + "-" + branch_hash + "-restart-" + timestamp_key - os.makedirs(os.path.dirname(results_file), exist_ok=True) - with open(results_file, "a", encoding="utf-8") as results_output: - results_output.write("\t".join([branch, commit, "pending", timestamp, "", artifact_key]) + "\n") - self.mark_latest_page_pending(branch) - self.send_response(303) - self.send_header("Location", "/" + return_to) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.end_headers() - self.wfile.write(b"Restart requested.\n") - - def handle_delete(self, form): - branch = form.get("branch", [""])[0] - commit = form.get("commit", [""])[0] - artifact_key = form.get("artifact_key", [""])[0] - return_to = form.get("return_to", ["index.html"])[0] - if not branch or "\n" in branch or "\r" in branch: - self.send_error(400) - return - if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit): - self.send_error(400) - return - if not re.fullmatch(r"[A-Za-z0-9._-]+", artifact_key): - self.send_error(400) - return - if return_to not in ("index.html", "branches.html", "history.html"): - return_to = "index.html" - if self.delete_stored_status(branch, commit, artifact_key): - self.publish_deleted_gitea_status(commit) - self.remove_visible_status(branch, commit, artifact_key) - self.send_response(303) - self.send_header("Location", "/" + return_to) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.end_headers() - self.wfile.write(b"Stored status deleted.\n") - - def delete_stored_status(self, wanted_branch, wanted_commit, wanted_artifact_key): - removed = False - kept_lines = [] - try: - with open(results_file, encoding="utf-8") as results_input: - lines = results_input.readlines() - except FileNotFoundError: - return False - for line in lines: - fields = line.rstrip("\n").split("\t") - while len(fields) < 6: - fields.append("") - branch, commit, status, timestamp, duration, artifact_key = fields[:6] - if branch == wanted_branch and commit == wanted_commit and artifact_key == wanted_artifact_key: - removed = True - continue - kept_lines.append(line) - if removed: - os.makedirs(os.path.dirname(results_file), exist_ok=True) - temp_file = results_file + ".delete" - with open(temp_file, "w", encoding="utf-8") as results_output: - results_output.writelines(kept_lines) - os.replace(temp_file, results_file) - return removed - - def publish_deleted_gitea_status(self, commit): - if not (gitea_base_url and gitea_owner and gitea_repo and gitea_token and gitea_status_context): - return - payload = json.dumps({ - "state": "warning", - "context": gitea_status_context, - "description": gitea_deleted_status_description, - }).encode("utf-8") - owner = urllib.parse.quote(gitea_owner, safe="") - repo = urllib.parse.quote(gitea_repo, safe="") - status_url = gitea_base_url.rstrip("/") + "/api/v1/repos/" + owner + "/" + repo + "/statuses/" + commit - request = urllib.request.Request( - status_url, - data=payload, - headers={ - "Authorization": "token " + gitea_token, - "Content-Type": "application/json", - }, - method="POST", - ) - try: - urllib.request.urlopen(request, timeout=10).close() - except Exception: - print("WARNING: could not publish deleted Gitea build status for " + commit + ".", file=sys.stderr) - - def remove_visible_status(self, branch, commit, artifact_key): - escaped_artifact_key = html.escape(artifact_key, quote=True) - row_pattern = re.compile( - r"\s*]*\bdata-artifact-key=\"" + re.escape(escaped_artifact_key) + - r"\"[^>]*>.*?\n?", - re.DOTALL, - ) - branch_row = ( - " unknown" + - "" + html.escape(branch) + "" + html.escape(commit[:12]) + - "n/a
    " + - "
    " + - "
    \n" - ) - for page_name in ("index.html", "branches.html", "history.html"): - page_file = os.path.join(directory, page_name) - try: - with open(page_file, encoding="utf-8") as page_input: - page_html = page_input.read() - except FileNotFoundError: - continue - if page_name == "branches.html": - page_html = row_pattern.sub(branch_row, page_html, count=1) - else: - page_html = row_pattern.sub("", page_html, count=1) - page_html = re.sub( - r"(\n)\s*()", - r"""\1 No builds archived yet.\n \2""", - page_html, - ) - with open(page_file, "w", encoding="utf-8") as page_output: - page_output.write(page_html) - - def mark_latest_page_pending(self, branch): - escaped_branch = html.escape(branch, quote=True) - row_pattern = re.compile( - r"()[^<]*()" - ) - for page_name in ("index.html", "branches.html"): - page_file = os.path.join(directory, page_name) - try: - with open(page_file, encoding="utf-8") as page_input: - page_html = page_input.read() - except FileNotFoundError: - continue - page_html = row_pattern.sub(r"\1status-pending\2status-pending\3pending\4", page_html, count=1) - with open(page_file, "w", encoding="utf-8") as page_output: - page_output.write(page_html) - -handler_class = functools.partial(ArtifactRequestHandler, directory=directory) -server = http.server.ThreadingHTTPServer((bind_address, port), handler_class) -try: - server.serve_forever() -finally: - server.server_close() -' "$port" "$bind_address" "$directory" "$cancel_request_file" "$cancel_token_file" "$results_file" -} - -start_artifact_http_server() { - local artifacts_root - local results_file - local port - local max_port - local url_host - local public_base_url - - if [ "$use_artifact_http_server" != true ]; then - return 0 - fi - - if ! command -v python3 >/dev/null 2>&1; then - echo "WARNING: cannot start artifact HTTP server because python3 is not in PATH." >&2 - return 0 - fi - - if ! [[ "$artifact_http_server_port" =~ ^[0-9]+$ ]] || - [ "$artifact_http_server_port" -lt 1 ] || - [ "$artifact_http_server_port" -gt 65535 ]; then - echo "WARNING: invalid GITTALLY_ARTIFACT_SERVER_PORT: $artifact_http_server_port" >&2 - return 0 - fi - - artifacts_root=$(build_artifacts_root) - results_file=$(build_results_file) - write_artifacts_root_index || { - echo "WARNING: could not write artifact index." >&2 - return 0 - } - - port=$artifact_http_server_port - max_port=$((artifact_http_server_port + 20)) - if [ "$max_port" -gt 65535 ]; then - max_port=65535 - fi - - while [ "$port" -le "$max_port" ]; do - start_artifact_http_server_process "$port" "$artifact_http_server_bind_address" "$artifacts_root" "$(build_cancel_request_file)" "$(build_cancel_token_file)" "$results_file" >/dev/null 2>&1 & - artifact_http_server_pid=$! - sleep 0.2 - if kill -0 "$artifact_http_server_pid" >/dev/null 2>&1; then - url_host=$(detect_artifact_http_server_host) - public_base_url="${artifact_public_base_url:-$(default_artifact_public_base_url "$port")}" - artifact_http_server_local_url="http://$url_host:$port/" - artifact_http_server_url=$(normalize_base_url "$public_base_url") - echo "Artifact HTTP server: $artifact_http_server_local_url" - echo "Artifact public URL: $artifact_http_server_url" - echo "Artifact HTTP server bind address: $artifact_http_server_bind_address" - echo "Artifact directory: file://$artifacts_root" - return 0 - fi - wait "$artifact_http_server_pid" 2>/dev/null || true - artifact_http_server_pid= - port=$((port + 1)) - done - - echo "WARNING: could not start artifact HTTP server on ${artifact_http_server_bind_address}:${artifact_http_server_port}-${max_port}." >&2 -} - -open_artifact_frontend_if_requested() { - if [ "$open_artifact_frontend" != true ]; then - return 0 - fi - - if [ -z "$artifact_http_server_local_url" ]; then - echo "WARNING: cannot open artifact frontend because the HTTP server is not running." >&2 - return 0 - fi - - open_in_local_browser "artifact frontend" "$artifact_http_server_local_url" -} - -artifact_nginx_write_ssl_options() { - local certbot_conf="$1" - - mkdir -p "$certbot_conf" || return 1 - cat >"$certbot_conf/options-ssl-nginx.conf" <<'EOF' -ssl_session_cache shared:le_nginx_SSL:1m; -ssl_session_timeout 1440m; -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; - -ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384"; -ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; -EOF - - if [ ! -f "$certbot_conf/ssl-dhparams.pem" ]; then - if command -v curl >/dev/null 2>&1; then - curl -fsSL -o "$certbot_conf/ssl-dhparams.pem" \ - https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem || return 1 - else - echo "WARNING: cannot prepare nginx SSL parameters because curl is not in PATH." >&2 - return 1 - fi - fi - chmod 644 "$certbot_conf/options-ssl-nginx.conf" "$certbot_conf/ssl-dhparams.pem" -} - -artifact_nginx_write_config() { - local config_file="$1" - local mode="$2" - - if [ "$mode" = init ]; then - cat >"$config_file" <"$config_file" -} - -remove_docker_container_by_name() { - local container_name="$1" - - if [ -n "$container_name" ]; then - docker rm -f "$container_name" >/dev/null 2>&1 || true - fi -} - -remove_gittally_containers_by_label() { - local role="$1" - local container_ids - - container_ids=$(docker ps -aq \ - --filter "label=org.hostsharing.gittally=true" \ - --filter "label=org.hostsharing.gittally.repository=$(repository_key)" \ - --filter "label=org.hostsharing.gittally.role=$role" 2>/dev/null || true) - if [ -n "$container_ids" ]; then - docker rm -f $container_ids >/dev/null 2>&1 || true - fi -} - -cleanup_stale_build_runtime() { - if ! command -v docker >/dev/null 2>&1; then - return 0 - fi - - remove_docker_container_by_name "$(docker_build_container_name)" - remove_gittally_containers_by_label build -} - -container_ports_include_host_port() { - local ports="$1" - local port="$2" - - [[ "$ports" == *":$port->"* ]] -} - -remove_gittally_port_containers() { - local port - local container_id - local container_name - local container_ports - local container_labels - - for port in "$@"; do - while IFS=$'\t' read -r container_id container_name container_ports container_labels; do - if [ -z "$container_id" ]; then - continue - fi - if ! container_ports_include_host_port "$container_ports" "$port"; then - continue - fi - if [[ "$container_labels" == *"org.hostsharing.gittally=true"* ]] || - [[ "$container_name" == gittally-* ]] || - [[ "$container_name" == git-watch-origin-and-test-nginx-* ]]; then - echo "Removing stale GitTally container using port $port: $container_name" - docker rm -f "$container_id" >/dev/null 2>&1 || true - fi - done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}' 2>/dev/null || true) - done -} - -warn_remaining_port_owners() { - local port - local container_id - local container_name - local container_ports - local container_labels - local has_owner=false - - for port in "$@"; do - while IFS=$'\t' read -r container_id container_name container_ports container_labels; do - if [ -z "$container_id" ]; then - continue - fi - if container_ports_include_host_port "$container_ports" "$port"; then - has_owner=true - echo "WARNING: artifact nginx port $port is already used by Docker container $container_name ($container_id)." >&2 - fi - done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}' 2>/dev/null || true) - done - - if [ "$has_owner" = true ]; then - echo "WARNING: remaining containers still use artifact nginx ports." >&2 - return 1 - fi - return 0 -} - -artifact_nginx_ports_free() { - local port - local container_id - local container_name - local container_ports - local container_labels - - for port in "$@"; do - while IFS=$'\t' read -r container_id container_name container_ports container_labels; do - if [ -n "$container_id" ] && container_ports_include_host_port "$container_ports" "$port"; then - return 1 - fi - done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}' 2>/dev/null || true) - done - return 0 -} - -wait_for_artifact_nginx_ports() { - local attempt - - if ! warn_remaining_port_owners "$artifact_nginx_http_port" "$artifact_nginx_https_port"; then - sleep 1 - fi - for attempt in 1 2 3 4; do - if artifact_nginx_ports_free "$artifact_nginx_http_port" "$artifact_nginx_https_port"; then - return 0 - fi - sleep 1 - done - artifact_nginx_ports_free "$artifact_nginx_http_port" "$artifact_nginx_https_port" -} - -cleanup_stale_artifact_nginx_containers() { - remove_docker_container_by_name "$artifact_nginx_container_name" - remove_gittally_containers_by_label nginx - remove_gittally_port_containers "$artifact_nginx_http_port" "$artifact_nginx_https_port" - wait_for_artifact_nginx_ports -} - -artifact_nginx_run_container() { - local config_file="$1" - local certbot_conf="$2" - local certbot_www="$3" - local nginx_log="$4" - local container_id - local -a docker_args - local -a docker_label_args - - remove_docker_container_by_name "$artifact_nginx_container_name" - artifact_nginx_container_started=false - artifact_nginx_container_id= - - docker_args=(run -d --name "$artifact_nginx_container_name" \ - --publish "$artifact_nginx_http_port:80" \ - --publish "$artifact_nginx_https_port:443" \ - --network bridge \ - -v "$certbot_conf:/etc/letsencrypt" \ - -v "$certbot_www:/var/www/certbot" \ - -v "$nginx_log:/var/log/nginx" \ - -v "$config_file:/etc/nginx/nginx.conf:ro") - mapfile -t docker_label_args < <(gittally_docker_label_args nginx) - docker_args+=("${docker_label_args[@]}") - - container_id=$(docker "${docker_args[@]}" nginx) || return 1 - artifact_nginx_container_id="$container_id" - artifact_nginx_container_started=true -} - -artifact_nginx_obtain_or_renew_certificate() { - local certbot_conf="$1" - local certbot_www="$2" - local certbot_log="$3" - local cert_file="$certbot_conf/live/$artifact_nginx_server_name/fullchain.pem" - local -a email_args=() - - if [ -n "$artifact_letsencrypt_email" ]; then - email_args=(--email "$artifact_letsencrypt_email") - else - email_args=(--register-unsafely-without-email) - fi - - if [ -f "$cert_file" ]; then - docker run --rm \ - -v "$certbot_conf:/etc/letsencrypt" \ - -v "$certbot_www:/var/www/certbot" \ - -v "$certbot_log:/var/log/letsencrypt" \ - certbot/certbot renew -q - return $? - fi - - docker run --rm \ - -v "$certbot_conf:/etc/letsencrypt" \ - -v "$certbot_www:/var/www/certbot" \ - -v "$certbot_log:/var/log/letsencrypt" \ - certbot/certbot \ - certonly --webroot --webroot-path /var/www/certbot --cert-name "$artifact_nginx_server_name" \ - -d "$artifact_nginx_server_name" --rsa-key-size 4096 \ - --non-interactive --agree-tos "${email_args[@]}" -} - -start_artifact_nginx() { - local certbot_conf - local certbot_www - local certbot_log - local nginx_log - local config_file - local cert_file - - if [ "$use_artifact_nginx" != true ]; then - return 0 - fi - - if ! command -v docker >/dev/null 2>&1; then - echo "WARNING: cannot start artifact nginx because docker is not in PATH." >&2 - return 0 - fi - - if [ -z "$artifact_nginx_server_name" ]; then - echo "WARNING: cannot start artifact nginx because GITTALLY_ARTIFACT_NGINX_SERVER_NAME is empty." >&2 - return 0 - fi - - if ! [[ "$artifact_nginx_http_port" =~ ^[0-9]+$ ]] || - ! [[ "$artifact_nginx_https_port" =~ ^[0-9]+$ ]] || - [ "$artifact_nginx_http_port" -lt 1 ] || [ "$artifact_nginx_http_port" -gt 65535 ] || - [ "$artifact_nginx_https_port" -lt 1 ] || [ "$artifact_nginx_https_port" -gt 65535 ]; then - echo "WARNING: cannot start artifact nginx because nginx ports are invalid." >&2 - return 0 - fi - - if [ -z "$artifact_http_server_url" ]; then - echo "WARNING: cannot start artifact nginx because artifact HTTP server is not running." >&2 - return 0 - fi - - if ! cleanup_stale_artifact_nginx_containers; then - echo "WARNING: artifact nginx was not started because a configured port is still in use." >&2 - return 0 - fi - - certbot_conf="$artifact_nginx_state_dir/certbot/conf" - certbot_www="$artifact_nginx_state_dir/certbot/www" - certbot_log="$artifact_nginx_state_dir/certbot/log" - nginx_log="$artifact_nginx_state_dir/nginx/log" - config_file="$artifact_nginx_state_dir/nginx/nginx.conf" - cert_file="$certbot_conf/live/$artifact_nginx_server_name/fullchain.pem" - - mkdir -p "$certbot_www" "$certbot_log" "$nginx_log" "$(dirname "$config_file")" || { - echo "WARNING: could not prepare artifact nginx state directory: $artifact_nginx_state_dir" >&2 - return 0 - } - chmod 755 "$certbot_www" "$certbot_log" "$nginx_log" - - artifact_nginx_write_ssl_options "$certbot_conf" || { - echo "WARNING: could not prepare artifact nginx SSL options." >&2 - return 0 - } - - if [ -f "$cert_file" ]; then - artifact_nginx_write_config "$config_file" full || return 0 - else - artifact_nginx_write_config "$config_file" init || return 0 - fi - - if ! artifact_nginx_run_container "$config_file" "$certbot_conf" "$certbot_www" "$nginx_log"; then - echo "WARNING: could not start artifact nginx container $artifact_nginx_container_name." >&2 - return 0 - fi - - if ! artifact_nginx_obtain_or_renew_certificate "$certbot_conf" "$certbot_www" "$certbot_log"; then - echo "WARNING: artifact nginx is running, but Let's Encrypt certificate setup failed." >&2 - return 0 - fi - - artifact_nginx_write_config "$config_file" full || return 0 - if ! artifact_nginx_run_container "$config_file" "$certbot_conf" "$certbot_www" "$nginx_log"; then - echo "WARNING: certificate is available, but restarting artifact nginx with HTTPS failed." >&2 - return 0 - fi - - echo "Artifact nginx proxy: http://$artifact_nginx_server_name:$artifact_nginx_http_port/ -> https://$artifact_nginx_server_name:$artifact_nginx_https_port/" - echo "Artifact nginx public URL: $(normalize_base_url "${artifact_public_base_url:-https://$artifact_nginx_server_name/}")" - echo "Artifact nginx upstream: http://$artifact_nginx_upstream_host:$artifact_http_server_port/" - echo "Artifact nginx state: $artifact_nginx_state_dir" -} - -is_below_known_report_index() { - local report_dir="$1" - shift - local known_report_dir - - for known_report_dir in "$@"; do - if [ "$known_report_dir" = "." ]; then - return 0 - fi - if [[ "$report_dir" == "$known_report_dir"/* ]]; then - return 0 - fi - done - return 1 -} - -archived_artefact_dir_path() { - local report_dir="$1" - - if [ "$report_dir" = build/reports ]; then - echo reports - else - echo "reports/$report_dir" - fi -} - -write_archived_artefact_dir_links() { - local index_file="$1" - local artifact_tmp_dir="$2" - local report_dir - local archived_path - local has_artefact_dirs=false - local IFS=';' - - for report_dir in $build_artefact_dirs; do - if [ -z "$report_dir" ]; then - continue - fi - archived_path=$(archived_artefact_dir_path "$report_dir") - if [ ! -d "$artifact_tmp_dir/$archived_path" ]; then - continue - fi - - has_artefact_dirs=true - write_html_link "$index_file" "$archived_path/" "$report_dir" - done - - [ "$has_artefact_dirs" = true ] -} - -write_artifact_index() { - local branch="$1" - local artifact_tmp_dir="$2" - local effective_build_command="$3" - local index_file - local relative_index - local report_dir - local -a report_index_dirs=() - - index_file=$(build_artifact_index_content_file "$artifact_tmp_dir") - { - printf '
    \n' - printf '

    Logs

    \n' - printf '
      \n' - } >"$index_file" - - printf '
    • Build command:
      %s
    • \n' \ - "$(printf '%s' "$effective_build_command" | html_escape)" \ - >>"$index_file" - if [ -f "$artifact_tmp_dir/$build_stdout_log" ]; then - write_html_link "$index_file" "$build_stdout_log" "Build stdout" - elif [ -f "$artifact_tmp_dir/gradle.stdout.log" ]; then - write_html_link "$index_file" "gradle.stdout.log" "Build stdout" - fi - if [ -f "$artifact_tmp_dir/$build_stderr_log" ]; then - write_html_link "$index_file" "$build_stderr_log" "Build stderr" - elif [ -f "$artifact_tmp_dir/gradle.stderr.log" ]; then - write_html_link "$index_file" "gradle.stderr.log" "Build stderr" - fi - - { - printf '
    \n' - printf '

    Build Artifacts

    \n' - printf '
      \n' - } >>"$index_file" - - if [ -d "$artifact_tmp_dir/reports" ]; then - write_archived_artefact_dir_links "$index_file" "$artifact_tmp_dir" || \ - write_html_link "$index_file" "reports/" "Archived artifact directories" - while IFS= read -r relative_index; do - report_dir=$(dirname "$relative_index") - if is_below_known_report_index "$report_dir" "${report_index_dirs[@]}"; then - continue - fi - report_index_dirs+=("$report_dir") - write_html_link "$index_file" "reports/$relative_index" "reports/$relative_index" - done < <(find "$artifact_tmp_dir/reports" -type f -name index.html -printf '%P\n' | awk '{ path=$0; depth=gsub("/", "/", path); print depth, length($0), $0 }' | sort -n -s | cut -d' ' -f3-) - else - printf '
    • No artifact directories were produced by this build.
    • \n' >>"$index_file" - fi - - { - printf '
    \n' - printf '
    \n' - } >>"$index_file" -} - -copy_build_artefact_dirs() { - local artifact_tmp_dir="$1" - local report_dir - local report_target - local IFS=';' - - for report_dir in $build_artefact_dirs; do - if [ -z "$report_dir" ] || [ ! -d "$report_dir" ]; then - continue - fi - mkdir -p "$artifact_tmp_dir/reports" || return 1 - if [ "$report_dir" = build/reports ]; then - cp -a "$report_dir/." "$artifact_tmp_dir/reports/" || return 1 - else - report_target="$artifact_tmp_dir/$(archived_artefact_dir_path "$report_dir")" - mkdir -p "$(dirname "$report_target")" || return 1 - cp -a "$report_dir" "$report_target" || return 1 - fi - done -} - -persist_build_artifacts() { - local branch="$1" - local stdout_file="$2" - local stderr_file="$3" - local artifact_key="$4" - local effective_build_command="$5" - local artifact_dir - local artifact_tmp_dir - - artifact_dir=$(build_artifact_dir "$branch" "$artifact_key") - artifact_tmp_dir="$artifact_dir.tmp.$$" - - rm -rf -- "$artifact_tmp_dir" || return 1 - mkdir -p "$artifact_tmp_dir" || return 1 - - cp "$stdout_file" "$artifact_tmp_dir/$build_stdout_log" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - cp "$stderr_file" "$artifact_tmp_dir/$build_stderr_log" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - copy_build_artefact_dirs "$artifact_tmp_dir" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - write_html_favicon "$(build_artifacts_root)" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - write_script_download "$(build_artifacts_root)" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - write_html_about_page "$(build_artifacts_root)" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - write_html_license_page "$(build_artifacts_root)" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - write_artifact_index "$branch" "$artifact_tmp_dir" "$effective_build_command" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - - rm -rf -- "$artifact_dir" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - mv "$artifact_tmp_dir" "$artifact_dir" || { - rm -rf -- "$artifact_tmp_dir" - return 1 - } - echo "persisted build artifacts:" - echo "file://$artifact_dir" - if [ -n "$artifact_http_server_url" ]; then - echo "${artifact_http_server_url}branches/$artifact_key/index.html" - fi -} - -prune_build_artifacts() { - local results_file - local artifacts_root - local branches_dir - local keep_file - local branch - local commit - local status - local timestamp - local duration - local artifact_key - local artifact_dir - - artifacts_root=$(build_artifacts_root) - branches_dir="$artifacts_root/branches" - if [ ! -d "$branches_dir" ]; then - return 0 - fi - - results_file=$(build_results_file) - keep_file=$(mktemp "$artifacts_root/keep.XXXXXX") - if [ -f "$results_file" ]; then - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - echo "$artifact_key" >>"$keep_file" - done <"$results_file" - fi - - for artifact_dir in "$branches_dir"/*; do - if [ ! -d "$artifact_dir" ]; then - continue - fi - artifact_key=$(basename "$artifact_dir") - if ! grep -Fxq -- "$artifact_key" "$keep_file"; then - rm -rf -- "$artifact_dir" - fi - done - - rm -f "$keep_file" - write_artifacts_root_index -} - -record_build_result() { - local branch="$1" - local status="$2" - local duration="${3:-}" - local timestamp="${4:-}" - local artifact_key="${5:-}" - local results_file - local results_dir - local tmp_file - local commit - - results_file=$(build_results_file) - results_dir=$(dirname "$results_file") - mkdir -p "$results_dir" - - tmp_file=$(mktemp "$results_dir/build-results.XXXXXX") - commit=$(git rev-parse HEAD) - if [ -z "$timestamp" ]; then - timestamp=$(date -Iseconds) - fi - if [ -z "$artifact_key" ]; then - artifact_key=$(build_artifact_branch_key "$branch") - fi - - if [ -f "$results_file" ]; then - awk -F '\t' \ - -v artifact_key="$artifact_key" \ - -v branch="$branch" \ - '($6 == "" && artifact_key == $1) || $6 == artifact_key || ($1 == branch && ($3 == "pending" || $3 == "running")) { next } { print }' \ - "$results_file" >"$tmp_file" - fi - - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file" - mv "$tmp_file" "$results_file" - publish_gitea_build_status "$commit" "$status" "$branch" "$artifact_key" || true - prune_build_results -} - -prune_build_results() { - local results_file - local results_dir - local tmp_file - local filtered_file - local sorted_file - local branch - local commit - local status - local timestamp - local duration - local artifact_key - local previous_branch= - local cutoff_epoch - local timestamp_epoch - - results_file=$(build_results_file) - if [ ! -f "$results_file" ]; then - prune_build_artifacts - write_artifacts_root_index - return 0 - fi - - results_dir=$(dirname "$results_file") - tmp_file=$(mktemp "$results_dir/build-results.XXXXXX") - filtered_file=$(mktemp "$results_dir/build-results.XXXXXX") - sorted_file=$(mktemp "$results_dir/build-results.XXXXXX") - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - if git show-ref --quiet --verify "refs/remotes/origin/$branch"; then - normalize_build_result_fields - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$filtered_file" - fi - done <"$results_file" - - sort -t $'\t' -k1,1 -k4,4r "$filtered_file" >"$sorted_file" - - if artifact_build_retention_is_count; then - awk -F '\t' -v limit="$artifact_build_retention_per_branch" '++seen[$1] <= limit' "$sorted_file" >"$tmp_file" - else - cutoff_epoch=$(artifact_build_retention_cutoff_epoch) - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ "$branch" != "$previous_branch" ]; then - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file" - previous_branch="$branch" - continue - fi - timestamp_epoch=$(date -d "$timestamp" +%s 2>/dev/null || echo 0) - if [ "$timestamp_epoch" -ge "$cutoff_epoch" ]; then - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file" - fi - done <"$sorted_file" - fi - - mv "$tmp_file" "$results_file" - rm -f "$filtered_file" "$sorted_file" - prune_build_artifacts - write_artifacts_root_index -} - -mark_running_builds_interrupted() { - local results_file - local results_dir - local tmp_file - local branch - local commit - local status - local timestamp - local duration - local artifact_key - local interrupted_count=0 - local superseded_pending_count=0 - local -A latest_timestamp_by_branch=() - - results_file=$(build_results_file) - [ -f "$results_file" ] || return 0 - - results_dir=$(dirname "$results_file") - tmp_file=$(mktemp "$results_dir/build-results.XXXXXX") || return 1 - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ -z "${latest_timestamp_by_branch[$branch]:-}" ] || - [[ "$timestamp" > "${latest_timestamp_by_branch[$branch]}" ]]; then - latest_timestamp_by_branch[$branch]="$timestamp" - fi - done <"$results_file" - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ "$status" = running ]; then - status=interrupted - interrupted_count=$((interrupted_count + 1)) - publish_gitea_build_status "$commit" "$status" "$branch" "$artifact_key" || true - elif [ "$status" = pending ] && [[ "$timestamp" < "${latest_timestamp_by_branch[$branch]}" ]]; then - status=interrupted - superseded_pending_count=$((superseded_pending_count + 1)) - publish_gitea_build_status "$commit" "$status" "$branch" "$artifact_key" || true - fi - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file" - done <"$results_file" - - if [ "$interrupted_count" -eq 0 ] && [ "$superseded_pending_count" -eq 0 ]; then - rm -f "$tmp_file" - return 0 - fi - - mv "$tmp_file" "$results_file" - if [ "$interrupted_count" -gt 0 ]; then - echo "Marked $interrupted_count stale running build(s) as interrupted." - fi - if [ "$superseded_pending_count" -gt 0 ]; then - echo "Marked $superseded_pending_count superseded pending build(s) as interrupted." - fi - write_current_build_page - write_artifacts_root_index -} - -is_restartable_build_status() { - case "$1" in - running|interrupted|pending) - return 0 - ;; - *) - return 1 - ;; - esac -} - -latest_build_status_for_branch() { - local wanted_branch="$1" - local results_file - local branch - local commit - local status - local timestamp - local duration - local artifact_key - - results_file=$(build_results_file) - [ -f "$results_file" ] || return 1 - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ "$branch" = "$wanted_branch" ]; then - effective_build_status "$commit" "$status" - return 0 - fi - done < <(sort -t $'\t' -k4,4r "$results_file") - - return 1 -} - -branch_has_restartable_build() { - local branch="$1" - local status - - status=$(latest_build_status_for_branch "$branch") || return 1 - is_restartable_build_status "$status" -} - -branch_has_failed_build() { - local branch="$1" - local status - - status=$(latest_build_status_for_branch "$branch") || return 1 - [ "$status" = failed ] -} - -restartable_build_branches() { - local results_file - local branch - local commit - local status - local local_status - local effective_status - local timestamp - local duration - local artifact_key - local -A seen=() - - results_file=$(build_results_file) - if [ ! -f "$results_file" ]; then - echo "No build results file found; no restartable builds to scan." >&2 - return 0 - fi - - echo "Scanning latest build results for restartable builds ..." >&2 - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ -n "${seen[$branch]:-}" ]; then - continue - fi - seen[$branch]=true - local_status="$status" - effective_status=$(effective_build_status "$commit" "$status") - if ! branch_exists_on_origin "$branch"; then - if is_restartable_build_status "$local_status" || is_restartable_build_status "$effective_status"; then - echo "Skipping restartable build for $branch: branch no longer exists on origin." >&2 - fi - continue - fi - if is_restartable_build_status "$effective_status"; then - echo "Found restartable build: $branch ($effective_status)." >&2 - echo "$branch" - elif is_restartable_build_status "$local_status"; then - echo "Skipping locally $local_status build for $branch: effective status is $effective_status." >&2 - fi - done < <(sort -t $'\t' -k4,4r "$results_file") -} - -failed_build_branches() { - local results_file - local branch - local commit - local status - local timestamp - local duration - local artifact_key - local -A seen=() - - results_file=$(build_results_file) - [ -f "$results_file" ] || return 0 - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ -n "${seen[$branch]:-}" ]; then - continue - fi - seen[$branch]=true - status=$(effective_build_status "$commit" "$status") - if [ "$status" = failed ] && branch_exists_on_origin "$branch"; then - echo "$branch" - fi - done < <(sort -t $'\t' -k4,4r "$results_file") -} - -next_pending_build_branch() { - local results_file - local branch - local commit - local status - local timestamp - local duration - local artifact_key - local -A seen=() - - results_file=$(build_results_file) - [ -f "$results_file" ] || return 1 - - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - if [ -n "${seen[$branch]:-}" ]; then - continue - fi - seen[$branch]=true - status=$(effective_build_status "$commit" "$status") - if [ "$status" = pending ] && branch_exists_on_origin "$branch"; then - if ! branch_matches_current_worktree_branch "$branch"; then - continue - fi - echo "Picking up pending build: $branch" >&2 - echo "$branch" - return 0 - fi - done < <(sort -t $'\t' -k4,4r "$results_file") - - return 1 -} - -fetch_origin() { - git_with_gitea_token fetch --prune origin >/dev/null || return 1 - prune_build_results -} - -retry_fetch_origin() { - until fetch_origin; do - echo "checking origin failed; retrying in 10s ..." >&2 - sleep 10 - done -} - -print_build_results() { - local results_file - local branch - local commit - local status - local display_status - local timestamp - local duration - local artifact_key - local has_results=false - local green="" - local yellow="" - local red="" - local blue="" - local reset="" - - prune_build_results - - if [ -t 1 ] && command -v tput >/dev/null 2>&1; then - green=$(tput setaf 2) - yellow=$(tput setaf 3) - red=$(tput setaf 1) - blue=$(tput setaf 4) - reset=$(tput sgr0) - fi - - results_file=$(build_results_file) - echo - print_build_banner "latest build results:" - if [ -f "$results_file" ]; then - while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do - normalize_build_result_fields - display_status=$(effective_build_status "$commit" "$status") - case "$display_status" in - success|passed) - echo "${green}success: $branch${reset}" - has_results=true - ;; - failed) - echo "${red}failed: $branch${reset}" - has_results=true - ;; - interrupted) - echo "${yellow}interrupted: $branch${reset}" - has_results=true - ;; - cancelled) - echo "${yellow}cancelled: $branch${reset}" - has_results=true - ;; - running) - echo "${blue}running: $branch${reset}" - has_results=true - ;; - pending) - echo "${yellow}pending: $branch${reset}" - has_results=true - ;; - esac - done <"$results_file" - fi - - if [ "$has_results" = false ]; then - echo "(none)" - fi - printf '%*s\n' 80 '' | tr ' ' '-' - echo -} - -print_build_summary() { - local branch="$1" - local status="$2" - - echo "BUILD $status: $branch" -} - -branch_config_value_or_bootstrap() { - local primary_name="$1" - local fallback_name="$2" - local bootstrap_value="$3" - local branch_value - - if branch_value=$(branch_config_value "$primary_name" "$fallback_name"); then - printf '%s' "$branch_value" - else - printf '%s' "$bootstrap_value" - fi -} - -resolve_branch_docker_config() { - docker_build_image=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKER_IMAGE HSADMIN_NG_BUILD_IMAGE "$bootstrap_docker_build_image") - docker_build_dockerfile=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKERFILE "" "$bootstrap_docker_build_dockerfile") - docker_build_context=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKER_CONTEXT "" "$bootstrap_docker_build_context") - docker_build_network=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKER_NETWORK HSADMIN_NG_BUILD_NETWORK "$bootstrap_docker_build_network") - docker_build_preflight_command=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "" "$bootstrap_docker_build_preflight_command") - docker_build_env=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKER_ENV "" "$bootstrap_docker_build_env") - docker_build_java_tool_options=$(branch_config_value_or_bootstrap \ - GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "" "$bootstrap_docker_build_java_tool_options") -} - -file_sha256() { - local file="$1" - - if [ ! -f "$file" ]; then - echo "ERROR: Dockerfile not found: $file" >&2 - return 1 - fi - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file" | awk '{ print $1 }' - elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" | awk '{ print $1 }' - else - echo "ERROR: cannot calculate Dockerfile checksum; sha256sum or shasum is required." >&2 - return 1 - fi -} - -text_sha256() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum | awk '{ print $1 }' - elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 | awk '{ print $1 }' - else - echo "ERROR: cannot calculate checksum; sha256sum or shasum is required." >&2 - return 1 - fi -} - -docker_build_inputs_sha256() { - local dockerfile_hash - - dockerfile_hash=$(file_sha256 "$docker_build_dockerfile") || return 1 - printf '%s\n%s\n%s\n' "$dockerfile_hash" "$docker_build_dockerfile" "$docker_build_context" | text_sha256 -} - -ensure_docker_build_image() { - local dockerfile_hash - local build_inputs_hash - local image_build_inputs_hash - - if ! command -v docker >/dev/null 2>&1; then - echo "ERROR: --docker requires docker in PATH." >&2 - return 1 - fi - - dockerfile_hash=$(file_sha256 "$docker_build_dockerfile") || return 1 - build_inputs_hash=$(docker_build_inputs_sha256) || return 1 - - if docker image inspect "$docker_build_image" >/dev/null 2>&1; then - image_build_inputs_hash=$(docker image inspect "$docker_build_image" \ - --format '{{ index .Config.Labels "org.gittally.build-inputs-sha256" }}' 2>/dev/null || true) - if [ "$image_build_inputs_hash" = "$build_inputs_hash" ]; then - return 0 - fi - echo "Docker build image is stale: $docker_build_image" - echo "Docker build input checksum changed or is missing on the image label." - else - echo "Docker build image not found: $docker_build_image" - fi - - echo "Building Docker image from $docker_build_dockerfile ..." - docker build \ - --label "org.gittally.dockerfile=$docker_build_dockerfile" \ - --label "org.gittally.dockerfile-sha256=$dockerfile_hash" \ - --label "org.gittally.build-context=$docker_build_context" \ - --label "org.gittally.build-inputs-sha256=$build_inputs_hash" \ - -t "$docker_build_image" \ - -f "$docker_build_dockerfile" \ - "$docker_build_context" || return 1 -} - -run_build_command() { - local branch="$1" - local effective_build_command="$2" - - if [ "$use_docker_build" = true ]; then - run_build_command_in_docker "$branch" "$effective_build_command" - else - if [ -n "$build_clean_command" ]; then - branch="$branch" bash -c "$build_clean_command" || return 1 - fi - branch="$branch" bash -c "$effective_build_command" - fi -} - -docker_gradle_user_home_volume_name() { - echo "gittally-gradle-$(safe_container_name_part "$(repository_key)")" -} - -docker_build_container_name() { - echo "gittally-build-$(safe_container_name_part "$(repository_key)")" -} - -prepare_docker_gradle_volume() { - local gradle_user_home_volume="$1" - local uid - local gid - - uid=$(id -u) - gid=$(id -g) - echo "Preparing Docker Gradle cache volume: $gradle_user_home_volume ..." - docker volume create "$gradle_user_home_volume" >/dev/null || { - echo "ERROR: cannot create Docker Gradle cache volume: $gradle_user_home_volume" >&2 - return 1 - } - docker run --rm --user 0 \ - --volume "$gradle_user_home_volume:/gradle-user-home" \ - "$docker_build_image" \ - sh -c 'mkdir -p /gradle-user-home/wrapper/dists && chown -R "$1:$2" /gradle-user-home && chmod -R u+rwX /gradle-user-home' \ - sh "$uid" "$gid" || { - echo "ERROR: cannot prepare Docker Gradle cache volume: $gradle_user_home_volume" >&2 - return 1 - } -} - -prepare_docker_workspace_build_dir() { - local uid - local gid - - uid=$(id -u) - gid=$(id -g) - echo "Preparing Docker workspace build directory ..." - docker run --rm --user 0 \ - --workdir "$PWD" \ - --volume "$PWD:$PWD" \ - "$docker_build_image" \ - sh -c ' - bash -c "$3" && - mkdir -p build && - chown -R "$1:$2" build && - chmod -R u+rwX build - ' \ - sh "$uid" "$gid" "$build_clean_command" || { - echo "ERROR: cannot prepare Docker workspace build directory: $PWD/build" >&2 - return 1 - } -} - -repair_docker_workspace_ownership() { - local uid - local gid - - uid=$(id -u) - gid=$(id -g) - docker run --rm --user 0 \ - --workdir "$PWD" \ - --volume "$PWD:$PWD" \ - "$docker_build_image" \ - sh -c ' - for path in build .gradle; do - if [ -e "$path" ]; then - chown -R "$1:$2" "$path" && - chmod -R u+rwX "$path" - fi - done - ' \ - sh "$uid" "$gid" || { - echo "WARNING: could not repair Docker workspace ownership." >&2 - return 1 - } -} - -run_build_command_in_docker() { - local branch="$1" - local effective_build_command="$2" - local docker_host="${DOCKER_HOST:-}" - local container_docker_host="$docker_host" - local docker_container_user - local docker_socket_path= - local docker_socket_group= - local build_exit_code - local gradle_user_home_volume - local build_container_name - local env_name - local build_env_assignment - local testcontainers_host_override="${TESTCONTAINERS_HOST_OVERRIDE:-}" - local java_tool_options="${JAVA_TOOL_OPTIONS:-}" - local -a docker_args - local -a docker_label_args - local -a build_env_assignments=() - - if [ -z "$docker_host" ]; then - if [ -S "/run/user/$(id -u)/docker.sock" ]; then - docker_host="unix:///run/user/$(id -u)/docker.sock" - else - docker_host="unix:///var/run/docker.sock" - fi - container_docker_host="$docker_host" - fi - - ensure_docker_build_image || return 1 - - gradle_user_home_volume=$(docker_gradle_user_home_volume_name) - prepare_docker_gradle_volume "$gradle_user_home_volume" || return 1 - prepare_docker_workspace_build_dir || return 1 - - build_container_name=$(docker_build_container_name) - remove_docker_container_by_name "$build_container_name" - docker_args=(run --rm --name "$build_container_name") - mapfile -t docker_label_args < <(gittally_docker_label_args build) - docker_args+=("${docker_label_args[@]}") - if [ -t 0 ]; then - docker_args+=(--interactive) - fi - if [ -t 1 ]; then - docker_args+=(--tty) - fi - docker_container_user=0 - - docker_args+=( - --workdir "$PWD" - --volume "$PWD:$PWD" - --volume "$gradle_user_home_volume:/gradle-user-home" - --env "HOME=/tmp/docker-home" - --env "GRADLE_USER_HOME=/gradle-user-home" - --env "branch=$branch" - ) - - if [ -n "$docker_build_env" ]; then - read -r -a build_env_assignments <<<"$docker_build_env" - for build_env_assignment in "${build_env_assignments[@]}"; do - docker_args+=(--env "$build_env_assignment") - done - fi - - if [ -n "$docker_build_network" ]; then - docker_args+=(--network "$docker_build_network") - fi - - if [[ "$docker_host" == unix://* ]]; then - docker_socket_path="${docker_host#unix://}" - if [ ! -S "$docker_socket_path" ]; then - echo "ERROR: Docker socket not found: $docker_socket_path" >&2 - return 1 - fi - if [ "$docker_socket_path" = "/run/user/$(id -u)/docker.sock" ]; then - docker_container_user="$(id -u)" - fi - container_docker_host="unix:///var/run/docker.sock" - docker_args+=( - --volume "$docker_socket_path:/var/run/docker.sock" - --env "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock" - ) - docker_socket_group=$(stat -c '%g' "$docker_socket_path" 2>/dev/null || true) - if [ -n "$docker_socket_group" ] && [ "$docker_socket_path" != "/run/user/$(id -u)/docker.sock" ]; then - docker_args+=(--group-add "$docker_socket_group") - fi - fi - - docker_args+=(--user "$docker_container_user") - - docker_args+=(--env "DOCKER_HOST=$container_docker_host") - if [ -z "$testcontainers_host_override" ]; then - if [ "$docker_build_network" = host ]; then - testcontainers_host_override=localhost - else - testcontainers_host_override=host.docker.internal - docker_args+=(--add-host "host.docker.internal:host-gateway") - fi - fi - docker_args+=(--env "TESTCONTAINERS_HOST_OVERRIDE=$testcontainers_host_override") - java_tool_options="$java_tool_options $docker_build_java_tool_options" - java_tool_options="$java_tool_options -Dtestcontainers.host.override=$testcontainers_host_override" - docker_args+=(--env "JAVA_TOOL_OPTIONS=$java_tool_options") - - for env_name in \ - HSADMINNG_POSTGRES_ADMIN_USERNAME \ - HSADMINNG_POSTGRES_RESTRICTED_USERNAME \ - HSADMINNG_MIGRATION_DATA_PATH \ - TESTCONTAINERS_LOG_LEVEL; do - if [ -n "${!env_name+x}" ]; then - docker_args+=(--env "$env_name") - fi - done - - echo "Checking Docker access inside build container ..." - if [ -n "$docker_build_preflight_command" ] && - ! docker "${docker_args[@]}" "$docker_build_image" bash -c "$docker_build_preflight_command" >/dev/null; then - echo "ERROR: Docker is not reachable from inside the build container." >&2 - echo " DOCKER_HOST inside container: $container_docker_host" >&2 - echo " TESTCONTAINERS_HOST_OVERRIDE inside container: $testcontainers_host_override" >&2 - return 1 - fi - - echo "Docker image: $docker_build_image" - if [[ "$container_docker_host" == unix://* ]]; then - docker "${docker_args[@]}" "$docker_build_image" \ - sh -c 'mkdir -p "$HOME" && { - printf "%s\n" "docker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy" - printf "%s\n" "docker.host=unix:///var/run/docker.sock" - printf "%s\n" "testcontainers.docker.socket.override=/var/run/docker.sock" - printf "%s\n" "testcontainers.host.override=$TESTCONTAINERS_HOST_OVERRIDE" - } >"$HOME/.testcontainers.properties" && exec bash -c "$1"' \ - sh "$effective_build_command" - else - docker "${docker_args[@]}" "$docker_build_image" bash -c "$effective_build_command" - fi - build_exit_code=$? - repair_docker_workspace_ownership || true - return "$build_exit_code" -} - -branch_config_build_command() { - local checkout_repo_root - local config_file - local value_file - local status - - checkout_repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1 - config_file="$checkout_repo_root/.gitTally" - if [ ! -f "$config_file" ]; then - return 1 - fi - - value_file=$(mktemp "${TMPDIR:-/tmp}/gittally-build-command.XXXXXX") || return 1 - ( - unset GITTALLY_BUILD_COMMAND - set -a - # shellcheck source=/dev/null - . "$config_file" >/dev/null - set +a - if [ -n "${GITTALLY_BUILD_COMMAND+x}" ]; then - printf '%s' "$GITTALLY_BUILD_COMMAND" >"$value_file" - else - exit 1 - fi - ) - status=$? - - if [ "$status" -eq 0 ]; then - cat "$value_file" - fi - rm -f "$value_file" - return "$status" -} - -build_command_for_current_checkout() { - local branch_build_command - - if branch_build_command=$(branch_config_build_command); then - printf '%s' "$branch_build_command" - else - printf '%s' "$environment_build_command" - fi -} - -build_current_checkout() { - local branch - local build_exit_code - local effective_build_command - local started_at - local ended_at - local started_timestamp - local ended_timestamp - local build_duration - local artifact_key - local build_lock_fd= - local build_lock_path - local build_lock_dir - local artifacts_root - local current_log_file - local build_stdout_file - local build_stderr_file - local build_stdout_pipe - local build_stderr_pipe - local tee_stdout_pid - local tee_stderr_pid - - branch=$(git branch --show-current) - if [ -z "$branch" ]; then - branch="detached HEAD" - fi - - effective_build_command=$(build_command_for_current_checkout) || return 1 - if [ "$use_docker_build" = true ]; then - resolve_branch_docker_config - fi - - print_build_banner "building branch: $branch" - echo "working directory: $PWD" - if [[ "$effective_build_command" == *"./gradlew"* ]] && [ ! -x ./gradlew ]; then - echo "ERROR: ./gradlew not found or not executable in $PWD" >&2 - return 1 - fi - if [[ "$effective_build_command" == *"./gradlew"* ]]; then - echo "gradle wrapper: $(realpath ./gradlew)" - fi - if [ "$use_docker_build" = true ]; then - echo "build runtime: Docker image $docker_build_image" - fi - echo "build command: $effective_build_command" - if [ "$use_docker_build" != true ] && [ -n "$build_clean_command" ]; then - echo "clean command: $build_clean_command" - fi - started_at=$(date +%s) - started_timestamp=$(date -Iseconds) - artifact_key=$(build_artifact_key "$branch" "$started_timestamp") - active_build_branch="$branch" - active_build_artifact_key="$artifact_key" - active_build_started_at="$started_at" - record_build_result "$branch" pending "" "$started_timestamp" "$artifact_key" - clear_build_cancel_request - if ! write_build_cancel_token; then - echo "WARNING: could not prepare build cancellation token." >&2 - fi - artifacts_root=$(build_artifacts_root) - mkdir -p "$artifacts_root" || return 1 - current_log_file=$(current_build_log_file) - { - printf 'building branch: %s\n' "$branch" - printf 'started: %s\n' "$(display_build_timestamp "$started_timestamp")" - printf 'working directory: %s\n' "$PWD" - printf 'build command: %s\n' "$effective_build_command" - if [ "$use_docker_build" != true ] && [ -n "$build_clean_command" ]; then - printf 'clean command: %s\n' "$build_clean_command" - fi - printf '\n\n' - } >"$current_log_file" - write_current_build_page "$branch" running "$started_timestamp" - build_stdout_file=$(mktemp "$artifacts_root/build-stdout.XXXXXX") || return 1 - build_stderr_file=$(mktemp "$artifacts_root/build-stderr.XXXXXX") || { - rm -f "$build_stdout_file" - return 1 - } - build_stdout_pipe=$(mktemp "$artifacts_root/build-stdout-pipe.XXXXXX") || { - rm -f "$build_stdout_file" "$build_stderr_file" - return 1 - } - build_stderr_pipe=$(mktemp "$artifacts_root/build-stderr-pipe.XXXXXX") || { - rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" - return 1 - } - rm -f "$build_stdout_pipe" "$build_stderr_pipe" - if ! mkfifo "$build_stdout_pipe" "$build_stderr_pipe"; then - rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" "$build_stderr_pipe" - return 1 - fi - build_lock_path=$(build_lock_file) - build_lock_dir=$(dirname "$build_lock_path") - mkdir -p "$build_lock_dir" - if command -v flock >/dev/null 2>&1; then - exec {build_lock_fd}>"$build_lock_path" || { - rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" "$build_stderr_pipe" - return 1 - } - echo "waiting for build lock: $build_lock_path" - if ! flock -n "$build_lock_fd"; then - echo "build lock is already held: $build_lock_path" - cleanup_stale_build_runtime || true - terminate_stale_build_lock_holders "$build_lock_path" || true - echo "waiting up to 30s for build lock: $build_lock_path" - fi - if ! flock -w 30 "$build_lock_fd"; then - echo "ERROR: could not acquire build lock: $build_lock_path" >&2 - exec {build_lock_fd}>&- - rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" "$build_stderr_pipe" - return 1 - fi - echo "acquired build lock: $build_lock_path" - fi - tee "$build_stdout_file" <"$build_stdout_pipe" | tee -a "$current_log_file" & - tee_stdout_pid=$! - tee "$build_stderr_file" <"$build_stderr_pipe" | tee -a "$current_log_file" >&2 & - tee_stderr_pid=$! - record_build_result "$branch" running "" "$started_timestamp" "$artifact_key" - run_build_command "$branch" "$effective_build_command" >"$build_stdout_pipe" 2>"$build_stderr_pipe" & - active_build_pid=$! - wait_for_active_build "$active_build_pid" - build_exit_code=$? - active_build_pid= - clear_build_cancel_request - wait "$tee_stdout_pid" || true - wait "$tee_stderr_pid" || true - rm -f "$build_stdout_pipe" "$build_stderr_pipe" - ended_at=$(date +%s) - ended_timestamp=$(date -Iseconds) - build_duration=$(format_build_duration "$((ended_at - started_at))") - if [ "$active_build_cancelled" = true ]; then - echo "build cancelled after $build_duration" - printf '\nbuild cancelled after %s\n' "$build_duration" >>"$current_log_file" - else - echo "build finished with exit code $build_exit_code after $build_duration" - printf '\nbuild finished with exit code %s after %s\n' "$build_exit_code" "$build_duration" >>"$current_log_file" - fi - persist_build_artifacts "$branch" "$build_stdout_file" "$build_stderr_file" "$artifact_key" "$effective_build_command" || \ - echo "WARNING: could not persist build artifacts for branch: $branch" >&2 - rm -f "$build_stdout_file" "$build_stderr_file" - if [ -n "$build_lock_fd" ]; then - flock -u "$build_lock_fd" || true - exec {build_lock_fd}>&- - fi - if [ "$active_build_cancelled" = true ]; then - active_build_branch= - active_build_artifact_key= - active_build_started_at= - active_build_cancelled=false - record_build_result "$branch" cancelled "$build_duration" "$ended_timestamp" "$artifact_key" - write_current_build_page "$branch" cancelled "$started_timestamp" - print_build_summary "$branch" CANCELLED - print_build_results - elif [ "$build_exit_code" -eq 0 ]; then - active_build_branch= - active_build_artifact_key= - active_build_started_at= - record_build_result "$branch" success "$build_duration" "$ended_timestamp" "$artifact_key" - write_current_build_page "$branch" success "$started_timestamp" - print_build_summary "$branch" SUCCESS - print_build_results - else - active_build_branch= - active_build_artifact_key= - active_build_started_at= - record_build_result "$branch" failed "$build_duration" "$ended_timestamp" "$artifact_key" - write_current_build_page "$branch" failed "$started_timestamp" - print_build_summary "$branch" FAILED - handle_build_failure_prompt "$branch" "$artifact_key" - print_build_results - return 0 - fi -} - -has_new_commits() { - local local_ref="$1" - local upstream_ref="$2" - local count - - if ! git show-ref --quiet --verify "$local_ref" || ! git show-ref --quiet --verify "$upstream_ref"; then - return 1 - fi - - count=$(git rev-list --count "$local_ref..$upstream_ref") || return 2 - [ "${count:-0}" -gt 0 ] -} - -changed_local_branches() { - local branches - local branch - local upstream - local has_new_commits_status - - branches=$(git for-each-ref --format='%(refname:strip=2)' refs/heads) || return 1 - while read -r branch; do - if [ -z "$branch" ]; then - continue - fi - - if ! branch_exists_on_origin "$branch"; then - continue - fi - - if ! branch_matches_current_worktree_branch "$branch"; then - continue - fi - - upstream=$(git for-each-ref --format='%(upstream)' "refs/heads/$branch") || return 1 - if [ -n "$upstream" ]; then - if has_new_commits "refs/heads/$branch" "$upstream"; then - echo "$branch" - else - has_new_commits_status=$? - if [ "$has_new_commits_status" -ne 1 ]; then - return "$has_new_commits_status" - fi - fi - elif git show-ref --quiet --verify "refs/remotes/origin/$branch"; then - if has_new_commits "refs/heads/$branch" "refs/remotes/origin/$branch"; then - echo "$branch" - else - has_new_commits_status=$? - if [ "$has_new_commits_status" -ne 1 ]; then - return "$has_new_commits_status" - fi - fi - fi - done <<<"$branches" -} - -recent_new_origin_branches() { - local cutoff - local branches - local branch - local commit_date - - if [ "$stay_on_current_branch" = true ]; then - return 0 - fi - - cutoff=$(new_branch_commit_max_age_cutoff_epoch) - branches=$(git for-each-ref --sort=-committerdate --format='%(refname:strip=3) %(committerdate:unix)' refs/remotes/origin) || return 1 - while read -r branch commit_date; do - if [ "$branch" = "HEAD" ]; then - continue - fi - if [ -z "$branch" ]; then - continue - fi - - if git show-ref --quiet --verify "refs/heads/$branch"; then - continue - fi - - if [ "$commit_date" -lt "$cutoff" ]; then - if ! grep -Fxq -- "$branch" "$reported_skipped_new_branches"; then - echo "$branch" >>"$reported_skipped_new_branches" - echo "skipping new origin branch $branch: latest commit is older than $new_branch_commit_max_age" >&2 - fi - continue - fi - - echo "$branch" - done <<<"$branches" -} - -next_branch_to_build() { - local branches - - branches=$( - changed_local_branches || exit 1 - recent_new_origin_branches || exit 1 - ) || return 1 - - if [ -n "$branches" ]; then - printf '%s\n' "$branches" | awk '!seen[$0]++' | head -n1 - fi -} - -auto_build_check() { - [ -n "$auto_build_branches" ] || return 0 - - local today now_hhmm matched_slot state_file state_dir - today=$(date -u +%Y-%m-%d) - now_hhmm=$(date -u +%H:%M) - - # Find the latest configured slot at or before current UTC time. - matched_slot="" - local IFS=';' - local slot - for slot in $auto_build_times; do - if [[ ! "$slot" =~ ^[0-2][0-9]:[0-5][0-9]$ ]]; then - echo "WARNING: skipping invalid auto-build time slot: '$slot'; expected HH:MM." >&2 - continue - fi - [[ ! "$slot" > "$now_hhmm" ]] && matched_slot="$slot" - done - [ -n "$matched_slot" ] || return 0 - - state_file=$(auto_builds_state_file) - state_dir=$(dirname "$state_file") - mkdir -p "$state_dir" - - local branch - for branch in $auto_build_branches; do - if grep -qF "${branch}"$'\t'"${today}"$'\t'"${matched_slot}" "$state_file" 2>/dev/null; then - continue - fi - printf '%s\t%s\t%s\n' "$branch" "$today" "$matched_slot" >> "$state_file" - echo "auto build scheduled: $branch (slot $matched_slot)" >&2 - echo "$branch" - return 0 - done -} - -retry_origin_change_check() { - local branch - - while true; do - if branch=$(next_pending_build_branch); then - echo "$branch" - return 0 - fi - retry_fetch_origin - if branch=$(next_branch_to_build); then - echo "$branch" - return 0 - fi - echo "checking for new branches or commits failed; retrying in 10s ..." >&2 - sleep 10 - done -} - -restart_interrupted_or_running_builds() { - local branch - local has_restartable_build=false - - while IFS= read -r branch; do - if ! branch_matches_current_worktree_branch "$branch"; then - continue - fi - has_restartable_build=true - echo "Restarting pending, interrupted, or stale running build: $branch" - checkout_and_build "$branch" || return 1 - done < <(restartable_build_branches) - - if [ "$has_restartable_build" = true ]; then - print_build_results - else - echo "No restartable pending, interrupted, or stale running builds found." - fi -} - -retry_failed_builds() { - local branch - local has_failed_build=false - - if [ "$retry_failed_builds_requested" != true ]; then - return 0 - fi - - while IFS= read -r branch; do - if ! branch_matches_current_worktree_branch "$branch"; then - continue - fi - has_failed_build=true - echo "Retrying failed build: $branch" - checkout_and_build "$branch" || return 1 - done < <(failed_build_branches) - - if [ "$has_failed_build" = true ]; then - print_build_results - fi -} - -for arg in "$@"; do - case "$arg" in - --install) - install_after_pull=true - ;; - --systemd) - install_systemd_after_install=true - systemd_command_given=true - ;; - --systemd:start|--systemd:stop|--systemd:reload|--systemd:status|--systemd:log|--systemd:watch|--systemd:enable|--systemd:disable) - systemd_action="${arg#--systemd:}" - systemd_command_given=true - ;; - --pull) - pull_current_branch=true - ;; - --docker) - use_docker_build=true - ;; - --http) - use_artifact_http_server=true - ;; - --open) - open_artifact_frontend=true - use_artifact_http_server=true - ;; - --nginx) - use_artifact_nginx=true - use_artifact_http_server=true - ;; - --retry) - retry_failed_builds_requested=true - ;; - --stay) - stay_on_current_branch=true - ;; - -h|--help) - usage - exit 0 - ;; - -*) - echo "Unknown option: $arg" - usage - exit 1 - ;; - *) - branches_to_build+=("$(normalize_branch_name "$arg")") - ;; - esac -done - -if [ -n "$systemd_action" ]; then - run_systemd_action "$systemd_action" - exit $? -fi - -if [ "$install_systemd_after_install" = true ] && [ "$install_after_pull" != true ]; then - echo "ERROR: --systemd requires --install." >&2 - exit 1 -fi - -validate_stay_on_current_branch || exit 1 - -echo "$tool_name version $script_version" - -detect_gitea_repo -configure_artifact_nginx_defaults -validate_artifact_build_retention_per_branch -validate_new_branch_commit_max_age -validate_auto_build_times -if [ "$pull_current_branch" = true ] || [ "$install_after_pull" != true ] || [ "${#branches_to_build[@]}" -gt 0 ]; then - validate_gitea_git_credentials || exit 1 -fi - -if gitea_status_enabled; then - echo "Gitea build status: ${gitea_base_url%/}/$gitea_owner/$gitea_repo ($gitea_status_context)" -elif [ -n "$gitea_token" ]; then - echo "WARNING: Gitea build status disabled because base URL, owner, repo, curl, or python3 is missing." >&2 -fi - -if [ "$pull_current_branch" = true ]; then - pull_current_branch_from_origin || exit 1 -fi - -if [ "$install_after_pull" = true ]; then - forwarded_args=() - for arg in "$@"; do - case "$arg" in - --install|--pull|--systemd) - ;; - --systemd:*) - ;; - *) - forwarded_args+=("$arg") - ;; - esac - done - - install_to_bin - if [ "$install_systemd_after_install" = true ]; then - install_systemd_service "$(dirname "$installed_script_path")/$systemd_unit_name" - fi - if [ "${#forwarded_args[@]}" -gt 0 ]; then - GITTALLY_BIN_FORWARD=true exec "$installed_script_path" "${forwarded_args[@]}" - fi - exit 0 -fi - -if [ "$pull_current_branch" = true ]; then - exit 0 -fi - -if [ "$systemd_command_given" = true ]; then - exit 0 -fi - -retry_fetch_origin -mark_running_builds_interrupted || exit 1 -start_artifact_http_server -open_artifact_frontend_if_requested -start_artifact_nginx -if [ "${#branches_to_build[@]}" -eq 0 ]; then - restart_interrupted_or_running_builds || exit 1 - retry_failed_builds || exit 1 -fi - -for branch in "${branches_to_build[@]}"; do - checkout_requested_branch "$branch" || exit 1 -done - -start_resource_monitor -write_system_page || true -write_env_page || true -echo "$tool_name version $script_version: service ready" - -while true; do - branch_to_build=$(auto_build_check) - if [ -z "$branch_to_build" ]; then - branch_to_build=$(retry_origin_change_check) - fi - - if [ -n "$branch_to_build" ]; then - checkout_and_build "$branch_to_build" - continue - fi - - # wait 10s with a little animation - echo -e -n "\r\033[K waiting for changes (/) ..." - sleep 2 - echo -e -n "\r\033[K waiting for changes (-) ..." - sleep 2 - echo -e -n "\r\033[K waiting for changes (\) ..." - sleep 2 - echo -e -n "\r\033[K waiting for changes (|) ..." - sleep 2 - echo -e -n "\r\033[K waiting for changes ( ) ... " - sleep 2 - echo -e -n "\r\033[K checking for changes" -done diff --git a/packaging/gittally b/packaging/gittally index 3473f8b..3fb290f 100755 --- a/packaging/gittally +++ b/packaging/gittally @@ -1,6 +1,6 @@ #!/bin/sh -# Launcher for the self-contained GitTally runtime bundle (jlink JRE + jar). +# Launcher for the self-contained werkator runtime bundle (jlink JRE + jar). # Built by `./gradlew runtimeBundle`; see docs/deployment.md. DIR=$(CDPATH='' cd -- "$(dirname -- "$(readlink -f -- "$0")")" && pwd) # shellcheck disable=SC2086 # JAVA_OPTS is intentionally word-split -exec "$DIR/../jre/bin/java" $JAVA_OPTS -jar "$DIR/../lib/gittally.jar" "$@" +exec "$DIR/../jre/bin/java" $JAVA_OPTS -jar "$DIR/../lib/werkator.jar" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d5c9a9..5d516b4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "gittally" +rootProject.name = "werkator" diff --git a/src/main/kotlin/de/hoennig/gittally/SecretFiles.kt b/src/main/kotlin/de/hoennig/gittally/SecretFiles.kt index ac684e4..632acc8 100644 --- a/src/main/kotlin/de/hoennig/gittally/SecretFiles.kt +++ b/src/main/kotlin/de/hoennig/gittally/SecretFiles.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally +package de.hoennig.werkator import java.nio.ByteBuffer import java.nio.file.Files @@ -8,7 +8,7 @@ import java.nio.file.attribute.PosixFilePermissions /** * Creation of files and directories that hold secrets — the Gitea token in - * `.git/gittally/.gittally.yml` and the control token. + * `.git/werkator/.werkator.yml` and the control token. * * The permissions are set *at creation*, never with a `chmod` after the write: * writing at the umask default first (typically `0644`) would leave a window in diff --git a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt b/src/main/kotlin/de/hoennig/gittally/WerkatorApplication.kt similarity index 85% rename from src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt rename to src/main/kotlin/de/hoennig/gittally/WerkatorApplication.kt index fe29e40..dc05b70 100644 --- a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt +++ b/src/main/kotlin/de/hoennig/gittally/WerkatorApplication.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally +package de.hoennig.werkator -import de.hoennig.gittally.config.ConfigException +import de.hoennig.werkator.config.ConfigException import org.springframework.boot.CommandLineRunner import org.springframework.boot.ExitCodeGenerator import org.springframework.boot.SpringApplication @@ -13,14 +13,14 @@ import picocli.CommandLine.IFactory import kotlin.system.exitProcess @SpringBootApplication -class GitTallyApplication +class WerkatorApplication /** Not in the `server` profile: the second context started by `ServerCommand` must not run picocli again. */ @Component @Profile("!server") class CliRunner( private val factory: IFactory, - private val rootCommand: GitTallyCommand, + private val rootCommand: werkatorCommand, ) : CommandLineRunner, ExitCodeGenerator { private var exitCode = 0 @@ -29,7 +29,7 @@ class CliRunner( exitCode = CommandLine(rootCommand, factory) .setExecutionExceptionHandler { exception, commandLine, _ -> - // a config GitTally must not read is a stated fact, not a crash: the message + // a config werkator must not read is a stated fact, not a crash: the message // names the file, the versions, and the way out — a stack trace would bury it if (exception is ConfigException) { commandLine.err.println("Error: ${exception.message}") @@ -49,5 +49,5 @@ class CliRunner( } fun main(args: Array) { - exitProcess(SpringApplication.exit(runApplication(*args))) + exitProcess(SpringApplication.exit(runApplication(*args))) } diff --git a/src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt b/src/main/kotlin/de/hoennig/gittally/WerkatorCommand.kt similarity index 73% rename from src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt rename to src/main/kotlin/de/hoennig/gittally/WerkatorCommand.kt index 5ab27fc..4a9f27b 100644 --- a/src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/WerkatorCommand.kt @@ -1,11 +1,11 @@ -package de.hoennig.gittally +package de.hoennig.werkator -import de.hoennig.gittally.commands.BuildCommand -import de.hoennig.gittally.commands.ConfigPrintCommand -import de.hoennig.gittally.commands.InitCommand -import de.hoennig.gittally.commands.RetryCommand -import de.hoennig.gittally.commands.ServerCommand -import de.hoennig.gittally.commands.StatusCommand +import de.hoennig.werkator.commands.BuildCommand +import de.hoennig.werkator.commands.ConfigPrintCommand +import de.hoennig.werkator.commands.InitCommand +import de.hoennig.werkator.commands.RetryCommand +import de.hoennig.werkator.commands.ServerCommand +import de.hoennig.werkator.commands.StatusCommand import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.info.BuildProperties import org.springframework.stereotype.Component @@ -14,7 +14,7 @@ import picocli.CommandLine.Command @Component @Command( - name = "gittally", + name = "werkator", subcommands = [ InitCommand::class, ServerCommand::class, @@ -27,7 +27,7 @@ import picocli.CommandLine.Command versionProvider = BuildPropertiesVersionProvider::class, description = ["Lightweight, declarative CI/CD system"], ) -class GitTallyCommand : Runnable { +class werkatorCommand : Runnable { override fun run(): Unit = throw CommandLine.ParameterException(CommandLine(this), "Specify a subcommand") } @@ -41,5 +41,5 @@ class GitTallyCommand : Runnable { class BuildPropertiesVersionProvider( private val buildProperties: ObjectProvider, ) : CommandLine.IVersionProvider { - override fun getVersion(): Array = arrayOf("GitTally v${buildProperties.getIfAvailable()?.version ?: "dev"}") + override fun getVersion(): Array = arrayOf("werkator v${buildProperties.getIfAvailable()?.version ?: "dev"}") } diff --git a/src/main/kotlin/de/hoennig/gittally/artifacts/ArtifactsConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/artifacts/ArtifactsConfiguration.kt index bcf9954..bb11163 100644 --- a/src/main/kotlin/de/hoennig/gittally/artifacts/ArtifactsConfiguration.kt +++ b/src/main/kotlin/de/hoennig/gittally/artifacts/ArtifactsConfiguration.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.artifacts +package de.hoennig.werkator.artifacts -import de.hoennig.gittally.build.ArtifactStore -import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.werkator.build.ArtifactStore +import de.hoennig.werkator.config.ConfigLoader import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/src/main/kotlin/de/hoennig/gittally/artifacts/FileArtifactStore.kt b/src/main/kotlin/de/hoennig/gittally/artifacts/FileArtifactStore.kt index f48ab4a..c1de69a 100644 --- a/src/main/kotlin/de/hoennig/gittally/artifacts/FileArtifactStore.kt +++ b/src/main/kotlin/de/hoennig/gittally/artifacts/FileArtifactStore.kt @@ -1,10 +1,10 @@ -package de.hoennig.gittally.artifacts +package de.hoennig.werkator.artifacts -import de.hoennig.gittally.build.ArtifactKeys -import de.hoennig.gittally.build.ArtifactStore -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.config.BranchConfig -import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.werkator.build.ArtifactKeys +import de.hoennig.werkator.build.ArtifactStore +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.config.BranchConfig +import de.hoennig.werkator.config.ConfigLoader import org.slf4j.LoggerFactory import java.io.IOException import java.nio.file.FileVisitResult @@ -22,7 +22,7 @@ import kotlin.concurrent.write /** * Stores build artifacts on the filesystem under `/branches//`. * The root is `artifacts.rootDir` from the config, or the platform default - * `XDG_STATE_HOME` (falling back to `~/.local/state`) plus `/gittally/artifacts/` when unset — + * `XDG_STATE_HOME` (falling back to `~/.local/state`) plus `/werkator/artifacts/` when unset — * deliberately not `/tmp` like legacy, where artifacts vanished on reboot. * * Each build is assembled in a temporary directory next to its target and moved @@ -115,7 +115,7 @@ class FileArtifactStore( env("XDG_STATE_HOME")?.takeIf { it.isNotBlank() }?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "state") return stateHome - .resolve("gittally") + .resolve("werkator") .resolve("artifacts") .resolve(ArtifactKeys.repoKey(workingDir)) .toAbsolutePath() @@ -160,9 +160,9 @@ class FileArtifactStore( } /** - * The settings [build] ran with, from the build [workspace]'s `.gittally.yml` layered + * The settings [build] ran with, from the build [workspace]'s `.werkator.yml` layered * on top of the primary config (see [ConfigLoader.loadForWorktree]) — resolved through - * [GitTallyConfig.buildSettings], so a job's own `artifactDirs` are archived and not + * [werkatorConfig.buildSettings], so a job's own `artifactDirs` are archived and not * only the ones its branch would have used. */ private fun buildSettings( diff --git a/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt b/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt index 93c9e79..13f0368 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import java.nio.file.Path import java.security.MessageDigest diff --git a/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt b/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt index 25a66e0..2cb06ea 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt @@ -1,11 +1,11 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import java.nio.file.Path /** * Persists the artifacts of finished builds (logs plus configured report directories) * and prunes them together with the result retention. - * Implemented by `de.hoennig.gittally.artifacts.FileArtifactStore`. + * Implemented by `de.hoennig.werkator.artifacts.FileArtifactStore`. */ interface ArtifactStore { /** diff --git a/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt b/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt index 5c40d36..6814c6c 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.git.GitService import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.nio.file.Files @@ -20,7 +20,7 @@ fun interface BranchWorkspaces { } /** - * One reusable git worktree per branch under `.git/gittally/worktrees/`, + * One reusable git worktree per branch under `.git/werkator/worktrees/`, * checked out detached at the requested commit. Reuse keeps incremental build * caches; the branch's `cleanCommand` decides how much of them survives. */ @@ -50,6 +50,6 @@ class GitWorktreeWorkspaces( } companion object { - const val WORKTREES_DIR = ".git/gittally/worktrees" + const val WORKTREES_DIR = ".git/werkator/worktrees" } } diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt index 165d13b..d6b2116 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -8,9 +8,9 @@ import java.nio.file.Paths class BuildConfiguration { /** * Results file relative to the working directory, matching how `ConfigLoader` - * resolves the `.git/gittally/` override file. Nothing is touched until the + * resolves the `.git/werkator/` override file. Nothing is touched until the * first build runs, so the bean is safe outside a git repository. */ @Bean - fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/gittally/build-results.json")) + fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/werkator/build-results.json")) } diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt index f95df36..9c3ffb1 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt @@ -1,9 +1,9 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build -import de.hoennig.gittally.config.BranchConfig -import de.hoennig.gittally.config.BuildDefinition -import de.hoennig.gittally.config.ConfigLoader -import de.hoennig.gittally.gitea.GiteaClient +import de.hoennig.werkator.config.BranchConfig +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.gitea.GiteaClient import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.context.event.ContextClosedEvent @@ -93,7 +93,7 @@ class BuildExecutor( return duplicate.runningBuild } val startedAt = Instant.now() - val stagingDir = Files.createTempDirectory("gittally-build-") + val stagingDir = Files.createTempDirectory("werkator-build-") val runningBuild = RunningBuild( branch = branch, @@ -249,7 +249,7 @@ class BuildExecutor( private fun serialWorker(branch: String): ExecutorService = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "gittally-build-${ArtifactKeys.branchKey(branch)}").apply { isDaemon = true } + Thread(runnable, "werkator-build-${ArtifactKeys.branchKey(branch)}").apply { isDaemon = true } } private fun runBuildCommands( @@ -324,7 +324,7 @@ class BuildExecutor( input: InputStream, vararg sinks: OutputStream, ): Thread = - thread(isDaemon = true, name = "gittally-build-log") { + thread(isDaemon = true, name = "werkator-build-log") { val buffer = ByteArray(8192) try { while (true) { @@ -480,7 +480,7 @@ class BuildExecutor( /** * The effective settings of this run: the branch config with the build [worktree]'s - * `.gittally.yml` layered on top (see [ConfigLoader.loadForWorktree]), then the + * `.werkator.yml` layered on top (see [ConfigLoader.loadForWorktree]), then the * build definition's overrides applied last — the job wins, and it always comes * from the primary config (`builds` is a pinned section). An unknown build name * (a stale result whose job was removed) falls back to the plain branch settings. diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt index b640899..cd43244 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build -import de.hoennig.gittally.config.BuildDefinition +import de.hoennig.werkator.config.BuildDefinition import java.time.Duration import java.time.Instant diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt index 971e9fc..4336f71 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import java.time.Instant diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt index 8a90d8e..d016b0d 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build -import de.hoennig.gittally.config.BranchConfig +import de.hoennig.werkator.config.BranchConfig import org.springframework.context.annotation.Primary import org.springframework.stereotype.Component import java.nio.file.Path diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt index 70997cc..1f79701 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build enum class BuildStatus { PENDING, diff --git a/src/main/kotlin/de/hoennig/gittally/build/DockerBuildRunner.kt b/src/main/kotlin/de/hoennig/gittally/build/DockerBuildRunner.kt index b1c0b35..cb597b3 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/DockerBuildRunner.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/DockerBuildRunner.kt @@ -1,8 +1,8 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build -import de.hoennig.gittally.config.BranchConfig -import de.hoennig.gittally.config.DockerConfig -import de.hoennig.gittally.git.GitCommandRunner +import de.hoennig.werkator.config.BranchConfig +import de.hoennig.werkator.config.DockerConfig +import de.hoennig.werkator.git.GitCommandRunner import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.nio.file.Files @@ -23,7 +23,7 @@ import java.nio.file.Path * same container run; under a rootless daemon the container runs as root, which * already is the host user, so the repair chown degenerates to `0:0`. * Git works inside the container: the primary `.git` is mounted read-only with - * `.git/gittally/` masked, see [gitMetadataMounts]. + * `.git/werkator/` masked, see [gitMetadataMounts]. */ @Component class DockerBuildRunner( @@ -114,11 +114,11 @@ class DockerBuildRunner( "docker", "build", "--label", - "org.gittally.dockerfile=${docker.dockerfile}", + "org.werkator.dockerfile=${docker.dockerfile}", "--label", - "org.gittally.dockerfile-sha256=$dockerfileHash", + "org.werkator.dockerfile-sha256=$dockerfileHash", "--label", - "org.gittally.build-context=${docker.context}", + "org.werkator.build-context=${docker.context}", "--label", "${DockerImageInputs.INPUTS_LABEL}=$inputsHash", "-t", @@ -194,11 +194,11 @@ class DockerBuildRunner( "ps", "-aq", "--filter", - "label=$GITTALLY_LABEL=true", + "label=$werkator_LABEL=true", "--filter", - "label=$GITTALLY_LABEL.repository=$repoKey", + "label=$werkator_LABEL.repository=$repoKey", "--filter", - "label=$GITTALLY_LABEL.role=build", + "label=$werkator_LABEL.role=build", ), repoDir, ) @@ -238,11 +238,11 @@ class DockerBuildRunner( args += listOf( "--label", - "$GITTALLY_LABEL=true", + "$werkator_LABEL=true", "--label", - "$GITTALLY_LABEL.repository=$repoKey", + "$werkator_LABEL.repository=$repoKey", "--label", - "$GITTALLY_LABEL.role=build", + "$werkator_LABEL.role=build", ) args += listOf("--workdir", "$workspace", "--volume", "$workspace:$workspace") args += gitMetadataMounts(workspace, repoDir) @@ -279,12 +279,12 @@ class DockerBuildRunner( } /** - * Makes git work inside the build container without exposing GitTally's secrets. + * Makes git work inside the build container without exposing werkator's secrets. * * The workspace is a git worktree whose `.git` file points into the primary * repository's `.git`, which is not part of the workspace mount — so any git call * in the build would fail. Three layered mounts fix that (Docker nests mounts by - * target path): the primary `.git` read-only, an empty tmpfs masking `.git/gittally/` + * target path): the primary `.git` read-only, an empty tmpfs masking `.git/werkator/` * (machine config with `git.token`, control token, build state — the workspace bind * resurfaces only this build's own worktree inside it), and this worktree's admin * directory read-write, so index-refreshing commands like `git status` keep working. @@ -312,9 +312,9 @@ class DockerBuildRunner( return emptyList() } val args = mutableListOf("--volume", "$gitDir:$gitDir:ro") - val gittallyDir = gitDir.resolve("gittally") - if (Files.isDirectory(gittallyDir)) { - args += listOf("--tmpfs", "$gittallyDir") + val werkatorDir = gitDir.resolve("werkator") + if (Files.isDirectory(werkatorDir)) { + args += listOf("--tmpfs", "$werkatorDir") } args += listOf("--volume", "$adminDir:$adminDir") return args @@ -326,15 +326,15 @@ class DockerBuildRunner( ): String = commandRunner.runOrThrow(listOf("id", flag), repoDir).stdout.trim() companion object { - /** Container label namespace; legacy used `org.hostsharing.gittally`. */ - const val GITTALLY_LABEL = "org.hoennig.gittally" + /** Container label namespace; legacy used `org.hostsharing.werkator`. */ + const val werkator_LABEL = "org.hoennig.werkator" - fun gradleVolumeName(repoKey: String): String = "gittally-gradle-$repoKey" + fun gradleVolumeName(repoKey: String): String = "werkator-gradle-$repoKey" fun containerName( repoKey: String, branch: String?, - ): String = "gittally-build-$repoKey" + (branch?.let { "-${ArtifactKeys.branchKey(it)}" } ?: "") + ): String = "werkator-build-$repoKey" + (branch?.let { "-${ArtifactKeys.branchKey(it)}" } ?: "") private val INSPECT_INPUTS_LABEL_FORMAT = """{{ index .Config.Labels "${DockerImageInputs.INPUTS_LABEL}" }}""" diff --git a/src/main/kotlin/de/hoennig/gittally/build/DockerImageInputs.kt b/src/main/kotlin/de/hoennig/gittally/build/DockerImageInputs.kt index 279b473..0272991 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/DockerImageInputs.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/DockerImageInputs.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import java.nio.file.Files import java.nio.file.Path @@ -10,7 +10,7 @@ import java.security.MessageDigest * with the configured Dockerfile and context paths, stored as an image label. */ object DockerImageInputs { - const val INPUTS_LABEL = "org.gittally.build-inputs-sha256" + const val INPUTS_LABEL = "org.werkator.build-inputs-sha256" fun dockerfileSha256(dockerfile: Path): String = sha256Hex(Files.readAllBytes(dockerfile)) diff --git a/src/main/kotlin/de/hoennig/gittally/build/DockerSocketLocator.kt b/src/main/kotlin/de/hoennig/gittally/build/DockerSocketLocator.kt index a32bdee..ccd3a6b 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/DockerSocketLocator.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/DockerSocketLocator.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import org.springframework.stereotype.Component import java.nio.file.Files diff --git a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt index 1fe31f2..99d0f62 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper @@ -13,7 +13,7 @@ import java.nio.file.StandardCopyOption import java.time.Instant /** - * Stores build results as a JSON file, e.g. `.git/gittally/build-results.json`. + * Stores build results as a JSON file, e.g. `.git/werkator/build-results.json`. * Writes are atomic (temp file + atomic move) so readers never see partial content. */ class FileBuildResultRepository( diff --git a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt index afef9e0..d1178f2 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.build +package de.hoennig.werkator.build -import de.hoennig.gittally.config.BuildDefinition +import de.hoennig.werkator.config.BuildDefinition import java.nio.file.Path import java.time.Instant diff --git a/src/main/kotlin/de/hoennig/gittally/commands/BranchNameResolution.kt b/src/main/kotlin/de/hoennig/gittally/commands/BranchNameResolution.kt index 601a63e..3ea45b1 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/BranchNameResolution.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/BranchNameResolution.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands /** * Port of the legacy `resolve_branch_name` partial-name matching: a branch-name diff --git a/src/main/kotlin/de/hoennig/gittally/commands/BuildCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/BuildCommand.kt index eab5d21..f044a97 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/BuildCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/BuildCommand.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.git.GitService import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.ExitCode diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt index 9bba4c3..8be8c22 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.werkator.config.ConfigLoader import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.Option diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt b/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt index 7f94abf..f0d73c1 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt @@ -1,13 +1,13 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.build.ArtifactStore -import de.hoennig.gittally.build.BuildExecutor -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.build.RunningBuild -import de.hoennig.gittally.config.BuildDefinition -import de.hoennig.gittally.server.UiFormats +import de.hoennig.werkator.build.ArtifactStore +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.build.RunningBuild +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.server.UiFormats import org.springframework.stereotype.Component import java.io.IOException import java.nio.channels.Channels diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 5a0afd8..8bd285a 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.SecretFiles -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.SecretFiles +import de.hoennig.werkator.git.GitService import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.info.BuildProperties import org.springframework.stereotype.Component @@ -13,19 +13,19 @@ import java.nio.file.Paths @Component @Command( name = "init", - description = ["Initialize GitTally for the current repository"], + description = ["Initialize werkator for the current repository"], mixinStandardHelpOptions = true, ) class InitCommand( private val gitService: GitService, - /** The version written into the generated config as `gitTally.version.since`. */ + /** The version written into the generated config as `werkator.version.since`. */ private val buildProperties: ObjectProvider? = null, ) : Runnable { var workingDir: Path = Paths.get(".") @Option( names = ["--systemd"], - description = ["also generate a systemd user unit that runs `gittally server` for this repository"], + description = ["also generate a systemd user unit that runs `werkator server` for this repository"], ) var systemd: Boolean = false @@ -56,7 +56,7 @@ class InitCommand( } /** - * The running version for `gitTally.version.since`; outside a built jar (IDE, tests) + * The running version for `werkator.version.since`; outside a built jar (IDE, tests) * there is none, and `0.0.0` then declares no floor at all rather than a wrong one. */ private fun runningVersion(): String = buildProperties?.getIfAvailable()?.version ?: "0.0.0" @@ -99,7 +99,7 @@ class InitCommand( detected: DetectedValues, normalizedWorkingDir: Path, ) { - val file = root.resolve(".git/gittally/.gittally.yml") + val file = root.resolve(".git/werkator/.werkator.yml") if (file.toFile().exists()) { println("${file.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten") return @@ -107,7 +107,7 @@ class InitCommand( SecretFiles.createDirectoriesOwnerOnly(file.parent) val content = """ - # Machine- or user-specific overrides and secrets. Keys here win over .gittally.yml. + # Machine- or user-specific overrides and secrets. Keys here win over .werkator.yml. git: account: "${detected.account}" # technical username for git HTTPS authentication token: "" # Gitea API token — never commit this @@ -123,31 +123,31 @@ class InitCommand( detected: DetectedValues, normalizedWorkingDir: Path, ) { - val file = root.resolve(".gittally.yml") + val file = root.resolve(".werkator.yml") if (file.toFile().exists()) { println("${file.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten") return } val content = """ - # The GitTally this file is written for. - # since: enforced — an older GitTally refuses to read this file instead of + # The werkator this file is written for. + # since: enforced — an older werkator refuses to read this file instead of # silently ignoring the keys it does not know yet. - # below: your release marker for a coming major; GitTally decides how strictly + # below: your release marker for a coming major; werkator decides how strictly # to take it, and warns rather than blocks unless the format really broke. - gitTally: + werkator: version: since: "${runningVersion()}" # below: "2.0" server: - # Public base URL of this GitTally installation — used for all links posted to Gitea. + # Public base URL of this werkator installation — used for all links posted to Gitea. publicBaseUrl: "" # HTTP port of the `server` subcommand port: 18080 # bind address of the `server` subcommand; loopback only, because the UI and the # API are unauthenticated — use 0.0.0.0 only without a reverse proxy in front - # (and with the managed nginx below, which reaches GitTally from its container) + # (and with the managed nginx below, which reaches werkator from its container) bindAddress: 127.0.0.1 # optional Impressum (legal disclosure) link in the web UI footer; empty hides the link impressumUrl: "" @@ -159,8 +159,8 @@ class InitCommand( httpPort: 8080 # host port published as nginx port 80 httpsPort: 8443 # host port published as nginx port 443 upstreamHost: "" # host nginx proxies to; empty = serverName - containerName: "" # empty = gittally-nginx- - stateDir: "" # empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/nginx/ + containerName: "" # empty = werkator-nginx- + stateDir: "" # empty = XDG_STATE_HOME (or ~/.local/state) + /werkator/nginx/ letsencryptEmail: "" # e-mail for the Let's Encrypt account; empty registers without one # Gitea integration for fetching commits and posting build statuses. @@ -168,7 +168,7 @@ class InitCommand( baseUrl: ${detected.baseUrl} # base URL of the Gitea instance owner: ${detected.owner} # repository owner (user or organisation) for Gitea API (e.g. status checks) repo: ${detected.repo} # repository name - statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) + statusContext: werkator # label shown on Gitea commit status checks (default: werkator) # Build execution settings, enforced for all builds regardless of their trigger. executor: @@ -178,7 +178,7 @@ class InitCommand( # Named build definitions (jobs); every key names a build. # "default" is the base every other definition inherits its settings from — never # its trigger — and is itself the build of every branch as long as it has one. - # A branch may add or override definitions in its own committed .gittally.yml; + # A branch may add or override definitions in its own committed .werkator.yml; # they apply to that branch alone, so a new job can be tried out on one branch. builds: default: @@ -218,11 +218,11 @@ class InitCommand( # atTimes: ["01:00"] # branches: ["master"] # buildCommand: ./gradlew pitestFull - # statusContext: GitTally/pitest + # statusContext: werkator/pitest # Build artifact storage and retention. artifacts: - # root directory for stored artifacts; empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/artifacts/ + # root directory for stored artifacts; empty = XDG_STATE_HOME (or ~/.local/state) + /werkator/artifacts/ rootDir: "" # number of builds to keep per branch retentionPerBranch: 3 @@ -256,14 +256,14 @@ class InitCommand( ) { val jarPath = jarPathResolver() if (jarPath == null) { - println("Error: cannot determine the GitTally jar path — run `init --systemd` via `java -jar /gittally.jar`") + println("Error: cannot determine the werkator jar path — run `init --systemd` via `java -jar /werkator.jar`") return } - val gittallyDir = root.resolve(".git/gittally") - SecretFiles.createDirectoriesOwnerOnly(gittallyDir) + val werkatorDir = root.resolve(".git/werkator") + SecretFiles.createDirectoriesOwnerOnly(werkatorDir) val unitName = SystemdServiceFiles.unitName(root) - val unitFile = gittallyDir.resolve(unitName) - val envFile = gittallyDir.resolve(SystemdServiceFiles.ENV_FILE_NAME) + val unitFile = werkatorDir.resolve(unitName) + val envFile = werkatorDir.resolve(SystemdServiceFiles.ENV_FILE_NAME) unitFile.toFile().writeText( SystemdServiceFiles.unitFileContent( @@ -283,9 +283,9 @@ class InitCommand( } // the nightly Docker cleanup is host-global: every repository generates the same - // units, so with several GitTally instances the symlinks simply coincide - val pruneServiceFile = gittallyDir.resolve(SystemdServiceFiles.PRUNE_SERVICE_NAME) - val pruneTimerFile = gittallyDir.resolve(SystemdServiceFiles.PRUNE_TIMER_NAME) + // units, so with several werkator instances the symlinks simply coincide + val pruneServiceFile = werkatorDir.resolve(SystemdServiceFiles.PRUNE_SERVICE_NAME) + val pruneTimerFile = werkatorDir.resolve(SystemdServiceFiles.PRUNE_TIMER_NAME) pruneServiceFile.toFile().writeText(SystemdServiceFiles.pruneServiceContent()) println("created ${pruneServiceFile.toFile().relativeTo(normalizedWorkingDir.toFile())}") pruneTimerFile.toFile().writeText(SystemdServiceFiles.pruneTimerContent()) diff --git a/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt index 68b354b..cc6ae02 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt @@ -1,9 +1,9 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.git.GitService import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.ExitCode diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt index c39c4e8..3c94653 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.GitTallyApplication -import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.werkator.WerkatorApplication +import de.hoennig.werkator.config.ConfigLoader import org.springframework.boot.WebApplicationType import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.context.ApplicationListener @@ -24,7 +24,7 @@ import java.util.concurrent.CountDownLatch @Component @Command( name = "server", - description = ["Start the GitTally server"], + description = ["Start the werkator server"], mixinStandardHelpOptions = true, ) class ServerCommand( @@ -35,7 +35,7 @@ class ServerCommand( override fun run() { val config = configLoader.load(workingDir) val context = - SpringApplicationBuilder(GitTallyApplication::class.java) + SpringApplicationBuilder(WerkatorApplication::class.java) .web(WebApplicationType.SERVLET) .profiles(SERVER_PROFILE) .properties( @@ -43,7 +43,7 @@ class ServerCommand( "server.address=${config.server.bindAddress}", ).run() val port = context.environment.getProperty("local.server.port", config.server.port.toString()) - println("GitTally server listening on http://${config.server.bindAddress}:$port/ — Ctrl-C to stop") + println("werkator server listening on http://${config.server.bindAddress}:$port/ — Ctrl-C to stop") awaitShutdown(context) } diff --git a/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt index f6e696b..29d1b21 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt @@ -1,8 +1,8 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.server.UiFormats +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.server.UiFormats import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.ExitCode diff --git a/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt b/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt index 6e0b826..4bd2bbc 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt @@ -1,21 +1,21 @@ -package de.hoennig.gittally.commands +package de.hoennig.werkator.commands import java.nio.file.Path /** * Generates the content of the systemd user unit and its `EnvironmentFile` for running - * `gittally server` as a service — the shape of the legacy `generate_systemd_config`, + * `werkator server` as a service — the shape of the legacy `generate_systemd_config`, * without the self-copy/self-update machinery (the unit points at the jar in place). */ object SystemdServiceFiles { - const val ENV_FILE_NAME = "gittally.env" + const val ENV_FILE_NAME = "werkator.env" - /** Host-global unit names of the nightly Docker cleanup — shared by all GitTally repositories on the host. */ - const val PRUNE_SERVICE_NAME = "gittally-docker-prune.service" - const val PRUNE_TIMER_NAME = "gittally-docker-prune.timer" + /** Host-global unit names of the nightly Docker cleanup — shared by all werkator repositories on the host. */ + const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service" + const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer" - /** Per-repository unit name, because one GitTally instance serves exactly one repository. */ - fun unitName(repoRoot: Path): String = "gittally-${sanitize(repoRoot.fileName.toString())}.service" + /** Per-repository unit name, because one werkator instance serves exactly one repository. */ + fun unitName(repoRoot: Path): String = "werkator-${sanitize(repoRoot.fileName.toString())}.service" fun unitFileContent( repoRoot: Path, @@ -25,7 +25,7 @@ object SystemdServiceFiles { ): String = """ [Unit] - Description=GitTally CI for ${repoRoot.fileName} + Description=werkator CI for ${repoRoot.fileName} Wants=network-online.target After=network-online.target docker.service @@ -49,7 +49,7 @@ object SystemdServiceFiles { fun pruneServiceContent(): String = """ [Unit] - Description=Clean up unused Docker containers and images (GitTally) + Description=Clean up unused Docker containers and images (werkator) [Service] Type=oneshot @@ -62,7 +62,7 @@ object SystemdServiceFiles { fun pruneTimerContent(): String = """ [Unit] - Description=Nightly Docker cleanup before the auto builds (GitTally) + Description=Nightly Docker cleanup before the auto builds (werkator) [Timer] OnCalendar=*-*-* 02:00:00 @@ -74,8 +74,8 @@ object SystemdServiceFiles { fun envFileContent(): String = """ - # EnvironmentFile for the GitTally systemd service. - # GitTally itself is configured via .gittally.yml and .git/gittally/.gittally.yml, + # EnvironmentFile for the werkator systemd service. + # werkator itself is configured via .werkator.yml and .git/werkator/.werkator.yml, # not via environment variables; this file only tunes the JVM process. #JAVA_OPTS=-Xmx256m """.trimIndent() + "\n" diff --git a/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt b/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt index 784ea1c..7be9dcc 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.config +package de.hoennig.werkator.config import java.time.Duration import java.time.Instant diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt index 52ddd1d..ac48b95 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.config +package de.hoennig.werkator.config import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper @@ -17,7 +17,7 @@ import java.util.concurrent.ConcurrentHashMap @Service class ConfigLoader( - /** The running version, for the `gitTally.version` check; absent outside a built jar (IDE, tests). */ + /** The running version, for the `werkator.version` check; absent outside a built jar (IDE, tests). */ private val buildProperties: ObjectProvider? = null, ) { private val log = LoggerFactory.getLogger(ConfigLoader::class.java) @@ -37,21 +37,21 @@ class ConfigLoader( /** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */ private val warnedSections = ConcurrentHashMap.newKeySet() - fun load(workingDir: Path = Paths.get(".")): GitTallyConfig = toConfig(loadRaw(workingDir)) + fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir)) /** - * Config for building a branch in [worktreeDir]: the worktree's `.gittally.yml` + * Config for building a branch in [worktreeDir]: the worktree's `.werkator.yml` * (the committed config of the branch being built) is applied as the branch layer, - * see [loadWithBranchLayer]. With no worktree `.gittally.yml` this is identical + * see [loadWithBranchLayer]. With no worktree `.werkator.yml` this is identical * to [load]. */ fun loadForWorktree( workingDir: Path, worktreeDir: Path, - ): GitTallyConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(".gittally.yml").toFile())) + ): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(".werkator.yml").toFile())) /** - * The primary/`.git` config with the committed `.gittally.yml` of one branch + * The primary/`.git` config with the committed `.werkator.yml` of one branch * ([branchConfigYaml], null or blank for a branch without one) merged on top: * precedence branch > `.git` > project. A branch describes its own CI — build * settings (`buildCommand`, `cleanCommand`, `artifactDirs`, `docker.image`/`env`, …) @@ -70,25 +70,25 @@ class ConfigLoader( fun loadWithBranchLayer( workingDir: Path, branchConfigYaml: String?, - ): GitTallyConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml)) + ): WerkatorConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml)) private fun withBranchLayer( workingDir: Path, branchLayer: Map, - ): GitTallyConfig { + ): WerkatorConfig { // scoped to this branch: an incompatible branch config fails its own builds and // must never stop the server or hold up the branches that are fine - checkVersion(branchLayer, "the committed .gittally.yml of this branch", BRANCH_HINT) - checkTriggerBlocks(branchLayer, "the committed .gittally.yml of this branch", BRANCH_HINT) + checkVersion(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT) + checkTriggerBlocks(branchLayer, "the committed .werkator.yml of this branch", BRANCH_HINT) return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer))) } - private fun toConfig(raw: Map): GitTallyConfig { + private fun toConfig(raw: Map): WerkatorConfig { val config = if (raw.isEmpty()) { - GitTallyConfig() + WerkatorConfig() } else { - yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), GitTallyConfig::class.java) + yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java) } return defaultPublicBaseUrl(config) } @@ -265,7 +265,7 @@ class ConfigLoader( } /** Legacy default: an empty `server.publicBaseUrl` becomes `https:///`. */ - private fun defaultPublicBaseUrl(config: GitTallyConfig): GitTallyConfig { + private fun defaultPublicBaseUrl(config: WerkatorConfig): WerkatorConfig { if (config.server.publicBaseUrl.isNotBlank() || config.server.nginx.serverName .isBlank() @@ -276,18 +276,18 @@ class ConfigLoader( } fun loadRaw(workingDir: Path = Paths.get(".")): Map { - val repoInstall = loadFile(workingDir.resolve(".git/gittally/.gittally.yml").toFile()) - val project = loadFile(workingDir.resolve(".gittally.yml").toFile()) + val repoInstall = loadFile(workingDir.resolve(".git/werkator/.werkator.yml").toFile()) + val project = loadFile(workingDir.resolve(".werkator.yml").toFile()) // per file, so the message names the file to fix — the merged map has no provenance - checkVersion(project, ".gittally.yml", ROLLBACK_HINT) - checkVersion(repoInstall, ".git/gittally/.gittally.yml", ROLLBACK_HINT) - checkTriggerBlocks(project, ".gittally.yml", ROLLBACK_HINT) - checkTriggerBlocks(repoInstall, ".git/gittally/.gittally.yml", ROLLBACK_HINT) + checkVersion(project, ".werkator.yml", ROLLBACK_HINT) + checkVersion(repoInstall, ".git/werkator/.werkator.yml", ROLLBACK_HINT) + checkTriggerBlocks(project, ".werkator.yml", ROLLBACK_HINT) + checkTriggerBlocks(repoInstall, ".git/werkator/.werkator.yml", ROLLBACK_HINT) return deepMerge(project, repoInstall) } /** - * Enforces the `gitTally.version` declaration of one configuration file. + * Enforces the `werkator.version` declaration of one configuration file. * An incompatible file throws — reading it would mean honoring keys that mean * something else now, which is worse than not building. A file that merely exceeds * its own `below` marker is a warning, logged once: an unmaintained marker must @@ -314,7 +314,7 @@ class ConfigLoader( @Suppress("UNCHECKED_CAST") private fun requirementOf(raw: Map): VersionRequirement { - val version = (raw["gitTally"] as? Map)?.get("version") as? Map ?: return VersionRequirement() + val version = (raw["werkator"] as? Map)?.get("version") as? Map ?: return VersionRequirement() return VersionRequirement( since = version["since"]?.toString()?.trim().orEmpty(), below = version["below"]?.toString()?.trim().orEmpty(), @@ -329,7 +329,7 @@ class ConfigLoader( return yaml.readValue(file, Map::class.java) as Map } - /** Parses a `.gittally.yml` read from git (not from disk); blank or null yields no layer. */ + /** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */ private fun parseYaml(text: String?): Map { if (text.isNullOrBlank()) return emptyMap() @Suppress("UNCHECKED_CAST") @@ -411,7 +411,7 @@ class ConfigLoader( private const val NO_TRIGGER_WARNING = "no-build-triggered" private const val ROLLBACK_HINT = - "Migrate the file, or roll back to the GitTally version it was written for." + "Migrate the file, or roll back to the werkator version it was written for." private const val BRANCH_HINT = "Migrate the file on this branch; the other branches keep building." diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt index 0b87ff5..d354176 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt @@ -1,33 +1,33 @@ -package de.hoennig.gittally.config +package de.hoennig.werkator.config /** - * The GitTally version a configuration file declares itself for, the `gitTally.version` + * The werkator version a configuration file declares itself for, the `werkator.version` * section: * * ```yaml - * gitTally: + * werkator: * version: - * since: "0.9.16" # always hard: an older GitTally refuses this file - * below: "2.0" # GitTally decides how hard, see ConfigVersions.verdict + * since: "0.9.16" # always hard: an older werkator refuses this file + * below: "2.0" # werkator decides how hard, see ConfigVersions.verdict * ``` * * There is deliberately no version of the file format itself (no `apiVersion`): no API is - * involved — GitTally reads its own configuration — and only one configuration generation + * involved — werkator reads its own configuration — and only one configuration generation * is ever supported. The declared version exists to make an incompatibility nameable, * never to run two parsers. */ data class VersionRequirement( - /** Oldest GitTally that understands this file; empty means the file does not say. */ + /** Oldest werkator that understands this file; empty means the file does not say. */ val since: String = "", - /** First GitTally this file was not released for; empty means no ceiling. */ + /** First werkator this file was not released for; empty means no ceiling. */ val below: String = "", ) -data class GitTallyMeta( +data class werkatorMeta( val version: VersionRequirement = VersionRequirement(), ) -/** What a [VersionRequirement] means for the GitTally that reads the file. */ +/** What a [VersionRequirement] means for the werkator that reads the file. */ sealed interface VersionVerdict { /** The running version is covered by the declaration. */ data object Compatible : VersionVerdict @@ -37,24 +37,24 @@ sealed interface VersionVerdict { val message: String, ) : VersionVerdict - /** Not usable: the file predates a change that GitTally cannot bridge. */ + /** Not usable: the file predates a change that werkator cannot bridge. */ data class Incompatible( val message: String, ) : VersionVerdict } -/** A configuration file this GitTally must not read; carries the file's name in its message. */ +/** A configuration file this werkator must not read; carries the file's name in its message. */ open class ConfigException( message: String, ) : RuntimeException(message) -/** The file declares a GitTally that cannot read it, see [ConfigVersions]. */ +/** The file declares a werkator that cannot read it, see [ConfigVersions]. */ class ConfigVersionException( message: String, ) : ConfigException(message) /** - * The file is written in a shape this GitTally no longer reads. Refusing it is the point: + * The file is written in a shape this werkator no longer reads. Refusing it is the point: * a key that moved and is silently ignored means a build that quietly stops happening. */ class ConfigFormatException( @@ -64,7 +64,7 @@ class ConfigFormatException( object ConfigVersions { /** * The version in which the configuration format last changed incompatibly — a file - * written before it cannot be read by this GitTally. Empty while no such change has + * written before it cannot be read by this werkator. Empty while no such change has * happened; set it to the release that introduces one, together with the migration * note the message points at. */ @@ -76,13 +76,13 @@ object ConfigVersions { /** * Decides what [requirement] means for [running]. * - * `since` is always hard — a file that needs a newer GitTally cannot be honored, and + * `since` is always hard — a file that needs a newer werkator cannot be honored, and * silently ignoring its unknown keys is exactly the failure mode this section exists * to prevent. * * `below` alone only warns: it is the team's release marker, and an unmaintained * marker must never stop a CI. Whether the running version really broke the file is - * GitTally's own knowledge ([FORMAT_BROKE_IN]) — a file written before that change + * werkator's own knowledge ([FORMAT_BROKE_IN]) — a file written before that change * and read after it is incompatible regardless of what it declares as its ceiling. */ fun verdict( @@ -95,13 +95,13 @@ object ConfigVersions { val since = parse(requirement.since) if (since != null && version < since) { return VersionVerdict.Incompatible( - "needs GitTally ${requirement.since} or newer (gitTally.version.since), this is $running", + "needs werkator ${requirement.since} or newer (werkator.version.since), this is $running", ) } val broke = parse(brokeIn) if (since != null && broke != null && since < broke && version >= broke) { return VersionVerdict.Incompatible( - "is written for GitTally ${requirement.since} (gitTally.version.since), " + + "is written for werkator ${requirement.since} (werkator.version.since), " + "but the configuration format changed incompatibly in $brokeIn" + brokeDescription.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty(), ) @@ -109,7 +109,7 @@ object ConfigVersions { val below = parse(requirement.below) if (below != null && version >= below) { return VersionVerdict.Warn( - "was released for GitTally below ${requirement.below} (gitTally.version.below), this is $running", + "was released for werkator below ${requirement.below} (werkator.version.below), this is $running", ) } return VersionVerdict.Compatible diff --git a/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt b/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt index fa8c461..0a9721f 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.config +package de.hoennig.werkator.config import java.time.Duration diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/WerkatorConfig.kt similarity index 93% rename from src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt rename to src/main/kotlin/de/hoennig/gittally/config/WerkatorConfig.kt index 41cc867..2eef6da 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/WerkatorConfig.kt @@ -1,10 +1,10 @@ -package de.hoennig.gittally.config +package de.hoennig.werkator.config import com.fasterxml.jackson.annotation.JsonProperty -data class GitTallyConfig( - /** What this file declares about the GitTally that reads it; see [VersionRequirement]. */ - val gitTally: GitTallyMeta = GitTallyMeta(), +data class WerkatorConfig( + /** What this file declares about the werkator that reads it; see [VersionRequirement]. */ + val werkator: werkatorMeta = werkatorMeta(), val server: ServerConfig = ServerConfig(), val git: GitConfig = GitConfig(), val gitea: GiteaConfig = GiteaConfig(), @@ -61,7 +61,7 @@ data class ServerConfig( ) /** - * Opt-in managed nginx+certbot Docker container serving GitTally over HTTPS, + * Opt-in managed nginx+certbot Docker container serving werkator over HTTPS, * for hosts without a usable reverse proxy (ADR 0005). Off by default; the * reverse-proxy deployment from `docs/deployment.md` stays the recommended setup. */ @@ -76,11 +76,11 @@ data class NginxConfig( val httpsPort: Int = 8443, /** Host nginx proxies to; empty uses [serverName] (the container cannot reach `localhost`). */ val upstreamHost: String = "", - /** Name of the managed container; empty means `gittally-nginx-`. */ + /** Name of the managed container; empty means `werkator-nginx-`. */ val containerName: String = "", /** * Directory for nginx config, certificates, and logs; empty means the platform - * default `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/nginx/`. + * default `XDG_STATE_HOME` (or `~/.local/state`) + `/werkator/nginx/`. */ val stateDir: String = "", /** E-mail for the Let's Encrypt account; empty registers without one. */ @@ -96,7 +96,7 @@ data class GiteaConfig( val baseUrl: String = "", val owner: String = "", val repo: String = "", - val statusContext: String = "GitTally", + val statusContext: String = "werkator", ) data class ArtifactsConfig( @@ -117,7 +117,7 @@ data class ArtifactsConfig( val keepLatestGreen: Boolean = true, /** * Root directory for stored build artifacts; empty means the platform default - * `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/artifacts/`. + * `XDG_STATE_HOME` (or `~/.local/state`) + `/werkator/artifacts/`. */ val rootDir: String = "", ) @@ -130,7 +130,7 @@ data class WatcherConfig( * Honor the `branches..requirePullRequest` gates. 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. Typically overridden per machine in - * `.git/gittally/.gittally.yml` when the committed config enables the gates. + * `.git/werkator/.werkator.yml` when the committed config enables the gates. */ val pullRequestGate: Boolean = true, /** diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt b/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt index 7c6cb14..d13d59a 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.git +package de.hoennig.werkator.git import java.nio.file.Files import java.nio.file.attribute.PosixFilePermissions @@ -15,10 +15,10 @@ object GitAskPass { #!/bin/sh case "${'$'}1" in *[Uu]sername*) - printf '%s\n' "${'$'}GITTALLY_GIT_ACCOUNT" + printf '%s\n' "${'$'}werkator_GIT_ACCOUNT" ;; *) - printf '%s\n' "${'$'}GITTALLY_GIT_TOKEN" + printf '%s\n' "${'$'}werkator_GIT_TOKEN" ;; esac """.trimIndent() + "\n" @@ -30,7 +30,7 @@ object GitAskPass { ): T { val script = Files.createTempFile( - "gittally-askpass", + "werkator-askpass", ".sh", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")), ) @@ -40,8 +40,8 @@ object GitAskPass { mapOf( "GIT_ASKPASS" to script.toAbsolutePath().toString(), "GIT_TERMINAL_PROMPT" to "0", - "GITTALLY_GIT_ACCOUNT" to account, - "GITTALLY_GIT_TOKEN" to token, + "werkator_GIT_ACCOUNT" to account, + "werkator_GIT_TOKEN" to token, ), ) } finally { diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt b/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt index 8e73119..61420f8 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.git +package de.hoennig.werkator.git import org.springframework.stereotype.Component import java.nio.file.Path diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt index d7872fa..6708151 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.git +package de.hoennig.werkator.git -import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.werkator.config.ConfigLoader import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.nio.file.Path @@ -260,7 +260,7 @@ class GitService( /** * The content of [path] as committed in [commit], or null when that commit has no - * such file — used to read a branch's committed `.gittally.yml` without a worktree. + * such file — used to read a branch's committed `.werkator.yml` without a worktree. */ fun showFileAtCommit( commit: String, diff --git a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt index e5fbb2c..891ab83 100644 --- a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt +++ b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt @@ -1,13 +1,13 @@ -package de.hoennig.gittally.gitea +package de.hoennig.werkator.gitea import com.fasterxml.jackson.core.JacksonException import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.config.ConfigLoader -import de.hoennig.gittally.config.GitTallyConfig +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.config.WerkatorConfig import org.slf4j.LoggerFactory import org.springframework.http.MediaType import org.springframework.http.client.JdkClientHttpRequestFactory @@ -170,7 +170,7 @@ class GiteaClient( } } - private fun isEnabled(config: GitTallyConfig): Boolean = + private fun isEnabled(config: WerkatorConfig): Boolean = config.gitea.baseUrl.isNotBlank() && config.gitea.owner.isNotBlank() && config.gitea.repo.isNotBlank() && @@ -182,7 +182,7 @@ class GiteaClient( HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).build(), ).apply { setReadTimeout(REQUEST_TIMEOUT) } - private fun restClient(config: GitTallyConfig): RestClient = + private fun restClient(config: WerkatorConfig): RestClient = RestClient .builder() .requestFactory(requestFactory) diff --git a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt index 2567b21..5027b87 100644 --- a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt +++ b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.gitea +package de.hoennig.werkator.gitea -import de.hoennig.gittally.build.BuildStatus +import de.hoennig.werkator.build.BuildStatus /** * Gitea commit-status state published for this build status. diff --git a/src/main/kotlin/de/hoennig/gittally/metrics/MetricsConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/metrics/MetricsConfiguration.kt index ae514c6..e3a384d 100644 --- a/src/main/kotlin/de/hoennig/gittally/metrics/MetricsConfiguration.kt +++ b/src/main/kotlin/de/hoennig/gittally/metrics/MetricsConfiguration.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.metrics +package de.hoennig.werkator.metrics -import de.hoennig.gittally.build.ArtifactStore +import de.hoennig.werkator.build.ArtifactStore import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.time.Clock diff --git a/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetrics.kt b/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetrics.kt index a883459..633b074 100644 --- a/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetrics.kt +++ b/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetrics.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.metrics +package de.hoennig.werkator.metrics import java.time.Instant diff --git a/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetricsCollector.kt b/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetricsCollector.kt index 1964d34..71ce384 100644 --- a/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetricsCollector.kt +++ b/src/main/kotlin/de/hoennig/gittally/metrics/SystemMetricsCollector.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.metrics +package de.hoennig.werkator.metrics import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper @@ -30,7 +30,7 @@ data class PersistedMetricsState( * repository size every 60 seconds and keeps running min/max/avg per metric. * The aggregation state is persisted, so restarts continue the series. * Every source degrades gracefully: an unreadable source makes its metric null - * (shown as `n/a`), never fails a sample. Like the [de.hoennig.gittally.watcher.Watcher], + * (shown as `n/a`), never fails a sample. Like the [de.hoennig.werkator.watcher.Watcher], * nothing is scheduled until [start] is called (server mode only). */ class SystemMetricsCollector( @@ -83,7 +83,7 @@ class SystemMetricsCollector( scheduler = Executors .newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "gittally-metrics").apply { isDaemon = true } + Thread(runnable, "werkator-metrics").apply { isDaemon = true } }.also { it.scheduleWithFixedDelay(::sampleSafely, 0, SAMPLE_INTERVAL_SECONDS, TimeUnit.SECONDS) } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt index ef4ae33..eb88f45 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildStatus +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildStatus import java.time.Instant /** JSON statuses are lowercase like the legacy TSV/HTML statuses. */ diff --git a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt index 62555ac..b03d6c1 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.ArtifactStore +import de.hoennig.werkator.build.ArtifactStore import jakarta.servlet.http.HttpServletRequest import org.springframework.core.io.FileSystemResource import org.springframework.core.io.Resource diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt index 4c41fea..1caa5f5 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.git.GitService import org.springframework.stereotype.Component import java.nio.file.Path import java.nio.file.Paths diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt index 820d60f..38ccd1d 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt @@ -1,8 +1,8 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.ArtifactKeys -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildResultRepository +import de.hoennig.werkator.build.ArtifactKeys +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildResultRepository import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ResponseStatusException diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index 691aaf0..0fb408b 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -1,12 +1,12 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.ArtifactStore -import de.hoennig.gittally.build.BuildExecutor -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.config.BuildDefinition -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.build.ArtifactStore +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.git.GitService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -192,7 +192,7 @@ class BuildsApiController( } companion object { - const val TOKEN_HEADER = "X-GitTally-Token" + const val TOKEN_HEADER = "X-werkator-Token" private const val MAX_LOG_CHUNK = 1024L * 1024L } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt b/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt index c35d52d..f29699a 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.SecretFiles +import de.hoennig.werkator.SecretFiles import java.nio.file.Files import java.nio.file.Path import java.security.MessageDigest diff --git a/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt b/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt index dc63a19..aec8664 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server /** * Generates the nginx configuration for the managed proxy container, ported from @@ -12,7 +12,7 @@ object NginxConfigFiles { * The `nginx.conf` content. Without [full] it is the init config for the * two-phase startup: HTTP only, serving the ACME webroot challenge and * redirecting everything else to HTTPS. With [full] an HTTPS server block - * with the Let's Encrypt certificate and the proxy to GitTally is added. + * with the Let's Encrypt certificate and the proxy to werkator is added. */ fun nginxConf( serverName: String, diff --git a/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt b/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt index 585cbae..815f1f2 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt @@ -1,9 +1,9 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.ArtifactKeys -import de.hoennig.gittally.build.DockerBuildRunner.Companion.GITTALLY_LABEL -import de.hoennig.gittally.config.ConfigLoader -import de.hoennig.gittally.git.GitCommandRunner +import de.hoennig.werkator.build.ArtifactKeys +import de.hoennig.werkator.build.DockerBuildRunner.Companion.werkator_LABEL +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.git.GitCommandRunner import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.nio.file.Files @@ -11,10 +11,10 @@ import java.nio.file.Path import java.nio.file.Paths /** - * Manages the opt-in nginx+certbot Docker container that serves GitTally over + * Manages the opt-in nginx+certbot Docker container that serves werkator over * HTTPS on hosts without a reverse proxy (ADR 0005), ported from the legacy * `start_artifact_nginx` subsystem. Shells out to the `docker` CLI via the - * generic [GitCommandRunner] process wrapper, like [de.hoennig.gittally.build.DockerBuildRunner]. + * generic [GitCommandRunner] process wrapper, like [de.hoennig.werkator.build.DockerBuildRunner]. * * Startup is two-phase: an HTTP-only init config serves the ACME webroot * challenge, the certificate is obtained via a certbot container, then nginx is @@ -197,7 +197,7 @@ class NginxProxyManager( /** * Legacy `cleanup_stale_artifact_nginx_containers`: remove the container by * name, all nginx-role containers of this repository by label, and any - * GitTally container still occupying the configured ports. + * werkator container still occupying the configured ports. */ private fun cleanupStaleContainers(settings: NginxSettings) { commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir) @@ -208,11 +208,11 @@ class NginxProxyManager( "ps", "-aq", "--filter", - "label=$GITTALLY_LABEL=true", + "label=$werkator_LABEL=true", "--filter", - "label=$GITTALLY_LABEL.repository=${settings.repoKey}", + "label=$werkator_LABEL.repository=${settings.repoKey}", "--filter", - "label=$GITTALLY_LABEL.role=nginx", + "label=$werkator_LABEL.role=nginx", ), workingDir, ) @@ -220,11 +220,11 @@ class NginxProxyManager( commandRunner.run(listOf("docker", "rm", "-f") + labelled.lines(), workingDir) } for (container in listContainersUsingPorts(settings)) { - if (container.labels.contains("$GITTALLY_LABEL=true") || - container.name.startsWith("gittally-") || + if (container.labels.contains("$werkator_LABEL=true") || + container.name.startsWith("werkator-") || container.name.startsWith("git-watch-origin-and-test-nginx-") ) { - log.info("removing stale GitTally container using an nginx port: {}", container.name) + log.info("removing stale werkator container using an nginx port: {}", container.name) commandRunner.run(listOf("docker", "rm", "-f", container.id), workingDir) } } @@ -303,11 +303,11 @@ class NginxProxyManager( "--volume", "${settings.nginxConf}:/etc/nginx/nginx.conf:ro", "--label", - "$GITTALLY_LABEL=true", + "$werkator_LABEL=true", "--label", - "$GITTALLY_LABEL.repository=${settings.repoKey}", + "$werkator_LABEL.repository=${settings.repoKey}", "--label", - "$GITTALLY_LABEL.role=nginx", + "$werkator_LABEL.role=nginx", "nginx", ) @@ -405,16 +405,16 @@ class NginxProxyManager( System.getenv("XDG_STATE_HOME")?.takeIf { it.isNotBlank() }?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "state") return stateHome - .resolve("gittally") + .resolve("werkator") .resolve("nginx") .resolve(ArtifactKeys.repoKey(repoDir)) .toAbsolutePath() .normalize() } - /** Legacy default `gittally-nginx-` with unsafe characters replaced. */ + /** Legacy default `werkator-nginx-` with unsafe characters replaced. */ fun defaultContainerName(repoDir: Path): String = - "gittally-nginx-" + repoDir.fileName.toString().replace(Regex("[^A-Za-z0-9_.-]"), "-") + "werkator-nginx-" + repoDir.fileName.toString().replace(Regex("[^A-Za-z0-9_.-]"), "-") /** * Certbot's pinned DH parameters (RFC 7919 ffdhe2048), bundled as a resource: diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt index b2b09ea..aafcde1 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -12,5 +12,5 @@ class ServerConfiguration { * until the first guarded request, so the bean is safe outside a git repository. */ @Bean - fun controlTokenService(): ControlTokenService = ControlTokenService(Paths.get(".git/gittally/control-token")) + fun controlTokenService(): ControlTokenService = ControlTokenService(Paths.get(".git/werkator/control-token")) } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerMetricsLifecycle.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerMetricsLifecycle.kt index 9f16ae4..574e390 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ServerMetricsLifecycle.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerMetricsLifecycle.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.metrics.SystemMetricsCollector +import de.hoennig.werkator.metrics.SystemMetricsCollector import jakarta.annotation.PreDestroy import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.annotation.Profile diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt index c032fc0..9501873 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server import jakarta.annotation.PreDestroy import org.springframework.boot.context.event.ApplicationReadyEvent @@ -26,7 +26,7 @@ class ServerNginxLifecycle( /** Replaceable for tests: the scheduler running startup and renewal checks. */ internal var schedulerFactory: () -> ScheduledExecutorService = { Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "gittally-nginx").apply { isDaemon = true } + Thread(runnable, "werkator-nginx").apply { isDaemon = true } } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt index c839bd2..996c99a 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt @@ -1,6 +1,6 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.watcher.Watcher +import de.hoennig.werkator.watcher.Watcher import jakarta.annotation.PreDestroy import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.annotation.Profile diff --git a/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt index 1a5be9b..ed4a71e 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt @@ -1,8 +1,8 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.gitea.GiteaClient -import de.hoennig.gittally.gitea.GiteaStatusResult +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.gitea.GiteaClient +import de.hoennig.werkator.gitea.GiteaStatusResult import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping diff --git a/src/main/kotlin/de/hoennig/gittally/server/SystemApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/SystemApiController.kt index f338c0e..d378ddf 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/SystemApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/SystemApiController.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.metrics.SystemMetrics -import de.hoennig.gittally.metrics.SystemMetricsCollector +import de.hoennig.werkator.metrics.SystemMetrics +import de.hoennig.werkator.metrics.SystemMetricsCollector import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index 0bf1161..e4b5c51 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -1,14 +1,14 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.ArtifactStore -import de.hoennig.gittally.build.BuildExecutor -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.config.ConfigLoader -import de.hoennig.gittally.git.GitService -import de.hoennig.gittally.metrics.SystemMetricsCollector -import de.hoennig.gittally.watcher.Watcher +import de.hoennig.werkator.build.ArtifactStore +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.git.GitService +import de.hoennig.werkator.metrics.SystemMetricsCollector +import de.hoennig.werkator.watcher.Watcher import jakarta.servlet.http.HttpServletRequest import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.info.BuildProperties @@ -28,7 +28,7 @@ import kotlin.streams.asSequence /** * Server-rendered Thymeleaf views over the JSON API. The pages render the full - * state server-side (usable without JavaScript); `gittally.js` then polls the + * state server-side (usable without JavaScript); `werkator.js` then polls the * `/api/…` endpoints and re-renders the table bodies — pages are never re-fetched * and diffed like legacy, so the UI cannot get stuck on a loading animation. */ diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index 7acfb4d..cd1dc0b 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -1,9 +1,9 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.build.BuildResult -import de.hoennig.gittally.config.GiteaConfig -import de.hoennig.gittally.metrics.MetricAggregate -import de.hoennig.gittally.metrics.SystemMetrics +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.config.GiteaConfig +import de.hoennig.werkator.metrics.MetricAggregate +import de.hoennig.werkator.metrics.SystemMetrics import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.time.Duration @@ -32,7 +32,7 @@ class GiteaWebLinks( .joinToString("/") { URLEncoder.encode(it, StandardCharsets.UTF_8).replace("+", "%20") } } -/** Display formatting shared by the server-rendered views; `gittally.js` renders the same formats. */ +/** Display formatting shared by the server-rendered views; `werkator.js` renders the same formats. */ object UiFormats { private val timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()) @@ -69,7 +69,7 @@ object UiFormats { /** * CSS class highlighting a critical utilization: `metric-warn` from 80% of [total], * `metric-crit` from 90%, empty below or when either value is unavailable. - * `gittally.js` (`utilizationClass`) must apply the same thresholds. + * `werkator.js` (`utilizationClass`) must apply the same thresholds. */ fun utilizationClass( used: Double?, @@ -176,7 +176,7 @@ data class LogFileView( val failed: Boolean, ) -/** One card of the current-builds view; the live log is fetched by `gittally.js`. */ +/** One card of the current-builds view; the live log is fetched by `werkator.js`. */ data class CurrentBuildView( val branch: String, /** The displayed build name; = [branch] unless a named auto-build slot triggered the build. */ @@ -194,7 +194,7 @@ data class CurrentBuildView( /** * One row of the system-metrics table. The [key] matches the JSON field of - * `GET /api/system`, so `gittally.js` can update the cells in place. + * `GET /api/system`, so `werkator.js` can update the cells in place. */ data class MetricRowView( val key: String, diff --git a/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt index 3d47c57..d8d40a7 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt @@ -1,7 +1,7 @@ -package de.hoennig.gittally.server +package de.hoennig.werkator.server -import de.hoennig.gittally.watcher.Watcher -import de.hoennig.gittally.watcher.WatcherState +import de.hoennig.werkator.watcher.Watcher +import de.hoennig.werkator.watcher.WatcherState import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt index 68e0507..58134dd 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.watcher +package de.hoennig.werkator.watcher import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper @@ -74,7 +74,7 @@ object AutoBuildSlots { /** * Persists which auto-build slots already triggered as a JSON file, - * e.g. `.git/gittally/auto-builds.json` (replaces the legacy `auto-builds.tsv`). + * e.g. `.git/werkator/auto-builds.json` (replaces the legacy `auto-builds.tsv`). * Entries of past days are dropped on write, so the file never grows unbounded. */ class FileAutoBuildState( diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt index f5d07e4..6427356 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt @@ -1,16 +1,16 @@ -package de.hoennig.gittally.watcher +package de.hoennig.werkator.watcher -import de.hoennig.gittally.build.ArtifactKeys -import de.hoennig.gittally.build.ArtifactStore -import de.hoennig.gittally.build.BuildExecutor -import de.hoennig.gittally.build.BuildResultRepository -import de.hoennig.gittally.build.BuildStatus -import de.hoennig.gittally.build.GitWorktreeWorkspaces -import de.hoennig.gittally.config.BuildDefinition -import de.hoennig.gittally.config.ConfigLoader -import de.hoennig.gittally.config.DurationParser -import de.hoennig.gittally.config.GitTallyConfig -import de.hoennig.gittally.git.GitService +import de.hoennig.werkator.build.ArtifactKeys +import de.hoennig.werkator.build.ArtifactStore +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResultRepository +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.build.GitWorktreeWorkspaces +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.config.DurationParser +import de.hoennig.werkator.config.WerkatorConfig +import de.hoennig.werkator.git.GitService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.nio.file.Files @@ -80,7 +80,7 @@ class Watcher( scheduler = Executors .newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "gittally-watcher").apply { isDaemon = true } + Thread(runnable, "werkator-watcher").apply { isDaemon = true } }.also { it.scheduleWithFixedDelay({ pollSafely(workingDir) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS) } @@ -207,7 +207,7 @@ class Watcher( } private fun enqueueDueBranches( - config: GitTallyConfig, + config: WerkatorConfig, originBranches: Set, workingDir: Path, ) { @@ -238,7 +238,7 @@ class Watcher( /** * The build definitions that apply to [branch]: the primary configuration with the - * branch's own committed `.gittally.yml` merged on top (the pinned keys stripped), + * branch's own committed `.werkator.yml` merged on top (the pinned keys stripped), * so a new `builds` configuration can be tried out on a branch without touching any * other branch's builds. A branch's definitions only ever apply to that branch — * their selectors are evaluated for it alone, so a definition committed on one branch @@ -254,7 +254,7 @@ class Watcher( branch: String, headCommit: String?, workingDir: Path, - primary: GitTallyConfig, + primary: WerkatorConfig, ): Map { val commit = headCommit ?: return primary.effectiveBuildDefinitions() branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions } @@ -279,7 +279,7 @@ class Watcher( private class CachedDefinitions( val commit: String, - val primary: GitTallyConfig, + val primary: WerkatorConfig, val definitions: Map, ) @@ -304,7 +304,7 @@ class Watcher( private fun startBuildIfDue( branch: String, allowSameCommit: Boolean, - config: GitTallyConfig, + config: WerkatorConfig, pullRequestHeads: Lazy>, workingDir: Path, build: String = BuildDefinition.DEFAULT, @@ -335,7 +335,7 @@ class Watcher( * the point of a scheduled build. */ private fun enqueueScheduledBuilds( - config: GitTallyConfig, + config: WerkatorConfig, originBranches: Set, heads: Map, pullRequestHeads: Lazy>, @@ -373,7 +373,7 @@ class Watcher( * `builds` entry with `atTimes` and a single-branch selector would do. */ private fun enqueueDeprecatedAutoBuilds( - config: GitTallyConfig, + config: WerkatorConfig, originBranches: Set, pullRequestHeads: Lazy>, workingDir: Path, @@ -413,7 +413,7 @@ class Watcher( /** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */ private fun prune( - config: GitTallyConfig, + config: WerkatorConfig, originBranches: List, workingDir: Path, ) { @@ -463,9 +463,9 @@ class Watcher( companion object { /** Auto-build trigger state next to the build results (replaces legacy `auto-builds.tsv`). */ - const val AUTO_BUILDS_FILE = ".git/gittally/auto-builds.json" + const val AUTO_BUILDS_FILE = ".git/werkator/auto-builds.json" /** The committed config read per branch for its build definitions. */ - const val CONFIG_FILE = ".gittally.yml" + const val CONFIG_FILE = ".werkator.yml" } } diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/WatcherConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/watcher/WatcherConfiguration.kt index b2ad7af..5ae80a4 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/WatcherConfiguration.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/WatcherConfiguration.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.watcher +package de.hoennig.werkator.watcher import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/WatcherState.kt b/src/main/kotlin/de/hoennig/gittally/watcher/WatcherState.kt index 28c7b39..62bd7ea 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/WatcherState.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/WatcherState.kt @@ -1,4 +1,4 @@ -package de.hoennig.gittally.watcher +package de.hoennig.werkator.watcher import java.time.Instant diff --git a/src/main/resources/application-server.yml b/src/main/resources/application-server.yml index 488fd38..f564b41 100644 --- a/src/main/resources/application-server.yml +++ b/src/main/resources/application-server.yml @@ -7,4 +7,4 @@ spring: logging: level: - de.hoennig.gittally: INFO + de.hoennig.werkator: INFO diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg index 8f89dfc..8eac398 100644 --- a/src/main/resources/static/favicon.svg +++ b/src/main/resources/static/favicon.svg @@ -1,4 +1,4 @@ - + diff --git a/src/main/resources/static/gittally.css b/src/main/resources/static/gittally.css index 97d11af..0ce508c 100644 --- a/src/main/resources/static/gittally.css +++ b/src/main/resources/static/gittally.css @@ -1,4 +1,4 @@ -/* GitTally web UI — loosely ported from the legacy generated pages. */ +/* werkator web UI — loosely ported from the legacy generated pages. */ :root { color-scheme: light dark; diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index b2941b5..d7a9412 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -1,4 +1,4 @@ -// GitTally web UI — polls the JSON API and re-renders table bodies from data. +// werkator web UI — polls the JSON API and re-renders table bodies from data. // Every fetch has a timeout and failures render an explicit error badge, so the // UI can never get stuck on a loading animation (the legacy defect). "use strict"; @@ -97,13 +97,13 @@ function metaContent(name) { return element ? element.content : ""; } -const giteaRepoUrl = metaContent("gittally-gitea-repo-url"); +const giteaRepoUrl = metaContent("werkator-gitea-repo-url"); // The control token is deliberately NOT embedded in the pages: reading them is // unauthenticated, so anyone could have read it out of the HTML. The operator -// pastes it once per browser from `.git/gittally/control-token` on the server; +// pastes it once per browser from `.git/werkator/control-token` on the server; // it is kept in localStorage and only ever sent as a request header. -const CONTROL_TOKEN_KEY = "gittally.controlToken"; +const CONTROL_TOKEN_KEY = "werkator.controlToken"; function storedControlToken() { try { @@ -131,7 +131,7 @@ function forgetControlToken() { function askForControlToken() { const answer = window.prompt( - "Control token — the content of .git/gittally/control-token on the GitTally host:", + "Control token — the content of .git/werkator/control-token on the werkator host:", "", ); return answer ? answer.trim() : ""; @@ -169,7 +169,7 @@ async function sendAction(url, method) { function sendWithToken(url, method, token) { return fetch(url, { method, - headers: { "X-GitTally-Token": token }, + headers: { "X-werkator-Token": token }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); } diff --git a/src/main/resources/templates/artifact.html b/src/main/resources/templates/artifact.html index 7c19351..ac8ad76 100644 --- a/src/main/resources/templates/artifact.html +++ b/src/main/resources/templates/artifact.html @@ -76,6 +76,6 @@
    - + diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index 23e3cbe..6056fe1 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -78,6 +78,6 @@
    - + diff --git a/src/main/resources/templates/current.html b/src/main/resources/templates/current.html index 5823bf7..ab1d5f6 100644 --- a/src/main/resources/templates/current.html +++ b/src/main/resources/templates/current.html @@ -35,6 +35,6 @@
    - + diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html index bc283e5..8cdfd95 100644 --- a/src/main/resources/templates/fragments.html +++ b/src/main/resources/templates/fragments.html @@ -3,10 +3,10 @@
    - GitTally + werkator - - + + @@ -34,13 +34,13 @@ -