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:
co-authored by
Claude Opus 5
parent
fa183ef1db
commit
8608170f5b
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user