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,14 +1,28 @@
package de.hoennig.gittally.config
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
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.util.Properties
class ConfigLoaderTest : FunSpec() {
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 {
test("returns defaults when no config files exist") {
val dir = Files.createTempDirectory("gittally-test")
@@ -92,6 +106,86 @@ class ConfigLoaderTest : FunSpec() {
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") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(