243 lines
13 KiB
Kotlin
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 }
|
|
}
|