added permanent artifact links for latest green builds: /branches/<branch-key>/... serves the latest green build's artifacts (green-only, resolved per request), keepLatestGreen retention protection, permanent artifact-index page, and latestGreenUrl links in the branches view

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hoennig
2026-07-08 15:35:16 +02:00
co-authored by Claude Fable 5
parent 83e70e73c3
commit bb06c8b731
25 changed files with 820 additions and 18 deletions
@@ -12,6 +12,13 @@ import java.time.Instant
object ArtifactKeys {
fun branchKey(branch: String): String = "${sanitize(branch)}-${sha256Prefix(branch)}"
/**
* The hash-free key of the permanent `/branches/<key>/…` artifact URLs, e.g.
* `feature/demo` → `feature_demo`. Unlike [branchKey] it is not necessarily
* unique; lookups must reject ambiguous matches.
*/
fun permanentBranchKey(branch: String): String = sanitize(branch)
fun buildKey(
branch: String,
startedAt: Instant,
@@ -17,6 +17,9 @@ interface BuildResultRepository {
fun latestFor(branch: String): BuildResult?
/** The newest SUCCESS entry of [branch] — the build behind the permanent `/branches/…` links. */
fun latestGreenFor(branch: String): BuildResult?
/** The newest entry of each branch, newest first. */
fun latestPerBranch(): List<BuildResult>
@@ -34,10 +37,13 @@ interface BuildResultRepository {
/**
* Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches
* not contained in [originBranches]. Returns the removed entries.
* not contained in [originBranches]. With [keepLatestGreen], the newest SUCCESS entry of
* each surviving branch is kept even beyond the retention count, so the permanent
* `/branches/…` artifact links stay valid while newer builds fail. Returns the removed entries.
*/
fun prune(
originBranches: Collection<String>,
retentionPerBranch: Int,
keepLatestGreen: Boolean = false,
): List<BuildResult>
}
@@ -70,6 +70,11 @@ class FileBuildResultRepository(
return indexOfLatest(results, branch)?.let { results[it] }
}
override fun latestGreenFor(branch: String): BuildResult? =
load()
.filter { it.branch == branch && it.status == BuildStatus.SUCCESS }
.maxByOrNull { it.startedAt }
override fun latestPerBranch(): List<BuildResult> =
load()
.groupBy { it.branch }
@@ -117,6 +122,7 @@ class FileBuildResultRepository(
override fun prune(
originBranches: Collection<String>,
retentionPerBranch: Int,
keepLatestGreen: Boolean,
): List<BuildResult> {
synchronized(lock) {
val results = load()
@@ -127,9 +133,15 @@ class FileBuildResultRepository(
.groupBy { it.branch }
.values
.flatMap { entries ->
entries
.sortedByDescending { it.startedAt }
.take(retentionPerBranch.coerceAtLeast(0))
val newest =
entries
.sortedByDescending { it.startedAt }
.take(retentionPerBranch.coerceAtLeast(0))
val latestGreen =
entries
.filter { keepLatestGreen && it.status == BuildStatus.SUCCESS }
.maxByOrNull { it.startedAt }
newest + listOfNotNull(latestGreen)
}.toSet()
val removed = results.filterNot { it in kept }
if (removed.isNotEmpty()) {
@@ -145,6 +145,9 @@ class InitCommand(
rootDir: ""
# number of builds to keep per branch
retentionPerBranch: 3
# keep each branch's latest green build beyond the retention count,
# so the permanent /branches/<branch-key>/... artifact URLs stay valid while newer builds fail
keepLatestGreen: true
# Controls the branch-polling loop.
watcher:
@@ -38,6 +38,12 @@ data class BuildsConfig(
data class ArtifactsConfig(
val retentionPerBranch: Int = 3,
/**
* Keep each branch's latest green (SUCCESS) build beyond [retentionPerBranch],
* so the permanent `/branches/<branch-key>/…` artifact URLs stay valid while newer
* builds fail; the build is still dropped once its branch is gone from origin.
*/
val keepLatestGreen: Boolean = true,
/**
* Root directory for stored build artifacts; empty means the platform default
* `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/artifacts/<repo-key>`.
@@ -32,6 +32,8 @@ data class BuildResultDto(
/**
* One entry of `GET /api/branches`, like a legacy branches-view row: an origin
* branch with its latest build, or an `unknown` placeholder when never built.
* [latestGreenUrl] is the permanent artifact URL of the branch's latest green
* build; null while the branch has never built successfully.
*/
data class BranchDto(
val branch: String,
@@ -40,12 +42,14 @@ data class BranchDto(
val startedAt: Instant?,
val durationSeconds: Long?,
val artifactKey: String,
val latestGreenUrl: String? = null,
) {
companion object {
fun from(
branch: String,
headCommit: String,
latest: BuildResult?,
hasGreenBuild: Boolean = false,
) = if (latest == null) {
BranchDto(
branch = branch,
@@ -63,6 +67,7 @@ data class BranchDto(
startedAt = latest.startedAt,
durationSeconds = latest.duration?.seconds,
artifactKey = latest.artifactKey,
latestGreenUrl = if (hasGreenBuild) BranchPermalinks.permanentUrl(branch) else null,
)
}
}
@@ -1,15 +1,19 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactStore
import jakarta.servlet.http.HttpServletRequest
import org.springframework.core.io.FileSystemResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.MediaTypeFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.net.URI
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
@@ -21,6 +25,7 @@ import java.nio.file.Path
@RestController
class ArtifactFileController(
private val artifactStore: ArtifactStore,
private val branchPermalinks: BranchPermalinks,
) {
@GetMapping("/artifacts/{artifactKey}/{*path}")
fun serve(
@@ -30,17 +35,77 @@ class ArtifactFileController(
val artifactDir =
artifactStore.artifactDir(artifactKey)
?: return ResponseEntity.notFound().build()
val relativePath = path.removePrefix("/")
val file =
resolveFile(artifactDir, path.removePrefix("/"))
?: return ResponseEntity.notFound().build()
return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS)
}
/**
* Permanent artifact URLs: serves the file from the branch's latest green build,
* so the URL outlives artifact pruning as long as the branch stays green-buildable.
* Directory paths serve their `index.html` (after a redirect adding the trailing
* slash, so relative links inside reports resolve correctly), and everything is
* `no-store` because the content behind a URL changes with every new green build.
*/
@GetMapping("/branches/{branchKey}/{*path}")
fun serveLatestGreen(
@PathVariable branchKey: String,
@PathVariable path: String,
request: HttpServletRequest,
): ResponseEntity<Resource> {
val build = branchPermalinks.latestGreenBuild(branchKey)
val artifactDir =
artifactStore.artifactDir(build.artifactKey)
?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"the artifacts of build '${build.artifactKey}' are not stored anymore",
)
val relativePath = path.removePrefix("/").removeSuffix("/")
if (relativePath.isBlank()) {
return ResponseEntity.notFound().build()
// the bare permanent URL is the artifact-index page rendered by the UI controller
return redirect(request.requestURI.trimEnd('/'))
}
val target = artifactDir.resolve(relativePath).normalize()
if (target.startsWith(artifactDir) &&
Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS) &&
Files.isRegularFile(target.resolve(INDEX_FILE), LinkOption.NOFOLLOW_LINKS)
) {
// relative links inside a report only resolve correctly under a trailing-slash URL
return if (request.requestURI.endsWith("/")) {
respond(target.resolve(INDEX_FILE), noStore = true)
} else {
redirect(request.requestURI + "/")
}
}
val file =
resolveFile(artifactDir, relativePath)
?: return ResponseEntity.notFound().build()
return respond(file, noStore = true)
}
/** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */
private fun resolveFile(
artifactDir: Path,
relativePath: String,
): Path? {
if (relativePath.isBlank()) {
return null
}
val file = artifactDir.resolve(relativePath).normalize()
if (!file.startsWith(artifactDir) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
return ResponseEntity.notFound().build()
return null
}
return file
}
private fun respond(
file: Path,
noStore: Boolean,
): ResponseEntity<Resource> {
val headers = HttpHeaders()
headers.contentType = mediaType(file)
if (file.extension() in NO_CACHE_EXTENSIONS) {
if (noStore) {
headers.cacheControl = "no-store, max-age=0"
headers.pragma = "no-cache"
headers.expires = 0
@@ -48,6 +113,14 @@ class ArtifactFileController(
return ResponseEntity.ok().headers(headers).body(FileSystemResource(file))
}
/** A permanent-URL redirect must never be cached — its target changes with the next green build. */
private fun redirect(encodedLocation: String): ResponseEntity<Resource> {
val headers = HttpHeaders()
headers.location = URI.create(encodedLocation)
headers.cacheControl = "no-store, max-age=0"
return ResponseEntity.status(HttpStatus.FOUND).headers(headers).build()
}
private fun mediaType(file: Path): MediaType =
when (file.extension()) {
"log" -> MediaType(MediaType.TEXT_PLAIN, Charsets.UTF_8)
@@ -61,5 +134,6 @@ class ArtifactFileController(
companion object {
private val NO_CACHE_EXTENSIONS = setOf("html", "json", "log")
private const val INDEX_FILE = "index.html"
}
}
@@ -23,7 +23,14 @@ class BranchListing(
.originBranchHeads(workingDir)
.entries
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
.map { (branch, headCommit) -> BranchDto.from(branch, headCommit, repository.latestFor(branch)) }
.map { (branch, headCommit) ->
BranchDto.from(
branch,
headCommit,
repository.latestFor(branch),
hasGreenBuild = repository.latestGreenFor(branch) != null,
)
}
private fun sortGroup(branch: String): Int =
when {
@@ -0,0 +1,45 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactKeys
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
/**
* Resolves the permanent `/branches/<branch-key>/…` artifact URLs: the key is the
* hash-free [ArtifactKeys.permanentBranchKey] (the full [ArtifactKeys.branchKey]
* works too), and the target is the branch's latest green build. Resolution happens
* per request, so a permanent URL follows every new green build and stays valid as
* long as the branch exists on origin and has ever built successfully — green only,
* a permanent link never points at a failed build's artifacts.
*/
@Component
class BranchPermalinks(
private val repository: BuildResultRepository,
) {
fun latestGreenBuild(branchKey: String): BuildResult {
val branches =
repository
.latestPerBranch()
.map { it.branch }
.filter { branchKey == ArtifactKeys.permanentBranchKey(it) || branchKey == ArtifactKeys.branchKey(it) }
val branch =
when (branches.size) {
0 -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds for branch key '$branchKey'")
1 -> branches.single()
else -> throw ResponseStatusException(
HttpStatus.CONFLICT,
"branch key '$branchKey' is ambiguous (${branches.joinToString()}); use the full branch key with hash suffix",
)
}
return repository.latestGreenFor(branch)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "branch '$branch' has no successful build")
}
companion object {
/** The permanent artifact-index URL of [branch], shown in the branches view. */
fun permanentUrl(branch: String): String = "/branches/${ArtifactKeys.permanentBranchKey(branch)}"
}
}
@@ -2,6 +2,7 @@ package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactStore
import de.hoennig.gittally.build.BuildExecutor
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.config.ConfigLoader
@@ -35,6 +36,7 @@ class UiController(
private val configLoader: ConfigLoader,
private val metricsCollector: SystemMetricsCollector,
private val branchListing: BranchListing,
private val branchPermalinks: BranchPermalinks,
private val buildProperties: ObjectProvider<BuildProperties>,
) {
var workingDir: Path = Paths.get(".")
@@ -112,8 +114,50 @@ class UiController(
if (result == null && artifactDir == null) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
}
val links = baseModel(model, view = "artifact", pageTitle = "Build Artifacts")
return artifactIndexView(
model,
pageTitle = "Build Artifacts",
result = result,
artifactKey = artifactKey,
artifactDir = artifactDir,
filesBase = "/artifacts/$artifactKey",
)
}
/**
* The permanent artifact index of a branch's latest green build; the file links
* stay on the permanent `/branches/…` paths, so every link copied from this page
* outlives artifact pruning.
*/
@GetMapping("/branches/{branchKey}")
fun latestGreenArtifactIndex(
@PathVariable branchKey: String,
model: Model,
): String {
val build = branchPermalinks.latestGreenBuild(branchKey)
model.addAttribute("permanentBranch", build.branch)
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
return artifactIndexView(
model,
pageTitle = "Latest Green Build",
result = build,
artifactKey = build.artifactKey,
artifactDir = artifactStore.artifactDir(build.artifactKey),
filesBase = "/branches/$branchKey",
)
}
private fun artifactIndexView(
model: Model,
pageTitle: String,
result: BuildResult?,
artifactKey: String,
artifactDir: Path?,
filesBase: String,
): String {
val links = baseModel(model, view = "artifact", pageTitle = pageTitle)
model.addAttribute("artifactKey", artifactKey)
model.addAttribute("filesBase", filesBase)
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
model.addAttribute("hasArtifacts", artifactDir != null)
model.addAttribute("buildCommand", result?.let { branchBuildCommand(it.branch) })
@@ -67,7 +67,7 @@ object UiFormats {
fun timeOfDay(instant: Instant): String = timeOfDayFormat.format(instant)
}
/** One row of the latest/history build tables. */
/** One row of the latest/history build tables; [latestGreenUrl] only on the branches view. */
data class BuildRowView(
val branch: String,
val commit: String,
@@ -79,6 +79,7 @@ data class BuildRowView(
val artifactKey: String,
val branchUrl: String?,
val commitUrl: String?,
val latestGreenUrl: String? = null,
) {
companion object {
fun from(
@@ -112,6 +113,7 @@ data class BuildRowView(
artifactKey = entry.artifactKey,
branchUrl = links.branchUrl(entry.branch),
commitUrl = links.commitUrl(entry.commit),
latestGreenUrl = entry.latestGreenUrl,
)
}
}
@@ -245,7 +245,7 @@ class Watcher(
originBranches: List<String>,
workingDir: Path,
) {
repository.prune(originBranches, config.artifacts.retentionPerBranch)
repository.prune(originBranches, config.artifacts.retentionPerBranch, config.artifacts.keepLatestGreen)
artifactStore.prune(repository.history())
pruneWorktrees(originBranches, workingDir)
}