Rename GitTally to Werkator

`gitTally` is the name of another product in the git space, so the
rename is a precaution; nothing about what the build system does changes.

The name follows one rule: `Werkator` where it is prose, capitalized
where it is a Kotlin type and its file, lowercase everywhere a machine
reads it — the command, packages, paths, configuration keys and values,
the Gitea check context. Environment variables keep their convention and
are uppercase throughout.

Every configuration file is still found under its pre-rename name
(`ConfigFiles`): `.gittally.yml` at the repository root, in a build
worktree and as committed on a branch, `.git/gittally/.gittally.yml` for
the machine layer. The current name wins where both exist, and the old
file is then ignored rather than merged — two files side by side are a
half-done rename, not a layering. Without the fallback an installation
that updated without renaming would not fail: a configuration that is
not found leaves every setting at its default, so it would come up
looking healthy while having forgotten its credentials and its builds.

`docs/werkator-migrationsplan.md` lists what the fallback does not
cover and has to be moved by hand — above all the state directory
`.git/werkator/`, which holds the build history, the control token and
the worktrees, and has no fallback of its own.

`docs/migration-from-legacy.md` is deleted with this: it mapped the
legacy script's environment variables, and every host it addressed has
long since moved to the YAML configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-30 19:39:55 +02:00
co-authored by Claude Opus 5
parent 7f550689dd
commit 35f06ec1ec
156 changed files with 604 additions and 401 deletions
@@ -20,7 +20,7 @@ class WerkatorApplication
@Profile("!server")
class CliRunner(
private val factory: IFactory,
private val rootCommand: werkatorCommand,
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 werkator 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}")
@@ -27,7 +27,7 @@ import picocli.CommandLine.Command
versionProvider = BuildPropertiesVersionProvider::class,
description = ["Lightweight, declarative CI/CD system"],
)
class werkatorCommand : Runnable {
class WerkatorCommand : Runnable {
override fun run(): Unit = throw CommandLine.ParameterException(CommandLine(this), "Specify a subcommand")
}
@@ -41,5 +41,5 @@ class werkatorCommand : Runnable {
class BuildPropertiesVersionProvider(
private val buildProperties: ObjectProvider<BuildProperties>,
) : CommandLine.IVersionProvider {
override fun getVersion(): Array<String> = arrayOf("werkator v${buildProperties.getIfAvailable()?.version ?: "dev"}")
override fun getVersion(): Array<String> = arrayOf("Werkator v${buildProperties.getIfAvailable()?.version ?: "dev"}")
}
@@ -162,7 +162,7 @@ class FileArtifactStore(
/**
* The settings [build] ran with, from the build [workspace]'s `.werkator.yml` layered
* on top of the primary config (see [ConfigLoader.loadForWorktree]) resolved through
* [werkatorConfig.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(
@@ -194,11 +194,11 @@ class DockerBuildRunner(
"ps",
"-aq",
"--filter",
"label=$werkator_LABEL=true",
"label=$WERKATOR_LABEL=true",
"--filter",
"label=$werkator_LABEL.repository=$repoKey",
"label=$WERKATOR_LABEL.repository=$repoKey",
"--filter",
"label=$werkator_LABEL.role=build",
"label=$WERKATOR_LABEL.role=build",
),
repoDir,
)
@@ -238,11 +238,11 @@ class DockerBuildRunner(
args +=
listOf(
"--label",
"$werkator_LABEL=true",
"$WERKATOR_LABEL=true",
"--label",
"$werkator_LABEL.repository=$repoKey",
"$WERKATOR_LABEL.repository=$repoKey",
"--label",
"$werkator_LABEL.role=build",
"$WERKATOR_LABEL.role=build",
)
args += listOf("--workdir", "$workspace", "--volume", "$workspace:$workspace")
args += gitMetadataMounts(workspace, repoDir)
@@ -279,7 +279,7 @@ class DockerBuildRunner(
}
/**
* Makes git work inside the build container without exposing werkator'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
@@ -327,7 +327,7 @@ class DockerBuildRunner(
companion object {
/** Container label namespace; legacy used `org.hostsharing.werkator`. */
const val werkator_LABEL = "org.hoennig.werkator"
const val WERKATOR_LABEL = "org.hoennig.werkator"
fun gradleVolumeName(repoKey: String): String = "werkator-gradle-$repoKey"
@@ -13,7 +13,7 @@ import java.nio.file.Paths
@Component
@Command(
name = "init",
description = ["Initialize werkator for the current repository"],
description = ["Initialize Werkator for the current repository"],
mixinStandardHelpOptions = true,
)
class InitCommand(
@@ -130,10 +130,10 @@ class InitCommand(
}
val content =
"""
# The werkator this file is written for.
# since: enforced an older werkator 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; werkator 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.
werkator:
version:
@@ -141,13 +141,13 @@ class InitCommand(
# below: "2.0"
server:
# Public base URL of this werkator 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 werkator 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: ""
@@ -256,7 +256,7 @@ class InitCommand(
) {
val jarPath = jarPathResolver()
if (jarPath == null) {
println("Error: cannot determine the werkator jar path — run `init --systemd` via `java -jar <path-to>/werkator.jar`")
println("Error: cannot determine the Werkator jar path — run `init --systemd` via `java -jar <path-to>/werkator.jar`")
return
}
val werkatorDir = root.resolve(".git/werkator")
@@ -283,7 +283,7 @@ class InitCommand(
}
// the nightly Docker cleanup is host-global: every repository generates the same
// units, so with several werkator instances the symlinks simply coincide
// 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())
@@ -24,7 +24,7 @@ import java.util.concurrent.CountDownLatch
@Component
@Command(
name = "server",
description = ["Start the werkator server"],
description = ["Start the Werkator server"],
mixinStandardHelpOptions = true,
)
class ServerCommand(
@@ -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("werkator 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)
}
@@ -10,11 +10,11 @@ import java.nio.file.Path
object SystemdServiceFiles {
const val ENV_FILE_NAME = "werkator.env"
/** Host-global unit names of the nightly Docker cleanup — shared by all werkator repositories on the host. */
/** 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 werkator instance serves exactly one repository. */
/** 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(
@@ -25,7 +25,7 @@ object SystemdServiceFiles {
): String =
"""
[Unit]
Description=werkator 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 (werkator)
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 (werkator)
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 werkator systemd service.
# werkator itself is configured via .werkator.yml and .git/werkator/.werkator.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"
@@ -0,0 +1,47 @@
package de.hoennig.werkator.config
import java.nio.file.Files
import java.nio.file.Path
/**
* The names a configuration file is looked up under, current name first and the name
* from before the rename to Werkator second — spelled exactly as it was.
*
* The fallback exists because a missing configuration is not an error: it leaves every
* setting at its default. An installation that updates without moving its files would
* therefore not fail, it would come up as a plausible-looking instance that has
* forgotten its credentials, its addresses, and what it builds.
*/
object ConfigFiles {
/** The committed configuration, at the repository root and in a build worktree. */
const val COMMITTED = ".werkator.yml"
/** The machine-specific configuration inside `.git`; secrets live here. */
const val REPO_INSTALL = ".git/werkator/$COMMITTED"
private const val LEGACY_COMMITTED = ".gittally.yml"
private const val LEGACY_REPO_INSTALL = ".git/gittally/$LEGACY_COMMITTED"
/** Both names of the committed configuration, current first. */
val committed = listOf(COMMITTED, LEGACY_COMMITTED)
/** Both paths of the machine-specific configuration, current first. */
val repoInstall = listOf(REPO_INSTALL, LEGACY_REPO_INSTALL)
/**
* The first of [candidates] that exists under [dir], or the current name when none
* does — so a message about a file names the one to write, never the one that is
* history.
*/
fun firstExisting(
dir: Path,
candidates: List<String> = committed,
): String = candidates.firstOrNull { Files.isRegularFile(dir.resolve(it)) } ?: candidates.first()
/**
* The committed configuration as [read] answers it for a name, current name first.
* Null when neither name is committed — used where the file is read out of git
* rather than off the filesystem.
*/
fun readCommitted(read: (String) -> String?): String? = committed.firstNotNullOfOrNull(read)
}
@@ -48,7 +48,7 @@ class ConfigLoader(
fun loadForWorktree(
workingDir: Path,
worktreeDir: Path,
): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(".werkator.yml").toFile()))
): WerkatorConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(ConfigFiles.firstExisting(worktreeDir)).toFile()))
/**
* The primary/`.git` config with the committed `.werkator.yml` of one branch
@@ -276,13 +276,16 @@ class ConfigLoader(
}
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
val repoInstall = loadFile(workingDir.resolve(".git/werkator/.werkator.yml").toFile())
val project = loadFile(workingDir.resolve(".werkator.yml").toFile())
// each layer under its current name, or under the one it had before the rename
val repoInstallName = ConfigFiles.firstExisting(workingDir, ConfigFiles.repoInstall)
val projectName = ConfigFiles.firstExisting(workingDir)
val repoInstall = loadFile(workingDir.resolve(repoInstallName).toFile())
val project = loadFile(workingDir.resolve(projectName).toFile())
// per file, so the message names the file to fix — the merged map has no provenance
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)
checkVersion(project, projectName, ROLLBACK_HINT)
checkVersion(repoInstall, repoInstallName, ROLLBACK_HINT)
checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
return deepMerge(project, repoInstall)
}
@@ -411,7 +414,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 werkator 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."
@@ -1,33 +1,33 @@
package de.hoennig.werkator.config
/**
* The werkator version a configuration file declares itself for, the `werkator.version`
* The Werkator version a configuration file declares itself for, the `werkator.version`
* section:
*
* ```yaml
* werkator:
* version:
* since: "0.9.16" # always hard: an older werkator refuses this file
* below: "2.0" # werkator 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 werkator 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 werkator 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 werkator 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 werkatorMeta(
data class WerkatorMeta(
val version: VersionRequirement = VersionRequirement(),
)
/** What a [VersionRequirement] means for the werkator 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 werkator cannot bridge. */
/** Not usable: the file predates a change that Werkator cannot bridge. */
data class Incompatible(
val message: String,
) : VersionVerdict
}
/** A configuration file this werkator 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 werkator 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 werkator 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(
@@ -76,13 +76,13 @@ object ConfigVersions {
/**
* Decides what [requirement] means for [running].
*
* `since` is always hard a file that needs a newer werkator 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
* werkator'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 werkator ${requirement.since} or newer (werkator.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 werkator ${requirement.since} (werkator.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 werkator below ${requirement.below} (werkator.version.below), this is $running",
"was released for Werkator below ${requirement.below} (werkator.version.below), this is $running",
)
}
return VersionVerdict.Compatible
@@ -3,8 +3,8 @@ package de.hoennig.werkator.config
import com.fasterxml.jackson.annotation.JsonProperty
data class WerkatorConfig(
/** What this file declares about the werkator that reads it; see [VersionRequirement]. */
val werkator: werkatorMeta = werkatorMeta(),
/** 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 werkator 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.
*/
@@ -15,10 +15,10 @@ object GitAskPass {
#!/bin/sh
case "${'$'}1" in
*[Uu]sername*)
printf '%s\n' "${'$'}werkator_GIT_ACCOUNT"
printf '%s\n' "${'$'}WERKATOR_GIT_ACCOUNT"
;;
*)
printf '%s\n' "${'$'}werkator_GIT_TOKEN"
printf '%s\n' "${'$'}WERKATOR_GIT_TOKEN"
;;
esac
""".trimIndent() + "\n"
@@ -40,8 +40,8 @@ object GitAskPass {
mapOf(
"GIT_ASKPASS" to script.toAbsolutePath().toString(),
"GIT_TERMINAL_PROMPT" to "0",
"werkator_GIT_ACCOUNT" to account,
"werkator_GIT_TOKEN" to token,
"WERKATOR_GIT_ACCOUNT" to account,
"WERKATOR_GIT_TOKEN" to token,
),
)
} finally {
@@ -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 werkator is added.
* with the Let's Encrypt certificate and the proxy to Werkator is added.
*/
fun nginxConf(
serverName: String,
@@ -1,7 +1,7 @@
package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactKeys
import de.hoennig.werkator.build.DockerBuildRunner.Companion.werkator_LABEL
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
@@ -11,7 +11,7 @@ import java.nio.file.Path
import java.nio.file.Paths
/**
* Manages the opt-in nginx+certbot Docker container that serves werkator 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.werkator.build.DockerBuildRunner].
@@ -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
* werkator 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=$werkator_LABEL=true",
"label=$WERKATOR_LABEL=true",
"--filter",
"label=$werkator_LABEL.repository=${settings.repoKey}",
"label=$WERKATOR_LABEL.repository=${settings.repoKey}",
"--filter",
"label=$werkator_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("$werkator_LABEL=true") ||
if (container.labels.contains("$WERKATOR_LABEL=true") ||
container.name.startsWith("werkator-") ||
container.name.startsWith("git-watch-origin-and-test-nginx-")
) {
log.info("removing stale werkator 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",
"$werkator_LABEL=true",
"$WERKATOR_LABEL=true",
"--label",
"$werkator_LABEL.repository=${settings.repoKey}",
"$WERKATOR_LABEL.repository=${settings.repoKey}",
"--label",
"$werkator_LABEL.role=nginx",
"$WERKATOR_LABEL.role=nginx",
"nginx",
)
@@ -5,10 +5,10 @@ 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.ConfigFiles
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
@@ -235,7 +235,7 @@ class UiController(
try {
configLoader.loadWithBranchLayer(
workingDir,
gitService.showFileAtCommit(result.commit, Watcher.CONFIG_FILE, workingDir),
ConfigFiles.readCommitted { gitService.showFileAtCommit(result.commit, it, workingDir) },
)
} catch (_: Exception) {
configLoader.load(workingDir)
@@ -7,6 +7,7 @@ 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.ConfigFiles
import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.config.DurationParser
import de.hoennig.werkator.config.WerkatorConfig
@@ -261,8 +262,10 @@ class Watcher(
val definitions =
try {
configLoader
.loadWithBranchLayer(workingDir, gitService.showFileAtCommit(commit, CONFIG_FILE, workingDir))
.effectiveBuildDefinitions()
.loadWithBranchLayer(
workingDir,
ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) },
).effectiveBuildDefinitions()
} catch (e: Exception) {
log.warn(
"ignoring the committed {} of branch {} at {}: {}",
@@ -466,6 +469,6 @@ class Watcher(
const val AUTO_BUILDS_FILE = ".git/werkator/auto-builds.json"
/** The committed config read per branch for its build definitions. */
const val CONFIG_FILE = ".werkator.yml"
const val CONFIG_FILE = ConfigFiles.COMMITTED
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="werkator">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Werkator">
<rect width="64" height="64" rx="14" fill="#155eef"/>
<path d="M17 47V18m0 14h13c7 0 10-4 10-11" fill="none" stroke="#f9fafb" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="17" cy="18" r="5" fill="#DD4901"/>

Before

Width:  |  Height:  |  Size: 564 B

After

Width:  |  Height:  |  Size: 564 B

@@ -1,4 +1,4 @@
/* werkator web UI — loosely ported from the legacy generated pages. */
/* Werkator web UI — loosely ported from the legacy generated pages. */
:root {
color-scheme: light dark;
@@ -1,4 +1,4 @@
// werkator 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";
@@ -131,7 +131,7 @@ function forgetControlToken() {
function askForControlToken() {
const answer = window.prompt(
"Control token — the content of .git/werkator/control-token on the werkator host:",
"Control token — the content of .git/werkator/control-token on the Werkator host:",
"",
);
return answer ? answer.trim() : "";
+2 -2
View File
@@ -3,7 +3,7 @@
<head th:fragment="head(title)">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${#strings.isEmpty(repoName)} ? ${title} + ' — werkator' : ${title} + ' — ' + ${repoName} + ' — werkator'">werkator</title>
<title th:text="${#strings.isEmpty(repoName)} ? ${title} + ' — Werkator' : ${title} + ' — ' + ${repoName} + ' — Werkator'">Werkator</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/werkator.css">
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
@@ -40,7 +40,7 @@
</th:block>
<footer th:fragment="footer" class="site-footer">
<strong><a href="/releases" title="Release notes"><em th:text="'werkator v' + ${version}">werkator</em></a></strong>
<strong><a href="/releases" title="Release notes"><em th:text="'Werkator v' + ${version}">Werkator</em></a></strong>
— © <a href="https://michael.hoennig.de" target="_blank" rel="noopener noreferrer">Michael Hönnig</a>, 2026
<th:block th:unless="${#strings.isEmpty(impressumUrl)}">
<a th:href="${impressumUrl}" target="_blank" rel="noopener noreferrer">Impressum (Legal Disclosure)</a>
+29 -1
View File
@@ -7,6 +7,34 @@
<div th:replace="~{fragments :: nav(${view})}"></div>
<div class="panel release-notes">
<h2>v0.9.22 <span class="muted">— unreleased</span></h2>
<ul>
<li>GitTally is now <strong>Werkator</strong>. The old name already belongs to another
product in the git space, so the rename is a precaution and nothing more: what the
build system does, how it is configured, and how it reports to Gitea are unchanged.
In prose it is Werkator, everywhere a machine reads the name it is
<code>werkator</code>.</li>
<li>The rename reaches the file names an installation depends on. The committed
configuration is <code>.werkator.yml</code>, the machine-specific one
<code>.git/werkator/.werkator.yml</code>, and all state — build results, artifacts,
worktrees, the control token — lives under <code>.git/werkator/</code>.</li>
<li>Every configuration is still found under its old name, spelled as it was:
<code>.gittally.yml</code> at the repository root, in a build worktree and as
committed on a branch, and <code>.git/gittally/.gittally.yml</code> for the machine
layer. The current name wins where both exist, and the old file is then ignored
rather than merged. The fallback is there because a configuration that is not found
is not an error — it leaves every setting at its default, so an installation that
updated without renaming would have come up looking healthy while having forgotten
its credentials and its builds. Rename at your convenience; the state directory
<code>.git/werkator/</code> has no such fallback and does have to be moved, or the
instance starts without its build history.</li>
<li>The default Gitea check context is <code>werkator</code>. Where the old name is
pinned in a branch protection rule, the rule has to be updated with it, or a pull
request waits forever for a check nobody posts any more. Statuses already written
keep their old context, so a commit built before and after the change shows
both.</li>
</ul>
<h2>v0.9.21 <span class="muted">— 2026-08-30</span></h2>
<ul>
<li>An origin the watcher cannot reach is now visible in the web UI: a banner above the
@@ -294,6 +322,6 @@
</div>
</main>
<footer th:replace="~{fragments :: footer}"></footer>
<script src="/gittally.js"></script>
<script src="/werkator.js"></script>
</body>
</html>
@@ -11,7 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class ApplicationContextTest : FunSpec() {
@Autowired
lateinit var rootCommand: werkatorCommand
lateinit var rootCommand: WerkatorCommand
@Autowired
lateinit var initCommand: InitCommand
@@ -20,9 +20,9 @@ class GitWorktreeWorkspacesTest : FunSpec() {
// hermetic git: fixed identity, no user/system config (hooks, gpg signing, ...)
private val gitEnvironment =
mapOf(
"GIT_AUTHOR_NAME" to "werkator Test",
"GIT_AUTHOR_NAME" to "Werkator Test",
"GIT_AUTHOR_EMAIL" to "test@example.com",
"GIT_COMMITTER_NAME" to "werkator Test",
"GIT_COMMITTER_NAME" to "Werkator Test",
"GIT_COMMITTER_EMAIL" to "test@example.com",
"GIT_CONFIG_GLOBAL" to "/dev/null",
"GIT_CONFIG_SYSTEM" to "/dev/null",
@@ -22,7 +22,7 @@ class SystemdServiceFilesTest : FunSpec() {
envFile = Paths.get("/srv/repos/my-repo/.git/werkator/werkator.env"),
)
content shouldContain "Description=werkator CI for my-repo"
content shouldContain "Description=Werkator CI for my-repo"
content shouldContain "After=network-online.target docker.service"
content shouldContain "WorkingDirectory=/srv/repos/my-repo"
content shouldContain "EnvironmentFile=-/srv/repos/my-repo/.git/werkator/werkator.env"
@@ -18,7 +18,7 @@ import java.util.Properties
class ConfigLoaderTest : FunSpec() {
private val loader = ConfigLoader()
/** A loader that knows which werkator it is, for the `werkator.version` checks. */
/** A loader that knows which Werkator it is, for the `werkator.version` checks. */
private fun loaderRunning(version: String): ConfigLoader {
val provider = mockk<ObjectProvider<BuildProperties>>()
every { provider.getIfAvailable() } returns BuildProperties(Properties().apply { setProperty("version", version) })
@@ -50,6 +50,54 @@ class ConfigLoaderTest : FunSpec() {
config.gitea.repo shouldBe "my-repo"
}
test("falls back to the pre-rename .gittally.yml at the repository root") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
gitea:
owner: my-org
repo: my-repo
""".trimIndent(),
)
loader.load(dir).gitea.owner shouldBe "my-org"
}
test("falls back to the pre-rename machine config under .git/gittally") {
val dir = Files.createTempDirectory("werkator-test")
Files.createDirectories(dir.resolve(".git/gittally"))
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
"""
git:
account: ci-user
""".trimIndent(),
)
loader.load(dir).git.account shouldBe "ci-user"
}
test("the current name wins where both exist, so a half-done rename is not merged") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText("gitea:\n owner: current\n")
dir.resolve(".gittally.yml").toFile().writeText("gitea:\n owner: legacy\n repo: legacy-repo\n")
val config = loader.load(dir)
config.gitea.owner shouldBe "current"
// not merged: the old file is a leftover, not a layer
config.gitea.repo shouldBe ""
}
test("a branch whose config is committed under the pre-rename name is still read as the branch layer") {
val dir = Files.createTempDirectory("werkator-test")
val worktree = Files.createTempDirectory("werkator-worktree")
worktree.resolve(".gittally.yml").toFile().writeText(
"""
builds:
default:
buildCommand: ./gradlew fromBranch
""".trimIndent(),
)
loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default").buildCommand shouldBe
"./gradlew fromBranch"
}
test("reads executor.maxConcurrent and defaults it to 1") {
val dir = Files.createTempDirectory("werkator-test")
loader.load(dir).executor.maxConcurrent shouldBe 1
@@ -113,7 +161,7 @@ class ConfigLoaderTest : FunSpec() {
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(trigger = TriggerConfig(onPush = false))
}
test("a config that needs a newer werkator is refused, naming the file and both versions") {
test("a config that needs a newer Werkator is refused, naming the file and both versions") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
@@ -745,7 +793,7 @@ class ConfigLoaderTest : FunSpec() {
loader.loadForWorktree(dir, worktree) shouldBe loader.load(dir)
}
test("toYaml serializes werkatorConfig with all sections") {
test("toYaml serializes WerkatorConfig with all sections") {
val yaml = loader.toYaml(WerkatorConfig())
yaml shouldContain "server:"
yaml shouldContain "git:"
@@ -14,7 +14,7 @@ class ConfigVersionsTest : FunSpec() {
) = ConfigVersions.verdict(VersionRequirement(since = since, below = below), running)
init {
test("a file needing a newer werkator is refused, naming both versions") {
test("a file needing a newer Werkator is refused, naming both versions") {
val result = verdict(since = "0.9.16", running = "0.9.15")
result
@@ -45,7 +45,7 @@ class ConfigVersionsTest : FunSpec() {
val description = "`builds:` is now `buildSpec:`"
val written14 = VersionRequirement(since = "1.4.0")
// no ceiling declared, and none needed: werkator knows its own breaking change
// no ceiling declared, and none needed: Werkator knows its own breaking change
ConfigVersions
.verdict(written14, "2.0.1", brokeIn, description)
.shouldBeInstanceOf<VersionVerdict.Incompatible>()
@@ -54,7 +54,7 @@ class ConfigVersionsTest : FunSpec() {
it shouldContain "2.0.0"
it shouldContain "buildSpec"
}
// a werkator from before the change still reads that file
// a Werkator from before the change still reads that file
ConfigVersions.verdict(written14, "1.9.0", brokeIn, description) shouldBe VersionVerdict.Compatible
// and a file written after the change is fine on both sides of it
ConfigVersions.verdict(
@@ -41,7 +41,7 @@ class GitCommandRunnerTest : FunSpec() {
}
test("passes extra environment variables") {
val result = runner.run(listOf("sh", "-c", "echo \"\$werkator_TEST_VAR\""), tempDir, mapOf("werkator_TEST_VAR" to "hello"))
val result = runner.run(listOf("sh", "-c", "echo \"\$WERKATOR_TEST_VAR\""), tempDir, mapOf("WERKATOR_TEST_VAR" to "hello"))
result.stdout.trim() shouldBe "hello"
}
@@ -27,9 +27,9 @@ class GitServiceTest : FunSpec() {
// hermetic git: fixed identity, no user/system config (hooks, gpg signing, ...)
private val gitEnvironment =
mapOf(
"GIT_AUTHOR_NAME" to "werkator Test",
"GIT_AUTHOR_NAME" to "Werkator Test",
"GIT_AUTHOR_EMAIL" to "test@example.com",
"GIT_COMMITTER_NAME" to "werkator Test",
"GIT_COMMITTER_NAME" to "Werkator Test",
"GIT_COMMITTER_EMAIL" to "test@example.com",
"GIT_CONFIG_GLOBAL" to "/dev/null",
"GIT_CONFIG_SYSTEM" to "/dev/null",

Some files were not shown because too many files have changed in this diff Show More