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
@@ -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`.
|
||||
|
||||
@@ -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/<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`.
|
||||
- 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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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