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
+5
View File
@@ -57,6 +57,11 @@ artifacts:
rootDir: ""
# number of builds to keep per branch
retentionPerBranch: 3
# Keep each branch's latest green (successful) build even beyond retentionPerBranch.
# This backs the permanent artifact URLs /branches/<branch-key>/... — they always serve
# the latest green build of a branch and stay valid while newer builds fail.
# The kept build is still dropped once its branch is deleted from origin.
keepLatestGreen: true
# Controls the branch-polling loop.
watcher:
@@ -0,0 +1,135 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
Artifact URLs contain the full build key (branch hash, timestamp, build hash), e.g. `/artifacts/main-81d8471cd4ea-2026-07-08T14_27_21Z-2c6fbf59869f/reports/build/doc/`.
These URLs are cryptic, and they die when the build is pruned by retention.
There is no stable URL to share in READMEs, wikis, or bookmarks that always shows the current artifacts of a branch — especially painful for `main`, which is never deleted.
## Non-Goals
- No permanent links to failed builds — permanent links are green-only by design.
- No change to the concrete `/artifacts/<artifact-key>/…` and `/builds/<artifact-key>` URLs; they keep working as before.
- No fallback from "latest green" to "latest build"; a branch that never built successfully has no permanent link (404).
## The Scenarios
### Feature: permanent artifact links for the latest green build of a branch
#### Background
- The *permanent branch key* is the sanitized branch name without the hash suffix, e.g. `feature_demo` for `feature/demo` (the full branch key with hash suffix is accepted too).
- *Green* means build status `success`.
#### Scenario#000.01: A permanent URL serves the latest green build's artifacts
So that links in READMEs, wikis, and bookmarks stay valid across new builds.
- **Given** a branch `main` with a green build that stored artifacts
- **When** `GET /branches/main/build.stdout.log` is requested
- **Then** the file from the latest green build of `main` is served
- **and** it is served with `no-store` cache headers, because the content behind the URL changes with every new green build.
##### Verified by
- [ArtifactFileControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt)
- [BranchPermalinksTest](../../src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt)
#### Scenario#000.02: Directory URLs serve their index page like a static web server
So that legacy-style report links such as `/branches/main/reports/build/doc/` work.
- **Given** a stored report directory containing an `index.html`
- **When** the directory URL with trailing slash is requested
- **Then** its `index.html` is served
- **When** the directory URL without trailing slash is requested
- **Then** a redirect to the trailing-slash form is answered, so relative links inside the report resolve correctly.
##### Verified by
- [ArtifactFileControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt)
#### Scenario#000.03: The bare permanent URL renders a permanent artifact index
So that users can browse the latest green artifacts from one stable bookmark.
- **Given** a branch with a green build
- **When** `GET /branches/<branch-key>` is requested
- **Then** the artifact index page of the latest green build renders
- **and** all its log and report links stay on the permanent `/branches/…` paths
- **and** a note explains the permanent semantics and links to the concrete build page.
##### Verified by
- [UiControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt)
- [PermanentBranchRoutesTest](../../src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt) (the `/branches`, `/branches/<key>`, and `/branches/<key>/<path>` routes coexist)
#### Scenario#000.04: Green-only and unambiguous resolution
So that a permanent link never points at broken artifacts or the wrong branch.
- **Given** a branch whose builds all failed
- **When** its permanent URL is requested
- **Then** 404 is answered.
- **Given** two branches whose names sanitize to the same permanent key (e.g. `feature/x` and `feature_x`)
- **When** the ambiguous permanent key is requested
- **Then** 409 is answered naming the candidates
- **and** the full branch key with hash suffix still resolves uniquely.
##### Verified by
- [BranchPermalinksTest](../../src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt)
#### Scenario#000.05: The latest green build survives pruning
So that a permanent link stays valid while newer builds fail, as long as the branch exists.
- **Given** `artifacts.keepLatestGreen: true` (the default)
- **and** a branch whose latest green build is older than `artifacts.retentionPerBranch` newer failed builds
- **When** the watcher prunes results and artifacts
- **Then** the latest green build and its artifacts are kept
- **and** they are still dropped once the branch is deleted from origin.
##### Verified by
- [FileBuildResultRepositoryTest](../../src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt)
- [WatcherTest](../../src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt)
#### Scenario#000.06: The branches view links the permanent URL
So that users can discover the permanent link without constructing it by hand.
- **Given** the branches view (`/branches` and `GET /api/branches`)
- **When** a branch has a green build
- **Then** the API entry carries `latestGreenUrl`
- **and** the Artifacts column shows a 🔗 link to it, next to the 📄 link of the concrete latest build.
##### Verified by
- [BranchListingTest](../../src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt)
- [UiControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt)
## The Solution
Permanent URLs are resolved dynamically per request instead of via filesystem symlinks, because `BuildResultRepository` already knows the latest green build authoritatively and symlinks would fight the `NOFOLLOW_LINKS` guards in serving and pruning.
- `ArtifactKeys.permanentBranchKey` is the hash-free sanitized branch name; `BranchPermalinks` resolves it (or the full branch key) to the branch's latest green build, rejecting ambiguous keys with 409.
- `ArtifactFileController` gained `GET /branches/{branchKey}/{*path}`: it serves the resolved build's files with the existing traversal guards, adds directory→`index.html` handling with a trailing-slash redirect, and answers everything `no-store`.
- `UiController` gained `GET /branches/{branchKey}`, reusing the artifact-index view with a parameterized link base (`filesBase`), so the permanent index page only emits permanent links.
- `BuildResultRepository.latestGreenFor` and a `keepLatestGreen` prune flag protect the link target: the newest SUCCESS entry per branch survives retention pruning (config `artifacts.keepLatestGreen`, default `true`), and the artifact store keeps its directory because it prunes by the surviving results.
- `GET /api/branches` entries carry `latestGreenUrl`; the branches view renders it as a 🔗 icon in the Artifacts column (server-rendered Thymeleaf and `gittally.js` alike).
## Open Questions
- `artifacts.keepLatestGreen` defaults to `true`; set it to `false` to restore strict retention (the permanent URL then 404s once the green build is pruned).
- Redirecting the bare `/branches/<key>/` (trailing slash) to the index page keeps the file route and the UI route disjoint; a direct render was not worth duplicating the page mapping.
## Additional Changes
- The concrete artifact index page (`/builds/<artifact-key>`) now builds its file links from the shared `filesBase` model attribute; rendered output is unchanged.
## Follow-up PRs
- Optionally mention the permanent URL in the Gitea commit status or README badge documentation.
@@ -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 ->
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)
}
+8 -1
View File
@@ -227,7 +227,14 @@ function renderBuildRow(build, allowRestart) {
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
artifactLink.title = "Open artifacts";
artifactsCell.appendChild(artifactLink);
} else {
}
if (build.latestGreenUrl) {
const permanentLink = elem("a", "artifact-link", "🔗");
permanentLink.href = build.latestGreenUrl;
permanentLink.title = "Permanent link: artifacts of the latest green build";
artifactsCell.appendChild(permanentLink);
}
if (!build.artifactKey && !build.latestGreenUrl) {
artifactsCell.textContent = "n/a";
}
row.appendChild(artifactsCell);
+7 -2
View File
@@ -6,6 +6,11 @@
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
<div th:replace="~{fragments :: nav(${view})}"></div>
<article class="panel">
<p th:if="${permanentBranch != null}" class="muted">
Permanent link: this page always shows the latest green build of branch
<b th:text="${permanentBranch}">main</b> — every link on it stays valid across new builds.
<a th:href="${concreteUrl}">Open this specific build</a> instead.
</p>
<h2>Build</h2>
<ul th:if="${result != null}" class="build-facts">
<li>
@@ -45,7 +50,7 @@
<h2>Logs</h2>
<ul th:if="${!#lists.isEmpty(logs)}">
<li th:each="log : ${logs}">
<a th:href="'/artifacts/' + ${artifactKey} + '/' + ${log}" target="_blank"
<a th:href="${filesBase} + '/' + ${log}" target="_blank"
rel="noopener noreferrer" th:text="${log}">build.log</a>
</li>
</ul>
@@ -57,7 +62,7 @@
<h2>Build Artifacts</h2>
<ul th:if="${!#lists.isEmpty(reportIndexes)}">
<li th:each="report : ${reportIndexes}">
<a th:href="'/artifacts/' + ${artifactKey} + '/reports/' + ${report}" target="_blank"
<a th:href="${filesBase} + '/reports/' + ${report}" target="_blank"
rel="noopener noreferrer" th:text="'reports/' + ${report}">reports/tests/index.html</a>
</li>
</ul>
+3
View File
@@ -53,6 +53,9 @@
<td data-label="Artifacts">
<a th:if="${row.artifactKey != ''}" class="artifact-link"
th:href="'/builds/' + ${row.artifactKey}" title="Open artifacts">📄</a>
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
th:href="${row.latestGreenUrl}"
title="Permanent link: artifacts of the latest green build">🔗</a>
<span th:if="${row.artifactKey == ''}">n/a</span>
</td>
<td class="actions-cell">
@@ -20,6 +20,11 @@ class ArtifactKeysTest : FunSpec() {
ArtifactKeys.branchKey("feature/x") shouldNotBe ArtifactKeys.branchKey("feature_x")
}
test("permanentBranchKey is the hash-free sanitized branch name") {
ArtifactKeys.permanentBranchKey("feature/x") shouldBe "feature_x"
ArtifactKeys.branchKey("feature/x") shouldContain ArtifactKeys.permanentBranchKey("feature/x")
}
test("buildKey is stable for the same input") {
ArtifactKeys.buildKey("main", startedAt) shouldBe ArtifactKeys.buildKey("main", startedAt)
}
@@ -80,6 +80,25 @@ class FileBuildResultRepositoryTest : FunSpec() {
repository.latestFor("main") shouldBe result(branch = "main", startedOffsetSeconds = 60)
}
test("latestGreenFor returns the newest SUCCESS entry even when newer builds failed") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 60))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
repository.append(result(branch = "other", status = BuildStatus.SUCCESS, startedOffsetSeconds = 180))
repository.latestGreenFor("main") shouldBe
result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 60)
}
test("latestGreenFor returns null for a branch without a successful build") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.FAILED))
repository.latestGreenFor("main").shouldBeNull()
repository.latestGreenFor("unknown").shouldBeNull()
}
test("latestPerBranch returns one entry per branch, newest first") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
@@ -201,6 +220,35 @@ class FileBuildResultRepositoryTest : FunSpec() {
)
}
test("prune with keepLatestGreen keeps the newest green build beyond the retention count") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60))
repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120))
val removed =
repository.prune(originBranches = listOf("main"), retentionPerBranch = 2, keepLatestGreen = true)
removed.shouldBeEmpty()
repository.history() shouldContainExactly
listOf(
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120),
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60),
result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0),
)
}
test("prune with keepLatestGreen still drops green builds of branches missing from origin") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "gone", status = BuildStatus.SUCCESS))
val removed =
repository.prune(originBranches = listOf("main"), retentionPerBranch = 3, keepLatestGreen = true)
removed shouldContainExactly listOf(result(branch = "gone", status = BuildStatus.SUCCESS))
repository.history().shouldBeEmpty()
}
test("prune drops entries of branches missing from origin") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
@@ -2,18 +2,24 @@ package de.hoennig.gittally.server
import com.ninjasquad.springmockk.MockkBean
import de.hoennig.gittally.build.ArtifactStore
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildStatus
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
import io.mockk.every
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
@WebMvcTest(ArtifactFileController::class, properties = ["spring.main.web-application-type=servlet"])
class ArtifactFileControllerTest : FunSpec() {
@@ -23,13 +29,29 @@ class ArtifactFileControllerTest : FunSpec() {
@MockkBean
lateinit var artifactStore: ArtifactStore
@MockkBean
lateinit var branchPermalinks: BranchPermalinks
private val artifactDir: Path = Files.createTempDirectory("gittally-artifact-serve-test")
private val greenBuild =
BuildResult(
branch = "main",
commit = "0123456789abcdef",
status = BuildStatus.SUCCESS,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = Duration.ofSeconds(83),
artifactKey = "known-key",
)
init {
beforeEach {
clearMocks(artifactStore)
clearMocks(artifactStore, branchPermalinks)
every { artifactStore.artifactDir(any()) } returns null
every { artifactStore.artifactDir("known-key") } returns artifactDir
every { branchPermalinks.latestGreenBuild(any()) } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
}
test("serves an html artifact with no-cache headers") {
@@ -85,5 +107,79 @@ class ArtifactFileControllerTest : FunSpec() {
Files.deleteIfExists(outside)
}
}
test("permanent URL serves the file from the branch's latest green build") {
Files.writeString(artifactDir.resolve("build.log"), "line one")
mockMvc
.perform(get("/branches/main/build.log"))
.andExpect(status().isOk)
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
.andExpect(content().string("line one"))
}
test("permanent URL serves even normally cacheable files with no-store") {
val nested = Files.createDirectories(artifactDir.resolve("reports/tests"))
Files.writeString(nested.resolve("summary.css"), "body {}")
mockMvc
.perform(get("/branches/main/reports/tests/summary.css"))
.andExpect(status().isOk)
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
}
test("permanent directory URL with trailing slash serves the directory's index.html") {
val reportDir = Files.createDirectories(artifactDir.resolve("reports/build/doc"))
Files.writeString(reportDir.resolve("index.html"), "<html>doc</html>")
mockMvc
.perform(get("/branches/main/reports/build/doc/"))
.andExpect(status().isOk)
.andExpect(header().string("Content-Type", "text/html"))
.andExpect(content().string("<html>doc</html>"))
}
test("permanent directory URL without trailing slash redirects to the trailing-slash form") {
val reportDir = Files.createDirectories(artifactDir.resolve("reports/build/doc"))
Files.writeString(reportDir.resolve("index.html"), "<html>doc</html>")
mockMvc
.perform(get("/branches/main/reports/build/doc"))
.andExpect(status().isFound)
.andExpect(header().string("Location", "/branches/main/reports/build/doc/"))
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
}
test("permanent URL with a bare trailing slash redirects to the artifact index page") {
mockMvc
.perform(get("/branches/main/"))
.andExpect(status().isFound)
.andExpect(header().string("Location", "/branches/main"))
}
test("permanent URL of an unknown branch key answers 404") {
mockMvc
.perform(get("/branches/no-such-branch/build.log"))
.andExpect(status().isNotFound)
}
test("permanent URL answers 404 when the green build's artifacts are gone") {
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key")
mockMvc
.perform(get("/branches/main/build.log"))
.andExpect(status().isNotFound)
}
test("permanent URL rejects path traversal out of the artifact directory") {
val outside = Files.writeString(artifactDir.parent.resolve("outside.txt"), "secret")
try {
mockMvc
.perform(get("/branches/main/../outside.txt"))
.andExpect(status().is4xxClientError)
} finally {
Files.deleteIfExists(outside)
}
}
}
}
@@ -37,6 +37,7 @@ class BranchListingTest : FunSpec() {
"develop" to "eee",
)
every { repository.latestFor(any()) } returns null
every { repository.latestGreenFor(any()) } returns null
listing.branches().map { it.branch } shouldBe
listOf("main", "develop", "zz-flat", "aa/nested", "feature/x")
@@ -46,7 +47,9 @@ class BranchListingTest : FunSpec() {
every { gitService.originBranchHeads(any()) } returns
mapOf("main" to "newer-head", "feature/x" to "fedcba98")
every { repository.latestFor("main") } returns mainResult
every { repository.latestGreenFor("main") } returns mainResult
every { repository.latestFor("feature/x") } returns null
every { repository.latestGreenFor("feature/x") } returns null
val branches = listing.branches()
@@ -54,11 +57,25 @@ class BranchListingTest : FunSpec() {
branches[0].status shouldBe "success"
branches[0].commit shouldBe mainResult.commit
branches[0].artifactKey shouldBe "main-abc123-key"
branches[0].latestGreenUrl shouldBe "/branches/main"
branches[1].branch shouldBe "feature/x"
branches[1].status shouldBe "unknown"
branches[1].commit shouldBe "fedcba98"
branches[1].startedAt shouldBe null
branches[1].artifactKey shouldBe ""
branches[1].latestGreenUrl shouldBe null
}
test("a failed latest build still links the older green build's permanent URL") {
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
every { repository.latestFor("feature/x") } returns
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED)
every { repository.latestGreenFor("feature/x") } returns mainResult.copy(branch = "feature/x")
val branches = listing.branches()
branches[0].status shouldBe "failed"
branches[0].latestGreenUrl shouldBe "/branches/feature_x"
}
}
}
@@ -0,0 +1,86 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactKeys
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.time.Duration
import java.time.Instant
class BranchPermalinksTest : FunSpec() {
private val repository = mockk<BuildResultRepository>()
private val permalinks = BranchPermalinks(repository)
private fun result(
branch: String,
status: BuildStatus = BuildStatus.SUCCESS,
) = BuildResult(
branch = branch,
commit = "0123456789abcdef",
status = status,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = Duration.ofSeconds(83),
artifactKey = "$branch-key",
)
init {
test("resolves the hash-free permanent key to the branch's latest green build") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("main"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
}
test("resolves the full branch key with hash suffix") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
}
test("an unknown branch key answers 404") {
every { repository.latestPerBranch() } returns listOf(result("main"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
exception.statusCode shouldBe HttpStatus.NOT_FOUND
}
test("a branch without a green build answers 404") {
every { repository.latestPerBranch() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestGreenFor("main") } returns null
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
exception.statusCode shouldBe HttpStatus.NOT_FOUND
}
test("a permanent key matching several branches answers 409 and names the candidates") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
exception.statusCode shouldBe HttpStatus.CONFLICT
exception.reason.orEmpty() shouldContain "feature/x"
}
test("with ambiguous permanent keys the full branch key still resolves") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
}
test("permanentUrl uses the hash-free branch key") {
BranchPermalinks.permanentUrl("feature/x") shouldBe "/branches/feature_x"
}
}
}
@@ -0,0 +1,118 @@
package de.hoennig.gittally.server
import com.ninjasquad.springmockk.MockkBean
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
import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.metrics.SystemMetricsCollector
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
import io.mockk.every
import org.hamcrest.Matchers.containsString
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
/**
* The three `/branches` routes live in two controllers; this slice registers both
* and proves the mappings coexist: the exact list page, the permanent index page,
* and the catch-all permanent file route.
*/
@WebMvcTest(
controllers = [UiController::class, ArtifactFileController::class],
properties = ["spring.main.web-application-type=servlet"],
)
class PermanentBranchRoutesTest : FunSpec() {
@Autowired
lateinit var mockMvc: MockMvc
@MockkBean
lateinit var repository: BuildResultRepository
@MockkBean
lateinit var buildExecutor: BuildExecutor
@MockkBean
lateinit var artifactStore: ArtifactStore
@MockkBean
lateinit var controlTokens: ControlTokenService
@MockkBean
lateinit var configLoader: ConfigLoader
@MockkBean
lateinit var metricsCollector: SystemMetricsCollector
@MockkBean
lateinit var branchListing: BranchListing
@MockkBean
lateinit var branchPermalinks: BranchPermalinks
private val artifactDir: Path = Files.createTempDirectory("gittally-permanent-routes-test")
private val greenBuild =
BuildResult(
branch = "main",
commit = "0123456789abcdef",
status = BuildStatus.SUCCESS,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = Duration.ofSeconds(83),
artifactKey = "main-key",
)
init {
beforeEach {
clearMocks(
repository,
buildExecutor,
artifactStore,
controlTokens,
configLoader,
metricsCollector,
branchListing,
branchPermalinks,
)
every { configLoader.load(any()) } returns GitTallyConfig()
every { controlTokens.token() } returns "test-token"
every { branchListing.branches(any()) } returns emptyList()
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
every { artifactStore.artifactDir("main-key") } returns artifactDir
}
test("/branches still renders the branch list page") {
mockMvc
.perform(get("/branches"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""data-api="/api/branches"""")))
}
test("/branches/<key> renders the permanent artifact index page") {
mockMvc
.perform(get("/branches/main"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("latest green build of branch")))
}
test("/branches/<key>/<path> serves the artifact file") {
Files.writeString(artifactDir.resolve("build.log"), "line one")
mockMvc
.perform(get("/branches/main/build.log"))
.andExpect(status().isOk)
.andExpect(content().string("line one"))
}
}
}
@@ -21,10 +21,12 @@ import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
@@ -58,6 +60,9 @@ class UiControllerTest : FunSpec() {
@MockkBean
lateinit var branchListing: BranchListing
@MockkBean
lateinit var branchPermalinks: BranchPermalinks
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics =
@@ -88,7 +93,16 @@ class UiControllerTest : FunSpec() {
init {
beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector, branchListing)
clearMocks(
repository,
buildExecutor,
artifactStore,
controlTokens,
configLoader,
metricsCollector,
branchListing,
branchPermalinks,
)
every { configLoader.load(any()) } returns
GitTallyConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
@@ -129,7 +143,7 @@ class UiControllerTest : FunSpec() {
test("branches view renders built and never-built branches with restart actions") {
every { branchListing.branches(any()) } returns
listOf(
BranchDto.from("main", "ignored-head", successResult),
BranchDto.from("main", "ignored-head", successResult, hasGreenBuild = true),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
@@ -142,6 +156,8 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("fedcba987654")))
.andExpect(content().string(containsString("""data-api="/api/branches"""")))
.andExpect(content().string(containsString("""data-action="restart"""")))
.andExpect(content().string(containsString("""href="/branches/main"""")))
.andExpect(content().string(containsString("Permanent link")))
}
test("history view renders mixed history without restart actions") {
@@ -227,6 +243,33 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("No log files are stored for this build")))
}
test("permanent artifact index renders the latest green build with permanent file links") {
val artifactDir = Files.createDirectories(tempDir.resolve("permanent-main-key"))
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
every { branchPermalinks.latestGreenBuild("main") } returns successResult
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
mockMvc
.perform(get("/branches/main"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("latest green build of branch")))
.andExpect(content().string(containsString("""/branches/main/build.stdout.log" target="_blank"""")))
.andExpect(content().string(containsString("""/branches/main/reports/tests/test/index.html"""")))
.andExpect(content().string(containsString("/builds/main-abc123-key")))
.andExpect(content().string(not(containsString("/artifacts/main-abc123-key"))))
}
test("permanent artifact index of a branch without a green build answers 404") {
every { branchPermalinks.latestGreenBuild("main") } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
mockMvc
.perform(get("/branches/main"))
.andExpect(status().isNotFound)
}
test("artifact index of an unknown key answers 404") {
every { repository.history() } returns emptyList()
every { artifactStore.artifactDir("no-such-key") } returns null
@@ -8,6 +8,7 @@ import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.FileBuildResultRepository
import de.hoennig.gittally.build.GitWorktreeWorkspaces
import de.hoennig.gittally.build.RunningBuild
import de.hoennig.gittally.config.ArtifactsConfig
import de.hoennig.gittally.config.AutoBuildConfig
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.ConfigLoader
@@ -379,6 +380,28 @@ class WatcherTest : FunSpec() {
verify { harness.gitService.worktreePrune(any()) }
}
test("poll keeps the latest green build beyond retention unless keepLatestGreen is disabled") {
val keeping = Harness(GitTallyConfig(artifacts = ArtifactsConfig(retentionPerBranch = 1)))
keeping.seed("main", BuildStatus.SUCCESS, commit = "commit-1")
keeping.seed("main", BuildStatus.FAILED, commit = "commit-2")
every { keeping.gitService.originBranches(any()) } returns listOf("main")
keeping.watcher.poll(keeping.workingDir)
keeping.repository.history().map { it.status } shouldContainExactly
listOf(BuildStatus.FAILED, BuildStatus.SUCCESS)
val dropping =
Harness(GitTallyConfig(artifacts = ArtifactsConfig(retentionPerBranch = 1, keepLatestGreen = false)))
dropping.seed("main", BuildStatus.SUCCESS, commit = "commit-1")
dropping.seed("main", BuildStatus.FAILED, commit = "commit-2")
every { dropping.gitService.originBranches(any()) } returns listOf("main")
dropping.watcher.poll(dropping.workingDir)
dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED)
}
test("worktrees of queued or running builds are never pruned") {
val harness = Harness()
harness.seed("busy", BuildStatus.RUNNING, commit = "commit-1")