Files
werkcrappit/build.gradle.kts
claude-code add607cc3d
werkator build succeeded after 02:41
werkator/publish build succeeded after 00:19
werkator/mutation build succeeded after 00:33
The version and the changelog follow from the PR documents (#70)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-14 21:19:06 +02:00

243 lines
13 KiB
Kotlin

plugins {
base
}
// main's version at its commit cb85e8e, 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 (docs/agentic-workflow.md);
// a version is published once and never replaced: the Gitea registry refuses a second upload
val releases = releasesOf("0.26.14", prDocuments(since = "cb85e8e", directory = "docs/prs"))
allprojects {
group = "de.javagil.werkcrappit"
version = releases.last().version
}
subprojects {
repositories {
mavenCentral()
}
// The Gitea Maven registry of git.javagil.de; only configured where a token is given, so a
// build without one can still build and publish locally.
plugins.withType<MavenPublishPlugin> {
val token = providers.environmentVariable("GITEA_PACKAGES_TOKEN").orNull
if (token != null) {
extensions.configure<PublishingExtension> {
repositories {
maven {
name = "gitea"
url = uri("https://git.javagil.de/api/packages/mi/maven")
credentials(HttpHeaderCredentials::class) {
name = "Authorization"
value = "token $token"
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
}
}
plugins.withType<JavaPlugin> {
// Temurin 25 builds (werkregie ADR 0001), but the bytecode stays Java 21: the Gradle plugin runs in
// the Gradle JVM of its users and the PIT plugin in their test JVM, e.g. OpenJDK 21 in Werkator's sandbox.
extensions.configure<JavaPluginExtension> {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
vendor = JvmVendorSpec.ADOPTIUM
}
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
}
dependencies {
"testImplementation"(platform("org.junit:junit-bom:6.1.3"))
"testImplementation"("org.junit.jupiter:junit-jupiter")
"testImplementation"("org.assertj:assertj-core:3.27.7")
"testRuntimeOnly"("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
systemProperty("werkcrappit.version", project.version.toString())
}
}
}
// ---- the version and the changelog follow from the PR documents (a copy of 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")
}
}
}
/** [history] is CHANGELOG.md with the entries up to 0.26.14, which the PRs wrote by hand. */
fun withHistory(changelog: String, history: String): String =
changelog + history.substringAfter("\n## ", "").let { if (it.isEmpty()) "" else "\n## $it" }
/** A branch that adds no PR document keeps the version of main, and tools/publish would skip its upload. */
fun branchWithoutPrDocument(documents: List<PrDocument>, git: (List<String>) -> String? = { git(*it.toTypedArray()) }) =
git(listOf("merge-base", "--is-ancestor", "HEAD", "origin/main")) == null && documents.none { it.merged == null }
val checkPrDocument = tasks.register("checkPrDocument") {
description = "Fails a branch that adds no PR document, since its merge would not raise the version."
val missing = branchWithoutPrDocument(releases.mapNotNull { it.document })
doLast { if (missing) throw GradleException("the branch adds no PR document under docs/prs, so its merge would keep the version") }
}
tasks.check { dependsOn(checkPrDocument) }
tasks.register("changelog") {
description = "Writes build/changelog/CHANGELOG.md from the release notes of the PR documents."
val text = withHistory(changelog(releases), file("CHANGELOG.md").readText())
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("src/c.txt", "c\n")
expect("branch without a PR document", branchWithoutPrDocument(prDocuments(since, "doc/prs", ::run), ::run), true)
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("branch with a PR document", branchWithoutPrDocument(prDocuments(since, "doc/prs", ::run), ::run), false)
expect("changelog with history", withHistory("# Changelog\n\n## 0.49.0 (date)\n\nA is new.\n", "# Changelog\n\nIntro.\n\n## 0.48.0\n\n- Old.\n"),
"# Changelog\n\n## 0.49.0 (date)\n\nA is new.\n\n## 0.48.0\n\n- Old.\n")
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) }
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 }
}