546 lines
29 KiB
Kotlin
546 lines
29 KiB
Kotlin
import com.github.jk1.license.filter.DependencyFilter
|
|
import com.github.jk1.license.filter.LicenseBundleNormalizer
|
|
import com.github.jk1.license.render.InventoryHtmlReportRenderer
|
|
import com.github.jk1.license.render.ReportRenderer
|
|
import java.net.URI
|
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
|
|
plugins {
|
|
kotlin("jvm") version "2.4.21"
|
|
application
|
|
// the required checks of doc/agentic-workflow.md; doc/risk-analysis.md sets their levels
|
|
id("org.owasp.dependencycheck") version "13.0.0"
|
|
id("com.github.jk1.dependency-license-report") version "3.1.4"
|
|
id("info.solidsoft.pitest") version "1.19.0"
|
|
id("de.javagil.werkcrappit") version "0.26.12"
|
|
}
|
|
|
|
group = "de.hoennig"
|
|
// main's version at its commit 74bd671, raised by each PR document merged after it, in the order of the merges;
|
|
// a branch adds its own PR document, so that no two pull requests change the same line (doc/agentic-workflow.md);
|
|
// the base changes only when a PR of the old rule merges with a version of its own: to that version at that merge
|
|
val releases = releasesOf("0.50.0", prDocuments(since = "74bd671", directory = "doc/prs"))
|
|
version = releases.last().version
|
|
|
|
java {
|
|
toolchain {
|
|
languageVersion = JavaLanguageVersion.of(25)
|
|
// jlink cuts the bundle's runtime out of this JDK, native libraries included: Temurin is
|
|
// built against an old glibc and starts on Hostsharing's hosts (Werkator, PR#62)
|
|
vendor = JvmVendorSpec.ADOPTIUM
|
|
}
|
|
}
|
|
|
|
kotlin {
|
|
compilerOptions {
|
|
jvmTarget = JvmTarget.JVM_25
|
|
}
|
|
}
|
|
|
|
repositories {
|
|
mavenCentral()
|
|
maven { url = uri("https://git.javagil.de/api/packages/mi/maven") } // werkcrappit-pit
|
|
}
|
|
|
|
val jacksonVersion = "2.22.3"
|
|
val trixnityVersion = "5.9.2"
|
|
|
|
dependencies {
|
|
// no web framework: the JDK's HttpServer and HttpClient are enough for a localhost dashboard
|
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
|
|
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion")
|
|
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
|
|
implementation("org.xerial:sqlite-jdbc:3.53.4.0")
|
|
// Matrix with the user's own account, end-to-end encrypted rooms included (werkregie PR#8)
|
|
implementation("de.connect2x.trixnity:trixnity-client:$trixnityVersion")
|
|
implementation("de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac:$trixnityVersion")
|
|
implementation("de.connect2x.trixnity:trixnity-client-repository-exposed:$trixnityVersion")
|
|
// the client's store: Trixnity's Exposed repositories need R2DBC, which SQLite does not offer
|
|
implementation("io.r2dbc:r2dbc-h2:1.1.0.RELEASE")
|
|
// Trixnity's Ktor version; the Java engine uses the JDK's HttpClient, as the other sources do
|
|
implementation("io.ktor:ktor-client-java:3.5.2")
|
|
runtimeOnly("org.slf4j:slf4j-nop:2.0.20")
|
|
constraints {
|
|
// Exposed, under Trixnity, brings 2.3.20; Kotlin before 2.4.20 has CVE-2026-53914 (OWASP Dependency-Check)
|
|
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.21")
|
|
}
|
|
|
|
testImplementation("io.kotest:kotest-runner-junit5:6.2.5")
|
|
testImplementation("io.kotest:kotest-assertions-core:6.2.5")
|
|
}
|
|
|
|
application {
|
|
mainClass = "de.hoennig.werkregie.dashboard.MainKt"
|
|
applicationName = "werkregie-dashboard"
|
|
// sqlite-jdbc and vodozemac load their native libraries
|
|
applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
|
|
}
|
|
|
|
// ---- the footer links the repository the build comes from ----
|
|
|
|
/**
|
|
* The web page of a git remote: `git@host:owner/repo.git` or `https://user:token@host/owner/repo.git`
|
|
* become `https://host/owner/repo`; null for a local path or a host alias without a domain.
|
|
* Only these characters reach the page, so a remote can carry neither a token nor HTML into it.
|
|
*/
|
|
fun repositoryWebUrl(remote: String): String? {
|
|
val scp = Regex("""[\w.-]+@([\w-]+(?:\.[\w-]+)+):(?!/)(.+)""").matchEntire(remote.trim())
|
|
val uri: URI? = runCatching { URI(remote.trim()) }.getOrNull()
|
|
val (host, path) = when {
|
|
scp != null -> scp.groupValues[1] to scp.groupValues[2]
|
|
uri != null && uri.scheme in setOf("https", "http", "ssh") && uri.host != null -> uri.host to uri.path
|
|
else -> return null
|
|
}
|
|
val url = "https://$host/" + path.trim('/').removeSuffix(".git")
|
|
return url.takeIf { Regex("""https://[\w-]+(\.[\w-]+)+(/[\w.~-]+)+""").matches(it) }
|
|
}
|
|
|
|
val repositoryUrl: String = runCatching {
|
|
providers.exec {
|
|
commandLine("git", "remote", "get-url", "origin")
|
|
isIgnoreExitValue = true
|
|
}.standardOutput.asText.get()
|
|
}.getOrNull()?.let(::repositoryWebUrl) ?: "https://git.javagil.de/mi/werkregie"
|
|
|
|
val checkRepositoryWebUrl = tasks.register("checkRepositoryWebUrl") {
|
|
description = "Checks how the build turns a git remote into the repository link of the footer."
|
|
val wrong = mapOf(
|
|
"mih09-git@git.javagil.de:mi/werkregie.git" to "https://git.javagil.de/mi/werkregie",
|
|
"ssh://git@git.javagil.de:2222/mi/werkregie.git" to "https://git.javagil.de/mi/werkregie",
|
|
"https://claude-code:secret@git.javagil.de/mi/werkregie.git" to "https://git.javagil.de/mi/werkregie",
|
|
"https://github.com/mhoennig/werkregie/" to "https://github.com/mhoennig/werkregie",
|
|
"gitea:mi/werkregie.git" to null,
|
|
"/srv/git/werkregie.git" to null,
|
|
"file:///srv/git/werkregie.git" to null,
|
|
"https://git.javagil.de/mi/a\"b" to null,
|
|
).map { (remote, expected) -> Triple(remote, expected, repositoryWebUrl(remote)) }
|
|
.filter { (_, expected, actual) -> actual != expected }
|
|
.map { (remote, expected, actual) -> "repositoryWebUrl(\"$remote\") = $actual, expected $expected" }
|
|
doLast { check(wrong.isEmpty()) { wrong.joinToString("\n") } }
|
|
}
|
|
tasks.check { dependsOn(checkRepositoryWebUrl) }
|
|
|
|
// ---- the version and the changelog follow from the PR documents (werkregie PR#79) ----
|
|
|
|
enum class Raise { MAJOR, MINOR, PATCH }
|
|
|
|
/** A PR document as its merge added it; [merged] is the date of the merge, null on a branch. */
|
|
data class PrDocument(val path: String, val merged: String?, val raise: Raise, val releaseNotes: String?)
|
|
|
|
data class Release(val version: String, val document: PrDocument?)
|
|
|
|
/** Without a line `Version:` under its title a document raises the patch version, as one opened before this rule. */
|
|
fun prDocument(path: String, merged: String?, text: String): PrDocument {
|
|
val value = Regex("""^Version: *(\S*) *$""", RegexOption.MULTILINE).find(text.substringBefore("\n## "))?.groupValues?.get(1)
|
|
val raise = value?.let { Raise.entries.find { raise -> raise.name.equals(it, ignoreCase = true) } }
|
|
if (value != null && raise == null) throw GradleException("$path: Version: $value, expected major, minor or patch")
|
|
val notes = Regex("""^## Release Notes *\n(.*?)(?=^## |\z)""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL))
|
|
.find(text)?.groupValues?.get(1)?.trim()?.ifEmpty { null }
|
|
return PrDocument(path, merged, raise ?: Raise.PATCH, notes)
|
|
}
|
|
|
|
/** The PR documents that the output of `git log -p --format="commit %cs"` or `git diff -p` adds, in its order. */
|
|
fun addedPrDocuments(patch: String): List<PrDocument> {
|
|
val added = mutableListOf<Triple<String, String?, MutableList<String>>>()
|
|
var merged: String? = null
|
|
var inHunk = false
|
|
patch.lines().forEach { line ->
|
|
when {
|
|
line.startsWith("commit ") -> merged = line.removePrefix("commit ")
|
|
line.startsWith("diff --git ") -> inHunk = false
|
|
line.startsWith("+++ b/") && !inHunk -> added += Triple(line.removePrefix("+++ b/"), merged, mutableListOf())
|
|
line.startsWith("@@") -> inHunk = true
|
|
line.startsWith("+") && inHunk -> added.last().third += line.substring(1)
|
|
}
|
|
}
|
|
return added.filter { (path) -> Regex("""\d{4}-\d{2}-\d{2}-[^/]*\.md""").matches(path.substringAfterLast('/')) }
|
|
.map { (path, merged, lines) -> prDocument(path, merged, lines.joinToString("\n")) }
|
|
}
|
|
|
|
/**
|
|
* The PR documents that main added after the commit [since], in the order of the merges, from the added lines,
|
|
* so that a later change of a document changes no version; on a branch, then the ones it adds against main.
|
|
*/
|
|
fun prDocuments(since: String, directory: String, git: (List<String>) -> String? = { git(*it.toTypedArray()) }): List<PrDocument> {
|
|
val pathspec = ":(top)$directory"
|
|
// independent of the user's git configuration, which the parsing of the patch would otherwise depend on
|
|
val patch = listOf("-M", "--diff-filter=A", "-p", "--no-color", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/")
|
|
// without main, a branch would miss what main added since it branched off, and the version would be too low
|
|
git(listOf("rev-parse", "--verify", "-q", "origin/main"))
|
|
?: throw GradleException("no origin/main, which the version needs; git fetch origin main")
|
|
val onMain = git(listOf("merge-base", "--is-ancestor", "HEAD", "origin/main")) != null
|
|
val main = if (onMain) "HEAD" else "origin/main"
|
|
val log = git(listOf("-c", "core.quotePath=false", "log", "--first-parent", "--diff-merges=first-parent", "--reverse") +
|
|
patch + listOf("--format=commit %cs", "$since..$main", "--", pathspec))
|
|
?: throw GradleException("no history from $since to $main, which the version needs; a shallow clone?")
|
|
val merged = addedPrDocuments(log)
|
|
if (onMain) return merged
|
|
val own = git(listOf("-c", "core.quotePath=false", "diff") + patch + listOf("origin/main...HEAD", "--", pathspec))
|
|
return merged + addedPrDocuments(own.orEmpty()).filter { document -> merged.none { it.path == document.path } }
|
|
}
|
|
|
|
fun raised(version: String, raise: Raise): String {
|
|
val (major, minor, patch) = version.split('.').map(String::toInt)
|
|
return when (raise) {
|
|
Raise.MAJOR -> "${major + 1}.0.0"
|
|
Raise.MINOR -> "$major.${minor + 1}.0"
|
|
Raise.PATCH -> "$major.$minor.${patch + 1}"
|
|
}
|
|
}
|
|
|
|
/** The [base] version first, then one release per document. */
|
|
fun releasesOf(base: String, documents: List<PrDocument>): List<Release> =
|
|
listOf(Release(base, null)) + documents.scan(base) { version, document -> raised(version, document.raise) }.drop(1)
|
|
.zip(documents, ::Release)
|
|
|
|
fun changelog(releases: List<Release>): String = buildString {
|
|
append("# Changelog\n")
|
|
releases.reversed().forEach { (version, document) ->
|
|
if (document != null) {
|
|
append("\n## $version (${document.merged ?: "not merged"})\n\n${document.releaseNotes ?: "Internal change."}\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.register("changelog") {
|
|
description = "Writes build/changelog/CHANGELOG.md from the release notes of the PR documents."
|
|
val text = changelog(releases)
|
|
val target = layout.buildDirectory.file("changelog/CHANGELOG.md")
|
|
inputs.property("text", text)
|
|
outputs.file(target)
|
|
doLast { target.get().asFile.writeText(text) }
|
|
}
|
|
|
|
val checkReleases = tasks.register("checkReleases") {
|
|
description = "Checks how the build derives the version and the changelog from the PR documents of a git history."
|
|
val repository = temporaryDir
|
|
doLast {
|
|
repository.deleteRecursively()
|
|
repository.mkdirs()
|
|
fun run(args: List<String>): String? {
|
|
val process = ProcessBuilder(listOf("git", "-c", "commit.gpgsign=false") + args).directory(repository).start()
|
|
val output = process.inputStream.bufferedReader().readText()
|
|
return output.takeIf { process.waitFor() == 0 }
|
|
}
|
|
fun git(vararg args: String) = checkNotNull(run(args.toList())) { "git ${args.joinToString(" ")} failed" }
|
|
fun commit(path: String, text: String) {
|
|
repository.resolve(path).apply { parentFile.mkdirs() }.writeText(text)
|
|
git("add", ".")
|
|
git("commit", "-qm", path)
|
|
}
|
|
val wrong = mutableListOf<String>()
|
|
fun expect(what: String, actual: Any, expected: Any) {
|
|
if (actual != expected) wrong += "$what: $actual, expected $expected"
|
|
}
|
|
git("init", "-q", "-b", "main")
|
|
git("config", "user.name", "check")
|
|
git("config", "user.email", "check@example.org")
|
|
commit("doc/prs/README.md", "# PR documents\n")
|
|
val since = git("rev-parse", "HEAD").trim()
|
|
expect("without origin/main", runCatching { prDocuments(since, "doc/prs", ::run) }.exceptionOrNull()?.message.orEmpty(),
|
|
"no origin/main, which the version needs; git fetch origin main")
|
|
fun versions() = releasesOf("0.48.0", prDocuments(since, "doc/prs", ::run)).map { it.version }
|
|
commit("doc/prs/2026-01-01-PR#1-a.md", "# PR#1: A\n\nVersion: minor\n\n## Problem\n\nA.\n\n## Release Notes\n\nA is new.\n\n## Open Questions\n\nNone.\n")
|
|
// a later change of a document changes no version, also in a commit that adds another one
|
|
repository.resolve("doc/prs/2026-01-01-PR#1-a.md").writeText("# PR#1: A\n\nVersion: major\n")
|
|
// a version line below the title does not count
|
|
commit("doc/prs/2026-01-02-PR#2-b.md", "# PR#2: B\n\nVersion: patch\n\n## Problem\n\nVersion: major\n")
|
|
git("update-ref", "refs/remotes/origin/main", "HEAD")
|
|
expect("main", versions(), listOf("0.48.0", "0.49.0", "0.49.1"))
|
|
git("checkout", "-qb", "claude-code/c")
|
|
commit("doc/prs/2026-01-03-PR#3-c.md", "# PR#3: C\n\nVersion: minor\n")
|
|
commit("doc/prs/2026-01-03-PR#4-d.md", "# PR#4: D, opened before the rule\n")
|
|
git("checkout", "-q", "main")
|
|
commit("doc/prs/2026-01-04-PR#5-e.md", "# PR#5: E\n\nVersion: patch\n")
|
|
git("update-ref", "refs/remotes/origin/main", "HEAD")
|
|
git("checkout", "-q", "claude-code/c")
|
|
expect("branch behind main", versions(), listOf("0.48.0", "0.49.0", "0.49.1", "0.49.2", "0.50.0", "0.50.1"))
|
|
val releases = releasesOf("0.48.0", prDocuments(since, "doc/prs", ::run))
|
|
expect("changelog", changelog(releases.take(3)).replace(Regex("""\(\d{4}-\d{2}-\d{2}\)"""), "(date)"),
|
|
"# Changelog\n\n## 0.49.1 (date)\n\nInternal change.\n\n## 0.49.0 (date)\n\nA is new.\n")
|
|
expect("changelog of a branch", changelog(releases).lines()[2], "## 0.50.1 (not merged)")
|
|
expect("wrong version line", runCatching { prDocument("x.md", null, "# X\n\nVersion: feature\n") }.exceptionOrNull()?.message.orEmpty(),
|
|
"x.md: Version: feature, expected major, minor or patch")
|
|
check(wrong.isEmpty()) { wrong.joinToString("\n") }
|
|
}
|
|
}
|
|
tasks.check { dependsOn(checkReleases) }
|
|
|
|
// the exact versions of what ships, in gradle.lockfile, so that a pull request's check knows what it changes;
|
|
// after a change of the dependencies: ./gradlew dependencies --configuration runtimeClasspath --write-locks
|
|
configurations.runtimeClasspath { resolutionStrategy.activateDependencyLocking() }
|
|
|
|
// criticality high in doc/risk-analysis.md: a known vulnerability from CVSS 4 fails the build
|
|
val failOnCvss = 4.0
|
|
// a pull request's check: only checkNewVulnerabilities decides, against the lockfile of the merge base with this ref
|
|
val vulnerabilityBaseline = providers.gradleProperty("vulnerabilityBaseline")
|
|
|
|
// not part of check, but Werkator's builds "dependencies" and "vulnerability-data":
|
|
// the first download of the NVD data exceeds the build's timeout
|
|
dependencyCheck {
|
|
failBuildOnCVSS = if (vulnerabilityBaseline.isPresent) 11.0f else failOnCvss.toFloat()
|
|
// the check only reads the NVD data; dependencyCheckUpdate loads it, in Werkator's nightly build "vulnerability-data"
|
|
autoUpdate = false
|
|
// what ships, not the test libraries
|
|
scanConfigurations = listOf("runtimeClasspath")
|
|
suppressionFile = file("config/dependency-check-suppressions.xml").path
|
|
formats = listOf("HTML", "JSON")
|
|
nvd {
|
|
// an empty key, e.g. a variable set to "", counts as none: the NVD refuses a key of length 0
|
|
apiKey = providers.environmentVariable("NVD_API_KEY").orElse(providers.gradleProperty("OWASP_API_KEY"))
|
|
.orNull?.takeIf { it.isNotBlank() }
|
|
// dependencyCheckUpdate skips the download when the data is younger
|
|
validForHours = 24
|
|
}
|
|
analyzers {
|
|
assemblyEnabled = false
|
|
// Sonatype's OSS Index answers only with credentials
|
|
ossIndex { enabled = false }
|
|
}
|
|
}
|
|
|
|
// its inputs are neither the resolved dependencies nor the NVD data, so Gradle would take an old report as up to date
|
|
tasks.named("dependencyCheckAnalyze") { outputs.upToDateWhen { false } }
|
|
|
|
/**
|
|
* Fails only on a vulnerability from [failOnCvss] in a dependency that the merge base with [vulnerabilityBaseline] lacks,
|
|
* i.e. one the pull request adds or changes; the vulnerabilities main has already are named, but fail nothing,
|
|
* so that a vulnerability found anew stops no unrelated work. Main itself is checked each night in full.
|
|
*/
|
|
val checkNewVulnerabilities = tasks.register("checkNewVulnerabilities") {
|
|
group = "verification"
|
|
description = "Fails on the vulnerabilities of the dependencies this branch adds or changes against -PvulnerabilityBaseline"
|
|
dependsOn("dependencyCheckAnalyze")
|
|
val report = layout.buildDirectory.file("reports/dependency-check/dependency-check-report.json")
|
|
val baseline = vulnerabilityBaseline
|
|
val projectDir = layout.projectDirectory.asFile
|
|
doLast {
|
|
fun git(vararg args: String): String? {
|
|
// stderr unread would fill its pipe and hold git; a failure shows in the exit code
|
|
val process = ProcessBuilder("git", *args).directory(projectDir)
|
|
.redirectError(ProcessBuilder.Redirect.DISCARD).start()
|
|
val out = process.inputStream.bufferedReader().readText()
|
|
return out.takeIf { process.waitFor() == 0 }
|
|
}
|
|
val ref = baseline.orNull
|
|
?: throw GradleException("checkNewVulnerabilities needs -PvulnerabilityBaseline, e.g. origin/main")
|
|
val base = git("merge-base", "HEAD", ref)?.trim() ?: throw GradleException("no merge base with $ref")
|
|
val prefix = git("rev-parse", "--show-prefix")?.trim().orEmpty()
|
|
// a base without a lockfile knows no dependency: then each vulnerable one counts as new, the safe side;
|
|
// only the branches from before the lockfile meet it
|
|
val lockfile = git("show", "$base:${prefix}gradle.lockfile")
|
|
if (lockfile == null) logger.warn("the merge base $base has no gradle.lockfile: each vulnerability counts as new")
|
|
val known = lockfile.orEmpty().lines()
|
|
.filter { it.isNotBlank() && !it.startsWith("#") && !it.startsWith("empty=") }
|
|
.map { it.substringBefore('=') }
|
|
.toSet()
|
|
|
|
fun score(vulnerability: Map<*, *>): Double =
|
|
listOf("cvssv4", "cvssv3", "cvssv2").mapNotNull { key ->
|
|
val cvss = vulnerability[key] as? Map<*, *> ?: return@mapNotNull null
|
|
val data = cvss["cvssData"] as? Map<*, *> ?: cvss
|
|
(data["baseScore"] ?: data["score"]) as? Number
|
|
}.maxOfOrNull { it.toDouble() } ?: 0.0
|
|
|
|
@Suppress("UNCHECKED_CAST")
|
|
val json = groovy.json.JsonSlurper().parse(report.get().asFile) as Map<String, Any?>
|
|
val dependencies = json["dependencies"] as List<Map<*, *>>
|
|
val found = dependencies.flatMap { dependency ->
|
|
val coordinates = (dependency["packages"] as? List<Map<*, *>>).orEmpty()
|
|
.mapNotNull { (it["id"] as? String)?.takeIf { id -> id.startsWith("pkg:maven/") } }
|
|
.map { it.removePrefix("pkg:maven/").replace('/', ':').replace('@', ':') }
|
|
val vulnerabilities = (dependency["vulnerabilities"] as? List<Map<*, *>>).orEmpty()
|
|
.filter { score(it) >= failOnCvss }
|
|
.map { "${it["name"]} (CVSS ${score(it)})" }
|
|
coordinates.flatMap { coordinate -> vulnerabilities.map { coordinate to it } }
|
|
}.distinct()
|
|
val (onMain, brought) = found.partition { (coordinate, _) -> coordinate in known }
|
|
onMain.forEach { (coordinate, vulnerability) -> logger.warn("already on main: $coordinate $vulnerability") }
|
|
if (brought.isNotEmpty()) {
|
|
throw GradleException("this branch brings vulnerable dependencies:\n" +
|
|
brought.joinToString("\n") { (coordinate, vulnerability) -> " $coordinate $vulnerability" })
|
|
}
|
|
logger.lifecycle(
|
|
"no vulnerability from CVSS $failOnCvss in a dependency this branch adds or changes; ${onMain.size} on main",
|
|
)
|
|
}
|
|
}
|
|
|
|
licenseReport {
|
|
// the plugin adds its own renderer for checkLicense to these arrays, so they need their base types
|
|
filters = arrayOf<DependencyFilter>(LicenseBundleNormalizer())
|
|
renderers = arrayOf<ReportRenderer>(InventoryHtmlReportRenderer("index.html", "werkregie-dashboard"))
|
|
excludeBoms = true
|
|
allowedLicensesFile = file("config/allowed-licenses.json")
|
|
}
|
|
|
|
// the license check takes seconds, so each build runs it; the plugin does not add it to check by itself
|
|
tasks.check { dependsOn("checkLicense") }
|
|
|
|
tasks.processResources {
|
|
// the server reports the version, the page's footer shows it, as Werkator's does
|
|
val version = project.version.toString()
|
|
inputs.property("version", version)
|
|
inputs.property("repositoryUrl", repositoryUrl)
|
|
filesMatching("werkregie.properties") { expand("version" to version) }
|
|
// the page compares its own version with the server's and loads itself again when they differ
|
|
filesMatching("static/index.html") { filter { it.replace("@repositoryUrl@", repositoryUrl).replace("@version@", version) } }
|
|
}
|
|
|
|
// ---- the runtime bundle for a host without Java: own JRE + jars + launcher (as Werkator's, ADR 0006) ----
|
|
|
|
val bundleRuntime = tasks.register<Exec>("bundleRuntime") {
|
|
description = "Cuts a minimal JRE for the runtime bundle with jlink."
|
|
val output = layout.buildDirectory.dir("bundle-runtime")
|
|
val jdk = javaToolchains.launcherFor(java.toolchain).map { it.metadata.installationPath }
|
|
outputs.dir(output)
|
|
doFirst {
|
|
delete(output)
|
|
executable = jdk.get().file("bin/jlink").asFile.absolutePath
|
|
}
|
|
args(
|
|
// jdeps: Jackson needs java.desktop (java.beans); jdk.crypto.ec for TLS to Werkator and Gitea;
|
|
// Trixnity, Ktor and coroutines need java.logging, java.management, java.instrument and jdk.unsupported
|
|
"--add-modules", "java.base,java.desktop,java.net.http,java.sql,jdk.httpserver,jdk.crypto.ec," +
|
|
"java.logging,java.management,java.instrument,jdk.unsupported",
|
|
"--strip-debug", "--no-header-files", "--no-man-pages", "--compress=zip-6",
|
|
"--output", output.get().asFile.absolutePath,
|
|
)
|
|
}
|
|
|
|
val runtimeBundle = tasks.register<Tar>("runtimeBundle") {
|
|
description = "Packs werkregie-dashboard-runtime-linux-x64.tar.gz: jre/, lib/, bin/."
|
|
group = "distribution"
|
|
archiveFileName = "werkregie-dashboard-runtime-linux-x64.tar.gz"
|
|
destinationDirectory = layout.buildDirectory.dir("distributions")
|
|
compression = Compression.GZIP
|
|
// reproducible archives normalize file modes since Gradle 9; jlink's executables must keep theirs
|
|
useFileSystemPermissions()
|
|
into("werkregie-dashboard") {
|
|
into("jre") { from(bundleRuntime) }
|
|
into("lib") { from(tasks.jar, configurations.runtimeClasspath) }
|
|
into("bin") {
|
|
from("src/bundle/bin")
|
|
filePermissions { unix("rwxr-xr-x") }
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.test {
|
|
useJUnitPlatform()
|
|
}
|
|
|
|
// ---- the JavaScript of the page: Node's own test runner, without npm packages (ADR 0002) ----
|
|
|
|
val jsTest = tasks.register<Exec>("jsTest") {
|
|
description = "Tests the JavaScript of the page with node --test, in the time zone Europe/Berlin."
|
|
group = "verification"
|
|
inputs.dir("src/main/resources/static")
|
|
inputs.dir("src/test/js")
|
|
val marker = layout.buildDirectory.file("js-test/passed")
|
|
outputs.file(marker)
|
|
// a fixed zone, so that the tests of the days, weeks and months do not depend on the machine
|
|
environment("TZ", "Europe/Berlin")
|
|
// the files one by one: Node resolves a glob only since version 21
|
|
val tests = fileTree("src/test/js") { include("**/*.test.js") }.files.map { it.path }.sorted()
|
|
commandLine(listOf("node", "--test") + tests)
|
|
doLast { marker.get().asFile.apply { parentFile.mkdirs() }.writeText("") }
|
|
}
|
|
tasks.check { dependsOn(jsTest) }
|
|
|
|
// ---- mutation tests: PIT mutates, werkcrappit measures the Mutation-CRAP of each method (doc/agentic-workflow.md) ----
|
|
|
|
dependencies {
|
|
pitest("io.kotest:kotest-extensions-pitest:6.2.5")
|
|
}
|
|
|
|
// each build runs pitestPartial on the classes this branch changes, the nightly build pitestFull on all of them;
|
|
// not PIT's incremental history: it keeps no result that a Kotest spec killed, since it does not recognize the spec's name
|
|
val partialMutation = gradle.startParameter.taskNames.any { it.endsWith("pitestPartial") }
|
|
val mutationBaseline = providers.gradleProperty("mutationBaseline").orElse("origin/main")
|
|
|
|
fun git(vararg args: String): String? {
|
|
val exec = providers.exec {
|
|
commandLine("git", *args)
|
|
isIgnoreExitValue = true
|
|
}
|
|
return exec.standardOutput.asText.get().takeIf { exec.result.get().exitValue == 0 }
|
|
}
|
|
|
|
/** Whether two versions of a Kotlin file differ only in their `package` and `import` lines, blank lines and indentation. */
|
|
fun movedOnly(before: String, after: String): Boolean {
|
|
fun code(source: String) = source.lines().map { it.trim() }
|
|
.filterNot { it.isEmpty() || it.startsWith("package ") || it.startsWith("import ") }
|
|
return code(before) == code(after)
|
|
}
|
|
|
|
val checkMovedOnly = tasks.register("checkMovedOnly") {
|
|
description = "Checks which changes of a moved Kotlin file the mutation tests of a branch ignore."
|
|
val wrong = listOf(
|
|
Triple("package a.b\n\nimport a.b.X\n\nclass C { fun f() = 1 }\n", "package c.d\n\nimport c.d.X\nimport c.d.Y\n\nclass C { fun f() = 1 }\n", true),
|
|
Triple("package a.b\n\nclass C\n", "package c.d\nclass C\n", true),
|
|
Triple("package a.b\n\nclass C { fun f() = 1 }\n", "package c.d\n\nclass C { fun f() = 2 }\n", false),
|
|
Triple("package a.b\n\nval x = \"a.b\"\n", "package c.d\n\nval x = \"c.d\"\n", false),
|
|
).filter { (before, after, expected) -> movedOnly(before, after) != expected }
|
|
.map { (before, after, expected) -> "movedOnly(${before.lines().last { it.isNotBlank() }}, …) is not $expected" }
|
|
doLast { check(wrong.isEmpty()) { wrong.joinToString("\n") } }
|
|
}
|
|
tasks.check { dependsOn(checkMovedOnly) }
|
|
|
|
/**
|
|
* The classes of the Kotlin files that this branch changes against the merge base with [mutationBaseline],
|
|
* uncommitted and new files included, files that only moved left out:
|
|
* per file its top-level classes and its file class `<Name>Kt`, each with its nested ones.
|
|
*/
|
|
val changedClasses: Set<String> by lazy {
|
|
val base = git("merge-base", "HEAD", mutationBaseline.get())?.trim()
|
|
?: throw GradleException("no merge base with ${mutationBaseline.get()}")
|
|
// a rename line reads "R<score>\t<old>\t<new>"; a file that only moved keeps its code (werkregie PR#90)
|
|
val changed = git("diff", "--name-status", "-M", "--diff-filter=d", "--relative", base, "--", "src/main/kotlin").orEmpty().lines()
|
|
.map { it.split('\t') }
|
|
.filterNot { it.size == 3 && it[0].startsWith("R") && movedOnly(git("show", "$base:./${it[1]}").orEmpty(), file(it[2]).readText()) }
|
|
.mapNotNull { it.lastOrNull() } +
|
|
git("ls-files", "--others", "--exclude-standard", "--", "src/main/kotlin").orEmpty().lines()
|
|
changed.filter { it.endsWith(".kt") }
|
|
.flatMap { path ->
|
|
val source = file(path).readText()
|
|
val pkg = Regex("""^package ([\w.]+)""", RegexOption.MULTILINE).find(source)?.groupValues?.get(1)
|
|
val names = Regex("""^(?:[a-z]+ )*(?:class|object|interface) (\w+)""", RegexOption.MULTILINE)
|
|
.findAll(source).map { it.groupValues[1] }.toList() + (file(path).nameWithoutExtension + "Kt")
|
|
names.flatMap { listOf(it, "$it\$*") }.map { if (pkg == null) it else "$pkg.$it" }
|
|
}
|
|
.toSet()
|
|
}
|
|
|
|
pitest {
|
|
pitestVersion = "1.30.0"
|
|
targetClasses = if (partialMutation) provider { changedClasses } else provider { setOf("de.hoennig.werkregie.*") }
|
|
targetTests = setOf("de.hoennig.werkregie.*")
|
|
// each Kotest run in a fresh minion takes longer than PIT's default of 4 s beyond a test's normal time (Werkator, PR#93)
|
|
timeoutConstInMillis = 20000
|
|
threads = providers.gradleProperty("pitestThreads").map { it.toInt() }.orElse(2)
|
|
jvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
|
|
outputFormats = setOf("HTML", "XML")
|
|
timestampedReports = false
|
|
}
|
|
|
|
tasks.register("pitestPartial") {
|
|
group = "verification"
|
|
description = "Mutates the classes this branch changes against -PmutationBaseline, by default origin/main."
|
|
dependsOn("pitest")
|
|
}
|
|
tasks.register("pitestFull") {
|
|
group = "verification"
|
|
description = "Mutates all classes."
|
|
dependsOn("pitest")
|
|
}
|
|
listOf("pitest", "mutationCrapCheck").forEach { name ->
|
|
tasks.named(name) { onlyIf("the branch changes a Kotlin class") { !partialMutation || changedClasses.isNotEmpty() } }
|
|
}
|
|
|
|
werkcrappit {
|
|
// a branch fails on each method above the target for Kotlin, 15, in the classes it changes;
|
|
// all of main is held by a ratchet from the highest value, lowered with each refactoring until it reaches 15
|
|
maxMutationCrap = if (partialMutation) 15 else 272
|
|
}
|