Added worktree-layered build config: resolves .gittally.yml from the worktree for per-branch build settings, with precedence worktree > .git > project; pinned secrets, server-side keys, and sandbox policy to .git.

This commit is contained in:
Michael Hoennig
2026-07-09 09:20:34 +02:00
parent 095e6fa44f
commit fb6d4501b9
7 changed files with 209 additions and 17 deletions
@@ -138,7 +138,7 @@ class FileArtifactStore(
log.warn("build {} has no workspace; storing only its logs", build.artifactKey)
return
}
for (artifactDir in branchConfig(build.branch).artifactDirs) {
for (artifactDir in branchConfig(build.branch, workspace).artifactDirs) {
if (artifactDir.isBlank()) {
continue
}
@@ -159,8 +159,12 @@ class FileArtifactStore(
"reports/$artifactDir"
}
private fun branchConfig(branch: String): BranchConfig {
val branches = configLoader.load(workingDir).branches
/** The build config for [branch], with the build [workspace]'s `.gittally.yml` layered on top (see [ConfigLoader.loadForWorktree]). */
private fun branchConfig(
branch: String,
workspace: Path,
): BranchConfig {
val branches = configLoader.loadForWorktree(workingDir, workspace).branches
return branches[branch] ?: branches["default"] ?: BranchConfig()
}
@@ -178,7 +178,7 @@ class BuildExecutor(
build: ActiveBuild,
workspace: Path,
): Int {
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir)
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir, workspace)
val stagingDir = build.runningBuild.stagingDir
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog ->
@@ -366,11 +366,13 @@ class BuildExecutor(
}
}
/** The build config for [branch], with the build [worktree]'s `.gittally.yml` layered on top (see [ConfigLoader.loadForWorktree]). */
private fun branchConfig(
branch: String,
workingDir: Path,
worktree: Path,
): BranchConfig {
val branches = configLoader.load(workingDir).branches
val branches = configLoader.loadForWorktree(workingDir, worktree).branches
return branches[branch] ?: branches["default"] ?: BranchConfig()
}
@@ -19,8 +19,33 @@ class ConfigLoader {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig {
val raw = loadRaw(workingDir)
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig = toConfig(loadRaw(workingDir))
/**
* Config for building a branch in [worktreeDir]: the worktree's `.gittally.yml`
* (the committed config of the branch being built) overrides the primary/`.git`
* config, giving the precedence worktree > `.git` > project. So a branch controls
* its own build settings (`buildCommand`, `cleanCommand`, `artifactDirs`,
* `docker.image`/`env`, …).
*
* The [pinned][stripPinned] keys are the exception: secrets (`git`), `gitea`/`server`
* settings, and the docker sandbox policy (`docker.enabled`/`docker.network`) always
* come from `.git`/primary — a branch must never be able to disable its own container,
* change its network mode, or reach the credentials. They are stripped from the
* worktree layer before it is merged, so a worktree cannot set them at all.
*
* With no worktree `.gittally.yml` this is identical to [load].
*/
fun loadForWorktree(
workingDir: Path,
worktreeDir: Path,
): GitTallyConfig {
val primary = loadRaw(workingDir)
val worktree = stripPinned(loadFile(worktreeDir.resolve(".gittally.yml").toFile()))
return toConfig(deepMerge(primary, worktree))
}
private fun toConfig(raw: Map<String, Any?>): GitTallyConfig {
val config =
if (raw.isEmpty()) {
GitTallyConfig()
@@ -30,6 +55,32 @@ class ConfigLoader {
return defaultPublicBaseUrl(config)
}
/**
* Removes the keys a build worktree must never override: the secret/server-side
* top-level sections and the per-branch docker sandbox policy. See [loadForWorktree].
*/
@Suppress("UNCHECKED_CAST")
private fun stripPinned(worktree: Map<String, Any?>): Map<String, Any?> {
if (worktree.isEmpty()) {
return worktree
}
val result = worktree.toMutableMap()
PINNED_TOP_LEVEL_KEYS.forEach { result.remove(it) }
val branches = result["branches"] as? Map<String, Any?>
if (branches != null) {
result["branches"] =
branches.mapValues { (_, value) ->
val branch = value as? Map<String, Any?> ?: return@mapValues value
val docker = branch["docker"] as? Map<String, Any?> ?: return@mapValues branch
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
branch.toMutableMap().apply {
if (strippedDocker.isEmpty()) remove("docker") else put("docker", strippedDocker)
}
}
}
return result
}
/** Legacy default: an empty `server.publicBaseUrl` becomes `https://<nginx.serverName>/`. */
private fun defaultPublicBaseUrl(config: GitTallyConfig): GitTallyConfig {
if (config.server.publicBaseUrl.isNotBlank() ||
@@ -88,4 +139,12 @@ class ConfigLoader {
}
return result
}
companion object {
/** Top-level sections a build worktree must never override: secrets and server-side settings. */
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server")
/** Per-branch `docker` keys the worktree must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
}
}
@@ -150,6 +150,112 @@ class ConfigLoaderTest : FunSpec() {
loader.load(dir).server.publicBaseUrl shouldBe ""
}
test("loadForWorktree lets the worktree override build config (worktree > .git > project)") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
branches:
default:
buildCommand: from-project
""".trimIndent(),
)
dir.resolve(".git/gittally").toFile().mkdirs()
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
"""
branches:
default:
buildCommand: from-git
""".trimIndent(),
)
val worktree = Files.createTempDirectory("gittally-worktree")
worktree.resolve(".gittally.yml").toFile().writeText(
"""
branches:
default:
buildCommand: from-worktree
""".trimIndent(),
)
loader.loadForWorktree(dir, worktree).branches["default"]!!.buildCommand shouldBe "from-worktree"
}
test("loadForWorktree falls back to .git over project when the worktree sets nothing") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
branches:
default:
buildCommand: from-project
""".trimIndent(),
)
dir.resolve(".git/gittally").toFile().mkdirs()
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
"""
branches:
default:
buildCommand: from-git
""".trimIndent(),
)
val worktree = Files.createTempDirectory("gittally-worktree")
loader.loadForWorktree(dir, worktree).branches["default"]!!.buildCommand shouldBe "from-git"
}
test("loadForWorktree pins secrets and the docker sandbox policy to .git, but allows docker.image") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".git/gittally").toFile().mkdirs()
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
"""
git:
token: real-secret
server:
port: 9000
branches:
default:
docker:
enabled: true
network: host
image: trusted-image
""".trimIndent(),
)
val worktree = Files.createTempDirectory("gittally-worktree")
worktree.resolve(".gittally.yml").toFile().writeText(
"""
git:
token: stolen
server:
port: 1234
branches:
default:
docker:
enabled: false
network: none
image: attacker-image
""".trimIndent(),
)
val config = loader.loadForWorktree(dir, worktree)
// pinned: never taken from the worktree
config.git.token shouldBe "real-secret"
config.server.port shouldBe 9000
config.branches["default"]!!.docker.enabled shouldBe true
config.branches["default"]!!.docker.network shouldBe "host"
// overridable: the worktree wins
config.branches["default"]!!.docker.image shouldBe "attacker-image"
}
test("loadForWorktree without a worktree config equals load") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
gitea:
owner: my-org
branches:
default:
buildCommand: ./mvnw test
""".trimIndent(),
)
val worktree = Files.createTempDirectory("gittally-worktree")
loader.loadForWorktree(dir, worktree) shouldBe loader.load(dir)
}
test("toYaml serializes GitTallyConfig with all sections") {
val yaml = loader.toYaml(GitTallyConfig())
yaml shouldContain "server:"