Files
werkator/build.gradle.kts
claude-codeandClaude Opus 5.5 5e26ad1ff0 A Docker image is built from the build's own workspace, under a tag of its branch (v1.43.8)
docker.dockerfile and docker.context are branch settings, and docker build ran in the primary
checkout without checking them: a branch could send .git/werkator or the home of the service
account to the daemon and copy the tokens into its own image. Both are now refused at load time
outside the workspace, and resolved in the workspace by real path before docker build.
An image built from a Dockerfile gets Werkator's own tag per repository and branch, with one
scope for the pull-request target builds, so no branch replaces the image another build runs in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:27:34 +02:00

341 lines
15 KiB
Kotlin

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.springframework.boot") version "4.0.6"
id("io.spring.dependency-management") version "1.1.7"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
kotlin("jvm") version "2.4.21"
kotlin("plugin.spring") version "2.4.21"
id("info.solidsoft.pitest") version "1.19.0"
id("de.javagil.werkcrappit") version "0.25.0"
}
group = "de.javagil"
// every PR bumps this — patch for a fix, minor for new behaviour — and brings its own
// release-notes entry, so the UI footer (BuildProperties), --version and the release notes
// identify what is running down to the pull request. Bumping per deployment instead left
// PR#50 and PR#51 both calling themselves v1.7.0, which is what this rule replaces (PR#52).
// ReleaseVersionConsistencyUnitTest fails the build if this and the top releases.html entry disagree.
version = "1.43.10"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
// The toolchain is not only what compiles: jlink cuts the runtime that ships
// to the host out of this very JDK, native libraries included, so the JDK's
// own build platform decides where that runtime will start (PR#62). Debian's
// OpenJDK is built against the distribution it ships with — the trixie one in
// the build sandbox needs glibc 2.38, and the production host has 2.36, so
// its runtime refused to start there. Temurin is built against a deliberately
// old base and asks for 2.17 (Temurin 25). Pinning the vendor therefore keeps one line,
// languageVersion, in charge of the Java version on the host as well.
// Temurin 25 for all werk repositories: werkregie ADR 0001.
vendor = JvmVendorSpec.ADOPTIUM
}
}
repositories {
mavenCentral()
maven { url = uri("https://git.javagil.de/api/packages/mi/maven") } // werkcrappit-pit
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework:spring-web")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("info.picocli:picocli-spring-boot-starter:4.7.6")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// @WebMvcTest lives in its own module since Spring Boot 4
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:6.1.5")
testImplementation("io.kotest:kotest-assertions-core:6.1.5")
testImplementation("io.kotest:kotest-extensions-spring:6.1.5")
// MockK
testImplementation("io.mockk:mockk:1.13.11")
testImplementation("com.ninja-squad:springmockk:5.0.1")
// WireMock
testImplementation("org.wiremock.integrations:wiremock-spring-boot:4.2.1")
// Testcontainers (versions managed by Spring Boot BOM)
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers")
}
springBoot {
// exposes the project version to the UI footer via the BuildProperties bean;
// the volatile build time is excluded to keep builds repeatable
buildInfo {
properties {
excludes.set(setOf("time"))
}
}
}
tasks.bootJar {
// version-free jar name, so docs and scripts never contain the version;
// the version itself stays available via BuildProperties (UI footer, --version)
archiveFileName = "werkator.jar"
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
jvmTarget = JvmTarget.JVM_25
}
}
// The dependency management of Spring Boot aligns every Kotlin artifact with the Kotlin plugin, but ktlint
// parses only with the compiler it was built against: 2.1.0 for ktlint 1.5.0, the default of the ktlint plugin.
configurations
.matching { it.name.startsWith("ktlint") }
.configureEach {
resolutionStrategy.eachDependency {
if (requested.group == "org.jetbrains.kotlin") useVersion("2.1.0")
}
}
// Self-contained runtime bundle for hosts without a Java runtime (plan step 15, ADR 0006):
// a jlink-trimmed JRE plus werkator.jar plus the packaging/werkator launcher, packed as a tarball.
// The JDK module list below was computed from the exploded boot jar via
// jdeps -q --ignore-missing-deps --multi-release 25 --print-module-deps \
// --class-path 'BOOT-INF/lib/*' BOOT-INF/classes BOOT-INF/lib/*.jar
// plus runtime-only modules jdeps cannot detect: java.logging (Tomcat JULI),
// jdk.crypto.ec (TLS ECDHE), jdk.management (extended OS MXBeans), jdk.zipfs (nested jar access).
// Re-check the jdeps output when dependencies change.
val runtimeBundleModules =
listOf(
"java.base",
"java.compiler",
"java.desktop",
"java.instrument",
"java.logging",
"java.management",
"java.naming",
"java.net.http",
"java.prefs",
"java.scripting",
"java.security.jgss",
"java.sql",
"jdk.crypto.ec",
"jdk.jfr",
"jdk.management",
"jdk.unsupported",
"jdk.zipfs",
).joinToString(",")
// The bundle is built in two steps, because its two consumers want different
// things (PR#61). A host that deploys itself unpacks the bundle on the very
// machine that built it, so compressing 64 MiB and decompressing it seconds
// later is work for nobody — that consumer takes the directory. Only a transfer
// to another host needs the archive, and that is what `runtimeBundle` is for.
val runtimeBundleDir by tasks.registering {
group = "distribution"
description = "Builds the self-contained runtime bundle (jlink JRE + jar + launcher) as a directory"
dependsOn(tasks.bootJar)
val jdkHome = javaToolchains.launcherFor(java.toolchain).map { it.metadata.installationPath.asFile }
val jarFile = tasks.bootJar.flatMap { it.archiveFile }
val launcherFile = layout.projectDirectory.file("packaging/werkator").asFile
val stagingDir =
layout.buildDirectory
.dir("runtime-bundle")
.get()
.asFile
inputs.files(tasks.bootJar.map { it.outputs.files })
inputs.file(launcherFile)
inputs.property("modules", runtimeBundleModules)
outputs.dir(stagingDir)
doLast {
val bundleRoot = stagingDir.resolve("werkator")
bundleRoot.deleteRecursively()
bundleRoot.parentFile.mkdirs()
val jlink = jdkHome.get().resolve("bin/jlink")
val jlinkProcess =
ProcessBuilder(
jlink.absolutePath,
"--add-modules",
runtimeBundleModules,
// not "--strip-debug": on Linux that also strips the native debug
// symbols, for which jlink shells out to objcopy — absent from the
// werkdock build sandbox, where this bundle is built (PR#61). This
// plugin strips the Java debug attributes only and needs no binutils.
"--strip-java-debug-attributes",
"--no-header-files",
"--no-man-pages",
"--compress",
"zip-6",
"--output",
bundleRoot.resolve("jre").absolutePath,
).redirectErrorStream(true).start()
val jlinkOutput = jlinkProcess.inputStream.bufferedReader().readText()
check(jlinkProcess.waitFor() == 0) { "jlink failed:\n$jlinkOutput" }
jarFile.get().asFile.copyTo(bundleRoot.resolve("lib/werkator.jar").also { it.parentFile.mkdirs() })
val launcher = launcherFile.copyTo(bundleRoot.resolve("bin/werkator").also { it.parentFile.mkdirs() })
check(launcher.setExecutable(true, false)) { "cannot make $launcher executable" }
}
}
val runtimeBundle by tasks.registering {
group = "distribution"
description = "Packs the runtime bundle directory as a tar.gz, for transfer to another host"
dependsOn(runtimeBundleDir)
val stagingDir =
layout.buildDirectory
.dir("runtime-bundle")
.get()
.asFile
val tarballFile =
layout.buildDirectory
.file("distributions/werkator-runtime-linux-x64.tar.gz")
.get()
.asFile
inputs.dir(stagingDir)
outputs.file(tarballFile)
doLast {
tarballFile.parentFile.mkdirs()
// system tar preserves the execute bits of jre/bin/* and jre/lib/jspawnhelper
val tarProcess =
ProcessBuilder("tar", "-czf", tarballFile.absolutePath, "-C", stagingDir.absolutePath, "werkator")
.redirectErrorStream(true)
.start()
val tarOutput = tarProcess.inputStream.bufferedReader().readText()
check(tarProcess.waitFor() == 0) { "tar failed:\n$tarOutput" }
}
}
// Tests run in one locale on every host (PR#144): the JVM formats by it, and so do the processes a
// test starts, bash and git among them; a German locale once turned bash's EPOCHREALTIME into
// "seconds,micros", which $((...)) read as its comma operator (PR#143).
val testLocale = mapOf("LANG" to "en_US.UTF-8", "LC_ALL" to "en_US.UTF-8")
tasks.withType<Test> {
useJUnitPlatform()
environment(testLocale)
systemProperty("user.language", "en")
systemProperty("user.country", "US")
}
// Every spec says by its name what it needs (TestNamingConventionUnitTest keeps it so):
// *UnitTest no Spring, no real git or process; *RestTest a @WebMvcTest slice;
// *IntegrationTest the full context, real git, processes, WireMock or containers.
// `test` runs all of them; the two tasks let a run pick the cheap or the expensive half,
// and mutation testing must run only on the cheap one.
val unitTest by tasks.registering(Test::class) {
description = "Runs the *UnitTest and *RestTest specs: no full Spring context, no real git or process."
group = "verification"
testClassesDirs =
sourceSets.test
.get()
.output.classesDirs
classpath = sourceSets.test.get().runtimeClasspath
filter {
includeTestsMatching("*UnitTest")
includeTestsMatching("*RestTest")
}
}
val integrationTest by tasks.registering(Test::class) {
description = "Runs the *IntegrationTest specs: full Spring context, real git, processes, WireMock or containers."
group = "verification"
testClassesDirs =
sourceSets.test
.get()
.output.classesDirs
classpath = sourceSets.test.get().runtimeClasspath
filter {
includeTestsMatching("*IntegrationTest")
}
}
// Mutation testing: PIT mutates the main classes and runs the specs covering each mutant against
// every mutant; werkcrappit writes mutation-crap.json/.md next to PIT's report. Not part of
// `check` — the nightly build definition `mutation` in .werkator.yml runs `pitest`.
dependencies {
pitest("io.kotest:kotest-extensions-pitest:6.1.5")
// PIT 1.30 keeps its incremental history only through this plugin
pitest("org.pitest:pitest-history-plugin:0.0.1")
}
pitest {
pitestVersion = "1.30.0"
// the plugin would pin junit-platform-launcher to the 1.x Kotest names, against JUnit 6 of
// Spring Boot, and break test discovery in every Test task
addJUnitPlatformLauncher = false
// the minions run the tests, so they need the toolchain's JVM: without this they start on the JVM
// that runs Gradle (21), cannot load the tests compiled for 25, find none, and every mutant
// shows as not covered (PR#154)
jvmPath = javaToolchains.launcherFor(java.toolchain).map { it.executablePath }
targetClasses = setOf("de.javagil.werkator.*")
// every spec but four: the container smoke test, and BuildExecutorIntegrationTest, which took
// 24-29 s per covering mutant and once failed PIT's coverage run on mih09 without a mutant,
// which aborts the whole run (PR#94); BuildExecutor's decisions have unit tests of their own (PR#99).
// The two executor specs with real processes run before the unit test, as PIT starts with the
// fastest, and wait up to 30 s for a build a mutant never ends: 25 timeouts for BuildExecutor
// alone; what only they killed is now in BuildExecutorUnitTest (PR#140)
targetTests = setOf("de.javagil.werkator.*")
excludedTestClasses =
setOf(
"de.javagil.werkator.framework.TestcontainersSmokeIntegrationTest",
"de.javagil.werkator.build.BuildExecutorIntegrationTest",
"de.javagil.werkator.build.PullRequestTargetBuildIntegrationTest",
"de.javagil.werkator.artifacts.BuildExecutorArtifactIntegrationTest",
// it checks that a normal test run does not fail fast, which PIT's test processes do: under
// PIT it fails without a mutant, and PIT aborts the whole run (PR#139)
"de.javagil.werkator.framework.FailFastUnitTest",
)
// PIT allows 4 s beyond a test's normal time; each Kotest run in a fresh minion takes longer,
// and at 4 s 323 of 787 mutants of the package metrics timed out, at 20 s two (PR#93)
timeoutConstInMillis = 20000
threads = providers.gradleProperty("pitestThreads").map { it.toInt() }.orElse(2)
// without a limit every JVM may take a quarter of the RAM: on mih09 (5.8 GiB) the main
// process and two minions filled RAM and swap, at a load of 111 (PR#134); the main process
// stays below 700 MiB, a minion does not fit into 512 MiB of heap (8 MEMORY_ERRORs, PR#135),
// so it keeps the default and only the serial collector, which holds its footprint lowest
mainProcessJvmArgs = listOf("-Xmx768m")
// fail-fast: a spec stops at its first failing test, which already kills the mutant, instead of
// letting every further test wait for a build the mutant never ends (PR#139)
jvmArgs = listOf("-XX:+UseSerialGC", "-Dwerkator.failfast=true")
// incremental: results of unchanged classes come from the history; outside build/, so that
// the clean command keeps it, and per worktree, so per branch
enableDefaultIncrementalAnalysis = true
historyInputLocation = layout.projectDirectory.file(".pitest/history.bin")
historyOutputLocation = layout.projectDirectory.file(".pitest/history.bin")
outputFormats = setOf("HTML", "XML")
timestampedReports = false
}
// The target for Kotlin is a Mutation-CRAP of at most 15, red from 12 (PR#104): the score never
// falls below the method's complexity, and JaCoCo counts branches the Kotlin compiler adds
// (an exhaustive when's else, inlined let/takeIf), so Java's 10 would cap complexity at 9 for
// code nobody wrote that way. The gate was a ratchet, lowered with every refactoring from 35 (PR#111)
// until it reached the target in PR#126: a method above 15 now fails the nightly build. A method is
// refused only when it exceeds the limit, so 15 itself still passes.
werkcrappit {
maxMutationCrap = 15
codeCharta {
colorRedFrom = 12
}
}
// PIT's test processes inherit the environment of its main process, and with it the test locale
tasks.withType<info.solidsoft.gradle.pitest.PitestTask>().configureEach {
environment(testLocale)
}