diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 0f3942b..e6ff812 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -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`. +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. 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`. diff --git a/AGENTS.md b/AGENTS.md index 30beda3..c4d56a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - Builds run detached in worktrees under `.git/gittally/worktrees/`; 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`. +- 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. - 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. diff --git a/docs/configuration.md b/docs/configuration.md index e4e53f0..6772b21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. +### 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 `.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. ```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: # Public base URL of this GitTally installation — used for all links posted to Gitea. publicBaseUrl: https://ci.example.org/ diff --git a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt b/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt index f6847a0..6f4937c 100644 --- a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt +++ b/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt @@ -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) { diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index d1d4383..546f018 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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? = 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: "" diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt index 72f4984..973b035 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt @@ -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? = 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() + /** Version warnings already reported; the config is loaded on every poll cycle, per branch. */ + private val warnedVersions = ConcurrentHashMap.newKeySet() + 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, + ): 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): GitTallyConfig { val config = @@ -144,9 +162,47 @@ class ConfigLoader { fun loadRaw(workingDir: Path = Paths.get(".")): Map { 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, + 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): VersionRequirement { + val version = (raw["gitTally"] as? Map)?.get("version") as? Map ?: 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 { @@ -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." } } diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt new file mode 100644 index 0000000..8dc6592 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigVersion.kt @@ -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? { + 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.compareTo(other: List): Int = + indices.firstNotNullOfOrNull { i -> (this[i] - other[i]).takeIf { it != 0 } } ?: 0 +} diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index 23fbea3..b186342 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -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(), diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt index 2c40364..fa4064b 100644 --- a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -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>() + 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 { 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 { + 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 { + 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( diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigVersionsTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigVersionsTest.kt new file mode 100644 index 0000000..a3fa8bd --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigVersionsTest.kt @@ -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() + .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().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() + .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() + verdict(since = "0.9.16", running = "0.10.0") shouldBe VersionVerdict.Compatible + } + } +}