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:
co-authored by
Claude Fable 5
parent
83e70e73c3
commit
bb06c8b731
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user