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
+2
View File
@@ -46,6 +46,8 @@ GitTally is configured by two YAML files, deep-merged by `ConfigLoader` (later w
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 — build settings and 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`, the per-branch `requirePullRequest`, and `docker.enabled`/`docker.network`. 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 — build settings and 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`, the per-branch `requirePullRequest`, 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.
After merging, `branches.default` is merged into every other named branch entry as its fallback, then the result is bound to the `GitTallyConfig` data classes (`config/GitTallyConfig.kt`), which define the schema and all defaults. After merging, `branches.default` is merged into every other named branch entry as its fallback, then the result is bound to the `GitTallyConfig` data classes (`config/GitTallyConfig.kt`), which define the schema and all defaults.
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 `GitTallyConfig` data classes, the commented templates generated by `InitCommand`, and the reference in `docs/configuration.md`.
+1
View File
@@ -38,6 +38,7 @@ All production code lives under `de.hoennig.gittally`, with sub-packages `comman
- 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. - 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/<branchKey>`; the primary checkout is never used for builds; never assume a single running build. - Builds run detached in worktrees under `.git/gittally/worktrees/<branchKey>`; 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`. - 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. - 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.
- 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/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.
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK. - Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
+50
View File
@@ -12,6 +12,50 @@ GitTally is configured via YAML files. Settings are merged from several sources
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/gittally/.gittally.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
Every configuration file may declare the GitTally 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:
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
```
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
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 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
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
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
generated config.
How far a violation reaches depends on the file, following the same rule as everything
else here: the machine and project configs abort the start (the message names the file and
the rollback), while an incompatible **branch** config fails only the builds of that
branch. A branch that was cut before a migration must never stop the server or hold up the
branches that are fine.
### The branch layer: a branch describes its own CI ### 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 `.gittally.yml` committed on a branch is applied as a third layer on top of the two
@@ -60,6 +104,12 @@ Add `--show-secrets` to print it in clear text.
Values shown are the defaults. Values shown are the defaults.
```yaml ```yaml
# The GitTally this file is written for (see the section above).
gitTally:
version:
since: "0.9.18" # enforced: older GitTally refuses this file
below: "2.0" # optional release marker; warns, does not block
server: server:
# Public base URL of this GitTally installation — used for all links posted to Gitea. # Public base URL of this GitTally installation — used for all links posted to Gitea.
publicBaseUrl: https://ci.example.org/ publicBaseUrl: https://ci.example.org/
@@ -1,5 +1,6 @@
package de.hoennig.gittally package de.hoennig.gittally
import de.hoennig.gittally.config.ConfigVersionException
import org.springframework.boot.CommandLineRunner import org.springframework.boot.CommandLineRunner
import org.springframework.boot.ExitCodeGenerator import org.springframework.boot.ExitCodeGenerator
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
@@ -25,10 +26,26 @@ class CliRunner(
private var exitCode = 0 private var exitCode = 0
override fun run(vararg args: String) { 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 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>) { fun main(args: Array<String>) {
@@ -2,6 +2,8 @@ package de.hoennig.gittally.commands
import de.hoennig.gittally.SecretFiles import de.hoennig.gittally.SecretFiles
import de.hoennig.gittally.git.GitService import de.hoennig.gittally.git.GitService
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.Option import picocli.CommandLine.Option
@@ -16,6 +18,8 @@ import java.nio.file.Paths
) )
class InitCommand( class InitCommand(
private val gitService: GitService, private val gitService: GitService,
/** The version written into the generated config as `gitTally.version.since`. */
private val buildProperties: ObjectProvider<BuildProperties>? = null,
) : Runnable { ) : Runnable {
var workingDir: Path = Paths.get(".") 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 { private fun detectFromUrl(url: String?): DetectedValues {
if (url == null) return DetectedValues() if (url == null) return DetectedValues()
@@ -120,6 +130,16 @@ class InitCommand(
} }
val content = 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: server:
# Public base URL of this GitTally installation — used for all links posted to Gitea. # Public base URL of this GitTally installation — used for all links posted to Gitea.
publicBaseUrl: "" publicBaseUrl: ""
@@ -7,6 +7,8 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator
import com.fasterxml.jackson.module.kotlin.registerKotlinModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.File import java.io.File
import java.nio.file.Path import java.nio.file.Path
@@ -14,7 +16,10 @@ import java.nio.file.Paths
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@Service @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 log = LoggerFactory.getLogger(ConfigLoader::class.java)
private val yaml = private val yaml =
@@ -26,6 +31,9 @@ class ConfigLoader {
/** Keys already reported by [dropNonDefinitionBuilds]; the config is loaded on every poll cycle. */ /** Keys already reported by [dropNonDefinitionBuilds]; the config is loaded on every poll cycle. */
private val warnedBuildKeys = ConcurrentHashMap.newKeySet<String>() 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)) fun load(workingDir: Path = Paths.get(".")): GitTallyConfig = toConfig(loadRaw(workingDir))
/** /**
@@ -37,7 +45,7 @@ class ConfigLoader {
fun loadForWorktree( fun loadForWorktree(
workingDir: Path, workingDir: Path,
worktreeDir: 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 * 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 * 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 * 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 * repository-side sections (`server`, `gitea`, `executor`, `watcher`), the docker
* (`docker.enabled`/`docker.network`), and the trust gate * sandbox policy (`docker.enabled`/`docker.network`), and the trust gate
* (`requirePullRequest`, which decides whether the branch is built at all). * (`requirePullRequest`, which decides whether the branch is built at all).
* They are stripped from the branch layer before merging, so a branch can neither * 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 * escape its container, nor bypass its own pull-request gate, nor raise the global
@@ -59,7 +67,17 @@ class ConfigLoader {
fun loadWithBranchLayer( fun loadWithBranchLayer(
workingDir: Path, workingDir: Path,
branchConfigYaml: String?, 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 { private fun toConfig(raw: Map<String, Any?>): GitTallyConfig {
val config = val config =
@@ -144,9 +162,47 @@ class ConfigLoader {
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> { fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
val repoInstall = loadFile(workingDir.resolve(".git/gittally/.gittally.yml").toFile()) val repoInstall = loadFile(workingDir.resolve(".git/gittally/.gittally.yml").toFile())
val project = loadFile(workingDir.resolve(".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) 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) fun toYaml(value: Any): String = yaml.writeValueAsString(value)
private fun loadFile(file: File): Map<String, Any?> { 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. */ /** Per-branch `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network") 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 import com.fasterxml.jackson.annotation.JsonProperty
data class GitTallyConfig( data class GitTallyConfig(
/** What this file declares about the GitTally that reads it; see [VersionRequirement]. */
val gitTally: GitTallyMeta = GitTallyMeta(),
val server: ServerConfig = ServerConfig(), val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(), val git: GitConfig = GitConfig(),
val gitea: GiteaConfig = GiteaConfig(), val gitea: GiteaConfig = GiteaConfig(),
@@ -1,14 +1,28 @@
package de.hoennig.gittally.config package de.hoennig.gittally.config
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.maps.shouldBeEmpty import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import java.nio.file.Files import java.nio.file.Files
import java.util.Properties
class ConfigLoaderTest : FunSpec() { class ConfigLoaderTest : FunSpec() {
private val loader = ConfigLoader() private val loader = ConfigLoader()
/** A loader that knows which GitTally it is, for the `gitTally.version` checks. */
private fun loaderRunning(version: String): ConfigLoader {
val provider = mockk<ObjectProvider<BuildProperties>>()
every { provider.getIfAvailable() } returns BuildProperties(Properties().apply { setProperty("version", version) })
return ConfigLoader(provider)
}
init { init {
test("returns defaults when no config files exist") { test("returns defaults when no config files exist") {
val dir = Files.createTempDirectory("gittally-test") val dir = Files.createTempDirectory("gittally-test")
@@ -92,6 +106,86 @@ class ConfigLoaderTest : FunSpec() {
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false) loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false)
} }
test("a config that needs a newer GitTally is refused, naming the file and both versions") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
gitTally:
version:
since: "0.9.16"
""".trimIndent(),
)
val error = shouldThrow<ConfigVersionException> { loaderRunning("0.9.15").load(dir) }
error.message.shouldNotBeNull().let {
it shouldContain ".gittally.yml"
it shouldContain "0.9.16"
it shouldContain "0.9.15"
it shouldContain "roll back"
}
}
test("a config within its declared range loads, and exceeding only the ceiling still loads") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
gitTally:
version:
since: "0.9.16"
below: "1.0"
gitea:
owner: my-org
""".trimIndent(),
)
loaderRunning("0.9.16").load(dir).gitea.owner shouldBe "my-org"
// beyond `below`: a warning, never a refusal — an unmaintained marker must not stop a CI
loaderRunning("1.4.0").load(dir).gitea.owner shouldBe "my-org"
loaderRunning("0.9.16").load(dir).gitTally.version shouldBe
VersionRequirement(since = "0.9.16", below = "1.0")
}
test("an incompatible branch config is refused as the branch's problem, not the server's") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText("gitea:\n owner: my-org")
val error =
shouldThrow<ConfigVersionException> {
loaderRunning("0.9.15").loadWithBranchLayer(
dir,
"""
gitTally:
version:
since: "2.0.0"
""".trimIndent(),
)
}
error.message.shouldNotBeNull().let {
it shouldContain "branch"
it shouldContain "the other branches keep building"
}
// the primary config alone is untouched by the branch's declaration
loaderRunning("0.9.15").load(dir).gitea.owner shouldBe "my-org"
}
test("the machine config is checked as its own file") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".git/gittally").toFile().mkdirs()
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
"""
gitTally:
version:
since: "1.0.0"
""".trimIndent(),
)
shouldThrow<ConfigVersionException> {
loaderRunning("0.9.16").load(dir)
}.message.shouldNotBeNull() shouldContain ".git/gittally/.gittally.yml"
}
test("a leftover builds.maxConcurrent is ignored instead of failing the config") { test("a leftover builds.maxConcurrent is ignored instead of failing the config") {
val dir = Files.createTempDirectory("gittally-test") val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText( dir.resolve(".gittally.yml").toFile().writeText(
@@ -0,0 +1,94 @@
package de.hoennig.gittally.config
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.types.shouldBeInstanceOf
class ConfigVersionsTest : FunSpec() {
private fun verdict(
since: String = "",
below: String = "",
running: String?,
) = ConfigVersions.verdict(VersionRequirement(since = since, below = below), running)
init {
test("a file needing a newer GitTally is refused, naming both versions") {
val result = verdict(since = "0.9.16", running = "0.9.15")
result
.shouldBeInstanceOf<VersionVerdict.Incompatible>()
.message
.let {
it shouldContain "0.9.16"
it shouldContain "0.9.15"
}
}
test("the running version satisfies its own floor") {
verdict(since = "0.9.16", running = "0.9.16") shouldBe VersionVerdict.Compatible
verdict(since = "0.9.16", running = "0.10.0") shouldBe VersionVerdict.Compatible
verdict(since = "1.2", running = "1.2.3") shouldBe VersionVerdict.Compatible
}
test("exceeding the declared ceiling only warns — an unmaintained marker must not stop a CI") {
val result = verdict(since = "0.9.16", below = "2.0", running = "2.1.0")
result.shouldBeInstanceOf<VersionVerdict.Warn>().message shouldContain "2.0"
verdict(since = "0.9.16", below = "2.0", running = "1.9.9") shouldBe VersionVerdict.Compatible
}
test("a file written before a breaking change is refused once that version runs") {
// the shipped constant is empty while no such change has happened
val brokeIn = "2.0.0"
val description = "`builds:` is now `buildSpec:`"
val written14 = VersionRequirement(since = "1.4.0")
// no ceiling declared, and none needed: GitTally knows its own breaking change
ConfigVersions
.verdict(written14, "2.0.1", brokeIn, description)
.shouldBeInstanceOf<VersionVerdict.Incompatible>()
.message
.let {
it shouldContain "2.0.0"
it shouldContain "buildSpec"
}
// a GitTally 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(
VersionRequirement(since = "2.0.0"),
"2.3.0",
brokeIn,
description,
) shouldBe VersionVerdict.Compatible
}
test("a file that declares nothing is never refused, even across a breaking change") {
ConfigVersions.verdict(VersionRequirement(), "2.0.1", "2.0.0", "x") shouldBe VersionVerdict.Compatible
}
test("an unparseable or absent version never makes a file look incompatible") {
verdict(since = "not-a-version", running = "1.0.0") shouldBe VersionVerdict.Compatible
verdict(since = "0.9.16", running = null) shouldBe VersionVerdict.Compatible
verdict(since = "0.9.16", running = "dev") shouldBe VersionVerdict.Compatible
verdict(running = "1.0.0") shouldBe VersionVerdict.Compatible
}
test("versions compare part by part, missing parts as zero, pre-release suffixes ignored") {
ConfigVersions.parse("2.0") shouldBe listOf(2, 0, 0)
ConfigVersions.parse("1") shouldBe listOf(1, 0, 0)
ConfigVersions.parse("1.0.0-rc1") shouldBe listOf(1, 0, 0)
ConfigVersions.parse("0.10.0") shouldBe listOf(0, 10, 0)
ConfigVersions.parse("").shouldBeNull()
ConfigVersions.parse(null).shouldBeNull()
ConfigVersions.parse("1.x").shouldBeNull()
}
test("0.10 is newer than 0.9, so the floor is not compared as text") {
verdict(since = "0.10.0", running = "0.9.16").shouldBeInstanceOf<VersionVerdict.Incompatible>()
verdict(since = "0.9.16", running = "0.10.0") shouldBe VersionVerdict.Compatible
}
}
}