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 { val token = providers.environmentVariable("GITEA_PACKAGES_TOKEN").orNull if (token != null) { extensions.configure { repositories { maven { name = "gitea" url = uri("https://git.javagil.de/api/packages/mi/maven") credentials(HttpHeaderCredentials::class) { name = "Authorization" value = "token $token" } authentication { create("header") } } } } } } plugins.withType { // 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 { toolchain { languageVersion = JavaLanguageVersion.of(25) vendor = JvmVendorSpec.ADOPTIUM } } tasks.withType().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().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 { val added = mutableListOf>>() 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? = { git(*it.toTypedArray()) }): List { 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): List = listOf(Release(base, null)) + documents.scan(base) { version, document -> raised(version, document.raise) }.drop(1) .zip(documents, ::Release) fun changelog(releases: List): 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, git: (List) -> 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? { 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() 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 } }