Configuration files declare the GitTally they are written for

Until now a version that renames or drops a key did not fail — it silently
ignored what it no longer understood, and the effect surfaced as a build
doing the wrong thing. Both directions of that happened within two days:
a branch config using `??:00` on a GitTally that did not know it yet, and a
`builds.maxConcurrent` that had moved to another section.

    gitTally:
      version:
        since: "0.9.18"   # enforced
        below: "2.0"      # release marker; GitTally decides how strictly

There is deliberately no version of the file format (no `apiVersion`): no API
is involved — GitTally reads its own configuration — and only one generation
is ever supported. The declaration exists to make an incompatibility
nameable, never to run two parsers.

`since` is hard in both directions. Too new a requirement is refused, and so
is a file written before the version in which the configuration format last
broke (`ConfigVersions.FORMAT_BROKE_IN`, empty for now) — that check needs no
declared ceiling, because GitTally knows its own breaking changes.

`below` is the team's release marker and only warns: an unmaintained caution
value must never stop a CI. The routine it serves is the one known from IDE
plugins — new version, warning, try it, then raise the marker and commit.

The reach of a violation follows the layer: the machine and project configs
abort the start naming the file and the rollback, while an incompatible
branch config fails only that branch's builds. A branch cut before a
migration must not stop the server or hold up the branches that are fine.
A file that declares nothing keeps working, and the CLI prints one line
instead of a stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-29 10:15:35 +02:00
co-authored by Claude Opus 5
parent fa183ef1db
commit 8608170f5b
10 changed files with 471 additions and 6 deletions
@@ -1,5 +1,6 @@
package de.hoennig.gittally
import de.hoennig.gittally.config.ConfigVersionException
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.ExitCodeGenerator
import org.springframework.boot.SpringApplication
@@ -25,10 +26,26 @@ class CliRunner(
private var exitCode = 0
override fun run(vararg args: String) {
exitCode = CommandLine(rootCommand, factory).execute(*args)
exitCode =
CommandLine(rootCommand, factory)
.setExecutionExceptionHandler { exception, commandLine, _ ->
// a config GitTally 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 ConfigVersionException) {
commandLine.err.println("Error: ${exception.message}")
CONFIG_ERROR_EXIT_CODE
} else {
throw exception
}
}.execute(*args)
}
override fun getExitCode() = exitCode
companion object {
/** Same code the commands use for usage and configuration errors. */
const val CONFIG_ERROR_EXIT_CODE = 2
}
}
fun main(args: Array<String>) {
@@ -2,6 +2,8 @@ package de.hoennig.gittally.commands
import de.hoennig.gittally.SecretFiles
import de.hoennig.gittally.git.GitService
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.stereotype.Component
import picocli.CommandLine.Command
import picocli.CommandLine.Option
@@ -16,6 +18,8 @@ import java.nio.file.Paths
)
class InitCommand(
private val gitService: GitService,
/** The version written into the generated config as `gitTally.version.since`. */
private val buildProperties: ObjectProvider<BuildProperties>? = null,
) : Runnable {
var workingDir: Path = Paths.get(".")
@@ -51,6 +55,12 @@ class InitCommand(
}
}
/**
* The running version for `gitTally.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"
private fun detectFromUrl(url: String?): DetectedValues {
if (url == null) return DetectedValues()
@@ -120,6 +130,16 @@ class InitCommand(
}
val content =
"""
# The GitTally this file is written for.
# since: enforced — an older GitTally 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
# to take it, and warns rather than blocks unless the format really broke.
gitTally:
version:
since: "${runningVersion()}"
# below: "2.0"
server:
# Public base URL of this GitTally installation — used for all links posted to Gitea.
publicBaseUrl: ""
@@ -7,6 +7,8 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.stereotype.Service
import java.io.File
import java.nio.file.Path
@@ -14,7 +16,10 @@ import java.nio.file.Paths
import java.util.concurrent.ConcurrentHashMap
@Service
class ConfigLoader {
class ConfigLoader(
/** The running version, for the `gitTally.version` check; absent outside a built jar (IDE, tests). */
private val buildProperties: ObjectProvider<BuildProperties>? = null,
) {
private val log = LoggerFactory.getLogger(ConfigLoader::class.java)
private val yaml =
@@ -26,6 +31,9 @@ class ConfigLoader {
/** Keys already reported by [dropNonDefinitionBuilds]; the config is loaded on every poll cycle. */
private val warnedBuildKeys = ConcurrentHashMap.newKeySet<String>()
/** Version warnings already reported; the config is loaded on every poll cycle, per branch. */
private val warnedVersions = ConcurrentHashMap.newKeySet<String>()
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig = toConfig(loadRaw(workingDir))
/**
@@ -37,7 +45,7 @@ class ConfigLoader {
fun loadForWorktree(
workingDir: Path,
worktreeDir: Path,
): GitTallyConfig = toConfig(deepMerge(loadRaw(workingDir), stripPinned(loadFile(worktreeDir.resolve(".gittally.yml").toFile()))))
): GitTallyConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(".gittally.yml").toFile()))
/**
* The primary/`.git` config with the committed `.gittally.yml` of one branch
@@ -49,8 +57,8 @@ class ConfigLoader {
*
* The [pinned][stripPinned] keys are the exception, and they are exactly the ones
* that are not a description of this branch's build: secrets (`git`), the host- and
* repository-side sections (`server`, `gitea`, `executor`), the docker sandbox policy
* (`docker.enabled`/`docker.network`), and the trust gate
* repository-side sections (`server`, `gitea`, `executor`, `watcher`), the docker
* sandbox policy (`docker.enabled`/`docker.network`), and the trust gate
* (`requirePullRequest`, which decides whether the branch is built at all).
* They are stripped from the branch layer before merging, so a branch can neither
* escape its container, nor bypass its own pull-request gate, nor raise the global
@@ -59,7 +67,17 @@ class ConfigLoader {
fun loadWithBranchLayer(
workingDir: Path,
branchConfigYaml: String?,
): GitTallyConfig = toConfig(deepMerge(loadRaw(workingDir), stripPinned(parseYaml(branchConfigYaml))))
): GitTallyConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml))
private fun withBranchLayer(
workingDir: Path,
branchLayer: Map<String, Any?>,
): GitTallyConfig {
// 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)
return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer)))
}
private fun toConfig(raw: Map<String, Any?>): GitTallyConfig {
val config =
@@ -144,9 +162,47 @@ class ConfigLoader {
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
val repoInstall = loadFile(workingDir.resolve(".git/gittally/.gittally.yml").toFile())
val project = loadFile(workingDir.resolve(".gittally.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)
return deepMerge(project, repoInstall)
}
/**
* Enforces the `gitTally.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
* never stop a CI.
*/
private fun checkVersion(
raw: Map<String, Any?>,
source: String,
hint: String,
) {
if (raw.isEmpty()) {
return
}
val running = buildProperties?.getIfAvailable()?.version
when (val verdict = ConfigVersions.verdict(requirementOf(raw), running)) {
is VersionVerdict.Compatible -> Unit
is VersionVerdict.Warn ->
if (warnedVersions.add("$source: ${verdict.message}")) {
log.warn("{} {}", source, verdict.message)
}
is VersionVerdict.Incompatible -> throw ConfigVersionException("$source ${verdict.message}. $hint")
}
}
@Suppress("UNCHECKED_CAST")
private fun requirementOf(raw: Map<String, Any?>): VersionRequirement {
val version = (raw["gitTally"] as? Map<String, Any?>)?.get("version") as? Map<String, Any?> ?: return VersionRequirement()
return VersionRequirement(
since = version["since"]?.toString()?.trim().orEmpty(),
below = version["below"]?.toString()?.trim().orEmpty(),
)
}
fun toYaml(value: Any): String = yaml.writeValueAsString(value)
private fun loadFile(file: File): Map<String, Any?> {
@@ -216,5 +272,11 @@ class ConfigLoader {
/** Per-branch `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
private const val ROLLBACK_HINT =
"Migrate the file, or roll back to the GitTally version it was written for."
private const val BRANCH_HINT =
"Migrate the file on this branch; the other branches keep building."
}
}
@@ -0,0 +1,123 @@
package de.hoennig.gittally.config
/**
* The GitTally version a configuration file declares itself for, the `gitTally.version`
* section:
*
* ```yaml
* gitTally:
* version:
* since: "0.9.16" # always hard: an older GitTally refuses this file
* below: "2.0" # GitTally 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
* 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. */
val since: String = "",
/** First GitTally this file was not released for; empty means no ceiling. */
val below: String = "",
)
data class GitTallyMeta(
val version: VersionRequirement = VersionRequirement(),
)
/** What a [VersionRequirement] means for the GitTally that reads the file. */
sealed interface VersionVerdict {
/** The running version is covered by the declaration. */
data object Compatible : VersionVerdict
/** Usable, but the file was not released for this version. */
data class Warn(
val message: String,
) : VersionVerdict
/** Not usable: the file predates a change that GitTally 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. */
class ConfigVersionException(
message: String,
) : RuntimeException(message)
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
* happened; set it to the release that introduces one, together with the migration
* note the message points at.
*/
const val FORMAT_BROKE_IN = ""
/** Human-readable description of that change, shown in the error message. */
const val FORMAT_BROKE_DESCRIPTION = ""
/**
* Decides what [requirement] means for [running].
*
* `since` is always hard — a file that needs a newer GitTally 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
* and read after it is incompatible regardless of what it declares as its ceiling.
*/
fun verdict(
requirement: VersionRequirement,
running: String?,
brokeIn: String = FORMAT_BROKE_IN,
brokeDescription: String = FORMAT_BROKE_DESCRIPTION,
): VersionVerdict {
val version = parse(running) ?: return VersionVerdict.Compatible
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",
)
}
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), " +
"but the configuration format changed incompatibly in $brokeIn" +
brokeDescription.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty(),
)
}
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",
)
}
return VersionVerdict.Compatible
}
/**
* `1.2.3` and shorter prefixes like `2.0`, compared numerically part by part with
* missing parts as 0 — `below: "2.0"` is the point 2.0.0, which is why the ceiling is
* exclusive: an inclusive one could not tell `1` (the release) from `1.x` (the series).
* A pre-release suffix (`1.0.0-rc1`) is ignored, and anything unparseable yields null,
* so a typo can never make a file look incompatible.
*/
fun parse(version: String?): List<Int>? {
val text = version?.trim()?.substringBefore('-').orEmpty()
if (text.isEmpty()) {
return null
}
val parts = text.split('.').map { it.toIntOrNull() ?: return null }
return (parts + listOf(0, 0, 0)).take(3)
}
private operator fun List<Int>.compareTo(other: List<Int>): Int =
indices.firstNotNullOfOrNull { i -> (this[i] - other[i]).takeIf { it != 0 } } ?: 0
}
@@ -3,6 +3,8 @@ package de.hoennig.gittally.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(),
val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(),
val gitea: GiteaConfig = GiteaConfig(),