From 33bd7773664ec1c3917026bf39c1edce46338567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Tue, 16 Dec 2025 14:00:50 +0100 Subject: [PATCH 01/17] chore(build): add changelog plugin --- CONTRIBUTING.md | 5 + build-plugin/README.md | 37 ++++ build-plugin/plugin/build.gradle.kts | 10 +- .../plugin/changelog/ChangelogPlugin.kt | 67 +++++++ .../plugin/changelog/FinalizeChangelogTask.kt | 88 +++++++++ .../plugin/changelog/UpdateChangelogTask.kt | 141 ++++++++++++++ .../plugin/changelog/internal/Changelog.kt | 55 ++++++ .../changelog/internal/ChangelogManager.kt | 73 +++++++ .../changelog/internal/fs/FileHelper.kt | 27 +++ .../internal/git/ConventionalCommit.kt | 9 + .../changelog/internal/git/GitClient.kt | 99 ++++++++++ .../git/GitConventionalCommitParser.kt | 57 ++++++ .../internal/parser/ChangelogParser.kt | 57 ++++++ .../changelog/internal/parser/HeaderParser.kt | 40 ++++ .../internal/parser/ReleaseParser.kt | 76 ++++++++ .../internal/render/ChangelogRenderer.kt | 13 ++ .../render/MarkdownChangelogRenderer.kt | 68 +++++++ .../plugin/library/kmp/LibraryKmpPlugin.kt | 1 + .../kmp/compose/LibraryKmpComposePlugin.kt | 1 + .../plugin/changelog/ChangelogPluginTest.kt | 118 ++++++++++++ .../changelog/FinalizeChangelogTaskTest.kt | 148 ++++++++++++++ .../changelog/UpdateChangelogTaskTest.kt | 181 ++++++++++++++++++ .../internal/ChangelogManagerTest.kt | 51 +++++ .../changelog/internal/fs/FileHelperTest.kt | 120 ++++++++++++ .../changelog/internal/git/GitClientTest.kt | 123 ++++++++++++ .../git/GitConventionalCommitParserTest.kt | 127 ++++++++++++ .../internal/parser/ChangelogParserTest.kt | 44 +++++ .../internal/parser/ReleaseParserTest.kt | 61 ++++++ .../render/MarkdownChangelogRendererTest.kt | 55 ++++++ build.gradle.kts | 1 + components/bom/CHANGELOG.md | 10 + components/bom/build.gradle.kts | 1 + components/bom/version.properties | 2 +- docs/commit-guide.md | 106 ++++++++++ gradle/libs.versions.toml | 5 +- 35 files changed, 2074 insertions(+), 3 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTask.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/Changelog.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManager.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelper.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/ConventionalCommit.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClient.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParser.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParser.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/HeaderParser.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParser.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/ChangelogRenderer.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRenderer.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManagerTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClientTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParserTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParserTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParserTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRendererTest.kt create mode 100644 components/bom/CHANGELOG.md create mode 100644 docs/commit-guide.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf13b32..c84c18f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,11 @@ Before you start contributing, please take a moment to familiarize yourself with - **Mozilla Community Participation Guidelines:** [https://www.mozilla.org/en-US/about/governance/policies/participation/](https://www.mozilla.org/en-US/about/governance/policies/participation/) +### Git Commit Guide + +We follow [Conventional Commits](https://www.conventionalcommits.org/). Please read our +[Git Commit Guide](docs/commit-guide.md) for examples, allowed types, scopes, and best practices. + ## Bug Reports and Feature Requests If you encounter a bug or have a feature request, please follow these steps: diff --git a/build-plugin/README.md b/build-plugin/README.md index 18c696d..badb765 100644 --- a/build-plugin/README.md +++ b/build-plugin/README.md @@ -33,6 +33,43 @@ Supportive/quality and tooling plugins: - `net.thunderbird.gradle.plugin.publishing` — Configures publishing (Maven coordinates, POM metadata, and common repositories like `mavenLocal()` and a local build repo under `build/maven-repo`) +- `net.thunderbird.gradle.plugin.changelog` — Maintains component‑local `CHANGELOG.md` files next to each component’s + nearest `version.properties` (modules that share the same nearest `version.properties` directory are grouped together). + - Task: + - `updateChangelog`: ensures a component‑local `CHANGELOG.md` exists and auto‑populates the + `## Unreleased` section from Conventional Commit titles in git. + - Scope: only commits that touch the component directory (relative to the repository root) are considered. + - History: entries are read from first-parent history on `origin/main`/`main`, so pull request branch commits + are ignored. + - Release boundary: the task uses an exact component tag range based on the latest released changelog version + (for example `bom-1.0.0..main`) and fails when the expected previous release tag does not exist. + - Grouping: entries are grouped by type into sections (`Features`, `Bug Fixes`, `Documentation`, `Styles`, + `Refactoring`, `Tests`, `Chores`, `Reverts`). + - De‑duplication: existing bullets are preserved and duplicates from git subjects are avoided. + - Workflow: run changelog updates during release preparation, not during normal builds. See the + [Release Guide](../docs/release-guide.md). + - `finalizeChangelog`: finalizes the current `## Unreleased` section for a release version and inserts a new + empty `## Unreleased` section above it. + - Required property: `-PreleaseVersion=` + - Optional property: `-PreleaseDate=YYYY-MM-DD` (defaults to the current local date) + - Validation: fails when `Unreleased` is missing, empty, or when the target release already exists. + - Usage: + ```kotlin + // In any project that should contribute a component changelog + plugins { + id("net.thunderbird.gradle.plugin.changelog") + } + ``` + + Command: + ```bash + # Run for a single component (example: BOM) + ./gradlew :components:bom:updateChangelog + + # Finalize the changelog for a release + ./gradlew :components:bom:finalizeChangelog -PreleaseVersion=1.0.0 + ``` + ### Applying a plugin In any module’s `build.gradle.kts`: diff --git a/build-plugin/plugin/build.gradle.kts b/build-plugin/plugin/build.gradle.kts index 11e95da..1bb3c0d 100644 --- a/build-plugin/plugin/build.gradle.kts +++ b/build-plugin/plugin/build.gradle.kts @@ -23,7 +23,10 @@ dependencies { implementation(plugin(libs.plugins.detekt)) implementation(plugin(libs.plugins.spotless)) - compileOnly(libs.kotlinx.datetime) + implementation(libs.kotlinx.datetime) + + testImplementation(libs.assertk) + testImplementation(libs.kotlin.test) } gradlePlugin { @@ -68,6 +71,11 @@ gradlePlugin { id = "net.thunderbird.gradle.plugin.versioning" implementationClass = "net.thunderbird.gradle.plugin.versioning.VersioningPlugin" } + + register("Changelog") { + id = "net.thunderbird.gradle.plugin.changelog" + implementationClass = "net.thunderbird.gradle.plugin.changelog.ChangelogPlugin" + } } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt new file mode 100644 index 0000000..1011469 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt @@ -0,0 +1,67 @@ +package net.thunderbird.gradle.plugin.changelog + +import java.io.File +import net.thunderbird.gradle.plugin.ProjectConfig +import net.thunderbird.gradle.plugin.changelog.internal.fs.FileHelper +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.register + +/** + * Plugin that helps maintain a component-oriented CHANGELOG.md. + * + * It derives the component group from the directory that contains the nearest + * version.properties (walking up from the current project directory). This way, + * multiple modules that share the same nearest version.properties are grouped together. + */ +class ChangelogPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val start = project.projectDir + val root = rootProject.projectDir + val versionDir = FileHelper.locateNearestVersionDir(start, root) + + if (versionDir == null) { + logger.warn( + "[changelog] No version.properties found between ${start.path} and ${root.path}. " + + "CHANGELOG.md will not be created or updated.", + ) + } else { + logger.info("[changelog] Using folder at: ${versionDir.path} for component changelog") + + tasks.register(UpdateChangelogTask.TASK_NAME) { + group = "documentation" + description = + "Ensure component-local CHANGELOG.md (next to nearest version.properties) exists and has Unreleased sections" + + versionFile.set( + project.layout.file( + project.provider { File(versionDir, FileHelper.VERSION_FILE) }, + ), + ) + changelogFile.set( + project.layout.file( + project.provider { File(versionDir, FileHelper.CHANGELOG_FILE) }, + ), + ) + + repoRootDir.set(rootProject.layout.projectDirectory) + repoUrl.set(ProjectConfig.Publishing.url) + } + + tasks.register(FinalizeChangelogTask.TASK_NAME) { + group = "documentation" + description = "Finalize the component-local CHANGELOG.md Unreleased section for a release version" + + changelogFile.set( + project.layout.file( + project.provider { File(versionDir, FileHelper.CHANGELOG_FILE) }, + ), + ) + releaseVersion.set(providers.gradleProperty("releaseVersion")) + releaseDate.set(providers.gradleProperty("releaseDate")) + } + } + } + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt new file mode 100644 index 0000000..356b2ed --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt @@ -0,0 +1,88 @@ +package net.thunderbird.gradle.plugin.changelog + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogManager +import net.thunderbird.gradle.plugin.changelog.internal.Release +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Finalizes the current Unreleased changelog section for a release version. + */ +@OptIn(ExperimentalTime::class) +abstract class FinalizeChangelogTask : DefaultTask() { + + @get:OutputFile + abstract val changelogFile: RegularFileProperty + + @get:Input + abstract val releaseVersion: Property + + @get:Input + @get:Optional + abstract val releaseDate: Property + + init { + releaseDate.convention( + project.provider { + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + }, + ) + } + + @TaskAction + fun finalizeChangelog() { + val version = releaseVersion.get().trim() + require(version.isNotBlank()) { "releaseVersion must not be blank." } + require(!version.equals(UNRELEASED, ignoreCase = true)) { "releaseVersion must not be '$UNRELEASED'." } + + val date = LocalDate.parse(releaseDate.get().trim()) + val manager = ChangelogManager(changelogFile.get().asFile) + val changelog = manager.get() + val releases = changelog.releases.toMutableList() + + val unreleasedIndex = releases.indexOfFirst { it.version.equals(UNRELEASED, ignoreCase = true) } + require(unreleasedIndex >= 0) { "No '$UNRELEASED' section found in ${changelogFile.get().asFile.path}." } + require( + releases.none { + it.version == version + }, + ) { "Release '$version' already exists in ${changelogFile.get().asFile.path}." } + + val unreleased = releases[unreleasedIndex] + require(unreleased.sections.values.any { entries -> entries.isNotEmpty() }) { + "'$UNRELEASED' section is empty in ${changelogFile.get().asFile.path}." + } + + releases[unreleasedIndex] = Release( + version = version, + date = date, + sections = unreleased.sections, + ) + releases.add( + unreleasedIndex, + Release( + version = UNRELEASED, + date = null, + sections = emptyMap(), + ), + ) + + manager.update(changelog.copy(releases = releases)) + logger.lifecycle("[changelog] Finalized ${changelogFile.get().asFile.path} for $version ($date)") + } + + companion object { + const val TASK_NAME = "finalizeChangelog" + private const val UNRELEASED = "Unreleased" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTask.kt new file mode 100644 index 0000000..7fb76b2 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTask.kt @@ -0,0 +1,141 @@ +package net.thunderbird.gradle.plugin.changelog + +import net.thunderbird.gradle.plugin.changelog.internal.Changelog +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogManager +import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.changelog.internal.SectionType +import net.thunderbird.gradle.plugin.changelog.internal.fs.FileHelper +import net.thunderbird.gradle.plugin.changelog.internal.git.GitClient +import net.thunderbird.gradle.plugin.changelog.internal.git.GitConventionalCommitParser +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Ensures the component-local CHANGELOG.md (written next to the nearest + * version.properties) exists and contains an `## [Unreleased]` section with + * conventional sub-sections. + */ +abstract class UpdateChangelogTask : DefaultTask() { + + @get:InputFile + @get:Optional + abstract val versionFile: RegularFileProperty + + @get:OutputFile + abstract val changelogFile: RegularFileProperty + + /** + * Repository root directory used for git commands. + * + * Git history is external task state; do not make Gradle snapshot the whole repository. + */ + @get:Internal + abstract val repoRootDir: DirectoryProperty + + @get:Input + @get:Optional + abstract val repoUrl: Property + + init { + outputs.upToDateWhen { false } + } + + @TaskAction + fun update() { + val versionPropsFile = versionFile.orNull?.asFile + val changelogFile = changelogFile.get().asFile + val root = repoRootDir.get().asFile + + val changelogManager = ChangelogManager(changelogFile) + val changelog = changelogManager.get() + + // Component-relative path (from repo root) to limit git scan + val componentDir = versionPropsFile?.parentFile ?: changelogFile.parentFile + val relativePathRaw = root.toPath().relativize(componentDir.toPath()).toString() + val relativePath = relativePathRaw.takeIf { it.isNotBlank() } + + val git = GitClient { msg -> logger.warn(msg) } + val latestRelease = changelogManager.getLatestRelease(changelog) + val startRef = if (latestRelease != null) { + val tagCandidates = releaseTagCandidates(latestRelease.version, componentDir.name) + git.firstExistingRef(root, tagCandidates) + ?: error( + "No git tag found for latest changelog release '${latestRelease.version}'. " + + "Tried: ${tagCandidates.joinToString()}. " + + "Create a release tag before updating the changelog.", + ) + } else { + null + } + + val subjects = git.logComponentSubjects(root, relativePath, startRef) + + val updated = updateChangelog(changelog, subjects) + + changelogManager.update(updated) + logger.lifecycle("[changelog] Updated ${changelogFile.path} with ${subjects.size} commits") + } + + private fun updateChangelog( + changelog: Changelog, + subjects: List, + ): Changelog { + // Find or create Unreleased release + val releases = changelog.releases.toMutableList() + val idx = releases.indexOfFirst { it.version.equals("Unreleased", ignoreCase = true) } + val unreleased = if (idx >= 0) { + releases[idx] + } else { + Release( + version = "Unreleased", + date = null, + sections = emptyMap(), + ) + } + + // Start with current sections + val sectionMap = linkedMapOf>() + unreleased.sections.forEach { (k, v) -> sectionMap.getOrPut(k) { mutableListOf() }.addAll(v) } + + // Collect existing bullets to avoid duplicates + val existingBullets = sectionMap.values.flatten().map { it.text }.toSet() + + // Parse and add subjects + val ccParser = GitConventionalCommitParser() + val url = repoUrl.orNull + subjects.forEach { subj -> + val cc = ccParser.parse(subj, url) ?: return@forEach + if (existingBullets.contains(cc.description)) return@forEach + sectionMap.getOrPut(cc.type) { mutableListOf() }.add(ChangelogEntry(text = cc.description)) + } + + // Keep only non-empty sections + val newSections = sectionMap + .filterValues { it.isNotEmpty() } + .mapValues { it.value.toList() } + + val newUnreleased = unreleased.copy(sections = newSections) + if (idx >= 0) releases[idx] = newUnreleased else releases.add(0, newUnreleased) + + return changelog.copy(releases = releases) + } + + private fun releaseTagCandidates(version: String, componentPrefix: String): List { + val normalizedVersion = version.removePrefix("v") + + return listOf("$componentPrefix-$normalizedVersion") + } + + companion object { + const val TASK_NAME = "updateChangelog" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/Changelog.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/Changelog.kt new file mode 100644 index 0000000..ec4bca1 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/Changelog.kt @@ -0,0 +1,55 @@ +package net.thunderbird.gradle.plugin.changelog.internal + +import kotlinx.datetime.LocalDate + +/** + * Changelog model. + */ +internal data class Changelog( + val header: Header, + val releases: List, +) + +/** + * The header information of the changelog. + */ +internal data class Header( + val title: String, + val descriptions: List, +) + +/** + * A release in the changelog. + */ +internal data class Release( + val version: String, + val date: LocalDate?, + val sections: Map>, +) + +/** + * A single entry in a changelog section. + */ +internal data class ChangelogEntry( + val text: String, +) + +/** + * Conventional sections used in this repository (1:1 with Conventional Commit types). + */ +internal enum class SectionType(val header: String) { + Features("Features"), // feat + BugFixes("Bug Fixes"), // fix + Documentation("Documentation"), // docs + Styles("Styles"), // style + Refactoring("Refactoring"), // refactor (+ perf) + Tests("Tests"), // test + Chores("Chores"), // chore (+ build/ci/deps) + Reverts("Reverts"), // revert + ; + + companion object { + private val byHeader = entries.associateBy { it.header } + fun fromHeader(header: String): SectionType? = byHeader[header] + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManager.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManager.kt new file mode 100644 index 0000000..d2f5ce1 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManager.kt @@ -0,0 +1,73 @@ +package net.thunderbird.gradle.plugin.changelog.internal + +import java.io.File +import kotlinx.datetime.LocalDate +import net.thunderbird.gradle.plugin.changelog.internal.parser.ChangelogParser +import net.thunderbird.gradle.plugin.changelog.internal.render.MarkdownChangelogRenderer + +/** + * ChangelogManager provides high-level operations to read and modify a component-local + * changelog as structured data and write the changes back to disk. + * + * @param file The changelog file to manage. + */ +internal class ChangelogManager( + val file: File, +) { + + private val parser = ChangelogParser() + private val markdownRenderer = MarkdownChangelogRenderer() + + fun get(): Changelog { + return read(file) ?: DEFAULT_CHANGELOG + } + + fun update(changelog: Changelog) { + val markdown = markdownRenderer.render(changelog) + write(file, markdown) + } + + fun getLatestRelease(changelog: Changelog): Release? { + return changelog.releases + .filter { it.version != "Unreleased" } + .maxByOrNull { it.version } + } + + private fun read(file: File): Changelog? { + if (!file.exists()) return null + val text = file.readText() + return parser.parse(text) + } + + private fun write(file: File, content: String) { + if (!file.exists()) file.parentFile.mkdirs() + file.writeText(content) + } + + private companion object { + val DEFAULT_CHANGELOG = Changelog( + header = Header( + title = "Changelog", + descriptions = + listOf( + ChangelogEntry( + "All notable changes to this component will be documented in this file.", + ), + ChangelogEntry( + "This project uses [Conventional Commits](https://www.conventionalcommits.org/) for commit messages.", + ), + ChangelogEntry( + "Changelog entries are derived from commit history and grouped by commit type.", + ), + ), + ), + releases = listOf( + Release( + version = "Unreleased", + date = null, + sections = mutableMapOf(), + ), + ), + ) + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelper.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelper.kt new file mode 100644 index 0000000..12d7dda --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelper.kt @@ -0,0 +1,27 @@ +package net.thunderbird.gradle.plugin.changelog.internal.fs + +import java.io.File + +/** + * Shared helpers to resolve component files and metadata for changelog tasks. + * Centralizes common file-handling logic to avoid duplication across tasks/classes. + */ +internal object FileHelper { + + /** + * Locate the directory that contains the nearest version.properties walking up to the repo root. + */ + fun locateNearestVersionDir(start: File, repoRoot: File): File? { + var dir: File? = start + while (dir != null) { + val candidate = File(dir, VERSION_FILE) + if (candidate.exists()) return dir + if (dir == repoRoot) break + dir = dir.parentFile + } + return null + } + + internal const val VERSION_FILE = "version.properties" + internal const val CHANGELOG_FILE = "CHANGELOG.md" +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/ConventionalCommit.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/ConventionalCommit.kt new file mode 100644 index 0000000..bf59ca6 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/ConventionalCommit.kt @@ -0,0 +1,9 @@ +package net.thunderbird.gradle.plugin.changelog.internal.git + +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +internal data class ConventionalCommit( + val type: SectionType, + val scope: String?, + val description: String, +) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClient.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClient.kt new file mode 100644 index 0000000..e1420c8 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClient.kt @@ -0,0 +1,99 @@ +package net.thunderbird.gradle.plugin.changelog.internal.git + +import java.io.File + +/** + * Small utility wrapper around invoking git for changelog purposes. + * Encapsulates command construction and error handling. + */ +internal class GitClient( + private val logWarn: (String) -> Unit = {}, +) { + + /** + * Returns commit subjects for commits that changed files within the given component path. + * + * It searches first-parent history from the given `startRef` (exclusive) to the mainline + * ref. If `startRef` is not given, it defaults to the mainline ref. + * + * First-parent history keeps merge commits and direct commits from the release branch, + * but excludes individual commits from merged PR branches. + * + * @param repoRoot absolute repository root directory + * @param relativePath path relative to repoRoot to restrict the log + * @param startRef optional exclusive lower bound; when present, logs `startRef..mainlineRef` + */ + fun logComponentSubjects( + repoRoot: File, + relativePath: String?, + startRef: String?, + refCandidates: List = DEFAULT_MAINLINE_REF_CANDIDATES, + ): List { + val mainlineRef = resolveMainlineRef(repoRoot, refCandidates) ?: return emptyList() + val revision = if (startRef != null) "$startRef..$mainlineRef" else mainlineRef + val args = mutableListOf( + "git", + "-C", + repoRoot.absolutePath, + "log", + "--first-parent", + "--min-parents=1", + "--pretty=%s", + revision, + ) + if (!relativePath.isNullOrBlank()) { + args.add("--") + args.add(relativePath) + } + return run(args) + .map { it.trim() } + .filter { it.isNotBlank() } + } + + fun firstExistingRef(repoRoot: File, refCandidates: List): String? { + return refCandidates.firstOrNull { ref -> refExists(repoRoot, ref) } + } + + private fun resolveMainlineRef(repoRoot: File, refCandidates: List): String? { + val mainlineRef = refCandidates.firstOrNull { ref -> + refExists(repoRoot, ref) + } + if (mainlineRef == null) { + logWarn("[changelog] No mainline git ref found. Tried: ${refCandidates.joinToString()}") + } + return mainlineRef + } + + private fun refExists(repoRoot: File, ref: String): Boolean { + val cmd = listOf("git", "-C", repoRoot.absolutePath, "rev-parse", "--verify", "--quiet", "$ref^{commit}") + return try { + ProcessBuilder(cmd) + .redirectErrorStream(true) + .start() + .waitFor() == 0 + } catch (_: Exception) { + false + } + } + + private fun run(cmd: List): List = try { + val pb = ProcessBuilder(cmd) + pb.redirectErrorStream(true) + val proc = pb.start() + val out = proc.inputStream.bufferedReader().readLines() + val exit = proc.waitFor() + if (exit != 0) { + logWarn("[changelog] Command failed ($exit): ${cmd.joinToString(" ")}") + emptyList() + } else { + out + } + } catch (e: Exception) { + logWarn("[changelog] Failed to run ${cmd.joinToString(" ")}: ${e.message}") + emptyList() + } + + private companion object { + val DEFAULT_MAINLINE_REF_CANDIDATES = listOf("origin/main", "main", "HEAD") + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParser.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParser.kt new file mode 100644 index 0000000..5a84da3 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParser.kt @@ -0,0 +1,57 @@ +package net.thunderbird.gradle.plugin.changelog.internal.git + +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +/** + * Parser for Git conventional commit messages. + */ +internal class GitConventionalCommitParser { + + fun parse(message: String, repoUrl: String? = null): ConventionalCommit? { + // Conventional Commits: type[scope][!]: description + // - scope is optional + // - breaking '!' may appear after type or after scope + // - allow optional space after ':' + val regex = Regex( + pattern = "^(?[a-zA-Z]+)(?:\\((?[^)]+)\\))?(?!)?:\\s*(?.+)", + ) + val match = regex.find(message) ?: return null + val type = match.groups["type"]?.value?.lowercase() + val scope = match.groups["scope"]?.value + var description = match.groups["desc"]?.value?.trim() ?: message.trim() + + if (type == null) return null + + if (repoUrl != null) { + description = linkPullRequests(description, repoUrl) + } + + return ConventionalCommit( + type = mapTypeToSectionType(type) ?: return null, + scope = scope, + description = description, + ) + } + + private fun linkPullRequests(description: String, repoUrl: String): String { + // Find (#123) and replace with ([#123](repoUrl/pull/123)) + val prRegex = Regex("\\(#(\\d+)\\)") + val baseUrl = repoUrl.removeSuffix("/") + return prRegex.replace(description) { matchResult -> + val prNumber = matchResult.groupValues[1] + "([#$prNumber]($baseUrl/pull/$prNumber))" + } + } + + private fun mapTypeToSectionType(type: String): SectionType? = when (type) { + "feat" -> SectionType.Features + "fix" -> SectionType.BugFixes + "docs" -> SectionType.Documentation + "style" -> SectionType.Styles + "refactor", "perf" -> SectionType.Refactoring + "test" -> SectionType.Tests + "chore", "build", "ci", "deps" -> SectionType.Chores + "revert" -> SectionType.Reverts + else -> null + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParser.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParser.kt new file mode 100644 index 0000000..ba9bd73 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParser.kt @@ -0,0 +1,57 @@ +package net.thunderbird.gradle.plugin.changelog.internal.parser + +import net.thunderbird.gradle.plugin.changelog.internal.Changelog +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.Header +import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +internal class ChangelogParser( + private val headerParser: HeaderParser = HeaderParser(), + private val releaseParser: ReleaseParser = ReleaseParser(), +) { + + fun parse(changelogText: String): Changelog? { + val lines = changelogText.lineSequence().toList() + val headerLines = lines.takeWhile { !it.trim().startsWith(RELEASE_HEADER_PREFIX) } + val releasesLines = lines.drop(headerLines.size) + + val header = headerParser.parse(headerLines) ?: return null + val releases = parseReleases(releasesLines) + + return Changelog( + header = header, + releases = releases, + ) + } + + private fun parseReleases(lines: List): List { + val releases = mutableListOf() + val releaseBlocks = splitIntoReleaseBlocks(lines) + + for (block in releaseBlocks) { + val release = releaseParser.parse(block) ?: continue + releases.add(release) + } + + return releases + } + + private fun splitIntoReleaseBlocks(lines: List): List> { + val blocks = mutableListOf>() + val releaseIndices = lines.mapIndexedNotNull { index, line -> + if (line.trim().startsWith(RELEASE_HEADER_PREFIX)) index else null + } + lines.size + + for (i in releaseIndices.indices) { + val from = releaseIndices[i] + val to = if (i + 1 < releaseIndices.size) releaseIndices[i + 1] else lines.size + blocks.add(lines.subList(from, to)) + } + return blocks + } + + private companion object { + const val RELEASE_HEADER_PREFIX = "## " + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/HeaderParser.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/HeaderParser.kt new file mode 100644 index 0000000..7fc248c --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/HeaderParser.kt @@ -0,0 +1,40 @@ +package net.thunderbird.gradle.plugin.changelog.internal.parser + +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.Header + +internal class HeaderParser { + + fun parse(lines: List): Header? { + var title: String? = null + val headerEntries = mutableListOf() + + for (raw in lines) { + val line = raw.trim() + when { + line.isBlank() -> continue + line.startsWith(HEADER_PREFIX) -> title = parseHeaderTitle(line) + else -> headerEntries.add(parseHeaderEntry(line)) + } + } + + if (title == null) return null + + return Header( + title = title, + descriptions = headerEntries, + ) + } + + private fun parseHeaderTitle(line: String): String? { + return line.removePrefix(HEADER_PREFIX).trim().ifBlank { null } + } + + private fun parseHeaderEntry(line: String): ChangelogEntry { + return ChangelogEntry(text = line.trim()) + } + + private companion object { + const val HEADER_PREFIX = "# " + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParser.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParser.kt new file mode 100644 index 0000000..5731912 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParser.kt @@ -0,0 +1,76 @@ +package net.thunderbird.gradle.plugin.changelog.internal.parser + +import kotlinx.datetime.LocalDate +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +internal class ReleaseParser { + + fun parse(lines: List): Release? { + val headerLine = lines.firstOrNull() ?: return null + val match = RELEASE_HEADER_REGEX.toRegex().matchEntire(headerLine.trim()) + ?: return null + + val version = (match.groups["bracketedVersion"] ?: match.groups["plainVersion"]) + ?.value + ?.trim() + ?: return null + val date = match.groups["date"]?.value?.let(LocalDate::parse) + + val sections = parseSections(lines.drop(1)) + + return Release( + version = version, + date = date, + sections = sections, + ) + } + + private fun parseSections(lines: List): Map> { + val sections = linkedMapOf>() + val sectionBlocks = splitIntoSectionBlocks(lines) + + for (block in sectionBlocks) { + val headerLine = block.firstOrNull() ?: continue + val sectionType = parseSectionType(headerLine) ?: continue + val entries = block.drop(1) + .mapNotNull { parseSectionEntryOrNull(it) } + if (entries.isNotEmpty()) { + sections.getOrPut(sectionType) { mutableListOf() }.addAll(entries) + } + } + + return sections.mapValues { it.value.toList() } + } + + private fun splitIntoSectionBlocks(lines: List): List> { + val indices = mutableListOf() + lines.forEachIndexed { idx, raw -> + if (raw.trim().startsWith(SECTION_HEADER_PREFIX)) indices += idx + } + if (indices.isEmpty()) return emptyList() + val ends = indices.drop(1) + lines.size + return indices.zip(ends).map { (from, to) -> lines.subList(from, to) } + } + + private fun parseSectionType(line: String): SectionType? { + val header = line.trim().removePrefix(SECTION_HEADER_PREFIX).trim() + return SectionType.fromHeader(header) + } + + private fun parseSectionEntryOrNull(line: String): ChangelogEntry? { + val trimmed = line.trim() + if (!trimmed.startsWith(CHANGELOG_ENTRY_PREFIX)) return null + val text = trimmed.removePrefix(CHANGELOG_ENTRY_PREFIX).trim() + if (text.isEmpty()) return null + return ChangelogEntry(text = text) + } + + private companion object { + const val SECTION_HEADER_PREFIX = "### " + const val RELEASE_HEADER_REGEX = + "^##\\s+(?:\\[(?[^]]+)]|(?[^-]+?))(?:\\s+-\\s+(?\\d{4}-\\d{2}-\\d{2}))?\\s*$" + const val CHANGELOG_ENTRY_PREFIX = "- " + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/ChangelogRenderer.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/ChangelogRenderer.kt new file mode 100644 index 0000000..fb7c784 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/ChangelogRenderer.kt @@ -0,0 +1,13 @@ +package net.thunderbird.gradle.plugin.changelog.internal.render + +import net.thunderbird.gradle.plugin.changelog.internal.Changelog + +/** + * Renders a changelog model into a text representation. + */ +internal interface ChangelogRenderer { + /** + * Render the entire changelog file (header + all blocks). + */ + fun render(changelog: Changelog): String +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRenderer.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRenderer.kt new file mode 100644 index 0000000..09e5cca --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRenderer.kt @@ -0,0 +1,68 @@ +package net.thunderbird.gradle.plugin.changelog.internal.render + +import kotlinx.datetime.LocalDate +import net.thunderbird.gradle.plugin.changelog.internal.Changelog +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.Header +import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +/** + * Markdown renderer for the changelog. + */ +internal class MarkdownChangelogRenderer : ChangelogRenderer { + + override fun render(changelog: Changelog): String = buildString { + append(renderHeader(changelog.header)) + append(renderReleases(changelog.releases)) + appendLine() + } + + private fun renderHeader(header: Header): String = buildString { + appendLine("# ${header.title}") + if (header.descriptions.isNotEmpty()) { + header.descriptions.forEach { + appendLine() + appendLine(it.text) + } + } + } + + private fun renderReleases(releases: List): String = buildString { + if (releases.isNotEmpty()) { + for (release in releases) { + appendLine() + appendLine("## ${release.version} ${renderReleaseDate(release.date)}") + if (release.sections.isNotEmpty()) { + append(renderSections(release.sections)) + } + } + } else { + appendLine() + appendLine("## Unreleased") + } + } + + private fun renderReleaseDate(date: LocalDate?): String { + return if (date != null) "- $date" else "" + } + + private fun renderSections(sections: Map>): String = buildString { + sections.forEach { (sectionType, entries) -> + append(renderSection(sectionType, entries)) + } + } + + private fun renderSection(type: SectionType, entries: List): String = buildString { + appendLine() + appendLine("### ${type.header}") + if (entries.isNotEmpty()) { + appendLine() + entries.forEach { entry -> + if (entry.text.isNotBlank()) { + appendLine("- ${entry.text}") + } + } + } + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt index c926082..5a21ae3 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt @@ -25,6 +25,7 @@ class LibraryKmpPlugin : Plugin { apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") + apply("net.thunderbird.gradle.plugin.changelog") apply("net.thunderbird.gradle.plugin.publishing") apply("net.thunderbird.gradle.plugin.versioning") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index b5e48ad..95976c3 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -28,6 +28,7 @@ class LibraryKmpComposePlugin : Plugin { apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") + apply("net.thunderbird.gradle.plugin.changelog") apply("net.thunderbird.gradle.plugin.publishing") apply("net.thunderbird.gradle.plugin.versioning") diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt new file mode 100644 index 0000000..72f6f32 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt @@ -0,0 +1,118 @@ +package net.thunderbird.gradle.plugin.changelog + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import java.io.File +import kotlin.test.Test +import net.thunderbird.gradle.plugin.ProjectConfig +import net.thunderbird.gradle.plugin.changelog.internal.fs.FileHelper +import net.thunderbird.gradle.plugin.versioning.VersioningPlugin +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class ChangelogPluginTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `apply registers changelog tasks next to nearest version properties`() { + // Arrange + val fixture = createNestedComponentProject() + + // Act + fixture.project.plugins.apply(ChangelogPlugin::class.java) + + // Assert + val task = fixture.project.tasks.named(UpdateChangelogTask.TASK_NAME).get() as UpdateChangelogTask + assertThat(task.versionFile.get().asFile.canonicalFile).isEqualTo(fixture.versionFile.canonicalFile) + assertThat(task.changelogFile.get().asFile.canonicalFile).isEqualTo(fixture.changelogFile.canonicalFile) + assertThat(task.repoRootDir.get().asFile.canonicalFile).isEqualTo(fixture.rootDir.canonicalFile) + assertThat(task.repoUrl.get()).isEqualTo(ProjectConfig.Publishing.url) + + val finalizeTask = fixture.project.tasks.named(FinalizeChangelogTask.TASK_NAME).get() as FinalizeChangelogTask + assertThat(finalizeTask.changelogFile.get().asFile.canonicalFile) + .isEqualTo(fixture.changelogFile.canonicalFile) + } + + @Test + fun `apply is compatible with versioning plugin version properties`() { + // Arrange + val fixture = createNestedComponentProject("changelog-versioning-plugin-test") + + // Act + fixture.project.plugins.apply(ChangelogPlugin::class.java) + fixture.project.plugins.apply(VersioningPlugin::class.java) + + // Assert + assertThat(fixture.project.version.toString()).isEqualTo("1.2.3") + assertThat(fixture.project.tasks.findByName(UpdateChangelogTask.TASK_NAME)).isNotNull() + assertThat(fixture.project.tasks.findByName("versionBumpPatch")).isNotNull() + } + + @Test + fun `apply does not register changelog tasks when version properties is absent`() { + // Arrange + val rootDir = temporaryFolder.newFolder("changelog-plugin-test") + val projectDir = rootDir.resolve("components/bom") + projectDir.mkdirs() + val project = ProjectBuilder.builder() + .withProjectDir(projectDir) + .build() + + // Act + project.plugins.apply(ChangelogPlugin::class.java) + + // Assert + assertThat(project.tasks.findByName(UpdateChangelogTask.TASK_NAME)).isNull() + assertThat(project.tasks.findByName(FinalizeChangelogTask.TASK_NAME)).isNull() + } + + private fun createNestedComponentProject( + rootFolderName: String = "changelog-plugin-test", + ): ComponentProject { + val rootDir = temporaryFolder.newFolder(rootFolderName) + val componentDir = rootDir.resolve("components/bom") + val projectDir = componentDir.resolve("nested/module") + projectDir.mkdirs() + componentDir.resolve(FileHelper.VERSION_FILE).writeText(versionProperties()) + + val rootProject = ProjectBuilder.builder() + .withProjectDir(rootDir) + .withName("root") + .build() + val project = ProjectBuilder.builder() + .withProjectDir(projectDir) + .withName("module") + .withParent(rootProject) + .build() + + return ComponentProject( + rootDir = rootDir, + componentDir = componentDir, + project = project, + ) + } + + private data class ComponentProject( + val rootDir: File, + val componentDir: File, + val project: Project, + ) { + val versionFile: File = componentDir.resolve(FileHelper.VERSION_FILE) + val changelogFile: File = componentDir.resolve(FileHelper.CHANGELOG_FILE) + } + + private companion object { + private fun versionProperties(): String = """ + MAJOR=1 + MINOR=2 + PATCH=3 + SNAPSHOT=false + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt new file mode 100644 index 0000000..a3ab550 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt @@ -0,0 +1,148 @@ +package net.thunderbird.gradle.plugin.changelog + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import java.io.File +import kotlin.test.Test +import net.thunderbird.gradle.plugin.changelog.internal.fs.FileHelper +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class FinalizeChangelogTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `finalizeChangelog moves Unreleased entries to release and creates empty Unreleased section`() { + // Arrange + val componentDir = createComponentDir(CHANGES_UNDER_UNRELEASED) + val task = createTask(componentDir, releaseVersion = "0.1.0", releaseDate = "2026-06-18") + + // Act + task.finalizeChangelog() + + // Assert + val changelog = componentDir.resolve(FileHelper.CHANGELOG_FILE).readText() + assertThat(changelog).contains( + "## Unreleased", + "## 0.1.0 - 2026-06-18", + "- update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4))", + ) + assertThat(changelog.substringAfter("## Unreleased").substringBefore("## 0.1.0").trim()).isEqualTo("") + } + + @Test + fun `finalizeChangelog fails when release version already exists`() { + // Arrange + val componentDir = createComponentDir(CHANGELOG_WITH_EXISTING_RELEASE) + val task = createTask(componentDir, releaseVersion = "0.1.0", releaseDate = "2026-06-18") + + // Act + val failure = assertFailure { task.finalizeChangelog() } + + // Assert + failure.isInstanceOf() + failure.messageContains("Release '0.1.0' already exists") + } + + @Test + fun `finalizeChangelog fails when Unreleased section is empty`() { + // Arrange + val componentDir = createComponentDir(EMPTY_UNRELEASED) + val task = createTask(componentDir, releaseVersion = "0.1.0", releaseDate = "2026-06-18") + + // Act + val failure = assertFailure { task.finalizeChangelog() } + + // Assert + failure.isInstanceOf() + failure.messageContains("'Unreleased' section is empty") + } + + @Test + fun `finalizeChangelog fails when release version is missing`() { + // Arrange + val componentDir = createComponentDir(CHANGES_UNDER_UNRELEASED) + val task = createTask(componentDir, releaseDate = "2026-06-18") + + // Act + val failure = assertFailure { task.finalizeChangelog() } + + // Assert + failure.isInstanceOf() + } + + private fun createComponentDir(changelog: String): File { + val componentDir = temporaryFolder.newFolder("finalize-changelog-task-test") + componentDir.resolve(FileHelper.VERSION_FILE).writeText(VERSION_PROPERTIES) + componentDir.resolve(FileHelper.CHANGELOG_FILE).writeText(changelog) + return componentDir + } + + private fun createTask( + componentDir: File, + releaseVersion: String? = null, + releaseDate: String, + ): FinalizeChangelogTask { + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .build() + return project.tasks.create(FinalizeChangelogTask.TASK_NAME, FinalizeChangelogTask::class.java).apply { + changelogFile.set(componentDir.resolve(FileHelper.CHANGELOG_FILE)) + if (releaseVersion != null) { + this.releaseVersion.set(releaseVersion) + } + this.releaseDate.set(releaseDate) + } + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=0 + MINOR=1 + PATCH=0 + SNAPSHOT=false + """.trimIndent() + + private val EMPTY_UNRELEASED = """ + # Changelog + + ## Unreleased + + """.trimIndent() + + private val CHANGES_UNDER_UNRELEASED = """ + # Changelog + + ## Unreleased + + ### Chores + + - update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4)) + + """.trimIndent() + + private val CHANGELOG_WITH_EXISTING_RELEASE = """ + # Changelog + + ## Unreleased + + ### Chores + + - update build-plugin + + ## 0.1.0 - 2026-06-17 + + ### Chores + + - previous release + + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt new file mode 100644 index 0000000..7ba3651 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt @@ -0,0 +1,181 @@ +package net.thunderbird.gradle.plugin.changelog + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import java.io.File +import kotlin.test.Test +import net.thunderbird.gradle.plugin.changelog.internal.fs.FileHelper +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class UpdateChangelogTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `update writes changelog from merge commits and remains idempotent`() { + // Arrange + val repo = createRepositoryWithMergedComponentCommits() + val task = createTask(repo) + + // Act + task.update() + task.update() + + // Assert + val changelog = repo.componentChangelogFile.readText() + assertThat(changelog).doesNotContain("- add changelog plugin", "- add versioning plugin") + assertThat(Regex("^- update build-plugin ", RegexOption.MULTILINE).findAll(changelog).count()).isEqualTo(1) + assertThat(changelog).contains( + "- update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4))", + ) + } + + @Test + fun `update uses latest release tag as exact lower bound`() { + // Arrange + val repo = createRepositoryWithReleaseTag() + val task = createTask(repo, taskName = "${UpdateChangelogTask.TASK_NAME}WithTag") + + // Act + task.update() + + // Assert + val changelog = repo.componentChangelogFile.readText() + val unreleased = changelog.substringBefore("## 1.0.0") + assertThat(unreleased).doesNotContain("- old released change") + assertThat(unreleased).contains("- new unreleased change") + } + + @Test + fun `update fails when latest release has no matching git tag`() { + // Arrange + val repo = createRepositoryWithReleaseTag(createTag = false) + val task = createTask(repo, taskName = "${UpdateChangelogTask.TASK_NAME}WithoutTag") + + // Act + val failure = assertFailure { task.update() } + + // Assert + failure.isInstanceOf() + failure.messageContains("No git tag found for latest changelog release '1.0.0'") + } + + private fun createRepositoryWithMergedComponentCommits(): File { + val repo = createGitRepository() + val componentDir = repo.componentDir + componentDir.mkdirs() + repo.componentVersionFile.writeText(VERSION_PROPERTIES) + componentDir.resolve("file.txt").writeText("initial") + repo.runGit("add", ".") + repo.runGit("commit", "-m", "Initial commit") + + repo.runGit("checkout", "-b", "feature/build-plugin") + componentDir.resolve("file.txt").appendText("\nchangelog") + repo.runGit("commit", "-am", "feat: add changelog plugin") + componentDir.resolve("file.txt").appendText("\nversioning") + repo.runGit("commit", "-am", "feat: add versioning plugin") + + repo.runGit("checkout", "-") + repo.runGit("merge", "--no-ff", "feature/build-plugin", "-m", "chore(build): update build-plugin (#4)") + return repo + } + + private fun createRepositoryWithReleaseTag(createTag: Boolean = true): File { + val repo = createGitRepository() + repo.runGit("branch", "-M", "main") + + val componentDir = repo.componentDir + componentDir.mkdirs() + repo.componentVersionFile.writeText(VERSION_PROPERTIES) + repo.componentChangelogFile.writeText(CHANGELOG_WITH_RELEASE) + componentDir.resolve("file.txt").writeText("initial") + repo.runGit("add", ".") + repo.runGit("commit", "-m", "chore: initial") + + componentDir.resolve("file.txt").appendText("\nold") + repo.runGit("commit", "-am", "fix: old released change") + if (createTag) { + repo.runGit("tag", "bom-1.0.0") + } + + componentDir.resolve("file.txt").appendText("\nnew") + repo.runGit("commit", "-am", "fix: new unreleased change") + return repo + } + + private fun createGitRepository(): File { + val repo = temporaryFolder.newFolder("update-changelog-task-test") + repo.runGit("init") + repo.runGit("config", "user.email", "test@example.com") + repo.runGit("config", "user.name", "Test User") + repo.runGit("config", "commit.gpgsign", "false") + return repo + } + + private fun createTask( + repo: File, + taskName: String = UpdateChangelogTask.TASK_NAME, + ): UpdateChangelogTask { + val project = ProjectBuilder.builder() + .withProjectDir(repo.componentDir) + .build() + + return project.tasks.create(taskName, UpdateChangelogTask::class.java).apply { + versionFile.set(repo.componentVersionFile) + changelogFile.set(repo.componentChangelogFile) + repoRootDir.set(repo) + repoUrl.set("https://github.com/thunderbird/thunderbird-mobile-components") + } + } + + private fun File.runGit(vararg args: String) { + val command = listOf("git", "-C", absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n$output" + } + } + + private val File.componentDir: File + get() = resolve("components/bom") + + private val File.componentVersionFile: File + get() = componentDir.resolve(FileHelper.VERSION_FILE) + + private val File.componentChangelogFile: File + get() = componentDir.resolve(FileHelper.CHANGELOG_FILE) + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=0 + PATCH=0 + SNAPSHOT=false + """.trimIndent() + + private val CHANGELOG_WITH_RELEASE = """ + # Changelog + + ## Unreleased + + ## [1.0.0] - 2026-06-18 + + ### Bug Fixes + + - old released change + + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManagerTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManagerTest.kt new file mode 100644 index 0000000..0aa87dd --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/ChangelogManagerTest.kt @@ -0,0 +1,51 @@ +package net.thunderbird.gradle.plugin.changelog.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class ChangelogManagerTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `get returns default changelog when file does not exist`() { + // Arrange + val changelogFile = temporaryFolder.root.resolve("CHANGELOG.md") + + // Act + val changelog = ChangelogManager(changelogFile).get() + + // Assert + assertThat(changelog.header.title).isEqualTo("Changelog") + assertThat(changelog.releases.single().version).isEqualTo("Unreleased") + } + + @Test + fun `update creates parent directories and writes renderable changelog`() { + // Arrange + val changelogFile = temporaryFolder.root.resolve("nested/component/CHANGELOG.md") + val changelog = Changelog( + header = Header("Changelog", listOf(ChangelogEntry("Description."))), + releases = listOf( + Release( + version = "Unreleased", + date = null, + sections = mapOf(SectionType.Features to listOf(ChangelogEntry("add feature"))), + ), + ), + ) + + // Act + ChangelogManager(changelogFile).update(changelog) + + // Assert + assertThat(changelogFile.exists()).isTrue() + val parsed = ChangelogManager(changelogFile).get() + assertThat(parsed).isEqualTo(changelog) + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt new file mode 100644 index 0000000..9b12c7b --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt @@ -0,0 +1,120 @@ +package net.thunderbird.gradle.plugin.changelog.internal.fs + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import java.io.File +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class FileHelperTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `locateNearestVersionDir walks up to nearest version properties`() { + // Arrange + val root = createRoot() + val component = createVersionedDir(root.resolve("components/bom")) + val nested = component.resolve("src/main") + nested.mkdirs() + + // Act + val versionDir = FileHelper.locateNearestVersionDir(nested, root) + + // Assert + assertThat(versionDir).isEqualTo(component) + } + + @Test + fun `locateNearestVersionDir returns start directory when it contains version properties`() { + // Arrange + val root = createRoot() + val component = createVersionedDir(root.resolve("components/bom")) + + // Act + val versionDir = FileHelper.locateNearestVersionDir(component, root) + + // Assert + assertThat(versionDir).isEqualTo(component) + } + + @Test + fun `locateNearestVersionDir returns nearest version properties when multiple parents have one`() { + // Arrange + val root = createRoot() + createVersionedDir(root) + val component = createVersionedDir(root.resolve("components/bom")) + val nested = component.resolve("nested/module") + nested.mkdirs() + + // Act + val versionDir = FileHelper.locateNearestVersionDir(nested, root) + + // Assert + assertThat(versionDir).isEqualTo(component) + } + + @Test + fun `locateNearestVersionDir can return repo root when root contains version properties`() { + // Arrange + val root = createVersionedDir(createRoot()) + val nested = root.resolve("components/bom/src/main") + nested.mkdirs() + + // Act + val versionDir = FileHelper.locateNearestVersionDir(nested, root) + + // Assert + assertThat(versionDir).isEqualTo(root) + } + + @Test + fun `locateNearestVersionDir stops at repo root`() { + // Arrange + val workspace = temporaryFolder.newFolder("workspace") + createVersionedDir(workspace) + val repoRoot = workspace.resolve("repo") + val nested = repoRoot.resolve("components/bom/src/main") + nested.mkdirs() + + // Act + val versionDir = FileHelper.locateNearestVersionDir(nested, repoRoot) + + // Assert + assertThat(versionDir).isNull() + } + + @Test + fun `locateNearestVersionDir returns null when version properties is absent`() { + // Arrange + val root = createRoot() + val nested = root.resolve("components/bom/src/main") + nested.mkdirs() + + // Act + val versionDir = FileHelper.locateNearestVersionDir(nested, root) + + // Assert + assertThat(versionDir).isNull() + } + + private fun createRoot() = temporaryFolder.newFolder("file-helper-test") + + private fun createVersionedDir(dir: File): File { + dir.mkdirs() + dir.resolve(FileHelper.VERSION_FILE).writeText(VERSION_PROPERTIES) + return dir + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=0 + PATCH=0 + SNAPSHOT=false + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClientTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClientTest.kt new file mode 100644 index 0000000..9f728b1 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitClientTest.kt @@ -0,0 +1,123 @@ +package net.thunderbird.gradle.plugin.changelog.internal.git + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.File +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class GitClientTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `logComponentSubjects returns merge and direct mainline commits only`() { + // Arrange + val repo = temporaryFolder.newFolder("git-client-test") + repo.initGit() + + val componentDir = repo.resolve("components/bom") + componentDir.mkdirs() + val componentFile = componentDir.resolve("file.txt") + componentFile.writeText("initial") + repo.runGit("add", ".") + repo.runGit("commit", "-m", "chore: initial") + + repo.runGit("checkout", "-b", "feature/build-plugin") + componentFile.appendText("\nchangelog") + repo.runGit("commit", "-am", "feat: add changelog plugin") + componentFile.appendText("\nversioning") + repo.runGit("commit", "-am", "feat: add versioning plugin") + + repo.runGit("checkout", "-") + repo.runGit("merge", "--no-ff", "feature/build-plugin", "-m", "chore(build): update build-plugin (#4)") + + componentFile.appendText("\ndirect") + repo.runGit("commit", "-am", "fix: direct mainline change") + + // Act + val subjects = GitClient().logComponentSubjects(repo, "components/bom", startRef = null) + + // Assert + assertThat(subjects).isEqualTo( + listOf( + "fix: direct mainline change", + "chore(build): update build-plugin (#4)", + ), + ) + } + + @Test + fun `logComponentSubjects reads mainline ref instead of current feature branch`() { + // Arrange + val repo = temporaryFolder.newFolder("git-client-test") + repo.initGit() + + val componentDir = repo.resolve("components/bom") + componentDir.mkdirs() + val componentFile = componentDir.resolve("file.txt") + componentFile.writeText("initial") + repo.runGit("add", ".") + repo.runGit("commit", "-m", "chore: initial") + + repo.runGit("checkout", "-b", "feature/build-plugin") + componentFile.appendText("\nchangelog") + repo.runGit("commit", "-am", "chore(build): add changelog plugin") + + // Act + val subjects = GitClient().logComponentSubjects(repo, "components/bom", startRef = null) + + // Assert + assertThat(subjects).isEqualTo(emptyList()) + } + + @Test + fun `logComponentSubjects uses start ref as exact exclusive lower bound`() { + // Arrange + val repo = temporaryFolder.newFolder("git-client-test") + repo.initGit() + + val componentDir = repo.resolve("components/bom") + componentDir.mkdirs() + val componentFile = componentDir.resolve("file.txt") + componentFile.writeText("initial") + repo.runGit("add", ".") + repo.runGit("commit", "-m", "chore: initial") + + componentFile.appendText("\nold") + repo.runGit("commit", "-am", "fix: old released change") + repo.runGit("tag", "v1.0.0") + + componentFile.appendText("\nnew") + repo.runGit("commit", "-am", "fix: new unreleased change") + + // Act + val subjects = GitClient().logComponentSubjects(repo, "components/bom", startRef = "v1.0.0") + + // Assert + assertThat(subjects).isEqualTo(listOf("fix: new unreleased change")) + } + + private fun File.initGit() { + runGit("init") + runGit("branch", "-M", "main") + runGit("config", "user.email", "test@example.com") + runGit("config", "user.name", "Test User") + runGit("config", "commit.gpgsign", "false") + } + + private fun File.runGit(vararg args: String): List { + val command = listOf("git", "-C", absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n${output.joinToString("\n")}" + } + return output + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParserTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParserTest.kt new file mode 100644 index 0000000..7ab6c1f --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/git/GitConventionalCommitParserTest.kt @@ -0,0 +1,127 @@ +package net.thunderbird.gradle.plugin.changelog.internal.git + +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.prop +import kotlin.test.Test +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +class GitConventionalCommitParserTest { + private val parser = GitConventionalCommitParser() + private val repoUrl = "https://github.com/thunderbird/thunderbird-mobile-components" + + @Test + fun `given simple commit when parsing then returns correct commit`() { + // Arrange + val message = "feat: add new feature" + + // Act + val result = parser.parse(message) + + // Assert + assertThat(result).isNotNull().all { + prop(ConventionalCommit::type).isEqualTo(SectionType.Features) + prop(ConventionalCommit::description).isEqualTo("add new feature") + prop(ConventionalCommit::scope).isNull() + } + } + + @Test + fun `given commit with scope when parsing then returns correct commit`() { + // Arrange + val message = "fix(ui): resolve crash" + + // Act + val result = parser.parse(message) + + // Assert + assertThat(result).isNotNull().all { + prop(ConventionalCommit::type).isEqualTo(SectionType.BugFixes) + prop(ConventionalCommit::description).isEqualTo("resolve crash") + prop(ConventionalCommit::scope).isEqualTo("ui") + } + } + + @Test + fun `given commit with PR number and repoUrl when parsing then links PR`() { + // Arrange + val message = "feat: add awesome feature (#123)" + + // Act + val result = parser.parse(message, repoUrl) + + // Assert + assertThat(result).isNotNull().all { + prop(ConventionalCommit::type).isEqualTo(SectionType.Features) + prop( + ConventionalCommit::description, + ).isEqualTo( + "add awesome feature ([#123](https://github.com/thunderbird/thunderbird-mobile-components/pull/123))", + ) + } + } + + @Test + fun `given commit with PR number and no repoUrl when parsing then does not link PR`() { + // Arrange + val message = "feat: add awesome feature (#123)" + + // Act + val result = parser.parse(message, repoUrl = null) + + // Assert + assertThat(result).isNotNull().prop(ConventionalCommit::description).isEqualTo("add awesome feature (#123)") + } + + @Test + fun `given commit with multiple PR numbers when parsing then links all PRs`() { + // Arrange + val message = "fix: fix issue (#456) and (#789)" + + // Act + val result = parser.parse(message, repoUrl) + + // Assert + assertThat( + result, + ).isNotNull().prop( + ConventionalCommit::description, + ).isEqualTo( + "fix issue ([#456](https://github.com/thunderbird/thunderbird-mobile-components/pull/456)) and ([#789](https://github.com/thunderbird/thunderbird-mobile-components/pull/789))", + ) + } + + @Test + fun `given merge commit with PR number when parsing then links PR`() { + // Arrange + val message = "chore(build): update build-plugin (#4)" + + // Act + val result = parser.parse(message, repoUrl) + + // Assert + assertThat(result).isNotNull().all { + prop(ConventionalCommit::type).isEqualTo(SectionType.Chores) + prop( + ConventionalCommit::description, + ).isEqualTo( + "update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4))", + ) + } + } + + @Test + fun `given invalid commit when parsing then returns null`() { + // Arrange + val message = "not a conventional commit" + + // Act + val result = parser.parse(message) + + // Assert + assertThat(result).isNull() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParserTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParserTest.kt new file mode 100644 index 0000000..943be1d --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ChangelogParserTest.kt @@ -0,0 +1,44 @@ +package net.thunderbird.gradle.plugin.changelog.internal.parser + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import kotlin.test.Test +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +class ChangelogParserTest { + private val parser = ChangelogParser() + + @Test + fun `parse handles full rendered changelog`() { + // Arrange + val content = """ + # Changelog + + All notable changes to this component will be documented in this file. + + ## Unreleased + + ### Chores + + - update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4)) + """.trimIndent() + + // Act + val changelog = parser.parse(content) + + // Assert + assertThat(changelog).isNotNull().given { parsed -> + assertThat(parsed.header.title).isEqualTo("Changelog") + assertThat(parsed.header.descriptions.map { it.text }).isEqualTo( + listOf("All notable changes to this component will be documented in this file."), + ) + assertThat(parsed.releases.single().version).isEqualTo("Unreleased") + assertThat(parsed.releases.single().sections.getValue(SectionType.Chores).map { it.text }).isEqualTo( + listOf( + "update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4))", + ), + ) + } + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParserTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParserTest.kt new file mode 100644 index 0000000..2f576e3 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/parser/ReleaseParserTest.kt @@ -0,0 +1,61 @@ +package net.thunderbird.gradle.plugin.changelog.internal.parser + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import kotlin.test.Test +import kotlinx.datetime.LocalDate +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +class ReleaseParserTest { + private val parser = ReleaseParser() + + @Test + fun `parse handles rendered unreleased release with sections`() { + // Arrange + val lines = listOf( + "## Unreleased ", + "", + "### Chores", + "", + "- update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4))", + ) + + // Act + val release = parser.parse(lines) + + // Assert + assertThat(release).isNotNull().given { parsed -> + assertThat(parsed.version).isEqualTo("Unreleased") + assertThat(parsed.date).isNull() + assertThat(parsed.sections.getValue(SectionType.Chores).map { it.text }).isEqualTo( + listOf( + "update build-plugin ([#4](https://github.com/thunderbird/thunderbird-mobile-components/pull/4))", + ), + ) + } + } + + @Test + fun `parse handles bracketed dated release`() { + // Arrange + val lines = listOf( + "## [1.2.3] - 2026-06-18", + "", + "### Bug Fixes", + "", + "- fix crash", + ) + + // Act + val release = parser.parse(lines) + + // Assert + assertThat(release).isNotNull().given { parsed -> + assertThat(parsed.version).isEqualTo("1.2.3") + assertThat(parsed.date).isEqualTo(LocalDate(2026, 6, 18)) + assertThat(parsed.sections.getValue(SectionType.BugFixes).map { it.text }).isEqualTo(listOf("fix crash")) + } + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRendererTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRendererTest.kt new file mode 100644 index 0000000..fe24c4c --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/render/MarkdownChangelogRendererTest.kt @@ -0,0 +1,55 @@ +package net.thunderbird.gradle.plugin.changelog.internal.render + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.gradle.plugin.changelog.internal.Changelog +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.Header +import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.changelog.internal.SectionType + +class MarkdownChangelogRendererTest { + private val renderer = MarkdownChangelogRenderer() + + @Test + fun `render writes header releases sections and entries`() { + // Arrange + val changelog = Changelog( + header = Header( + title = "Changelog", + descriptions = listOf(ChangelogEntry("All notable changes are documented here.")), + ), + releases = listOf( + Release( + version = "Unreleased", + date = null, + sections = mapOf( + SectionType.Chores to + listOf(ChangelogEntry("update build-plugin ([#4](https://example.test/pull/4))")), + ), + ), + ), + ) + + // Act + val markdown = renderer.render(changelog) + + // Assert + assertThat(markdown).isEqualTo( + """ + # Changelog + + All notable changes are documented here. + + ## Unreleased + + ### Chores + + - update build-plugin ([#4](https://example.test/pull/4)) + + + """.trimIndent(), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 118667c..d3a01d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.tb.changelog) alias(libs.plugins.tb.dependency.check) alias(libs.plugins.tb.quality.code.coverage) alias(libs.plugins.tb.quality.detekt) diff --git a/components/bom/CHANGELOG.md b/components/bom/CHANGELOG.md new file mode 100644 index 0000000..e2c1cae --- /dev/null +++ b/components/bom/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this component will be documented in this file. + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. + +Changelog entries are derived from commit history and grouped by commit type. + +## Unreleased + diff --git a/components/bom/build.gradle.kts b/components/bom/build.gradle.kts index b9434d0..ede81e5 100644 --- a/components/bom/build.gradle.kts +++ b/components/bom/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-platform` + id("net.thunderbird.gradle.plugin.changelog") id("net.thunderbird.gradle.plugin.publishing") id("net.thunderbird.gradle.plugin.versioning") } diff --git a/components/bom/version.properties b/components/bom/version.properties index 518f6dc..b753846 100644 --- a/components/bom/version.properties +++ b/components/bom/version.properties @@ -1,5 +1,5 @@ # Generated by VersioningPlugin -# 2025-12-15T11:09:13Z +# 2025-12-16T11:09:13Z MAJOR=0 MINOR=1 PATCH=0 diff --git a/docs/commit-guide.md b/docs/commit-guide.md new file mode 100644 index 0000000..85dd3a6 --- /dev/null +++ b/docs/commit-guide.md @@ -0,0 +1,106 @@ +# Git Commit Guide + +Use [Conventional Commits](https://www.conventionalcommits.org/) to write consistent and meaningful commit messages. +This makes your work easier to review, track, and maintain for everyone involved in the project. + +## ✍️ Commit Message Format + +```git +(): + + + + +``` + +Components: + +- ``: The [type of change](#-commit-types) being made (e.g., feat, fix, docs). +- `` **(optional)**: The [scope](#optional-scope) indicates the area of the codebase affected by the change (e.g., auth, ui). +- ``: Short description of the change (50 characters or less) +- `` **(optional)**: Explain what changed and why, include context if helpful. +- `` **(optional)**: Include issue references, breaking changes, etc. + +### Examples + +Basic: + +```git +feat: add QR code scanner +``` + +With scope: + +```git +feat(auth): add login functionality +``` + +With body and issue reference: + +```git +fix(api): handle null response from login endpoint + +Checks for missing tokens to prevent app crash during login. + +Fixes #123 +``` + +### 🏷️ Commit Types + +| Type | Use for... | Example | +|------------|----------------------------------|-------------------------------------------| +| `feat` | New features | `feat(camera): add zoom support` | +| `fix` | Bug fixes | `fix(auth): handle empty username crash` | +| `docs` | Documentation only | `docs(readme): update setup instructions` | +| `style` | Code style (no logic changes) | `style: reformat settings screen` | +| `refactor` | Code changes (no features/fixes) | `refactor(nav): simplify stack setup` | +| `test` | Adding/editing tests | `test(api): add unit test for login` | +| `chore` | Tooling, CI, dependencies | `chore(ci): update GitHub Actions config` | +| `revert` | Reverting previous commits | `revert: remove feature flag` | + +### 📍Optional Scope + +The **scope** is optional but recommended for clarity, especially for large changes or or when multiple areas of the +codebase are involved. + +| Scope | Use for... | Example | +|------------|----------------|------------------------------------------| +| `auth` | Authentication | `feat(auth): add login functionality` | +| `settings` | User settings | `feat(settings): add dark mode toggle` | +| `build` | Build system | `fix(build): improve build performance` | +| `ui` | UI/theme | `refactor(ui): split theme into modules` | +| `deps` | Dependencies | `chore(deps): bump Kotlin to 2.0.0` | + +## 🧠 Best Practices + +### 1. One Commit, One Purpose + +- ✅ Each commit should represent a single logical change or addition to the codebase. +- ❌ Don’t mix unrelated changes together (e.g., fixing a bug and updating docs, or changing a style and ) + adding a feature). + +### 2. Keep It Manageable + +- ✅ Break up large changes into smaller, more manageable commits. +- ✅ If a commit changes more than 200 lines of code, consider breaking it up. +- ❌ Avoid massive, hard-to-review commits. + +### 3. Keep It Working + +- ✅ Each commit should leave the codebase in a buildable and testable state. +- ❌ Never commit broken code or failing tests. + +### 4. Think About Reviewers (and Future You) + +- ✅ Write messages for your teammates and future self, assuming they have no context. +- ✅ Explain non-obvious changes or decisions in the message body. +- ✅ Consider the commit as a documentation tool. +- ❌ Avoid jargon, acronyms, or vague messages like `update stuff`. + +## Summary + +- Use [Conventional Commits](#-conventional-commits) for consistency. +- Keep commit messages short, structured, and focused. +- Make each commit purposeful and self-contained. +- Write commits that make collaboration and future development easier for everyone—including you. + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a00c8f3..8c94f25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ [versions] androidGradlePlugin = "8.13.2" androidXActivity = "1.10.1" +assertk = "0.28.1" dependencyCheckPlugin = "0.53.0" detektPlugin = "1.23.8" detektPluginCompose = "0.5.8" @@ -49,9 +50,10 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPlugin" } # Build plugins tb-app-kmp-compose = { id = "net.thunderbird.gradle.plugin.app.kmp.compose" } +tb-changelog = { id = "net.thunderbird.gradle.plugin.changelog" } +tb-dependency-check = { id = "net.thunderbird.gradle.plugin.dependency.check" } tb-library-kmp = { id = "net.thunderbird.gradle.plugin.library.kmp" } tb-library-kmp-compose = { id = "net.thunderbird.gradle.plugin.library.kmp.compose" } -tb-dependency-check = { id = "net.thunderbird.gradle.plugin.dependency.check" } tb-publishing = { id = "net.thunderbird.gradle.plugin.publishing" } tb-quality-code-coverage = { id = "net.thunderbird.gradle.plugin.quality.coverage" } tb-quality-detekt = { id = "net.thunderbird.gradle.plugin.quality.detekt" } @@ -60,6 +62,7 @@ tb-versioning = { id = "net.thunderbird.gradle.plugin.versioning" } [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidXActivity" } +assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } detekt-plugin-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektPluginCompose" } jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } jetbrains-compose-components-ui-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } From 9047e3d1817c5fc522862d78926f8e4d68c29b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 18 Jun 2026 15:23:28 +0200 Subject: [PATCH 02/17] chore(workflows): add PR title validation workflow --- .github/workflows/validate-pr-title.yml | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/validate-pr-title.yml diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml new file mode 100644 index 0000000..a123dfd --- /dev/null +++ b/.github/workflows/validate-pr-title.yml @@ -0,0 +1,47 @@ +name: Validate - Pull Request Title + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: none + +concurrency: + group: validate-pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate-pr-title: + name: Validate PR title + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Validate Conventional Commit title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + + pattern='^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|deps|revert)(\([a-zA-Z0-9._ -]+\))?!?: .+$' + + if [[ ! "$PR_TITLE" =~ $pattern ]]; then + cat <<'MESSAGE' + Pull request title must use Conventional Commit format because release changelogs are generated from PR merge commits. + + Expected format: + (): + + Examples: + feat(account): add OAuth setup + fix(build): restore publishing metadata + chore(deps): update Kotlin + + Allowed types: + feat, fix, docs, style, refactor, perf, test, chore, build, ci, deps, revert + MESSAGE + echo + echo "Actual title: $PR_TITLE" + exit 1 + fi From eee50b09dff8958400f3cd9925d527a261a2ff00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 18 Jun 2026 15:23:46 +0200 Subject: [PATCH 03/17] chore(docs): add release guide and link it in contributing guidelines --- CONTRIBUTING.md | 5 +++ docs/release-guide.md | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 docs/release-guide.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c84c18f..d1049ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,11 @@ Before you start contributing, please take a moment to familiarize yourself with We follow [Conventional Commits](https://www.conventionalcommits.org/). Please read our [Git Commit Guide](docs/commit-guide.md) for examples, allowed types, scopes, and best practices. +### Release Guide + +Maintainers and release owners should read the [Release Guide](docs/release-guide.md) for the +release-preparation workflow, changelog generation, and tagging conventions. + ## Bug Reports and Feature Requests If you encounter a bug or have a feature request, please follow these steps: diff --git a/docs/release-guide.md b/docs/release-guide.md new file mode 100644 index 0000000..8d60013 --- /dev/null +++ b/docs/release-guide.md @@ -0,0 +1,74 @@ +# Release Guide + +This guide describes the release workflow for Thunderbird Mobile Components. + +## Prerequisites + +- Start from an up-to-date `main` branch. + +## Release Workflow + +1. Run the changelog update task for the component being released. +2. Review the generated `Unreleased` changelog entries. +3. Apply the release version changes. +4. Finalize the changelog for the release version. +5. Open a release pull request with the changelog and version changes. +6. Merge the release pull request. +7. Create the component release tag from the merged release commit. +8. Publish the release. + +## Changelog Tasks + +For a component, run the changelog task on that component project. Example for `:components:bom`: + +```bash +./gradlew :components:bom:updateChangelog +``` + +The changelog is written next to the component `version.properties` file. + +After reviewing the generated entries, finalize the `Unreleased` section: + +```bash +./gradlew :components:bom:finalizeChangelog -PreleaseVersion=1.0.0 +``` + +To use a specific release date: + +```bash +./gradlew :components:bom:finalizeChangelog -PreleaseVersion=1.0.0 -PreleaseDate=2026-06-18 +``` + +The finalize task updates the changelog only. It does not create a git tag. + +## Release Tags + +After the release pull request has been merged, update `main` to the merged release commit and create the component +release tag. Example for `:components:bom`: + +```bash +./gradlew :components:bom:createReleaseTag +``` + +The task reads the component version from `version.properties` and creates a local git tag in this format: + +```text +- +``` + +For example: + +```text +bom-1.0.0 +``` + +The task fails if the component version is still a snapshot or the tag already exists. + +## Review Checklist + +Before merging the release pull request, verify: + +- The changelog contains only entries for the release being prepared. +- Entries are grouped under the expected sections. +- The release version and changelog version match. + From 67df55be1e8a0a36c0720002601f59089ee8ee35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 18 Jun 2026 15:46:44 +0200 Subject: [PATCH 04/17] chore(build): remove versioning snapshot toggling and add release tagging --- build-plugin/README.md | 14 ++ .../plugin/versioning/CreateReleaseTagTask.kt | 75 ++++++++ .../plugin/versioning/PrintVersionTask.kt | 5 +- .../plugin/versioning/ToggleSnapshotTask.kt | 50 ------ .../plugin/versioning/VersioningPlugin.kt | 31 ++-- .../versioning/internal/GitVersionProvider.kt | 26 +++ .../versioning/internal/GitVersionReader.kt | 35 ++++ .../versioning/internal/GitVersionResolver.kt | 26 +++ .../internal/ProviderBackedVersion.kt | 9 + .../plugin/versioning/internal/Version.kt | 3 - .../versioning/internal/VersionManager.kt | 13 +- .../internal/VersionPropertiesMapper.kt | 4 +- .../plugin/changelog/ChangelogPluginTest.kt | 3 +- .../changelog/FinalizeChangelogTaskTest.kt | 1 - .../changelog/UpdateChangelogTaskTest.kt | 1 - .../changelog/internal/fs/FileHelperTest.kt | 1 - .../versioning/CreateReleaseTagTaskTest.kt | 110 ++++++++++++ .../plugin/versioning/PrintVersionTaskTest.kt | 58 +++++++ .../plugin/versioning/VersionBumpTaskTest.kt | 146 ++++++++++++++++ .../plugin/versioning/VersioningPluginTest.kt | 160 ++++++++++++++++++ .../internal/GitVersionReaderTest.kt | 121 +++++++++++++ .../internal/GitVersionResolverTest.kt | 56 ++++++ .../internal/PropertiesWriterTest.kt | 63 +++++++ .../versioning/internal/VersionManagerTest.kt | 152 +++++++++++++++++ .../internal/VersionPropertiesMapperTest.kt | 87 ++++++++++ .../plugin/versioning/internal/VersionTest.kt | 56 ++++++ components/bom/version.properties | 1 - docs/release-guide.md | 2 +- 28 files changed, 1225 insertions(+), 84 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTask.kt delete mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/ToggleSnapshotTask.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionProvider.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReader.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolver.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/ProviderBackedVersion.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTaskTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTaskTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersionBumpTaskTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReaderTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolverTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/PropertiesWriterTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManagerTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapperTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionTest.kt diff --git a/build-plugin/README.md b/build-plugin/README.md index badb765..3550ab9 100644 --- a/build-plugin/README.md +++ b/build-plugin/README.md @@ -32,6 +32,20 @@ Supportive/quality and tooling plugins: - Common tasks: `./gradlew koverHtmlReport`, `./gradlew koverXmlReport`, `./gradlew koverVerify` - `net.thunderbird.gradle.plugin.publishing` — Configures publishing (Maven coordinates, POM metadata, and common repositories like `mavenLocal()` and a local build repo under `build/maven-repo`) +- `net.thunderbird.gradle.plugin.versioning` — Reads and updates component versions from the nearest + `version.properties`. + - Version resolution: builds from a commit tagged with `-` use ``; + all other builds use `-SNAPSHOT`. + - Tasks: + - `versionBumpMajor`, `versionBumpMinor`, `versionBumpPatch`: update the nearest `version.properties`. + - `printVersion`: prints the version resolved from the nearest `version.properties`. + - `createReleaseTag`: creates the component release git tag from `version.properties`. + - Format: `-` (for example `bom-1.0.0`) + - Validation: fails for existing tags. + - Release tag command: + ```bash + ./gradlew :components:bom:createReleaseTag + ``` - `net.thunderbird.gradle.plugin.changelog` — Maintains component‑local `CHANGELOG.md` files next to each component’s nearest `version.properties` (modules that share the same nearest `version.properties` directory are grouped together). diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTask.kt new file mode 100644 index 0000000..8753c75 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTask.kt @@ -0,0 +1,75 @@ +package net.thunderbird.gradle.plugin.versioning + +import java.io.File +import net.thunderbird.gradle.plugin.versioning.internal.VersionManager +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Creates the component release tag for the version resolved from version.properties. + */ +abstract class CreateReleaseTagTask : DefaultTask() { + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val startDir: DirectoryProperty + + @get:Internal + abstract val repoRootDir: DirectoryProperty + + init { + outputs.upToDateWhen { false } + } + + @TaskAction + fun createReleaseTag() { + val versionManager = VersionManager( + base = startDir.get().asFile, + root = repoRootDir.get().asFile, + ) + val version = versionManager.get() + + val versionFile = versionManager.sourceFile() + ?: error("No version.properties file found to create a release tag.") + val tagName = "${versionFile.parentFile.name}-${version.toStringValue()}" + val repoRoot = repoRootDir.get().asFile + + require(!tagExists(repoRoot, tagName)) { + "Release tag '$tagName' already exists." + } + + runGit(repoRoot, "tag", tagName) + + logger.lifecycle("[versioning] Created release tag $tagName") + } + + private fun tagExists(repoRoot: File, tagName: String): Boolean { + val command = + listOf("git", "-C", repoRoot.absolutePath, "rev-parse", "--verify", "--quiet", "refs/tags/$tagName") + return ProcessBuilder(command) + .redirectErrorStream(true) + .start() + .waitFor() == 0 + } + + private fun runGit(repoRoot: File, vararg args: String) { + val command = listOf("git", "-C", repoRoot.absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n$output" + } + } + + companion object { + const val TASK_NAME = "createReleaseTag" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTask.kt index 2048709..84d9393 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTask.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTask.kt @@ -1,5 +1,6 @@ package net.thunderbird.gradle.plugin.versioning +import net.thunderbird.gradle.plugin.versioning.internal.GitVersionReader import net.thunderbird.gradle.plugin.versioning.internal.VersionManager import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty @@ -27,8 +28,10 @@ abstract class PrintVersionTask : DefaultTask() { root = root, ) val version = versionManager.get() + val versionFile = versionManager.sourceFile() + ?: error("No version.properties file found to print the project version.") - println(version.toStringValue()) + println(GitVersionReader().read(root, versionFile, version)) } companion object { diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/ToggleSnapshotTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/ToggleSnapshotTask.kt deleted file mode 100644 index 25d98dc..0000000 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/ToggleSnapshotTask.kt +++ /dev/null @@ -1,50 +0,0 @@ -package net.thunderbird.gradle.plugin.versioning - -import java.io.File -import net.thunderbird.gradle.plugin.versioning.internal.VersionManager -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -/** - * Configuration-cache–friendly task to toggle the SNAPSHOT flag in the nearest version.properties. - */ -abstract class ToggleSnapshotTask : DefaultTask() { - - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val startDir: DirectoryProperty - - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val repoRootDir: DirectoryProperty - - @TaskAction - fun toggle() { - val base = startDir.asFile.get() - val root = repoRootDir.asFile.get() - val versionManager = VersionManager( - base = base, - root = root, - ) - - val version = versionManager.get() - val toggled = version.toggleSnapshot() - - versionManager.update(toggled) - - logger.lifecycle("[versioning] Set SNAPSHOT=${toggled.snapshot} for (version=${toggled.toStringValue()})") - } - - private fun relativeToRoot(file: File, root: File): String = try { - file.relativeTo(root).path - } catch (_: IllegalArgumentException) { - file.path - } -} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt index 43b5a38..c9fef29 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt @@ -1,5 +1,7 @@ package net.thunderbird.gradle.plugin.versioning +import net.thunderbird.gradle.plugin.versioning.internal.GitVersionProvider +import net.thunderbird.gradle.plugin.versioning.internal.ProviderBackedVersion import net.thunderbird.gradle.plugin.versioning.internal.VersionManager import org.gradle.api.Plugin import org.gradle.api.Project @@ -12,7 +14,7 @@ import org.gradle.kotlin.dsl.register * - Sets the project version at configuration time based on the nearest version.properties file. * - Resolution is per-project by walking up from each project’s directory to the repo root * and using the nearest version.properties file. - * - Provides tasks to bump version parts and toggle SNAPSHOT status. + * - Provides tasks to bump version parts and create release tags. * * Usage: * - Apply the plugin in your build.gradle.kts: `plugins { id("net.thunderbird.gradle.plugin.versioning") }` @@ -23,7 +25,6 @@ import org.gradle.kotlin.dsl.register * - versionBumpMajor: Bump MAJOR version. * - versionBumpMinor: Bump MINOR version. * - versionBumpPatch: Bump PATCH version. - * - versionToggleSnapshot: Toggle SNAPSHOT status. * - printVersion: Print the effective project version. */ class VersioningPlugin : Plugin { @@ -32,6 +33,7 @@ class VersioningPlugin : Plugin { configureVersioning() registerBumpTasks() registerPrintVersionTask() + registerCreateReleaseTagTask() } } @@ -41,9 +43,13 @@ class VersioningPlugin : Plugin { base = projectDir, root = root.projectDir, ) - val versionString = versionManager.get() - this.version = versionString - logger.lifecycle("[versioning] Set project version to $versionString") + val version = versionManager.get() + val versionFile = versionManager.sourceFile() + ?: error("No version.properties file found to resolve the project version.") + val versionProvider = GitVersionProvider(providers).resolve(root.projectDir, versionFile, version) + + this.version = ProviderBackedVersion(versionProvider) + logger.lifecycle("[versioning] Project version will be resolved from ${versionFile.path}") } private fun Project.registerBumpTasks() { @@ -68,12 +74,6 @@ class VersioningPlugin : Plugin { repoRootDir.set(project.rootProject.layout.projectDirectory) part.set("patch") } - tasks.register("versionToggleSnapshot") { - group = "versioning" - description = "Toggle SNAPSHOT in nearest version.properties" - startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) - } } private fun Project.registerPrintVersionTask() { @@ -84,4 +84,13 @@ class VersioningPlugin : Plugin { repoRootDir.set(project.rootProject.layout.projectDirectory) } } + + private fun Project.registerCreateReleaseTagTask() { + tasks.register(CreateReleaseTagTask.TASK_NAME) { + group = "release" + description = "Create the component release git tag from version.properties" + startDir.set(project.layout.projectDirectory) + repoRootDir.set(project.rootProject.layout.projectDirectory) + } + } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionProvider.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionProvider.kt new file mode 100644 index 0000000..26b9668 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionProvider.kt @@ -0,0 +1,26 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import java.io.File +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory + +internal class GitVersionProvider( + private val providers: ProviderFactory, + private val resolver: GitVersionResolver = GitVersionResolver(), +) { + + fun resolve( + repoRoot: File, + versionFile: File, + version: Version, + ): Provider { + val tagName = resolver.tagName(versionFile, version) + + return providers.exec { + commandLine("git", "-C", repoRoot.absolutePath, "tag", "--points-at", "HEAD", "--list", tagName) + isIgnoreExitValue = true + }.standardOutput.asText.map { tagOutput -> + resolver.resolve(version, tagName, tagOutput) + } + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReader.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReader.kt new file mode 100644 index 0000000..49b58d4 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReader.kt @@ -0,0 +1,35 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import java.io.File + +internal class GitVersionReader( + private val resolver: GitVersionResolver = GitVersionResolver(), +) { + + fun read( + repoRoot: File, + versionFile: File, + version: Version, + ): String { + val tagName = resolver.tagName(versionFile, version) + val tagOutput = tagsPointingAtHead(repoRoot, tagName) + return resolver.resolve(version, tagName, tagOutput) + } + + private fun tagsPointingAtHead(repoRoot: File, tagName: String): String { + val command = listOf("git", "-C", repoRoot.absolutePath, "tag", "--points-at", "HEAD", "--list", tagName) + return try { + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + if (process.waitFor() == 0) { + output.joinToString("\n") + } else { + "" + } + } catch (_: Exception) { + "" + } + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolver.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolver.kt new file mode 100644 index 0000000..4ed7238 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolver.kt @@ -0,0 +1,26 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import java.io.File + +internal class GitVersionResolver { + + fun resolve( + version: Version, + tagName: String, + tagOutput: String, + ): String { + val releaseVersion = version.toStringValue() + return if (tagOutput.lineSequence().any { it.trim() == tagName }) { + releaseVersion + } else { + "$releaseVersion-SNAPSHOT" + } + } + + fun tagName( + versionFile: File, + version: Version, + ): String { + return "${versionFile.parentFile.name}-${version.toStringValue()}" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/ProviderBackedVersion.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/ProviderBackedVersion.kt new file mode 100644 index 0000000..8e0963a --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/ProviderBackedVersion.kt @@ -0,0 +1,9 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import org.gradle.api.provider.Provider + +internal class ProviderBackedVersion( + private val provider: Provider, +) { + override fun toString(): String = provider.get() +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/Version.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/Version.kt index 0557128..a8d2cbc 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/Version.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/Version.kt @@ -4,15 +4,12 @@ internal data class Version( val major: Int, val minor: Int, val patch: Int, - val snapshot: Boolean, ) { fun toStringValue(): String = buildString { append(major).append('.').append(minor).append('.').append(patch) - if (snapshot) append("-SNAPSHOT") } fun bumpMajor(): Version = copy(major = major + 1, minor = 0, patch = 0) fun bumpMinor(): Version = copy(minor = minor + 1, patch = 0) fun bumpPatch(): Version = copy(patch = patch + 1) - fun toggleSnapshot(): Version = copy(snapshot = !snapshot) } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManager.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManager.kt index 3651654..612324c 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManager.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManager.kt @@ -2,8 +2,6 @@ package net.thunderbird.gradle.plugin.versioning.internal import java.io.File import java.util.Properties -import net.thunderbird.gradle.plugin.versioning.internal.Version -import net.thunderbird.gradle.plugin.versioning.internal.VersionPropertiesMapper /** * Internal utility to centralize version file discovery, parsing, and persistence. @@ -30,7 +28,7 @@ internal class VersionManager( } throw IllegalStateException( "[versioning] No version.properties found between ${base.path} and ${root.path}. " + - "Create one with MAJOR, MINOR, PATCH and optional SNAPSHOT (defaults to true).", + "Create one with MAJOR, MINOR and PATCH.", ) } @@ -43,21 +41,18 @@ internal class VersionManager( } } + fun sourceFile(): File? = source + private fun locateNearestVersionFile(start: File, repoRoot: File): File? { var dir: File? = start while (dir != null) { val candidate = File(dir, FILE_NAME) if (candidate.exists()) { - // Log the located version.properties file for visibility - println("[versioning] Using version file: ${candidate.path}") return candidate } if (dir == repoRoot) break dir = dir.parentFile } - println( - "[versioning] No version.properties found for ${start.path} and nearest parents up to ${repoRoot.path}.", - ) return null } @@ -67,7 +62,7 @@ internal class VersionManager( if (parsed != null) return parsed throw IllegalStateException( "[versioning] Invalid version.properties at ${file.path}. " + - "Expected keys: MAJOR, MINOR, PATCH and optional SNAPSHOT (defaults to true).", + "Expected keys: MAJOR, MINOR and PATCH.", ) } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapper.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapper.kt index 5a5c6c5..e4320b5 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapper.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapper.kt @@ -13,8 +13,7 @@ internal class VersionPropertiesMapper { val major = properties["MAJOR"]?.toString()?.toIntOrNull() ?: return null val minor = properties["MINOR"]?.toString()?.toIntOrNull() ?: return null val patch = properties["PATCH"]?.toString()?.toIntOrNull() ?: return null - val snapshot = properties["SNAPSHOT"]?.toString()?.equals("true", ignoreCase = true) ?: true - return Version(major, minor, patch, snapshot) + return Version(major, minor, patch) } fun from(version: Version): Properties { @@ -22,7 +21,6 @@ internal class VersionPropertiesMapper { properties["MAJOR"] = version.major.toString() properties["MINOR"] = version.minor.toString() properties["PATCH"] = version.patch.toString() - properties["SNAPSHOT"] = version.snapshot.toString() return properties } } diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt index 72f6f32..8b2b163 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt @@ -49,7 +49,7 @@ class ChangelogPluginTest { fixture.project.plugins.apply(VersioningPlugin::class.java) // Assert - assertThat(fixture.project.version.toString()).isEqualTo("1.2.3") + assertThat(fixture.project.version.toString()).isEqualTo("1.2.3-SNAPSHOT") assertThat(fixture.project.tasks.findByName(UpdateChangelogTask.TASK_NAME)).isNotNull() assertThat(fixture.project.tasks.findByName("versionBumpPatch")).isNotNull() } @@ -112,7 +112,6 @@ class ChangelogPluginTest { MAJOR=1 MINOR=2 PATCH=3 - SNAPSHOT=false """.trimIndent() } } diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt index a3ab550..8f949c5 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt @@ -107,7 +107,6 @@ class FinalizeChangelogTaskTest { MAJOR=0 MINOR=1 PATCH=0 - SNAPSHOT=false """.trimIndent() private val EMPTY_UNRELEASED = """ diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt index 7ba3651..43b398e 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/UpdateChangelogTaskTest.kt @@ -162,7 +162,6 @@ class UpdateChangelogTaskTest { MAJOR=1 MINOR=0 PATCH=0 - SNAPSHOT=false """.trimIndent() private val CHANGELOG_WITH_RELEASE = """ diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt index 9b12c7b..c49daec 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/internal/fs/FileHelperTest.kt @@ -114,7 +114,6 @@ class FileHelperTest { MAJOR=1 MINOR=0 PATCH=0 - SNAPSHOT=false """.trimIndent() } } diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTaskTest.kt new file mode 100644 index 0000000..a4d7bb0 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/CreateReleaseTagTaskTest.kt @@ -0,0 +1,110 @@ +package net.thunderbird.gradle.plugin.versioning + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isNotNull +import assertk.assertions.messageContains +import java.io.File +import kotlin.test.Test +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class CreateReleaseTagTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `versioning plugin registers createReleaseTag task`() { + // Arrange + val repo = createRepository(VERSION_PROPERTIES) + val project = ProjectBuilder.builder() + .withProjectDir(repo.resolve("components/bom")) + .build() + + // Act + project.plugins.apply(VersioningPlugin::class.java) + + // Assert + assertThat(project.tasks.findByName(CreateReleaseTagTask.TASK_NAME)).isNotNull() + } + + @Test + fun `createReleaseTag creates component tag from version properties`() { + // Arrange + val repo = createRepository(VERSION_PROPERTIES) + val task = createTask(repo) + + // Act + task.createReleaseTag() + + // Assert + assertThat(repo.runGit("tag", "--list")).contains("bom-1.2.3") + } + + @Test + fun `createReleaseTag fails when tag already exists`() { + // Arrange + val repo = createRepository(VERSION_PROPERTIES) + repo.runGit("tag", "bom-1.2.3") + val task = createTask(repo) + + // Act + val failure = assertFailure { task.createReleaseTag() } + + // Assert + failure.messageContains("Release tag 'bom-1.2.3' already exists") + } + + private fun createRepository(versionProperties: String): File { + val repo = temporaryFolder.newFolder("create-release-tag-test") + repo.runGit("init") + repo.runGit("config", "user.email", "test@example.com") + repo.runGit("config", "user.name", "Test User") + repo.runGit("config", "commit.gpgsign", "false") + + val componentDir = repo.resolve("components/bom") + componentDir.mkdirs() + componentDir.resolve("version.properties").writeText(versionProperties) + componentDir.resolve("file.txt").writeText("initial") + repo.runGit("add", ".") + repo.runGit("commit", "-m", "chore: initial") + + return repo + } + + private fun createTask(repo: File): CreateReleaseTagTask { + val componentDir = repo.resolve("components/bom") + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .build() + + return project.tasks.create(CreateReleaseTagTask.TASK_NAME, CreateReleaseTagTask::class.java).apply { + startDir.set(project.layout.projectDirectory) + repoRootDir.set(repo) + } + } + + private fun File.runGit(vararg args: String): List { + val command = listOf("git", "-C", absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n${output.joinToString("\n")}" + } + return output + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTaskTest.kt new file mode 100644 index 0000000..cc84720 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintVersionTaskTest.kt @@ -0,0 +1,58 @@ +package net.thunderbird.gradle.plugin.versioning + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import kotlin.test.Test +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PrintVersionTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `print writes resolved version to standard output`() { + // Arrange + val rootDir = temporaryFolder.newFolder("print-version-task-test") + val componentDir = rootDir.resolve("components/bom") + componentDir.mkdirs() + componentDir.resolve("version.properties").writeText(VERSION_PROPERTIES) + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .build() + val task = project.tasks.create(PrintVersionTask.TASK_NAME, PrintVersionTask::class.java).apply { + startDir.set(project.layout.projectDirectory) + repoRootDir.set(rootDir) + } + + // Act + val output = captureStandardOut { task.print() } + + // Assert + assertThat(output.trim()).isEqualTo("1.2.3-SNAPSHOT") + } + + private fun captureStandardOut(block: () -> Unit): String { + val originalOut = System.out + val buffer = ByteArrayOutputStream() + System.setOut(PrintStream(buffer)) + try { + block() + } finally { + System.setOut(originalOut) + } + return buffer.toString() + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersionBumpTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersionBumpTaskTest.kt new file mode 100644 index 0000000..67cfb08 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersionBumpTaskTest.kt @@ -0,0 +1,146 @@ +package net.thunderbird.gradle.plugin.versioning + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import java.io.File +import java.util.Properties +import kotlin.test.Test +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class VersionBumpTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `bump increments major and resets minor and patch`() { + // Arrange + val fixture = createComponentProject() + val task = createTask(fixture, part = "major") + + // Act + task.bump() + + // Assert + assertThat(fixture.versionProperties.readProperties()).isEqualTo( + mapOf( + "MAJOR" to "2", + "MINOR" to "0", + "PATCH" to "0", + ), + ) + } + + @Test + fun `bump increments minor and resets patch`() { + // Arrange + val fixture = createComponentProject() + val task = createTask(fixture, part = "minor") + + // Act + task.bump() + + // Assert + assertThat(fixture.versionProperties.readProperties()).isEqualTo( + mapOf( + "MAJOR" to "1", + "MINOR" to "3", + "PATCH" to "0", + ), + ) + } + + @Test + fun `bump increments patch`() { + // Arrange + val fixture = createComponentProject() + val task = createTask(fixture, part = "patch") + + // Act + task.bump() + + // Assert + assertThat(fixture.versionProperties.readProperties()).isEqualTo( + mapOf( + "MAJOR" to "1", + "MINOR" to "2", + "PATCH" to "4", + ), + ) + } + + @Test + fun `bump fails for invalid part`() { + // Arrange + val fixture = createComponentProject() + val task = createTask(fixture, part = "build") + + // Act + val failure = assertFailure { task.bump() } + + // Assert + failure.isInstanceOf() + failure.messageContains("Invalid part to bump: build") + } + + private fun createComponentProject(): ComponentProject { + val rootDir = temporaryFolder.newFolder("version-bump-task-test") + val componentDir = rootDir.resolve("components/bom") + componentDir.mkdirs() + val versionProperties = componentDir.resolve("version.properties") + versionProperties.writeText(VERSION_PROPERTIES) + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .build() + + return ComponentProject( + rootDir = rootDir, + componentDir = componentDir, + versionProperties = versionProperties, + project = project, + ) + } + + private fun createTask( + fixture: ComponentProject, + part: String, + ): VersionBumpTask { + return fixture.project.tasks.create( + "versionBump${part.replaceFirstChar { + it.uppercase() + }}", + VersionBumpTask::class.java, + ) + .apply { + startDir.set(fixture.project.layout.projectDirectory) + repoRootDir.set(fixture.rootDir) + this.part.set(part) + } + } + + private fun File.readProperties(): Map { + val properties = Properties() + inputStream().use(properties::load) + return properties.entries.associate { (key, value) -> key.toString() to value.toString() } + } + + private data class ComponentProject( + val rootDir: File, + val componentDir: File, + val versionProperties: File, + val project: org.gradle.api.Project, + ) + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt new file mode 100644 index 0000000..9649f8c --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt @@ -0,0 +1,160 @@ +package net.thunderbird.gradle.plugin.versioning + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import java.io.File +import kotlin.test.Test +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class VersioningPluginTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `apply uses snapshot version when head does not have release tag`() { + // Arrange + val fixture = createNestedComponentProject(VERSION_PROPERTIES) + + // Act + fixture.project.plugins.apply(VersioningPlugin::class.java) + + // Assert + assertThat(fixture.project.version.toString()).isEqualTo("1.2.3-SNAPSHOT") + } + + @Test + fun `apply uses release version when head has component release tag`() { + // Arrange + val fixture = createNestedComponentProject(VERSION_PROPERTIES) + fixture.rootDir.runGit("init") + fixture.rootDir.runGit("config", "user.email", "test@example.com") + fixture.rootDir.runGit("config", "user.name", "Test User") + fixture.rootDir.runGit("config", "commit.gpgsign", "false") + fixture.rootDir.runGit("add", ".") + fixture.rootDir.runGit("commit", "-m", "chore: release") + fixture.rootDir.runGit("tag", "bom-1.2.3") + + // Act + fixture.project.plugins.apply(VersioningPlugin::class.java) + + // Assert + assertThat(fixture.project.version.toString()).isEqualTo("1.2.3") + } + + @Test + fun `apply registers versioning tasks`() { + // Arrange + val fixture = createNestedComponentProject(VERSION_PROPERTIES) + + // Act + fixture.project.plugins.apply(VersioningPlugin::class.java) + + // Assert + assertThat(fixture.project.tasks.findByName("versionBumpMajor")).isNotNull() + assertThat(fixture.project.tasks.findByName("versionBumpMinor")).isNotNull() + assertThat(fixture.project.tasks.findByName("versionBumpPatch")).isNotNull() + assertThat(fixture.project.tasks.findByName(PrintVersionTask.TASK_NAME)).isNotNull() + assertThat(fixture.project.tasks.findByName(CreateReleaseTagTask.TASK_NAME)).isNotNull() + } + + @Test + fun `apply fails when version properties is missing`() { + // Arrange + val rootDir = temporaryFolder.newFolder("versioning-plugin-test") + val projectDir = rootDir.resolve("components/bom") + projectDir.mkdirs() + val project = ProjectBuilder.builder() + .withProjectDir(projectDir) + .build() + + // Act + val failure = runCatching { + project.plugins.apply(VersioningPlugin::class.java) + }.exceptionOrNull() + + // Assert + assertThat(failure).isNotNull().isInstanceOf() + assertThat(failure?.cause).isNotNull().isInstanceOf() + assertThat(failure?.cause?.message).isNotNull().contains("No version.properties found") + } + + @Test + fun `apply fails when version properties is invalid`() { + // Arrange + val fixture = createNestedComponentProject(INVALID_VERSION_PROPERTIES) + + // Act + val failure = runCatching { + fixture.project.plugins.apply(VersioningPlugin::class.java) + }.exceptionOrNull() + + // Assert + assertThat(failure).isNotNull().isInstanceOf() + assertThat(failure?.cause).isNotNull().isInstanceOf() + assertThat(failure?.cause?.message).isNotNull().contains("Invalid version.properties") + } + + private fun createNestedComponentProject(versionProperties: String): ComponentProject { + val rootDir = temporaryFolder.newFolder("versioning-plugin-test") + val componentDir = rootDir.resolve("components/bom") + val projectDir = componentDir.resolve("nested/module") + projectDir.mkdirs() + + val versionPropertiesFile = componentDir.resolve("version.properties") + versionPropertiesFile.writeText(versionProperties) + + val rootProject = ProjectBuilder.builder() + .withProjectDir(rootDir) + .withName("root") + .build() + val project = ProjectBuilder.builder() + .withProjectDir(projectDir) + .withName("module") + .withParent(rootProject) + .build() + + return ComponentProject( + rootDir = rootDir, + project = project, + ) + } + + private fun File.runGit(vararg args: String): List { + val command = listOf("git", "-C", absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n${output.joinToString("\n")}" + } + return output + } + + private data class ComponentProject( + val rootDir: File, + val project: Project, + ) + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + + private val INVALID_VERSION_PROPERTIES = """ + MAJOR=1 + PATCH=3 + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReaderTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReaderTest.kt new file mode 100644 index 0000000..3b1c879 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionReaderTest.kt @@ -0,0 +1,121 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.File +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class GitVersionReaderTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `read returns release version when head has matching component tag`() { + // Arrange + val repo = createRepository() + repo.runGit("tag", "bom-1.2.3") + val versionFile = repo.resolve("components/bom/version.properties") + + // Act + val version = GitVersionReader().read( + repoRoot = repo, + versionFile = versionFile, + version = Version(major = 1, minor = 2, patch = 3), + ) + + // Assert + assertThat(version).isEqualTo("1.2.3") + } + + @Test + fun `read returns snapshot version when head has no matching component tag`() { + // Arrange + val repo = createRepository() + val versionFile = repo.resolve("components/bom/version.properties") + + // Act + val version = GitVersionReader().read( + repoRoot = repo, + versionFile = versionFile, + version = Version(major = 1, minor = 2, patch = 3), + ) + + // Assert + assertThat(version).isEqualTo("1.2.3-SNAPSHOT") + } + + @Test + fun `read ignores tags for other components`() { + // Arrange + val repo = createRepository() + repo.runGit("tag", "other-1.2.3") + val versionFile = repo.resolve("components/bom/version.properties") + + // Act + val version = GitVersionReader().read( + repoRoot = repo, + versionFile = versionFile, + version = Version(major = 1, minor = 2, patch = 3), + ) + + // Assert + assertThat(version).isEqualTo("1.2.3-SNAPSHOT") + } + + @Test + fun `read returns snapshot version outside a git repository`() { + // Arrange + val repo = temporaryFolder.newFolder("not-a-git-repo") + val componentDir = repo.resolve("components/bom") + componentDir.mkdirs() + val versionFile = componentDir.resolve("version.properties") + versionFile.writeText("") + + // Act + val version = GitVersionReader().read( + repoRoot = repo, + versionFile = versionFile, + version = Version(major = 1, minor = 2, patch = 3), + ) + + // Assert + assertThat(version).isEqualTo("1.2.3-SNAPSHOT") + } + + private fun createRepository(): File { + val repo = temporaryFolder.newFolder("git-version-reader-test") + repo.runGit("init") + repo.runGit("config", "user.email", "test@example.com") + repo.runGit("config", "user.name", "Test User") + repo.runGit("config", "commit.gpgsign", "false") + + val componentDir = repo.resolve("components/bom") + componentDir.mkdirs() + componentDir.resolve("version.properties").writeText( + """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent(), + ) + repo.runGit("add", ".") + repo.runGit("commit", "-m", "chore: initial") + return repo + } + + private fun File.runGit(vararg args: String): List { + val command = listOf("git", "-C", absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n${output.joinToString("\n")}" + } + return output + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolverTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolverTest.kt new file mode 100644 index 0000000..3c60e69 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/GitVersionResolverTest.kt @@ -0,0 +1,56 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.File +import kotlin.test.Test + +class GitVersionResolverTest { + + private val resolver = GitVersionResolver() + + @Test + fun `resolve returns release version when tag output contains expected tag`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val resolvedVersion = resolver.resolve( + version = version, + tagName = "bom-1.2.3", + tagOutput = "bom-1.2.3\n", + ) + + // Assert + assertThat(resolvedVersion).isEqualTo("1.2.3") + } + + @Test + fun `resolve returns snapshot version when tag output does not contain expected tag`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val resolvedVersion = resolver.resolve( + version = version, + tagName = "bom-1.2.3", + tagOutput = "other-1.2.3\n", + ) + + // Assert + assertThat(resolvedVersion).isEqualTo("1.2.3-SNAPSHOT") + } + + @Test + fun `tagName returns component name and version`() { + // Arrange + val versionFile = File("components/bom/version.properties") + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val tagName = resolver.tagName(versionFile, version) + + // Assert + assertThat(tagName).isEqualTo("bom-1.2.3") + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/PropertiesWriterTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/PropertiesWriterTest.kt new file mode 100644 index 0000000..157344b --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/PropertiesWriterTest.kt @@ -0,0 +1,63 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.startsWith +import java.util.Properties +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PropertiesWriterTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `write creates generated header and writes properties`() { + // Arrange + val file = temporaryFolder.newFile("version.properties") + val properties = versionProperties() + + // Act + PropertiesWriter().write(file, properties) + + // Assert + val lines = file.readLines() + assertThat(lines[0]).isEqualTo("# Generated by VersioningPlugin") + assertThat(lines[1]).startsWith("# ") + assertThat(file.readText()).contains("MAJOR=1", "MINOR=2", "PATCH=3") + } + + @Test + fun `write replaces existing generated header`() { + // Arrange + val file = temporaryFolder.newFile("version.properties") + file.writeText( + """ + # Old header + # Old date + MAJOR=0 + """.trimIndent(), + ) + val properties = versionProperties() + + // Act + PropertiesWriter().write(file, properties) + + // Assert + val lines = file.readLines() + assertThat(lines[0]).isEqualTo("# Generated by VersioningPlugin") + assertThat(lines[1]).startsWith("# ") + assertThat(file.readText()).contains("MAJOR=1", "MINOR=2", "PATCH=3") + } + + private fun versionProperties(): Properties { + return Properties().apply { + setProperty("MAJOR", "1") + setProperty("MINOR", "2") + setProperty("PATCH", "3") + } + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManagerTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManagerTest.kt new file mode 100644 index 0000000..6b9757a --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionManagerTest.kt @@ -0,0 +1,152 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import assertk.assertions.messageContains +import java.io.File +import java.util.Properties +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class VersionManagerTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `get reads nearest version properties`() { + // Arrange + val root = createRoot() + createVersionFile(root, Version(major = 9, minor = 9, patch = 9)) + val component = root.resolve("components/bom") + val nested = component.resolve("nested/module") + nested.mkdirs() + val componentVersionFile = createVersionFile( + component, + Version(major = 1, minor = 2, patch = 3), + ) + + // Act + val manager = VersionManager(base = nested, root = root) + + // Assert + assertThat(manager.get()).isEqualTo(Version(major = 1, minor = 2, patch = 3)) + assertThat(manager.sourceFile()?.canonicalFile).isEqualTo(componentVersionFile.canonicalFile) + } + + @Test + fun `get can read root version properties`() { + // Arrange + val root = createRoot() + val rootVersionFile = createVersionFile(root, Version(major = 1, minor = 0, patch = 0)) + val nested = root.resolve("components/bom") + nested.mkdirs() + + // Act + val manager = VersionManager(base = nested, root = root) + + // Assert + assertThat(manager.get()).isEqualTo(Version(major = 1, minor = 0, patch = 0)) + assertThat(manager.sourceFile()?.canonicalFile).isEqualTo(rootVersionFile.canonicalFile) + } + + @Test + fun `get stops searching at repo root`() { + // Arrange + val workspace = temporaryFolder.newFolder("workspace") + createVersionFile(workspace, Version(major = 9, minor = 9, patch = 9)) + val repoRoot = workspace.resolve("repo") + val nested = repoRoot.resolve("components/bom") + nested.mkdirs() + val manager = VersionManager(base = nested, root = repoRoot) + + // Act + val failure = assertFailure { manager.get() } + + // Assert + failure.isInstanceOf() + failure.messageContains("No version.properties found") + assertThat(manager.sourceFile()).isNull() + } + + @Test + fun `get fails when version properties is invalid`() { + // Arrange + val root = createRoot() + root.resolve("version.properties").writeText( + """ + MAJOR=1 + PATCH=3 + """.trimIndent(), + ) + val manager = VersionManager(base = root, root = root) + + // Act + val failure = assertFailure { manager.get() } + + // Assert + failure.isInstanceOf() + failure.messageContains("Invalid version.properties") + } + + @Test + fun `update writes version to source file`() { + // Arrange + val root = createRoot() + val versionFile = createVersionFile(root, Version(major = 1, minor = 2, patch = 3)) + val manager = VersionManager(base = root, root = root) + + // Act + manager.update(Version(major = 2, minor = 0, patch = 0)) + + // Assert + assertThat(versionFile.readProperties()).isEqualTo( + mapOf( + "MAJOR" to "2", + "MINOR" to "0", + "PATCH" to "0", + ), + ) + } + + @Test + fun `update fails when source file is missing`() { + // Arrange + val root = createRoot() + val manager = VersionManager(base = root, root = root) + + // Act + val failure = assertFailure { + manager.update(Version(major = 1, minor = 0, patch = 0)) + } + + // Assert + failure.isInstanceOf() + failure.messageContains("No version.properties file found to update") + } + + private fun createRoot(): File = temporaryFolder.newFolder("version-manager-test") + + private fun createVersionFile(dir: File, version: Version): File { + dir.mkdirs() + val file = dir.resolve("version.properties") + file.writeText( + """ + MAJOR=${version.major} + MINOR=${version.minor} + PATCH=${version.patch} + """.trimIndent(), + ) + return file + } + + private fun File.readProperties(): Map { + val properties = Properties() + inputStream().use(properties::load) + return properties.entries.associate { (key, value) -> key.toString() to value.toString() } + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapperTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapperTest.kt new file mode 100644 index 0000000..ebe1268 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionPropertiesMapperTest.kt @@ -0,0 +1,87 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import java.util.Properties +import kotlin.test.Test + +class VersionPropertiesMapperTest { + + private val mapper = VersionPropertiesMapper() + + @Test + fun `to maps valid properties to version`() { + // Arrange + val properties = properties( + "MAJOR" to "1", + "MINOR" to "2", + "PATCH" to "3", + ) + + // Act + val version = mapper.to(properties) + + // Assert + assertThat(version).isEqualTo(Version(major = 1, minor = 2, patch = 3)) + } + + @Test + fun `to returns null when required property is missing`() { + // Arrange + val properties = properties( + "MAJOR" to "1", + "PATCH" to "3", + ) + + // Act + val version = mapper.to(properties) + + // Assert + assertThat(version).isNull() + } + + @Test + fun `to returns null when numeric property is invalid`() { + // Arrange + val properties = properties( + "MAJOR" to "one", + "MINOR" to "2", + "PATCH" to "3", + ) + + // Act + val version = mapper.to(properties) + + // Assert + assertThat(version).isNull() + } + + @Test + fun `from maps version to properties`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val properties = mapper.from(version) + + // Assert + assertThat(properties.toMap()).isEqualTo( + mapOf( + "MAJOR" to "1", + "MINOR" to "2", + "PATCH" to "3", + ), + ) + } + + private fun properties(vararg values: Pair): Properties { + return Properties().apply { + values.forEach { (key, value) -> setProperty(key, value) } + } + } + + private fun Properties.toMap(): Map { + return entries.associate { (key, value) -> key.toString() to value.toString() } + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionTest.kt new file mode 100644 index 0000000..ff24720 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/internal/VersionTest.kt @@ -0,0 +1,56 @@ +package net.thunderbird.gradle.plugin.versioning.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class VersionTest { + + @Test + fun `toStringValue returns base semantic version`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val value = version.toStringValue() + + // Assert + assertThat(value).isEqualTo("1.2.3") + } + + @Test + fun `bumpMajor increments major and resets minor and patch`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val bumped = version.bumpMajor() + + // Assert + assertThat(bumped).isEqualTo(Version(major = 2, minor = 0, patch = 0)) + } + + @Test + fun `bumpMinor increments minor and resets patch`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val bumped = version.bumpMinor() + + // Assert + assertThat(bumped).isEqualTo(Version(major = 1, minor = 3, patch = 0)) + } + + @Test + fun `bumpPatch increments patch`() { + // Arrange + val version = Version(major = 1, minor = 2, patch = 3) + + // Act + val bumped = version.bumpPatch() + + // Assert + assertThat(bumped).isEqualTo(Version(major = 1, minor = 2, patch = 4)) + } +} diff --git a/components/bom/version.properties b/components/bom/version.properties index b753846..23c14d2 100644 --- a/components/bom/version.properties +++ b/components/bom/version.properties @@ -3,4 +3,3 @@ MAJOR=0 MINOR=1 PATCH=0 -SNAPSHOT=true diff --git a/docs/release-guide.md b/docs/release-guide.md index 8d60013..d4b2116 100644 --- a/docs/release-guide.md +++ b/docs/release-guide.md @@ -62,7 +62,7 @@ For example: bom-1.0.0 ``` -The task fails if the component version is still a snapshot or the tag already exists. +The task fails if the tag already exists. ## Review Checklist From 1b26d1f78c4cc45f99385dd34dac3175f6ea4cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 18 Jun 2026 17:13:54 +0200 Subject: [PATCH 05/17] chore(build): add Bom plugin --- build-plugin/plugin/build.gradle.kts | 4 + .../gradle/plugin/bom/BomPlugin.kt | 22 ++++++ .../gradle/plugin/bom/BomPluginTest.kt | 78 +++++++++++++++++++ components/bom/build.gradle.kts | 9 +-- 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/bom/BomPlugin.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt diff --git a/build-plugin/plugin/build.gradle.kts b/build-plugin/plugin/build.gradle.kts index 1bb3c0d..c59b8c5 100644 --- a/build-plugin/plugin/build.gradle.kts +++ b/build-plugin/plugin/build.gradle.kts @@ -43,6 +43,10 @@ gradlePlugin { id = "net.thunderbird.gradle.plugin.library.kmp.compose" implementationClass = "net.thunderbird.gradle.plugin.library.kmp.compose.LibraryKmpComposePlugin" } + register("Bom") { + id = "net.thunderbird.gradle.plugin.bom" + implementationClass = "net.thunderbird.gradle.plugin.bom.BomPlugin" + } register("DependencyCheck") { id = "net.thunderbird.gradle.plugin.dependency.check" diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/bom/BomPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/bom/BomPlugin.kt new file mode 100644 index 0000000..658e298 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/bom/BomPlugin.kt @@ -0,0 +1,22 @@ +package net.thunderbird.gradle.plugin.bom + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPlatformExtension +import org.gradle.kotlin.dsl.configure + +class BomPlugin : Plugin { + + override fun apply(target: Project) { + with(target) { + pluginManager.apply("java-platform") + pluginManager.apply("net.thunderbird.gradle.plugin.changelog") + pluginManager.apply("net.thunderbird.gradle.plugin.versioning") + pluginManager.apply("net.thunderbird.gradle.plugin.publishing") + + extensions.configure { + allowDependencies() + } + } + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt new file mode 100644 index 0000000..c88b5df --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt @@ -0,0 +1,78 @@ +package net.thunderbird.gradle.plugin.bom + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import kotlin.test.Test +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPlatformPlugin +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class BomPluginTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `apply configures bom project`() { + // Arrange + val project = createBomProject(parentName = "components") + + // Act + project.plugins.apply(BomPlugin::class.java) + + // Assert + assertThat(project.group.toString()).isEqualTo("net.thunderbird.components") + assertThat(project.plugins.findPlugin(JavaPlatformPlugin::class.java)).isNotNull() + assertThat(project.tasks.findByName("updateChangelog")).isNotNull() + assertThat(project.tasks.findByName("printVersion")).isNotNull() + assertThat(project.tasks.findByName("publishDailySnapshotToMavenLocal")).isNotNull() + } + + @Test + fun `apply derives group from bom parent path`() { + // Arrange + val project = createBomProject(parentName = "platform") + + // Act + project.plugins.apply(BomPlugin::class.java) + + // Assert + assertThat(project.group.toString()).isEqualTo("net.thunderbird.platform") + } + + private fun createBomProject(parentName: String) = createProject(parentName = parentName, projectName = "bom") + + private fun createProject( + parentName: String, + projectName: String, + ): Project { + val rootDir = temporaryFolder.newFolder("$parentName-$projectName-plugin-test") + val projectDir = rootDir.resolve("$parentName/$projectName") + projectDir.mkdirs() + projectDir.resolve("version.properties").writeText(VERSION_PROPERTIES) + val rootProject = ProjectBuilder.builder() + .withProjectDir(rootDir) + .withName("root") + .build() + val parentProject = ProjectBuilder.builder() + .withName(parentName) + .withParent(rootProject) + .build() + return ProjectBuilder.builder() + .withProjectDir(projectDir) + .withName(projectName) + .withParent(parentProject) + .build() + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + } +} diff --git a/components/bom/build.gradle.kts b/components/bom/build.gradle.kts index ede81e5..224b1eb 100644 --- a/components/bom/build.gradle.kts +++ b/components/bom/build.gradle.kts @@ -1,12 +1,5 @@ plugins { - `java-platform` - id("net.thunderbird.gradle.plugin.changelog") - id("net.thunderbird.gradle.plugin.publishing") - id("net.thunderbird.gradle.plugin.versioning") -} - -javaPlatform { - allowDependencies() + id("net.thunderbird.gradle.plugin.bom") } dependencies { From 9995ce256ff6d5aaca14bdf4e53e65045d442085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 18 Jun 2026 17:46:26 +0200 Subject: [PATCH 06/17] chore(build): refine publishing plugins with version validation and documentation --- build-plugin/README.md | 8 +- .../plugin/changelog/ChangelogPlugin.kt | 9 +- .../plugin/changelog/FinalizeChangelogTask.kt | 29 ++- .../plugin/library/kmp/LibraryKmpPlugin.kt | 2 +- .../kmp/compose/LibraryKmpComposePlugin.kt | 2 +- .../publishing/PublishingCoordinates.kt | 37 ++++ .../plugin/publishing/PublishingPlugin.kt | 26 ++- .../ValidatePublicationVersionTask.kt | 51 +++++ .../gradle/plugin/bom/BomPluginTest.kt | 3 +- .../changelog/FinalizeChangelogTaskTest.kt | 38 +++- .../publishing/PublishingCoordinatesTest.kt | 88 ++++++++ .../plugin/publishing/PublishingPluginTest.kt | 205 ++++++++++++++++++ docs/release-guide.md | 114 +++++++--- 13 files changed, 561 insertions(+), 51 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinatesTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPluginTest.kt diff --git a/build-plugin/README.md b/build-plugin/README.md index 3550ab9..81c8fe5 100644 --- a/build-plugin/README.md +++ b/build-plugin/README.md @@ -64,7 +64,8 @@ Supportive/quality and tooling plugins: [Release Guide](../docs/release-guide.md). - `finalizeChangelog`: finalizes the current `## Unreleased` section for a release version and inserts a new empty `## Unreleased` section above it. - - Required property: `-PreleaseVersion=` + - Version source: nearest `version.properties` + - Optional property: `-PreleaseVersion=` (must match `version.properties` when provided) - Optional property: `-PreleaseDate=YYYY-MM-DD` (defaults to the current local date) - Validation: fails when `Unreleased` is missing, empty, or when the target release already exists. - Usage: @@ -81,7 +82,7 @@ Supportive/quality and tooling plugins: ./gradlew :components:bom:updateChangelog # Finalize the changelog for a release - ./gradlew :components:bom:finalizeChangelog -PreleaseVersion=1.0.0 + ./gradlew :components:bom:finalizeChangelog ``` ### Applying a plugin @@ -132,6 +133,9 @@ The `net.thunderbird.gradle.plugin.publishing` plugin: - Sets Maven coordinates from the project and configures POM metadata - Adds local repositories: `mavenLocal()` and `${rootProject}/build/maven-repo` - Configures publishing to Maven Central and signs all publications +- Adds release-oriented validation tasks: + - `validateStableVersionForPublishing`: validates a non-`SNAPSHOT` version before stable publishing. + - `validateSnapshotVersionForPublishing`: validates a `SNAPSHOT` version before snapshot publishing. Signing properties can be supplied from a file at `${rootProject}/.signing/signing.properties` with keys: diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt index 1011469..3bd8120 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt @@ -58,8 +58,13 @@ class ChangelogPlugin : Plugin { project.provider { File(versionDir, FileHelper.CHANGELOG_FILE) }, ), ) - releaseVersion.set(providers.gradleProperty("releaseVersion")) - releaseDate.set(providers.gradleProperty("releaseDate")) + versionFile.set( + project.layout.file( + project.provider { File(versionDir, FileHelper.VERSION_FILE) }, + ), + ) + releaseVersion.convention(providers.gradleProperty("releaseVersion")) + releaseDate.convention(providers.gradleProperty("releaseDate")) } } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt index 356b2ed..6bba062 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTask.kt @@ -7,12 +7,16 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import net.thunderbird.gradle.plugin.changelog.internal.ChangelogManager import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.versioning.internal.VersionManager import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction /** @@ -25,8 +29,13 @@ abstract class FinalizeChangelogTask : DefaultTask() { abstract val changelogFile: RegularFileProperty @get:Input + @get:Optional abstract val releaseVersion: Property + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val versionFile: RegularFileProperty + @get:Input @get:Optional abstract val releaseDate: Property @@ -41,7 +50,7 @@ abstract class FinalizeChangelogTask : DefaultTask() { @TaskAction fun finalizeChangelog() { - val version = releaseVersion.get().trim() + val version = resolveReleaseVersion() require(version.isNotBlank()) { "releaseVersion must not be blank." } require(!version.equals(UNRELEASED, ignoreCase = true)) { "releaseVersion must not be '$UNRELEASED'." } @@ -81,6 +90,24 @@ abstract class FinalizeChangelogTask : DefaultTask() { logger.lifecycle("[changelog] Finalized ${changelogFile.get().asFile.path} for $version ($date)") } + private fun resolveReleaseVersion(): String { + val configuredVersion = VersionManager( + base = versionFile.get().asFile.parentFile, + root = versionFile.get().asFile.parentFile, + ).get().toStringValue() + val overrideVersion = releaseVersion.orNull?.trim() + + if (!releaseVersion.isPresent) { + return configuredVersion + } + require(!overrideVersion.isNullOrBlank()) { "releaseVersion must not be blank." } + + require(overrideVersion == configuredVersion) { + "releaseVersion '$overrideVersion' does not match version.properties '$configuredVersion'." + } + return overrideVersion + } + companion object { const val TASK_NAME = "finalizeChangelog" private const val UNRELEASED = "Unreleased" diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt index 5a21ae3..791a1d9 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt @@ -26,8 +26,8 @@ class LibraryKmpPlugin : Plugin { apply("org.jetbrains.kotlin.plugin.serialization") apply("net.thunderbird.gradle.plugin.changelog") - apply("net.thunderbird.gradle.plugin.publishing") apply("net.thunderbird.gradle.plugin.versioning") + apply("net.thunderbird.gradle.plugin.publishing") apply("net.thunderbird.gradle.plugin.quality.coverage") apply("net.thunderbird.gradle.plugin.quality.detekt") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index 95976c3..d7a0de3 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -29,8 +29,8 @@ class LibraryKmpComposePlugin : Plugin { apply("org.jetbrains.kotlin.plugin.serialization") apply("net.thunderbird.gradle.plugin.changelog") - apply("net.thunderbird.gradle.plugin.publishing") apply("net.thunderbird.gradle.plugin.versioning") + apply("net.thunderbird.gradle.plugin.publishing") apply("net.thunderbird.gradle.plugin.quality.coverage") apply("net.thunderbird.gradle.plugin.quality.detekt") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt new file mode 100644 index 0000000..0cc7fbb --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt @@ -0,0 +1,37 @@ +package net.thunderbird.gradle.plugin.publishing + +import net.thunderbird.gradle.plugin.ProjectConfig +import org.gradle.api.Project + +internal fun Project.configurePublishedGroup() { + if (hasDefaultGroup()) { + group = publishedGroup() + } +} + +private fun Project.hasDefaultGroup(): Boolean { + val currentGroup = group.toString() + return currentGroup == DEFAULT_GROUP || currentGroup == defaultGradleGroup() +} + +private fun Project.defaultGradleGroup(): String { + val parentSegments = path + .split(":") + .filter(String::isNotBlank) + .dropLast(1) + + return (listOf(rootProject.name) + parentSegments) + .joinToString(".") +} + +private fun Project.publishedGroup(): String { + val parentSegments = path + .split(":") + .filter(String::isNotBlank) + .dropLast(1) + + return (listOf(ProjectConfig.group) + parentSegments) + .joinToString(".") +} + +private const val DEFAULT_GROUP = "unspecified" diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt index 2f154b4..dd18ecd 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt @@ -7,6 +7,7 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.publish.PublishingExtension import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register /** * Publishing plugin configuration. @@ -28,20 +29,17 @@ class PublishingPlugin : Plugin { override fun apply(target: Project) { with(target) { - ensureProjectGroup() + configurePublishedGroup() loadSigningProperties() pluginManager.apply("com.vanniktech.maven.publish") configurePublishing() configurePublish() + registerReleasePublishingTasks() } } - private fun Project.ensureProjectGroup() { - group = group.toString().replace("tmc", ProjectConfig.group + ".") - } - private fun Project.loadSigningProperties() { val signingPropsFile = rootProject.file(".signing/signing.properties") if (signingPropsFile.exists()) { @@ -109,4 +107,22 @@ class PublishingPlugin : Plugin { signAllPublications() } } + + private fun Project.registerReleasePublishingTasks() { + val currentProjectPath = path + tasks.register("validateStableVersionForPublishing") { + group = "publishing" + description = "Validate that this project resolves to a stable release version." + version.set(project.version.toString()) + projectPath.set(currentProjectPath) + snapshotRequired.set(false) + } + tasks.register("validateSnapshotVersionForPublishing") { + group = "publishing" + description = "Validate that this project resolves to a snapshot version." + version.set(project.version.toString()) + projectPath.set(currentProjectPath) + snapshotRequired.set(true) + } + } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt new file mode 100644 index 0000000..c660525 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt @@ -0,0 +1,51 @@ +package net.thunderbird.gradle.plugin.publishing + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +abstract class ValidatePublicationVersionTask : DefaultTask() { + + @get:Input + abstract val version: Property + + @get:Input + abstract val projectPath: Property + + @get:Input + abstract val snapshotRequired: Property + + @TaskAction + fun validate() { + val versionString = version.get() + if (snapshotRequired.get()) { + requireSnapshotVersion(versionString) + } else { + requireStableVersion(versionString) + } + } + + private fun requireStableVersion(versionString: String) { + if (versionString.endsWith(SNAPSHOT_SUFFIX)) { + throw GradleException( + "Stable releases require a non-SNAPSHOT version, but project '${projectPath.get()}' " + + "resolved '$versionString'. Create the component release tag before publishing a stable release.", + ) + } + } + + private fun requireSnapshotVersion(versionString: String) { + if (!versionString.endsWith(SNAPSHOT_SUFFIX)) { + throw GradleException( + "Daily snapshots require a SNAPSHOT version, but project '${projectPath.get()}' " + + "resolved '$versionString'. Daily snapshots should be published from an untagged main commit.", + ) + } + } + + private companion object { + private const val SNAPSHOT_SUFFIX = "-SNAPSHOT" + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt index c88b5df..2e27110 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/bom/BomPluginTest.kt @@ -28,7 +28,8 @@ class BomPluginTest { assertThat(project.plugins.findPlugin(JavaPlatformPlugin::class.java)).isNotNull() assertThat(project.tasks.findByName("updateChangelog")).isNotNull() assertThat(project.tasks.findByName("printVersion")).isNotNull() - assertThat(project.tasks.findByName("publishDailySnapshotToMavenLocal")).isNotNull() + assertThat(project.tasks.findByName("validateSnapshotVersionForPublishing")).isNotNull() + assertThat(project.tasks.findByName("publishToMavenLocal")).isNotNull() } @Test diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt index 8f949c5..9d840c9 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/FinalizeChangelogTaskTest.kt @@ -22,7 +22,7 @@ class FinalizeChangelogTaskTest { fun `finalizeChangelog moves Unreleased entries to release and creates empty Unreleased section`() { // Arrange val componentDir = createComponentDir(CHANGES_UNDER_UNRELEASED) - val task = createTask(componentDir, releaseVersion = "0.1.0", releaseDate = "2026-06-18") + val task = createTask(componentDir, releaseDate = "2026-06-18") // Act task.finalizeChangelog() @@ -37,6 +37,34 @@ class FinalizeChangelogTaskTest { assertThat(changelog.substringAfter("## Unreleased").substringBefore("## 0.1.0").trim()).isEqualTo("") } + @Test + fun `finalizeChangelog accepts matching release version override`() { + // Arrange + val componentDir = createComponentDir(CHANGES_UNDER_UNRELEASED) + val task = createTask(componentDir, releaseVersion = "0.1.0", releaseDate = "2026-06-18") + + // Act + task.finalizeChangelog() + + // Assert + val changelog = componentDir.resolve(FileHelper.CHANGELOG_FILE).readText() + assertThat(changelog).contains("## 0.1.0 - 2026-06-18") + } + + @Test + fun `finalizeChangelog fails when release version override differs from version properties`() { + // Arrange + val componentDir = createComponentDir(CHANGES_UNDER_UNRELEASED) + val task = createTask(componentDir, releaseVersion = "0.2.0", releaseDate = "2026-06-18") + + // Act + val failure = assertFailure { task.finalizeChangelog() } + + // Assert + failure.isInstanceOf() + failure.messageContains("releaseVersion '0.2.0' does not match version.properties '0.1.0'") + } + @Test fun `finalizeChangelog fails when release version already exists`() { // Arrange @@ -66,16 +94,17 @@ class FinalizeChangelogTaskTest { } @Test - fun `finalizeChangelog fails when release version is missing`() { + fun `finalizeChangelog fails when release version is blank`() { // Arrange val componentDir = createComponentDir(CHANGES_UNDER_UNRELEASED) - val task = createTask(componentDir, releaseDate = "2026-06-18") + val task = createTask(componentDir, releaseVersion = " ", releaseDate = "2026-06-18") // Act val failure = assertFailure { task.finalizeChangelog() } // Assert - failure.isInstanceOf() + failure.isInstanceOf() + failure.messageContains("releaseVersion must not be blank") } private fun createComponentDir(changelog: String): File { @@ -95,6 +124,7 @@ class FinalizeChangelogTaskTest { .build() return project.tasks.create(FinalizeChangelogTask.TASK_NAME, FinalizeChangelogTask::class.java).apply { changelogFile.set(componentDir.resolve(FileHelper.CHANGELOG_FILE)) + versionFile.set(componentDir.resolve(FileHelper.VERSION_FILE)) if (releaseVersion != null) { this.releaseVersion.set(releaseVersion) } diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinatesTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinatesTest.kt new file mode 100644 index 0000000..6222623 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinatesTest.kt @@ -0,0 +1,88 @@ +package net.thunderbird.gradle.plugin.publishing + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PublishingCoordinatesTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `configurePublishedGroup uses parent path as group`() { + // Arrange + val rootProject = ProjectBuilder.builder() + .withProjectDir(temporaryFolder.newFolder("publishing-group-test")) + .withName("root") + .build() + val componentProject = ProjectBuilder.builder() + .withName("components") + .withParent(rootProject) + .build() + val publishedProject = ProjectBuilder.builder() + .withName("example") + .withParent(componentProject) + .build() + + // Act + publishedProject.configurePublishedGroup() + + // Assert + assertThat(publishedProject.group.toString()).isEqualTo("net.thunderbird.components") + } + + @Test + fun `configurePublishedGroup includes nested parent path segments`() { + // Arrange + val rootProject = ProjectBuilder.builder() + .withProjectDir(temporaryFolder.newFolder("nested-publishing-group-test")) + .withName("root") + .build() + val componentProject = ProjectBuilder.builder() + .withName("components") + .withParent(rootProject) + .build() + val featureProject = ProjectBuilder.builder() + .withName("feature") + .withParent(componentProject) + .build() + val publishedProject = ProjectBuilder.builder() + .withName("sync") + .withParent(featureProject) + .build() + + // Act + publishedProject.configurePublishedGroup() + + // Assert + assertThat(publishedProject.group.toString()).isEqualTo("net.thunderbird.components.feature") + } + + @Test + fun `configurePublishedGroup keeps explicitly configured group`() { + // Arrange + val rootProject = ProjectBuilder.builder() + .withProjectDir(temporaryFolder.newFolder("explicit-publishing-group-test")) + .withName("root") + .build() + val componentProject = ProjectBuilder.builder() + .withName("components") + .withParent(rootProject) + .build() + val publishedProject = ProjectBuilder.builder() + .withName("example") + .withParent(componentProject) + .build() + publishedProject.group = "custom.group" + + // Act + publishedProject.configurePublishedGroup() + + // Assert + assertThat(publishedProject.group.toString()).isEqualTo("custom.group") + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPluginTest.kt new file mode 100644 index 0000000..35738b9 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPluginTest.kt @@ -0,0 +1,205 @@ +package net.thunderbird.gradle.plugin.publishing + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.messageContains +import java.io.File +import kotlin.test.Test +import net.thunderbird.gradle.plugin.versioning.VersioningPlugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PublishingPluginTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `apply derives project group from parent path`() { + // Arrange + val rootProject = ProjectBuilder.builder() + .withProjectDir(temporaryFolder.newFolder("publishing-group-test")) + .withName("root") + .build() + val componentProject = ProjectBuilder.builder() + .withName("components") + .withParent(rootProject) + .build() + val project = ProjectBuilder.builder() + .withName("example") + .withParent(componentProject) + .build() + + // Act + project.plugins.apply(PublishingPlugin::class.java) + + // Assert + assertThat(project.group.toString()).isEqualTo("net.thunderbird.components") + } + + @Test + fun `apply keeps explicitly configured project group`() { + // Arrange + val rootProject = ProjectBuilder.builder() + .withProjectDir(temporaryFolder.newFolder("explicit-publishing-group-test")) + .withName("root") + .build() + val componentProject = ProjectBuilder.builder() + .withName("components") + .withParent(rootProject) + .build() + val project = ProjectBuilder.builder() + .withName("example") + .withParent(componentProject) + .build() + project.group = "custom.group" + + // Act + project.plugins.apply(PublishingPlugin::class.java) + + // Assert + assertThat(project.group.toString()).isEqualTo("custom.group") + } + + @Test + fun `apply registers validation tasks and keeps vanniktech publishing tasks`() { + // Arrange + val project = ProjectBuilder.builder() + .withProjectDir(temporaryFolder.newFolder("publishing-task-test")) + .build() + + // Act + project.plugins.apply(PublishingPlugin::class.java) + + // Assert + assertThat(project.tasks.findByName("validateStableVersionForPublishing")).isNotNull() + assertThat(project.tasks.findByName("validateSnapshotVersionForPublishing")).isNotNull() + assertThat(project.tasks.findByName("publishToMavenLocal")).isNotNull() + assertThat(project.tasks.findByName("publishToMavenCentral")).isNotNull() + assertThat(project.tasks.findByName("publishAndReleaseToMavenCentral")).isNotNull() + } + + @Test + fun `validateSnapshotVersionForPublishing accepts snapshot version`() { + // Arrange + val project = createVersionedPublishingProject() + + // Act + project.executeTask("validateSnapshotVersionForPublishing") + + // Assert + assertThat(project.version.toString()).isEqualTo("1.2.3-SNAPSHOT") + } + + @Test + fun `validateStableVersionForPublishing rejects snapshot version`() { + // Arrange + val project = createVersionedPublishingProject() + + // Act + val failure = assertFailure { + project.executeTask("validateStableVersionForPublishing") + } + + // Assert + failure.messageContains("Stable releases require a non-SNAPSHOT version") + } + + @Test + fun `validateStableVersionForPublishing accepts stable version`() { + // Arrange + val fixture = createVersionedPublishingProjectFixture(releaseTagged = true) + + // Act + fixture.project.executeTask("validateStableVersionForPublishing") + + // Assert + assertThat(fixture.project.version.toString()).isEqualTo("1.2.3") + } + + @Test + fun `validateSnapshotVersionForPublishing rejects stable version`() { + // Arrange + val fixture = createVersionedPublishingProjectFixture(releaseTagged = true) + + // Act + val failure = assertFailure { + fixture.project.executeTask("validateSnapshotVersionForPublishing") + } + + // Assert + failure.messageContains("Daily snapshots require a SNAPSHOT version") + } + + private fun createVersionedPublishingProject(): Project { + return createVersionedPublishingProjectFixture().project + } + + private fun createVersionedPublishingProjectFixture(releaseTagged: Boolean = false): ProjectFixture { + val rootDir = temporaryFolder.newFolder("publishing-version-test") + rootDir.runGit("init") + rootDir.runGit("config", "user.email", "test@example.com") + rootDir.runGit("config", "user.name", "Test User") + rootDir.runGit("config", "commit.gpgsign", "false") + + val componentDir = rootDir.resolve("components/bom") + componentDir.mkdirs() + componentDir.resolve("version.properties").writeText(VERSION_PROPERTIES) + componentDir.resolve("file.txt").writeText("initial") + rootDir.runGit("add", ".") + rootDir.runGit("commit", "-m", "chore: initial") + if (releaseTagged) { + rootDir.runGit("tag", "bom-1.2.3") + } + + val rootProject = ProjectBuilder.builder() + .withProjectDir(rootDir) + .withName("root") + .build() + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .withName("bom") + .withParent(rootProject) + .build() + project.plugins.apply(VersioningPlugin::class.java) + project.plugins.apply(PublishingPlugin::class.java) + + return ProjectFixture(rootDir, project) + } + + private fun Project.executeTask(name: String) { + val task = tasks.getByName(name) + task.actions.forEach { action -> action.execute(task) } + } + + private fun File.runGit(vararg args: String): List { + val command = listOf("git", "-C", absolutePath) + args + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + val exitCode = process.waitFor() + check(exitCode == 0) { + "Command failed ($exitCode): ${command.joinToString(" ")}\n${output.joinToString("\n")}" + } + return output + } + + private data class ProjectFixture( + val rootDir: File, + val project: Project, + ) + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + } +} diff --git a/docs/release-guide.md b/docs/release-guide.md index d4b2116..bfea1d8 100644 --- a/docs/release-guide.md +++ b/docs/release-guide.md @@ -1,74 +1,120 @@ # Release Guide -This guide describes the release workflow for Thunderbird Mobile Components. +This guide is for maintainers preparing and publishing Thunderbird Mobile Components releases. ## Prerequisites -- Start from an up-to-date `main` branch. +- Maven Central credentials and signing properties are available to the publishing environment. -## Release Workflow +## Stable Release -1. Run the changelog update task for the component being released. -2. Review the generated `Unreleased` changelog entries. -3. Apply the release version changes. -4. Finalize the changelog for the release version. -5. Open a release pull request with the changelog and version changes. -6. Merge the release pull request. -7. Create the component release tag from the merged release commit. -8. Publish the release. +Stable releases start with a release preparation pull request. -## Changelog Tasks +1. Create a release branch from `main`. -For a component, run the changelog task on that component project. Example for `:components:bom`: +Use this branch naming convention: -```bash -./gradlew :components:bom:updateChangelog +```text +release// ``` -The changelog is written next to the component `version.properties` file. +For nested components, use the component path without the leading colon and replace `:` with `-`. -After reviewing the generated entries, finalize the `Unreleased` section: +Example: ```bash -./gradlew :components:bom:finalizeChangelog -PreleaseVersion=1.0.0 +git switch -c release/components-bom/1.0.0 +git switch -c release/components-feature-sync/1.0.0 ``` -To use a specific release date: +2. Update the component `version.properties` to the release version. + +3. Update the component changelog: ```bash -./gradlew :components:bom:finalizeChangelog -PreleaseVersion=1.0.0 -PreleaseDate=2026-06-18 +./gradlew :updateChangelog ``` -The finalize task updates the changelog only. It does not create a git tag. +4. Review `CHANGELOG.md` and keep only entries intended for this release. + +5. Finalize the changelog: + +```bash +./gradlew :finalizeChangelog +``` -## Release Tags +The task uses the component version from `version.properties`. -After the release pull request has been merged, update `main` to the merged release commit and create the component -release tag. Example for `:components:bom`: +To use a specific release date: ```bash -./gradlew :components:bom:createReleaseTag +./gradlew :finalizeChangelog -PreleaseDate=2026-06-18 ``` -The task reads the component version from `version.properties` and creates a local git tag in this format: +6. Open the release pull request. + +7. Before merging, verify: + +- `version.properties` contains the intended release version. +- `CHANGELOG.md` contains the finalized release section. +- The finalized changelog version matches `version.properties`. +- The pull request contains no unrelated changes. + +## Stable Release Publishing + +Publish a stable release only after the release pull request has merged into `main`. + +The release tag must be created from the merged release commit. The tag format is: ```text - ``` -For example: +Example: ```text -bom-1.0.0 +-1.0.0 ``` -The task fails if the tag already exists. +The release job should run from the merged `main` commit and perform these steps: -## Review Checklist +```bash +./gradlew :createReleaseTag +./gradlew :validateStableVersionForPublishing :publishAndReleaseToMavenCentral +``` -Before merging the release pull request, verify: +For local verification before publishing to Maven Central, publish to Maven Local instead: + +```bash +./gradlew :validateStableVersionForPublishing :publishToMavenLocal +``` + +Before publishing, verify: + +- The release pull request has been merged. +- The job runs from the merged `main` commit. +- The component release tag is created on that commit. +- `validateStableVersionForPublishing` succeeds. + +## Daily Snapshot Publishing + +Daily snapshots are published from an untagged `main` commit. Do not create a release pull request, do not finalize the +changelog, and do not create a release tag for a daily snapshot. + +The snapshot job should run from the intended `main` commit: + +```bash +./gradlew :validateSnapshotVersionForPublishing :publishToMavenCentral +``` + +For local verification, publish to Maven Local instead: + +```bash +./gradlew :validateSnapshotVersionForPublishing :publishToMavenLocal +``` -- The changelog contains only entries for the release being prepared. -- Entries are grouped under the expected sections. -- The release version and changelog version match. +Before publishing a snapshot, verify: +- The job runs from the intended `main` commit. +- The commit is not tagged with the matching component release tag. +- `validateSnapshotVersionForPublishing` succeeds. From 5fbd6bfa447cd532367540afed9feb0165832f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 18 Jun 2026 18:15:56 +0200 Subject: [PATCH 07/17] chore(build): add release and snapshot publishing GitHub workflows --- .github/workflows/publish-release.yml | 119 +++++++++++++++ .github/workflows/publish-snapshot.yml | 72 +++++++++ build-plugin/README.md | 8 +- .../plugin/changelog/ChangelogPlugin.kt | 21 +++ .../plugin/changelog/WriteReleaseNotesTask.kt | 94 ++++++++++++ .../plugin/publishing/PublishingPlugin.kt | 2 +- .../ValidatePublicationVersionTask.kt | 2 +- .../plugin/versioning/PrintReleaseTagTask.kt | 37 +++++ .../plugin/versioning/VersioningPlugin.kt | 11 ++ .../plugin/changelog/ChangelogPluginTest.kt | 7 + .../changelog/WriteReleaseNotesTaskTest.kt | 139 ++++++++++++++++++ .../versioning/PrintReleaseTagTaskTest.kt | 88 +++++++++++ .../plugin/versioning/VersioningPluginTest.kt | 1 + docs/release-guide.md | 51 +++++-- 14 files changed, 634 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/publish-release.yml create mode 100644 .github/workflows/publish-snapshot.yml create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTask.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTask.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTaskTest.kt create mode 100644 build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTaskTest.kt diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..67d345f --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,119 @@ +name: Publish - Release + +on: + workflow_dispatch: + inputs: + component_path: + description: "Gradle path of the component to publish, e.g. :components:bom" + required: true + default: ":components:bom" + +permissions: + contents: write + +concurrency: + group: publish-release-${{ inputs.component_path }} + cancel-in-progress: false + +jobs: + publish-release: + name: Publish release + runs-on: ubuntu-latest + timeout-minutes: 60 + + env: + COMPONENT_PATH: ${{ inputs.component_path }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + + steps: + - name: Validate branch + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_NAME}" != "main" ]]; then + echo "Releases must be published from main. Current ref: ${GITHUB_REF_NAME}" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - name: Setup Gradle environment + uses: ./.github/actions/setup-gradle + + - name: Configure Git author + run: | + git config user.name "TMC Release Bot" + git config user.email "tmc-release-bot@thunderbird.net" + + - name: Create release tag + id: release-tag + run: | + set -euo pipefail + + tag="$(./gradlew -q "${COMPONENT_PATH}:printReleaseTag")" + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + tag_commit="$(git rev-list -n 1 "${tag}")" + head_commit="$(git rev-parse HEAD)" + + if [[ "${tag_commit}" != "${head_commit}" ]]; then + echo "Release tag ${tag} already exists but does not point at HEAD." + exit 1 + fi + + echo "Release tag ${tag} already exists on HEAD." + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + ./gradlew "${COMPONENT_PATH}:createReleaseTag" + + if ! git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Expected release tag ${tag} to be created." + exit 1 + fi + + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + - name: Write release notes + run: ./gradlew "${COMPONENT_PATH}:writeReleaseNotes" -PreleaseNotesFile="${RUNNER_TEMP}/release-notes.md" + + - name: Publish release + run: ./gradlew "${COMPONENT_PATH}:validateStableVersionForPublishing" "${COMPONENT_PATH}:publishAndReleaseToMavenCentral" + + - name: Push release tag + run: | + set -euo pipefail + + tag="${{ steps.release-tag.outputs.tag }}" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Remote release tag ${tag} already exists." + exit 0 + fi + + git push origin "refs/tags/${tag}" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + tag="${{ steps.release-tag.outputs.tag }}" + if gh release view "${tag}" >/dev/null 2>&1; then + echo "GitHub release ${tag} already exists." + exit 0 + fi + + gh release create "${tag}" \ + --title "${tag}" \ + --target "${GITHUB_SHA}" \ + --verify-tag \ + --notes-file "${RUNNER_TEMP}/release-notes.md" diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml new file mode 100644 index 0000000..9e364f1 --- /dev/null +++ b/.github/workflows/publish-snapshot.yml @@ -0,0 +1,72 @@ +name: Publish - Snapshot + +on: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: publish-snapshot + cancel-in-progress: false + +jobs: + publish-snapshot: + name: Publish snapshot + runs-on: ubuntu-latest + timeout-minutes: 60 + + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: main + fetch-depth: 0 + + - name: Check snapshot marker + id: snapshot-marker + run: | + set -euo pipefail + + marker="snapshot/latest" + head_commit="$(git rev-parse HEAD)" + + if git rev-parse -q --verify "refs/tags/${marker}" >/dev/null; then + marker_commit="$(git rev-list -n 1 "${marker}")" + + if [[ "${marker_commit}" == "${head_commit}" ]]; then + echo "Snapshot marker ${marker} already points at HEAD. Nothing to publish." + echo "publish=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + fi + + echo "publish=true" >> "${GITHUB_OUTPUT}" + + - name: Setup Gradle environment + if: steps.snapshot-marker.outputs.publish == 'true' + uses: ./.github/actions/setup-gradle + + - name: Publish snapshot + if: steps.snapshot-marker.outputs.publish == 'true' + run: | + set -euo pipefail + + ./gradlew validateSnapshotVersionForPublishing + ./gradlew publishToMavenCentral + + - name: Update snapshot marker + if: steps.snapshot-marker.outputs.publish == 'true' + run: | + set -euo pipefail + + marker="snapshot/latest" + git tag --force "${marker}" HEAD + git push --force origin "refs/tags/${marker}" diff --git a/build-plugin/README.md b/build-plugin/README.md index 81c8fe5..06f9859 100644 --- a/build-plugin/README.md +++ b/build-plugin/README.md @@ -68,6 +68,9 @@ Supportive/quality and tooling plugins: - Optional property: `-PreleaseVersion=` (must match `version.properties` when provided) - Optional property: `-PreleaseDate=YYYY-MM-DD` (defaults to the current local date) - Validation: fails when `Unreleased` is missing, empty, or when the target release already exists. + - `writeReleaseNotes`: writes the finalized release section to a markdown file for GitHub Releases. + - Version source: nearest `version.properties` + - Optional property: `-PreleaseNotesFile=` (defaults to `build/release/release-notes.md`) - Usage: ```kotlin // In any project that should contribute a component changelog @@ -134,8 +137,11 @@ The `net.thunderbird.gradle.plugin.publishing` plugin: - Adds local repositories: `mavenLocal()` and `${rootProject}/build/maven-repo` - Configures publishing to Maven Central and signs all publications - Adds release-oriented validation tasks: - - `validateStableVersionForPublishing`: validates a non-`SNAPSHOT` version before stable publishing. + - `validateStableVersionForPublishing`: validates a non-`SNAPSHOT` version before release publishing. - `validateSnapshotVersionForPublishing`: validates a `SNAPSHOT` version before snapshot publishing. +- Adds release tag tasks: + - `printReleaseTag`: prints the component release tag derived from `version.properties`. + - `createReleaseTag`: creates the component release tag locally. Signing properties can be supplied from a file at `${rootProject}/.signing/signing.properties` with keys: diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt index 3bd8120..253ad85 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt @@ -66,6 +66,27 @@ class ChangelogPlugin : Plugin { releaseVersion.convention(providers.gradleProperty("releaseVersion")) releaseDate.convention(providers.gradleProperty("releaseDate")) } + + tasks.register(WriteReleaseNotesTask.TASK_NAME) { + group = "documentation" + description = "Write GitHub release notes from the finalized component-local CHANGELOG.md section" + + changelogFile.set( + project.layout.file( + project.provider { File(versionDir, FileHelper.CHANGELOG_FILE) }, + ), + ) + versionFile.set( + project.layout.file( + project.provider { File(versionDir, FileHelper.VERSION_FILE) }, + ), + ) + outputFile.convention(layout.buildDirectory.file("release/release-notes.md")) + providers.gradleProperty("releaseNotesFile").orNull?.let { releaseNotesFile -> + outputFile.set(layout.file(provider { File(releaseNotesFile) })) + } + releaseVersion.convention(providers.gradleProperty("releaseVersion")) + } } } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTask.kt new file mode 100644 index 0000000..e0a0813 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTask.kt @@ -0,0 +1,94 @@ +package net.thunderbird.gradle.plugin.changelog + +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogEntry +import net.thunderbird.gradle.plugin.changelog.internal.ChangelogManager +import net.thunderbird.gradle.plugin.changelog.internal.Release +import net.thunderbird.gradle.plugin.versioning.internal.VersionManager +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Writes the finalized component changelog section for the current release. + */ +abstract class WriteReleaseNotesTask : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val changelogFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val versionFile: RegularFileProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @get:Input + @get:Optional + abstract val releaseVersion: Property + + @TaskAction + fun writeReleaseNotes() { + val version = resolveReleaseVersion() + val changelog = ChangelogManager(changelogFile.get().asFile).get() + val release = changelog.releases.firstOrNull { it.version == version } + ?: error("Release '$version' was not found in ${changelogFile.get().asFile.path}.") + + require(release.sections.values.any { it.isNotEmpty() }) { + "Release '$version' is empty in ${changelogFile.get().asFile.path}." + } + + val output = outputFile.get().asFile + output.parentFile.mkdirs() + output.writeText(renderReleaseNotes(release)) + + logger.lifecycle("[changelog] Wrote release notes to ${output.path}") + } + + private fun resolveReleaseVersion(): String { + val configuredVersion = VersionManager( + base = versionFile.get().asFile.parentFile, + root = versionFile.get().asFile.parentFile, + ).get().toStringValue() + val overrideVersion = releaseVersion.orNull?.trim() + + if (!releaseVersion.isPresent) { + return configuredVersion + } + require(!overrideVersion.isNullOrBlank()) { "releaseVersion must not be blank." } + require(overrideVersion == configuredVersion) { + "releaseVersion '$overrideVersion' does not match version.properties '$configuredVersion'." + } + return overrideVersion + } + + private fun renderReleaseNotes(release: Release): String = buildString { + appendLine("## ${release.version}${release.date?.let { " - $it" }.orEmpty()}") + release.sections.forEach { (sectionType, entries) -> + if (entries.isNotEmpty()) { + appendLine() + appendLine("### ${sectionType.header}") + appendLine() + entries.forEach { appendEntry(it) } + } + } + } + + private fun StringBuilder.appendEntry(entry: ChangelogEntry) { + if (entry.text.isNotBlank()) { + appendLine("- ${entry.text}") + } + } + + companion object { + const val TASK_NAME = "writeReleaseNotes" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt index dd18ecd..dfe891e 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt @@ -112,7 +112,7 @@ class PublishingPlugin : Plugin { val currentProjectPath = path tasks.register("validateStableVersionForPublishing") { group = "publishing" - description = "Validate that this project resolves to a stable release version." + description = "Validate that this project resolves to a release version." version.set(project.version.toString()) projectPath.set(currentProjectPath) snapshotRequired.set(false) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt index c660525..1c9b75e 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/ValidatePublicationVersionTask.kt @@ -31,7 +31,7 @@ abstract class ValidatePublicationVersionTask : DefaultTask() { if (versionString.endsWith(SNAPSHOT_SUFFIX)) { throw GradleException( "Stable releases require a non-SNAPSHOT version, but project '${projectPath.get()}' " + - "resolved '$versionString'. Create the component release tag before publishing a stable release.", + "resolved '$versionString'. Create the component release tag before publishing a release.", ) } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTask.kt new file mode 100644 index 0000000..f66e3bb --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTask.kt @@ -0,0 +1,37 @@ +package net.thunderbird.gradle.plugin.versioning + +import net.thunderbird.gradle.plugin.versioning.internal.VersionManager +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +abstract class PrintReleaseTagTask : DefaultTask() { + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val startDir: DirectoryProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val repoRootDir: DirectoryProperty + + @TaskAction + fun print() { + val versionManager = VersionManager( + base = startDir.get().asFile, + root = repoRootDir.get().asFile, + ) + val version = versionManager.get() + val versionFile = versionManager.sourceFile() + ?: error("No version.properties file found to print the release tag.") + + println("${versionFile.parentFile.name}-${version.toStringValue()}") + } + + companion object { + const val TASK_NAME = "printReleaseTag" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt index c9fef29..ddc3cf3 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt @@ -26,6 +26,7 @@ import org.gradle.kotlin.dsl.register * - versionBumpMinor: Bump MINOR version. * - versionBumpPatch: Bump PATCH version. * - printVersion: Print the effective project version. + * - printReleaseTag: Print the component release tag. */ class VersioningPlugin : Plugin { override fun apply(target: Project) { @@ -33,6 +34,7 @@ class VersioningPlugin : Plugin { configureVersioning() registerBumpTasks() registerPrintVersionTask() + registerPrintReleaseTagTask() registerCreateReleaseTagTask() } } @@ -85,6 +87,15 @@ class VersioningPlugin : Plugin { } } + private fun Project.registerPrintReleaseTagTask() { + tasks.register(PrintReleaseTagTask.TASK_NAME) { + group = "versioning" + description = "Print the component release git tag from version.properties" + startDir.set(project.layout.projectDirectory) + repoRootDir.set(project.rootProject.layout.projectDirectory) + } + } + private fun Project.registerCreateReleaseTagTask() { tasks.register(CreateReleaseTagTask.TASK_NAME) { group = "release" diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt index 8b2b163..7e3f027 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPluginTest.kt @@ -37,6 +37,11 @@ class ChangelogPluginTest { val finalizeTask = fixture.project.tasks.named(FinalizeChangelogTask.TASK_NAME).get() as FinalizeChangelogTask assertThat(finalizeTask.changelogFile.get().asFile.canonicalFile) .isEqualTo(fixture.changelogFile.canonicalFile) + + val writeReleaseNotesTask = + fixture.project.tasks.named(WriteReleaseNotesTask.TASK_NAME).get() as WriteReleaseNotesTask + assertThat(writeReleaseNotesTask.changelogFile.get().asFile.canonicalFile) + .isEqualTo(fixture.changelogFile.canonicalFile) } @Test @@ -51,6 +56,7 @@ class ChangelogPluginTest { // Assert assertThat(fixture.project.version.toString()).isEqualTo("1.2.3-SNAPSHOT") assertThat(fixture.project.tasks.findByName(UpdateChangelogTask.TASK_NAME)).isNotNull() + assertThat(fixture.project.tasks.findByName(WriteReleaseNotesTask.TASK_NAME)).isNotNull() assertThat(fixture.project.tasks.findByName("versionBumpPatch")).isNotNull() } @@ -70,6 +76,7 @@ class ChangelogPluginTest { // Assert assertThat(project.tasks.findByName(UpdateChangelogTask.TASK_NAME)).isNull() assertThat(project.tasks.findByName(FinalizeChangelogTask.TASK_NAME)).isNull() + assertThat(project.tasks.findByName(WriteReleaseNotesTask.TASK_NAME)).isNull() } private fun createNestedComponentProject( diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTaskTest.kt new file mode 100644 index 0000000..c32d063 --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/changelog/WriteReleaseNotesTaskTest.kt @@ -0,0 +1,139 @@ +package net.thunderbird.gradle.plugin.changelog + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.messageContains +import kotlin.test.Test +import net.thunderbird.gradle.plugin.changelog.internal.fs.FileHelper +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class WriteReleaseNotesTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `writeReleaseNotes writes finalized release section`() { + // Arrange + val componentDir = createComponentDir(CHANGELOG) + val outputFile = temporaryFolder.newFile("release-notes.md") + val task = createTask(componentDir).apply { + this.outputFile.set(outputFile) + } + + // Act + task.writeReleaseNotes() + + // Assert + assertThat(outputFile.readText()).isEqualTo( + """ + ## 1.2.3 - 2026-06-18 + + ### Features + + - add feature + + ### Bug Fixes + + - fix bug + + """.trimIndent(), + ) + } + + @Test + fun `writeReleaseNotes accepts matching release version override`() { + // Arrange + val componentDir = createComponentDir(CHANGELOG) + val outputFile = temporaryFolder.newFile("release-notes.md") + val task = createTask(componentDir).apply { + this.outputFile.set(outputFile) + releaseVersion.set("1.2.3") + } + + // Act + task.writeReleaseNotes() + + // Assert + assertThat(outputFile.readText()).contains("## 1.2.3 - 2026-06-18") + } + + @Test + fun `writeReleaseNotes fails when release section is missing`() { + // Arrange + val componentDir = createComponentDir(CHANGELOG_WITHOUT_RELEASE) + val task = createTask(componentDir) + + // Act + val failure = assertFailure { task.writeReleaseNotes() } + + // Assert + failure.messageContains("Release '1.2.3' was not found") + } + + @Test + fun `writeReleaseNotes fails when release version override differs from version properties`() { + // Arrange + val componentDir = createComponentDir(CHANGELOG) + val task = createTask(componentDir).apply { + releaseVersion.set("2.0.0") + } + + // Act + val failure = assertFailure { task.writeReleaseNotes() } + + // Assert + failure.messageContains("releaseVersion '2.0.0' does not match version.properties '1.2.3'") + } + + private fun createComponentDir(changelog: String) = temporaryFolder.newFolder("component").apply { + resolve(FileHelper.VERSION_FILE).writeText(VERSION_PROPERTIES) + resolve(FileHelper.CHANGELOG_FILE).writeText(changelog) + } + + private fun createTask(componentDir: java.io.File): WriteReleaseNotesTask { + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .build() + + return project.tasks.create(WriteReleaseNotesTask.TASK_NAME, WriteReleaseNotesTask::class.java).apply { + changelogFile.set(componentDir.resolve(FileHelper.CHANGELOG_FILE)) + versionFile.set(componentDir.resolve(FileHelper.VERSION_FILE)) + outputFile.set(temporaryFolder.newFile("release-notes-${System.nanoTime()}.md")) + } + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + + private val CHANGELOG = """ + # Changelog + + ## Unreleased + + ## 1.2.3 - 2026-06-18 + + ### Features + + - add feature + + ### Bug Fixes + + - fix bug + """.trimIndent() + + private val CHANGELOG_WITHOUT_RELEASE = """ + # Changelog + + ## Unreleased + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTaskTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTaskTest.kt new file mode 100644 index 0000000..2fb41df --- /dev/null +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/PrintReleaseTagTaskTest.kt @@ -0,0 +1,88 @@ +package net.thunderbird.gradle.plugin.versioning + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import kotlin.test.Test +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PrintReleaseTagTaskTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `print writes release tag to standard output`() { + // Arrange + val rootDir = temporaryFolder.newFolder("print-release-tag-task-test") + val componentDir = rootDir.resolve("components/bom") + componentDir.mkdirs() + componentDir.resolve("version.properties").writeText(VERSION_PROPERTIES) + val project = ProjectBuilder.builder() + .withProjectDir(componentDir) + .build() + val task = project.tasks.create(PrintReleaseTagTask.TASK_NAME, PrintReleaseTagTask::class.java).apply { + startDir.set(project.layout.projectDirectory) + repoRootDir.set(rootDir) + } + + // Act + val output = captureStandardOut { task.print() } + + // Assert + assertThat(output.trim()).isEqualTo("bom-1.2.3") + } + + @Test + fun `print uses nearest version properties for nested module`() { + // Arrange + val rootDir = temporaryFolder.newFolder("print-release-tag-task-test") + rootDir.resolve("version.properties").writeText(ROOT_VERSION_PROPERTIES) + val componentDir = rootDir.resolve("components/feature/sync") + val moduleDir = componentDir.resolve("impl") + moduleDir.mkdirs() + componentDir.resolve("version.properties").writeText(VERSION_PROPERTIES) + val project = ProjectBuilder.builder() + .withProjectDir(moduleDir) + .build() + val task = project.tasks.create(PrintReleaseTagTask.TASK_NAME, PrintReleaseTagTask::class.java).apply { + startDir.set(project.layout.projectDirectory) + repoRootDir.set(rootDir) + } + + // Act + val output = captureStandardOut { task.print() } + + // Assert + assertThat(output.trim()).isEqualTo("sync-1.2.3") + } + + private fun captureStandardOut(block: () -> Unit): String { + val originalOut = System.out + val buffer = ByteArrayOutputStream() + System.setOut(PrintStream(buffer)) + try { + block() + } finally { + System.setOut(originalOut) + } + return buffer.toString() + } + + private companion object { + private val VERSION_PROPERTIES = """ + MAJOR=1 + MINOR=2 + PATCH=3 + """.trimIndent() + + private val ROOT_VERSION_PROPERTIES = """ + MAJOR=9 + MINOR=8 + PATCH=7 + """.trimIndent() + } +} diff --git a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt index 9649f8c..903231c 100644 --- a/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt +++ b/build-plugin/plugin/src/test/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPluginTest.kt @@ -62,6 +62,7 @@ class VersioningPluginTest { assertThat(fixture.project.tasks.findByName("versionBumpMinor")).isNotNull() assertThat(fixture.project.tasks.findByName("versionBumpPatch")).isNotNull() assertThat(fixture.project.tasks.findByName(PrintVersionTask.TASK_NAME)).isNotNull() + assertThat(fixture.project.tasks.findByName(PrintReleaseTagTask.TASK_NAME)).isNotNull() assertThat(fixture.project.tasks.findByName(CreateReleaseTagTask.TASK_NAME)).isNotNull() } diff --git a/docs/release-guide.md b/docs/release-guide.md index bfea1d8..092a216 100644 --- a/docs/release-guide.md +++ b/docs/release-guide.md @@ -6,9 +6,17 @@ This guide is for maintainers preparing and publishing Thunderbird Mobile Compon - Maven Central credentials and signing properties are available to the publishing environment. -## Stable Release +The publishing workflows expect these repository secrets: -Stable releases start with a release preparation pull request. +- `MAVEN_CENTRAL_USERNAME` +- `MAVEN_CENTRAL_PASSWORD` +- `SIGNING_IN_MEMORY_KEY` +- `SIGNING_IN_MEMORY_KEY_ID` +- `SIGNING_IN_MEMORY_KEY_PASSWORD` + +## Release + +Releases start with a release preparation pull request. 1. Create a release branch from `main`. @@ -60,9 +68,9 @@ To use a specific release date: - The finalized changelog version matches `version.properties`. - The pull request contains no unrelated changes. -## Stable Release Publishing +## Release Publishing -Publish a stable release only after the release pull request has merged into `main`. +Publish a release only after the release pull request has merged into `main`. The release tag must be created from the merged release commit. The tag format is: @@ -76,10 +84,17 @@ Example: -1.0.0 ``` -The release job should run from the merged `main` commit and perform these steps: +Trigger the `Publish Release` workflow from `main` and provide the component path, for example +`:components:bom`. + +The workflow creates the release tag locally, writes GitHub Release notes from the finalized component changelog, +publishes the component to Maven Central, then pushes the tag and creates the GitHub Release after publishing succeeds. + +The workflow performs these Gradle steps: ```bash ./gradlew :createReleaseTag +./gradlew :writeReleaseNotes ./gradlew :validateStableVersionForPublishing :publishAndReleaseToMavenCentral ``` @@ -92,29 +107,35 @@ For local verification before publishing to Maven Central, publish to Maven Loca Before publishing, verify: - The release pull request has been merged. -- The job runs from the merged `main` commit. -- The component release tag is created on that commit. -- `validateStableVersionForPublishing` succeeds. +- The workflow is started from `main`. +- The component path input points at the component being released. -## Daily Snapshot Publishing +## Snapshot Publishing -Daily snapshots are published from an untagged `main` commit. Do not create a release pull request, do not finalize the -changelog, and do not create a release tag for a daily snapshot. +Snapshots are published from an untagged `main` commit. Do not create a release pull request, do not finalize the +changelog, and do not create a release tag for a snapshot. -The snapshot job should run from the intended `main` commit: +The `Publish Snapshot` workflow is triggered manually from `main` and publishes all publishable components. + +The workflow skips publishing when the mutable `snapshot/latest` marker tag already points at the current `main` +commit. After a successful publish, the workflow moves `snapshot/latest` to the published commit. + +The workflow performs these Gradle steps: ```bash -./gradlew :validateSnapshotVersionForPublishing :publishToMavenCentral +./gradlew validateSnapshotVersionForPublishing +./gradlew publishToMavenCentral ``` For local verification, publish to Maven Local instead: ```bash -./gradlew :validateSnapshotVersionForPublishing :publishToMavenLocal +./gradlew validateSnapshotVersionForPublishing +./gradlew publishToMavenLocal ``` Before publishing a snapshot, verify: - The job runs from the intended `main` commit. - The commit is not tagged with the matching component release tag. -- `validateSnapshotVersionForPublishing` succeeds. + From 270f33d97b61b505d39ae7c981f3b560f106e18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 10:31:01 +0200 Subject: [PATCH 08/17] chore(deps): bump AGP 8.13.2 -> 9.2.1 # Conflicts: # gradle/libs.versions.toml --- .../gradle/plugin/ProjectConfig.kt | 2 +- .../kmp/KotlinMultiplatformExtension.kt | 47 ++++++++++++++ .../plugin/library/kmp/LibraryKmpPlugin.kt | 41 +++++++++--- .../kmp/compose/LibraryKmpComposePlugin.kt | 63 +++++++++++++------ gradle/libs.versions.toml | 57 +++++++++++++++-- 5 files changed, 175 insertions(+), 35 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/ProjectConfig.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/ProjectConfig.kt index 054ba5f..185333a 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/ProjectConfig.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/ProjectConfig.kt @@ -8,7 +8,7 @@ object ProjectConfig { const val group = "net.thunderbird" object Android { - const val sdkMin = 21 + const val sdkMin = 23 // Only needed for application const val sdkTarget = 35 diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt new file mode 100644 index 0000000..5d631c2 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt @@ -0,0 +1,47 @@ +package net.thunderbird.gradle.plugin.library.kmp + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import org.gradle.api.Action +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.artifacts.ExternalModuleDependency +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.named +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet + +val NamedDomainObjectContainer.androidHostTest: NamedDomainObjectProvider + get() = this.named("androidHostTest") + +fun KotlinMultiplatformExtension.android(configure: Action) { + (this as ExtensionAware).extensions.configure("android", configure) +} + +/** + * Creates a dependency with the given configuration. + * + * This is a workaround for kotlin-multiplatform's implementation() function not supporting excludes when + * declaring dependencies from a version catalog. + * + * Example: + * + * ```kotlin + * implementationWithExcludes(libs.foo.bar) { + * exclude(group = "org.foo.bar", module = "dependency") + * } + * ``` + * + * @param dependency the dependency to create + * @param configure the configuration to apply to the dependency + */ +fun KotlinDependencyHandler.implementationWithExcludes( + dependency: Provider, + configure: ExternalModuleDependency.() -> Unit, +) { + val copy = dependency.get().copy() + copy.configure() + implementation(copy) +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt index 791a1d9..36dee65 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt @@ -1,11 +1,9 @@ package net.thunderbird.gradle.plugin.library.kmp -import com.android.build.api.dsl.androidLibrary import net.thunderbird.gradle.plugin.ProjectConfig import net.thunderbird.gradle.plugin.libs import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.internal.Actions.with import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @@ -35,16 +33,23 @@ class LibraryKmpPlugin : Plugin { } extensions.configure { - @Suppress("UnstableApiUsage") - androidLibrary { + explicitApi() + + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { minSdk = ProjectConfig.Android.sdkMin compileSdk = ProjectConfig.Android.sdkCompile + + withHostTest { } + compilerOptions { jvmTarget.set(ProjectConfig.Compiler.jvmTarget) } } - iosX64() iosArm64() iosSimulatorArm64() @@ -55,18 +60,34 @@ class LibraryKmpPlugin : Plugin { } sourceSets { - androidMain.dependencies { - implementation(libs.bundles.shared.kmp.android) - } - commonMain.dependencies { implementation(project.dependencies.platform(libs.kotlin.bom)) implementation(libs.bundles.shared.kmp.common) } - commonTest.dependencies { implementation(libs.bundles.shared.kmp.common.test) } + + androidMain.dependencies { + implementation(libs.bundles.shared.kmp.android) + } + androidHostTest.dependencies { + implementation(libs.bundles.shared.kmp.android.test) + } + + jvmMain.dependencies { + implementation(libs.bundles.shared.kmp.jvm) + } + jvmTest.dependencies { + implementation(libs.bundles.shared.kmp.jvm.test) + } + + nativeMain.dependencies { + implementation(libs.bundles.shared.kmp.native) + } + nativeTest.dependencies { + implementation(libs.bundles.shared.kmp.native.test) + } } } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index d7a0de3..ecf3e9e 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -1,12 +1,11 @@ package net.thunderbird.gradle.plugin.library.kmp.compose -import com.android.build.api.dsl.androidLibrary import net.thunderbird.gradle.plugin.ProjectConfig -import net.thunderbird.gradle.plugin.compose +import net.thunderbird.gradle.plugin.library.kmp.android +import net.thunderbird.gradle.plugin.library.kmp.androidHostTest import net.thunderbird.gradle.plugin.libs import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.internal.Actions import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @@ -38,16 +37,27 @@ class LibraryKmpComposePlugin : Plugin { } extensions.configure { - @Suppress("UnstableApiUsage") - androidLibrary { + explicitApi() + + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { minSdk = ProjectConfig.Android.sdkMin compileSdk = ProjectConfig.Android.sdkCompile + + androidResources.enable = true + + withHostTest { + isIncludeAndroidResources = true + } + compilerOptions { jvmTarget.set(ProjectConfig.Compiler.jvmTarget) } } - iosX64() iosArm64() iosSimulatorArm64() @@ -58,24 +68,41 @@ class LibraryKmpComposePlugin : Plugin { } sourceSets { - androidMain.dependencies { - implementation(libs.bundles.shared.kmp.android) - } - commonMain.dependencies { implementation(project.dependencies.platform(libs.kotlin.bom)) implementation(libs.bundles.shared.kmp.common) - implementation(libs.bundles.shared.kmp.compose) - - implementation(libs.jetbrains.compose.runtime) - implementation(libs.jetbrains.compose.foundation) - implementation(libs.jetbrains.compose.ui) - implementation(libs.jetbrains.compose.components.resources) - implementation(libs.jetbrains.compose.components.ui.preview) + implementation(libs.bundles.shared.kmp.compose.common) } - commonTest.dependencies { implementation(libs.bundles.shared.kmp.common.test) + implementation(libs.bundles.shared.kmp.compose.common.test) + } + + androidMain.dependencies { + implementation(libs.bundles.shared.kmp.android) + implementation(libs.bundles.shared.kmp.compose.android) + } + androidHostTest.dependencies { + implementation(libs.bundles.shared.kmp.android.test) + implementation(libs.bundles.shared.kmp.compose.android.test) + } + + jvmMain.dependencies { + implementation(libs.bundles.shared.kmp.jvm) + implementation(libs.bundles.shared.kmp.compose.jvm) + } + jvmTest.dependencies { + implementation(libs.bundles.shared.kmp.jvm.test) + implementation(libs.bundles.shared.kmp.compose.jvm.test) + } + + nativeMain.dependencies { + implementation(libs.bundles.shared.kmp.native) + implementation(libs.bundles.shared.kmp.compose.native) + } + nativeTest.dependencies { + implementation(libs.bundles.shared.kmp.native.test) + implementation(libs.bundles.shared.kmp.compose.native.test) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c94f25..016ff22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,9 @@ # 3. Run the examples and check for any issues. [versions] -androidGradlePlugin = "8.13.2" +androidGradlePlugin = "9.2.1" androidXActivity = "1.10.1" +androidXCompose = "1.11.3" assertk = "0.28.1" dependencyCheckPlugin = "0.53.0" detektPlugin = "1.23.8" @@ -29,6 +30,7 @@ kotlinxDateTime = "0.8.0" kotlinxSerialization = "1.10.0" kover = "0.9.8" mavenPublish = "0.37.0" +robolectric = "4.16.1" spotlessPlugin = "8.6.0" turbine = "1.2.1" @@ -62,6 +64,7 @@ tb-versioning = { id = "net.thunderbird.gradle.plugin.versioning" } [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidXActivity" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidXCompose" } assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } detekt-plugin-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektPluginCompose" } jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } @@ -73,6 +76,8 @@ jetbrains-compose-lifecycle-viewmodel-compose = { module = "org.jetbrains.androi jetbrains-compose-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "jetbrainsComposeLifecycle" } jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +jetbrains-compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } +jetbrains-compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } konsist = { module = "com.lemonappdev:konsist", version.ref = "konsist" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlinBom" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } @@ -83,6 +88,7 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDateTime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [bundles] @@ -95,14 +101,53 @@ shared-kmp-common = [ shared-kmp-android = [ "kotlinx-coroutines-android", ] -shared-kmp-compose = [ - "jetbrains-compose-lifecycle-runtime", - "jetbrains-compose-lifecycle-viewmodel", - "jetbrains-compose-lifecycle-viewmodel-compose", - "jetbrains-compose-lifecycle-viewmodel-savedstate", +shared-kmp-jvm = [ +] +shared-kmp-native = [ ] + shared-kmp-common-test = [ + "assertk", "kotlin-test", "kotlinx-coroutines-test", "turbine", ] +shared-kmp-android-test = [ +] +shared-kmp-jvm-test = [ +] +shared-kmp-native-test = [ +] + +shared-kmp-compose-commmon = [ + "jetbrains-compose-runtime", + "jetbrains-compose-foundation", + "jetbrains-compose-components-resources", + "jetbrains-compose-components-ui-preview", + "jetbrains-compose-lifecycle-runtime", + "jetbrains-compose-lifecycle-viewmodel", + "jetbrains-compose-lifecycle-viewmodel-compose", + "jetbrains-compose-lifecycle-viewmodel-savedstate", + "jetbrains-compose-ui", + # Disabled, as it's not used yet + # "jetbrains-compose-navigation3", + # "jetbrains-compose-navigation-event", +] +shared-kmp-compose-android = [ +] +shared-kmp-compose-jvm = [ +] +shared-kmp-compose-native = [ +] + +shared-kmp-compose-common-test = [ + "jetbrains-compose-ui-test" +] +shared-kmp-compose-android-test = [ + "androidx-compose-ui-test-manifest", + "robolectric", +] +shared-kmp-compose-jvm-test = [ +] +shared-kmp-compose-native-test = [ +] From 34a628c546890820b0b7ab5fd9d2fb50f54fd517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 10:34:55 +0200 Subject: [PATCH 09/17] chore(deps): bump AndroidX Activity 1.10.1 -> 1.13.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 016ff22..b8a4ea3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ [versions] androidGradlePlugin = "9.2.1" -androidXActivity = "1.10.1" +androidXActivity = "1.13.0" androidXCompose = "1.11.3" assertk = "0.28.1" dependencyCheckPlugin = "0.53.0" From 3d0dd0ce78517fe865e1d1cead83bafc7074816d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 10:35:29 +0200 Subject: [PATCH 10/17] chore(deps): bump dependency check plugin 0.53.0 -> 0.54.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8a4ea3..5e4f072 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ androidGradlePlugin = "9.2.1" androidXActivity = "1.13.0" androidXCompose = "1.11.3" assertk = "0.28.1" -dependencyCheckPlugin = "0.53.0" +dependencyCheckPlugin = "0.54.0" detektPlugin = "1.23.8" detektPluginCompose = "0.5.8" gradle = "9.5.1" From 8e195974cccd42349c7e46444e436551a33eea64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 10:36:11 +0200 Subject: [PATCH 11/17] chore(deps): bump kotlinx serialization 1.10.0 -> 1.11.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e4f072..3e465e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ kotlinKsp = "2.3.9" kotlinxCoroutines = "1.11.0" kotlinxCollectionsImmutable = "0.4.0" kotlinxDateTime = "0.8.0" -kotlinxSerialization = "1.10.0" +kotlinxSerialization = "1.11.0" kover = "0.9.8" mavenPublish = "0.37.0" robolectric = "4.16.1" From a6b4d914558b09e17423b481356f04cb1f3a3f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 10:36:44 +0200 Subject: [PATCH 12/17] chore(deps): bump detekt compose plugin 0.5.8 -> 0.6.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e465e0..d02ed68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ androidXCompose = "1.11.3" assertk = "0.28.1" dependencyCheckPlugin = "0.54.0" detektPlugin = "1.23.8" -detektPluginCompose = "0.5.8" +detektPluginCompose = "0.6.2" gradle = "9.5.1" gradleSha256 = "c72fb9991f6025cbe337d52ba77e531b3faf62bdd3e348fe1ccee9f51c71adb0" composeMultiplatform = "1.11.1" From 4730c2911345cdfad36412429eb5ed12a4a38b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 10:56:43 +0200 Subject: [PATCH 13/17] chore(build): add dokka plugin for documentation --- build-plugin/plugin/build.gradle.kts | 1 + .../thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt | 1 + .../plugin/library/kmp/compose/LibraryKmpComposePlugin.kt | 1 + build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ 5 files changed, 6 insertions(+) diff --git a/build-plugin/plugin/build.gradle.kts b/build-plugin/plugin/build.gradle.kts index c59b8c5..d7a362f 100644 --- a/build-plugin/plugin/build.gradle.kts +++ b/build-plugin/plugin/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { compileOnly(plugin(libs.plugins.compose.multiplatform)) implementation(plugin(libs.plugins.dependency.check)) + implementation(plugin(libs.plugins.dokka)) implementation(plugin(libs.plugins.maven.publish)) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt index 36dee65..c494cce 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt @@ -20,6 +20,7 @@ class LibraryKmpPlugin : Plugin { with(target) { with(pluginManager) { apply("com.android.kotlin.multiplatform.library") + apply("org.jetbrains.dokka") apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index ecf3e9e..387c338 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -23,6 +23,7 @@ class LibraryKmpComposePlugin : Plugin { with(pluginManager) { apply("com.android.kotlin.multiplatform.library") apply("org.jetbrains.compose") + apply("org.jetbrains.dokka") apply("org.jetbrains.kotlin.plugin.compose") apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") diff --git a/build.gradle.kts b/build.gradle.kts index d3a01d9..0aa4f4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.test) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.dokka) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.serialization) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d02ed68..7403e8c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ assertk = "0.28.1" dependencyCheckPlugin = "0.54.0" detektPlugin = "1.23.8" detektPluginCompose = "0.6.2" +dokkaPlugin = "2.2.0" gradle = "9.5.1" gradleSha256 = "c72fb9991f6025cbe337d52ba77e531b3faf62bdd3e348fe1ccee9f51c71adb0" composeMultiplatform = "1.11.1" @@ -42,6 +43,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } dependency-check = { id = "com.github.ben-manes.versions", version.ref = "dependencyCheckPlugin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detektPlugin" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaPlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinBom" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinBom" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinBom" } From abf9142dc049d14b72e85fa91fd69dfa6b726493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 11:12:41 +0200 Subject: [PATCH 14/17] chore(build): enable ABI compatibility check --- .../thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt | 3 +++ .../plugin/library/kmp/compose/LibraryKmpComposePlugin.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt index c494cce..f08385b 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/LibraryKmpPlugin.kt @@ -7,6 +7,7 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation /** * A Gradle plugin to configure a Kotlin Multiplatform Library project. @@ -33,8 +34,10 @@ class LibraryKmpPlugin : Plugin { apply("net.thunderbird.gradle.plugin.quality.spotless") } + @OptIn(ExperimentalAbiValidation::class) extensions.configure { explicitApi() + abiValidation() compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index 387c338..83f80b7 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -9,6 +9,7 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation /** * A Gradle plugin to configure a Kotlin Multiplatform Compose Library project. @@ -37,8 +38,10 @@ class LibraryKmpComposePlugin : Plugin { apply("net.thunderbird.gradle.plugin.quality.spotless") } + @OptIn(ExperimentalAbiValidation::class) extensions.configure { explicitApi() + abiValidation() compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") From 3dee31ee1cae24505c906cbfaed9a6216a3437dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 11:36:22 +0200 Subject: [PATCH 15/17] chore(build): enable isolated Gradle projects and resolve failures --- .../app/kmp/compose/AppKmpComposePlugin.kt | 3 ++- .../plugin/changelog/ChangelogPlugin.kt | 7 ++++-- .../publishing/PublishingCoordinates.kt | 5 ++-- .../plugin/publishing/PublishingPlugin.kt | 6 +++-- .../quality/coverage/CodeCoveragePlugin.kt | 2 +- .../plugin/quality/detekt/DetektPlugin.kt | 14 +++++------ .../plugin/quality/spotless/SpotlessPlugin.kt | 16 ++++++++---- .../plugin/versioning/VersioningPlugin.kt | 25 ++++++++++++------- gradle.properties | 11 ++++++++ 9 files changed, 59 insertions(+), 30 deletions(-) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/kmp/compose/AppKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/kmp/compose/AppKmpComposePlugin.kt index 038d88d..cf18377 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/kmp/compose/AppKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/kmp/compose/AppKmpComposePlugin.kt @@ -126,7 +126,8 @@ class AppKmpComposePlugin : Plugin { warningsAsErrors = false abortOnError = true checkDependencies = true - lintConfig = project.file("${project.rootProject.projectDir}/config/lint/lint.xml") + @Suppress("UnstableApiUsage") + lintConfig = project.isolated.rootProject.projectDirectory.file("config/lint/lint.xml").asFile } packaging { diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt index 253ad85..2a75c2b 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/changelog/ChangelogPlugin.kt @@ -18,7 +18,9 @@ class ChangelogPlugin : Plugin { override fun apply(target: Project) { with(target) { val start = project.projectDir - val root = rootProject.projectDir + + @Suppress("UnstableApiUsage") + val root = isolated.rootProject.projectDirectory.asFile val versionDir = FileHelper.locateNearestVersionDir(start, root) if (versionDir == null) { @@ -45,7 +47,8 @@ class ChangelogPlugin : Plugin { ), ) - repoRootDir.set(rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(isolated.rootProject.projectDirectory) repoUrl.set(ProjectConfig.Publishing.url) } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt index 0cc7fbb..0359b27 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingCoordinates.kt @@ -14,13 +14,14 @@ private fun Project.hasDefaultGroup(): Boolean { return currentGroup == DEFAULT_GROUP || currentGroup == defaultGradleGroup() } +@Suppress("UnstableApiUsage") private fun Project.defaultGradleGroup(): String { - val parentSegments = path + val parentPath = path .split(":") .filter(String::isNotBlank) .dropLast(1) - return (listOf(rootProject.name) + parentSegments) + return (listOf(isolated.rootProject.name) + parentPath) .joinToString(".") } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt index dfe891e..efec110 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/publishing/PublishingPlugin.kt @@ -40,8 +40,9 @@ class PublishingPlugin : Plugin { } } + @Suppress("UnstableApiUsage") private fun Project.loadSigningProperties() { - val signingPropsFile = rootProject.file(".signing/signing.properties") + val signingPropsFile = isolated.rootProject.projectDirectory.file(".signing/signing.properties").asFile if (signingPropsFile.exists()) { val properties = Properties() signingPropsFile.inputStream().use { properties.load(it) } @@ -61,7 +62,8 @@ class PublishingPlugin : Plugin { maven { name = "localBuild" - url = rootProject.layout.buildDirectory.dir("maven-repo").get().asFile.toURI() + @Suppress("UnstableApiUsage") + url = isolated.rootProject.projectDirectory.dir("build/maven-repo").asFile.toURI() } } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/coverage/CodeCoveragePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/coverage/CodeCoveragePlugin.kt index d7e5b37..6ab4f81 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/coverage/CodeCoveragePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/coverage/CodeCoveragePlugin.kt @@ -60,7 +60,7 @@ class CodeCoveragePlugin : Plugin { afterEvaluate { configureKover( coverageExtension = extension, - isRoot = this == rootProject, + isRoot = path == ":", ) } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt index a43eb2c..8eaf7a6 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt @@ -24,7 +24,7 @@ class DetektPlugin : Plugin { add("detektPlugins", libs.detekt.plugin.compose) } - if (this == rootProject) { + if (path == ":") { configureRootDetektTasks() } else { configureDetekt() @@ -33,12 +33,14 @@ class DetektPlugin : Plugin { } } + @Suppress("UnstableApiUsage") private fun Project.configureDetekt() { extensions.configure("detekt") { - config.setFrom(project.rootProject.files("config/detekt/detekt.yml")) + config.setFrom(project.isolated.rootProject.projectDirectory.file("config/detekt/detekt.yml")) val name = project.path.replace(":", "-").replace("/", "-") - baseline = project.rootProject.file("config/detekt/detekt-baseline$name.xml") + baseline = project.isolated.rootProject.projectDirectory + .file("config/detekt/detekt-baseline$name.xml").asFile ignoredBuildTypes = listOf("release") } @@ -79,11 +81,7 @@ class DetektPlugin : Plugin { with(tasks) { register("detektAll") { group = "verification" - description = "Runs detekt on the whole project" - - allprojects { - this@register.dependsOn(tasks.withType()) - } + description = "Runs detekt on the root project" } } } diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/spotless/SpotlessPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/spotless/SpotlessPlugin.kt index 8b5759a..ed8f335 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/spotless/SpotlessPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/spotless/SpotlessPlugin.kt @@ -15,7 +15,7 @@ class SpotlessPlugin : Plugin { with(target) { pluginManager.apply("com.diffplug.spotless") - if (this == rootProject) { + if (path == ":") { configureSpotlessRoot() } else { configureSpotless() @@ -23,8 +23,11 @@ class SpotlessPlugin : Plugin { } } + @Suppress("UnstableApiUsage") private fun Project.configureSpotless() { extensions.configure { + val editorConfigPath = isolated.rootProject.projectDirectory.file(".editorconfig").asFile.path + kotlin { target( "src/*/kotlin/*.kt", @@ -32,7 +35,7 @@ class SpotlessPlugin : Plugin { ) ktlint() - .setEditorConfigPath("${rootProject.projectDir}/.editorconfig") + .setEditorConfigPath(editorConfigPath) .editorConfigOverride(kotlinEditorConfigOverride) } @@ -42,7 +45,7 @@ class SpotlessPlugin : Plugin { ) ktlint() - .setEditorConfigPath("${rootProject.projectDir}/.editorconfig") + .setEditorConfigPath(editorConfigPath) .editorConfigOverride( mapOf( "ktlint_code_style" to "intellij_idea", @@ -66,15 +69,18 @@ class SpotlessPlugin : Plugin { } } + @Suppress("UnstableApiUsage") private fun Project.configureSpotlessRoot() { extensions.configure { + val editorConfigPath = isolated.rootProject.projectDirectory.file(".editorconfig").asFile.path + kotlin { target( "build-plugin/plugin/src/*/kotlin/*.kt", "build-plugin/plugin/src/*/kotlin/**/*.kt", ) ktlint() - .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig") + .setEditorConfigPath(editorConfigPath) .editorConfigOverride(kotlinEditorConfigOverride) } @@ -86,7 +92,7 @@ class SpotlessPlugin : Plugin { ) ktlint() - .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig") + .setEditorConfigPath(editorConfigPath) .editorConfigOverride( mapOf( "ktlint_code_style" to "intellij_idea", diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt index ddc3cf3..9a15783 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/versioning/VersioningPlugin.kt @@ -40,15 +40,16 @@ class VersioningPlugin : Plugin { } private fun Project.configureVersioning() { - val root = this.rootProject + @Suppress("UnstableApiUsage") + val repoRoot = isolated.rootProject.projectDirectory.asFile val versionManager = VersionManager( base = projectDir, - root = root.projectDir, + root = repoRoot, ) val version = versionManager.get() val versionFile = versionManager.sourceFile() ?: error("No version.properties file found to resolve the project version.") - val versionProvider = GitVersionProvider(providers).resolve(root.projectDir, versionFile, version) + val versionProvider = GitVersionProvider(providers).resolve(repoRoot, versionFile, version) this.version = ProviderBackedVersion(versionProvider) logger.lifecycle("[versioning] Project version will be resolved from ${versionFile.path}") @@ -59,21 +60,24 @@ class VersioningPlugin : Plugin { group = "versioning" description = "Bump MAJOR and reset MINOR/PATCH to 0 in nearest version.properties" startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(project.isolated.rootProject.projectDirectory) part.set("major") } tasks.register("versionBumpMinor") { group = "versioning" description = "Bump MINOR and reset PATCH to 0 in nearest version.properties" startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(project.isolated.rootProject.projectDirectory) part.set("minor") } tasks.register("versionBumpPatch") { group = "versioning" description = "Bump PATCH in nearest version.properties" startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(project.isolated.rootProject.projectDirectory) part.set("patch") } } @@ -83,7 +87,8 @@ class VersioningPlugin : Plugin { group = "versioning" description = "Print the version resolved from the nearest version.properties" startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(project.isolated.rootProject.projectDirectory) } } @@ -92,7 +97,8 @@ class VersioningPlugin : Plugin { group = "versioning" description = "Print the component release git tag from version.properties" startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(project.isolated.rootProject.projectDirectory) } } @@ -101,7 +107,8 @@ class VersioningPlugin : Plugin { group = "release" description = "Create the component release git tag from version.properties" startDir.set(project.layout.projectDirectory) - repoRootDir.set(project.rootProject.layout.projectDirectory) + @Suppress("UnstableApiUsage") + repoRootDir.set(project.isolated.rootProject.projectDirectory) } } } diff --git a/gradle.properties b/gradle.properties index ac2c43f..f4e1ac3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,18 @@ org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.configuration-cache.parallel=true org.gradle.kotlin.dsl.allWarningsAsErrors=true +org.gradle.tooling.parallel=true +org.gradle.unsafe.isolated-projects=true # Kotlin kotlin.code.style=official kotlin.incremental=true kotlin.compiler.execution.strategy=in-process +## Dokka workaround for Gradle project isolation violations +## See https://github.com/Kotlin/dokka/issues/4488 +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true +org.jetbrains.dokka.experimental.gradle.pluginMode.nowarn=true +org.jetbrains.dokka.experimental.tryK2=true +org.jetbrains.dokka.experimental.tryK2.noWarn=true +org.jetbrains.dokka.experimental.tryK2.nowarn=true +org.jetbrains.dokka.internal.enableWorkaroundKT80551=true From c509cfe7011770d73526bf01433136e500e50a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 12:59:39 +0200 Subject: [PATCH 16/17] chore(ci): ignore updates for Gradle in Dependabot config --- .github/dependabot.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4d634ef..791d109 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,3 +24,5 @@ updates: commit-message: prefix: chore include: scope + ignore: + - dependency-name: "gradle" From 3ce3f0cbf131dc6c8ce87e6ca129022f23d02b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Fri, 19 Jun 2026 18:35:21 +0200 Subject: [PATCH 17/17] chore(deps): bump detekt to 2.0.0-alpha.5 --- .../plugin/quality/detekt/DetektPlugin.kt | 48 ++- config/detekt/detekt.yml | 305 ++++++++++++------ gradle/libs.versions.toml | 4 +- 3 files changed, 236 insertions(+), 121 deletions(-) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt index 8eaf7a6..a0c448a 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt @@ -1,12 +1,13 @@ package net.thunderbird.gradle.plugin.quality.detekt -import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask -import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.DetektCreateBaselineTask +import dev.detekt.gradle.extensions.DetektExtension import net.thunderbird.gradle.plugin.ProjectConfig import net.thunderbird.gradle.plugin.libs import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.withType @@ -18,18 +19,14 @@ import org.gradle.kotlin.dsl.withType class DetektPlugin : Plugin { override fun apply(target: Project) { with(target) { - pluginManager.apply("io.gitlab.arturbosch.detekt") + pluginManager.apply("dev.detekt") dependencies { add("detektPlugins", libs.detekt.plugin.compose) } - if (path == ":") { - configureRootDetektTasks() - } else { - configureDetekt() - configureDetektTasks() - } + configureDetekt() + configureDetektTasks() } } @@ -38,10 +35,6 @@ class DetektPlugin : Plugin { extensions.configure("detekt") { config.setFrom(project.isolated.rootProject.projectDirectory.file("config/detekt/detekt.yml")) - val name = project.path.replace(":", "-").replace("/", "-") - baseline = project.isolated.rootProject.projectDirectory - .file("config/detekt/detekt-baseline$name.xml").asFile - ignoredBuildTypes = listOf("release") } } @@ -49,21 +42,30 @@ class DetektPlugin : Plugin { private fun Project.configureDetektTasks() { with(tasks) { withType().configureEach { - jvmTarget = ProjectConfig.Compiler.javaCompatibility.toString() + if (name.contains("androidHostTest", ignoreCase = true)) { + enabled = false + } + + jvmTarget = ProjectConfig.Compiler.jvmTarget.target exclude(defaultExcludes) reports { - html.required.set(true) + checkstyle.required.set(false) + html.required.set(false) sarif.required.set(true) - xml.required.set(true) + markdown.required.set(true) } tasks.getByName("build").dependsOn(this) } withType().configureEach { - jvmTarget = ProjectConfig.Compiler.javaCompatibility.toString() + if (name.contains("androidHostTest", ignoreCase = true)) { + enabled = false + } + + jvmTarget = ProjectConfig.Compiler.jvmTarget.target exclude(defaultExcludes) } @@ -76,21 +78,13 @@ class DetektPlugin : Plugin { } } } - - private fun Project.configureRootDetektTasks() { - with(tasks) { - register("detektAll") { - group = "verification" - description = "Runs detekt on the root project" - } - } - } } private val defaultExcludes = listOf( "**/.gradle/**", "**/.idea/**", "**/build/**", + "**/generated/**", ".github/**", "gradle/**", ) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 27f02ce..ea9e35f 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -1,66 +1,46 @@ -build: - maxIssues: 0 - excludeCorrectable: false - weights: - # complexity: 2 - # LongParameterList: 1 - # style: 1 - # comments: 1 - config: validation: true warningsAsErrors: false checkExhaustiveness: false - # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' - excludes: '' + # when writing own rules with new properties, exclude the property path e.g.: ['my_rule_set', '.*>.*>[my_property]'] + excludes: [] processors: active: true exclude: - - 'DetektProgressListener' # - 'KtFileCountProcessor' # - 'PackageCountProcessor' # - 'ClassCountProcessor' # - 'FunctionCountProcessor' # - 'PropertyCountProcessor' - # - 'ProjectComplexityProcessor' + # - 'ProjectCyclomaticComplexityProcessor' # - 'ProjectCognitiveComplexityProcessor' # - 'ProjectLLOCProcessor' # - 'ProjectCLOCProcessor' # - 'ProjectLOCProcessor' # - 'ProjectSLOCProcessor' - # - 'LicenseHeaderLoaderExtension' console-reports: active: true exclude: - - 'ProjectStatisticsReport' - - 'ComplexityReport' - - 'NotificationReport' - - 'FindingsReport' - - 'FileBasedFindingsReport' - # - 'LiteFindingsReport' - -output-reports: - active: true - exclude: - # - 'TxtOutputReport' - # - 'XmlOutputReport' - # - 'HtmlOutputReport' - # - 'MdOutputReport' - # - 'SarifOutputReport' + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'IssuesReport' + - 'FileBasedIssuesReport' + # - 'LiteIssuesReport' comments: active: true AbsentOrWrongFileLicense: active: false - licenseTemplateFile: 'license.template' licenseTemplateIsRegex: false - CommentOverPrivateFunction: + licenseTemplate: '' + DeprecatedBlockTag: active: false - CommentOverPrivateProperty: + DocumentationOverPrivateFunction: active: false - DeprecatedBlockTag: + DocumentationOverPrivateProperty: active: false EndOfSentenceFormat: active: false @@ -73,6 +53,7 @@ comments: matchTypeParameters: true matchDeclarationsOrder: true allowParamOnConstructorProperties: false + exhaustive: true UndocumentedPublicClass: active: false excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] @@ -81,6 +62,7 @@ comments: searchInInnerObject: true searchInInnerInterface: true searchInProtectedClass: false + ignoreDefaultCompanionObject: false UndocumentedPublicFunction: active: false excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] @@ -89,27 +71,29 @@ comments: active: false excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] searchProtectedProperty: false + ignoreEnumEntries: false complexity: active: true CognitiveComplexMethod: active: false - threshold: 15 + allowedComplexity: 15 ComplexCondition: active: true - threshold: 5 + allowedConditions: 3 ComplexInterface: active: false - threshold: 10 + allowedDefinitions: 10 includeStaticDeclarations: false includePrivateDeclarations: false ignoreOverloaded: false CyclomaticComplexMethod: active: true - threshold: 15 + allowedComplexity: 15 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false ignoreNestingFunctions: false + ignoreLocalFunctions: false nestingFunctions: - 'also' - 'apply' @@ -125,30 +109,31 @@ complexity: ignoredLabels: [] LargeClass: active: true - threshold: 600 + allowedLines: 600 LongMethod: active: true - threshold: 60 + allowedLines: 60 LongParameterList: active: true - functionThreshold: 8 - constructorThreshold: 8 + allowedFunctionParameters: 8 + allowedConstructorParameters: 8 ignoreDefaultParameters: true ignoreDataClasses: true ignoreAnnotatedParameter: [] MethodOverloading: active: false - threshold: 6 + allowedOverloads: 6 NamedArguments: active: true - threshold: 3 + allowedArguments: 3 + ignoreMethods: [] ignoreArgumentsMatchingNames: true NestedBlockDepth: active: true - threshold: 4 + allowedDepth: 4 NestedScopeFunctions: active: false - threshold: 1 + allowedDepth: 1 functions: - 'kotlin.apply' - 'kotlin.run' @@ -160,24 +145,28 @@ complexity: StringLiteralDuplication: active: false excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - threshold: 3 + allowedDuplications: 2 ignoreAnnotation: true - excludeStringsWithLessThan5Characters: true + allowedWithLengthLessThan: 5 ignoreStringsRegex: '$^' TooManyFunctions: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - thresholdInFiles: 11 - thresholdInClasses: 11 - thresholdInInterfaces: 11 - thresholdInObjects: 11 - thresholdInEnums: 11 + allowedFunctionsPerFile: 11 + allowedFunctionsPerClass: 11 + allowedFunctionsPerInterface: 11 + allowedFunctionsPerObject: 11 + allowedFunctionsPerEnum: 11 ignoreDeprecated: false ignorePrivate: false + ignoreInternal: false ignoreOverridden: false + ignoreAnnotatedFunctions: ['Preview'] coroutines: active: true + CoroutineLaunchedInTestWithoutRunTest: + active: false GlobalCoroutineUsage: active: false InjectDispatcher: @@ -190,10 +179,13 @@ coroutines: active: true SleepInsteadOfDelay: active: true + SuspendFunInFinallySection: + active: false SuspendFunSwallowedCancellation: active: true SuspendFunWithCoroutineScopeReceiver: active: true + aliases: ['SuspendFunctionOnCoroutineScope'] SuspendFunWithFlowReturnType: active: true @@ -221,7 +213,7 @@ empty-blocks: active: true EmptyInitBlock: active: true - EmptyKtFile: + EmptyKotlinFile: active: true EmptySecondaryConstructor: active: true @@ -234,6 +226,8 @@ empty-blocks: exceptions: active: true + ErrorUsageWithThrowable: + active: false ExceptionRaisedInUnexpectedLocation: active: true methodNames: @@ -310,6 +304,7 @@ naming: allowedPattern: '^(is|has|are)' ClassNaming: active: true + aliases: ['ClassName'] classPattern: '[A-Z][a-zA-Z0-9]*' ConstructorParameterNaming: active: true @@ -318,20 +313,24 @@ naming: excludeClassPattern: '$^' EnumNaming: active: true + aliases: ['EnumEntryName'] enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false forbiddenName: [] - FunctionMaxLength: + FunctionNameMaxLength: active: false + aliases: ['FunctionMaxNameLength'] maximumFunctionNameLength: 30 - FunctionMinLength: + FunctionNameMinLength: active: false + aliases: ['FunctionMinNameLength'] minimumFunctionNameLength: 3 FunctionNaming: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - functionPattern: '[a-z][a-zA-Z0-9]*' + aliases: ['FunctionName'] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/nativeTest/**'] + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' excludeClassPattern: '$^' ignoreAnnotated: - 'Composable' @@ -341,6 +340,7 @@ naming: excludeClassPattern: '$^' InvalidPackageDeclaration: active: true + aliases: ['PackageDirectoryMismatch'] rootPackage: '' requireRootInDeclaration: false LambdaParameterNaming: @@ -349,6 +349,17 @@ naming: MatchingDeclarationName: active: true mustBeFirst: true + multiplatformTargets: + - 'ios' + - 'android' + - 'js' + - 'jvm' + - 'native' + - 'iosArm64' + - 'iosX64' + - 'macosX64' + - 'mingwX64' + - 'linuxX64' MemberNameEqualsClassName: active: true ignoreOverridden: true @@ -356,13 +367,16 @@ naming: active: true NonBooleanPropertyPrefixedWithIs: active: false + allowSingleTypedGenerics: false ObjectPropertyNaming: active: true + aliases: ['ObjectPropertyName'] constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true + aliases: ['PackageName'] packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true @@ -377,6 +391,7 @@ naming: minimumVariableNameLength: 1 VariableNaming: active: true + aliases: ['PropertyName'] variablePattern: '[a-z][A-Za-z0-9]*' privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' @@ -387,17 +402,21 @@ performance: active: true CouldBeSequence: active: false - threshold: 3 + allowedOperations: 2 ForEachOnRange: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] SpreadOperator: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryInitOnArray: + active: false UnnecessaryPartOfBinaryExpression: active: false UnnecessaryTemporaryInstantiation: active: true + UnnecessaryTypeCasting: + active: false potential-bugs: active: true @@ -407,14 +426,19 @@ potential-bugs: - 'kotlin.String' CastNullableToNonNullableType: active: true + ignorePlatformTypes: true CastToNullableType: active: true + CharArrayToStringCall: + active: false Deprecation: active: true + aliases: ['DEPRECATION'] DontDowncastCollectionTypes: active: true DoubleMutabilityForCollection: active: true + aliases: ['DoubleMutability'] mutableTypes: - 'kotlin.collections.MutableList' - 'kotlin.collections.MutableMap' @@ -449,6 +473,7 @@ potential-bugs: - 'CanIgnoreReturnValue' - '*.CanIgnoreReturnValue' returnValueTypes: + - 'kotlin.Function*' - 'kotlin.sequences.Sequence' - 'kotlinx.coroutines.flow.*Flow' - 'java.util.stream.*Stream' @@ -457,6 +482,7 @@ potential-bugs: active: true ImplicitUnitReturnType: active: false + ignoreAnnotated: ['Test'] allowExplicitReturnType: true InvalidRange: active: true @@ -473,6 +499,16 @@ potential-bugs: MissingPackageDeclaration: active: false excludes: ['**/*.kts'] + MissingSuperCall: + active: true + mustInvokeSuperAnnotations: + - 'androidx.annotation.CallSuper' + - 'javax.annotation.OverridingMethodsMustInvokeSuper' + MissingUseCall: + active: true + ignoreClass: + - 'java.io.ByteArrayInputStream' + - 'java.io.ByteArrayOutputStream' NullCheckOnMutableProperty: active: false NullableToStringCall: @@ -481,6 +517,12 @@ potential-bugs: active: false UnconditionalJumpStatementInLoop: active: false + UnnamedParameterUse: + active: true + allowAdjacentDifferentTypeParams: true + allowSingleParamUse: true + ignoreArgumentsMatchingNames: true + ignoreFunctionCall: [] UnnecessaryNotNullCheck: active: false UnnecessaryNotNullOperator: @@ -496,6 +538,7 @@ potential-bugs: excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] UnsafeCast: active: true + aliases: ['UNCHECKED_CAST'] UnusedUnaryOperator: active: true UselessPostfixExpression: @@ -505,6 +548,10 @@ potential-bugs: style: active: true + AbstractClassCanBeConcreteClass: + active: true + AbstractClassCanBeInterface: + active: true AlsoCouldBeApply: active: true BracesOnIfStatements: @@ -534,6 +581,8 @@ style: DestructuringDeclarationWithTooManyEntries: active: true maxDestructuringEntries: 3 + DoubleNegativeExpression: + active: true DoubleNegativeLambda: active: false negativeFunctions: @@ -550,6 +599,8 @@ style: active: false ExplicitCollectionElementAccessMethod: active: true + ExplicitItLambdaMultipleParameters: + active: true ExplicitItLambdaParameter: active: true ExpressionBodySyntax: @@ -570,22 +621,20 @@ style: value: 'java.lang.annotation.Retention' - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' value: 'java.lang.annotation.Repeatable' - - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' - value: 'java.lang.annotation.Inherited' ForbiddenComment: active: true comments: - - reason: 'Forbidden FIXME todo marker in comment, please fix the problem or create an issue to address it.' + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' value: 'FIXME:' - - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping.' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' value: 'STOPSHIP:' - - reason: 'Forbidden TODO todo marker in comment, please do the changes or create an issue to address it.' - value: 'TODO:' - allowedPatterns: '(TODO|FIXME)\((@[a-zA-z0-9-]+|#[0-9-]+)\):' + # - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + # value: 'TODO:' + allowedPatterns: '' ForbiddenImport: active: false - imports: [] - forbiddenPatterns: '' + forbiddenImports: [] + allowedImports: [] ForbiddenMethodCall: active: false methods: @@ -593,6 +642,18 @@ style: value: 'kotlin.io.print' - reason: 'println does not allow you to configure the output stream. Use a logger instead.' value: 'kotlin.io.println' + - reason: 'using `BigDecimal(Double)` can result in unexpected floating point precision behavior. Use `BigDecimal.valueOf(Double)` or `String.toBigDecimalOrNull()` instead.' + value: 'java.math.BigDecimal.(kotlin.Double)' + - reason: 'using `BigDecimal(String)` can result in a `NumberFormatException`. Use `String.toBigDecimalOrNull()`' + value: 'java.math.BigDecimal.(kotlin.String)' + - reason: 'It is marked as obsolete. Use `kotlin.time.measureTime` instead.' + value: 'kotlin.system.measureTimeMillis' + ForbiddenNamedParam: + active: false + methods: [] + ForbiddenOptIn: + active: false + markerClasses: [] ForbiddenSuppress: active: false rules: [] @@ -618,13 +679,10 @@ style: - '2' ignoreHashCodeFunction: true ignorePropertyDeclaration: false - ignoreLocalVariableDeclaration: false + ignoreLocalVariableDeclaration: true ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false - ignoreAnnotated: - - 'Preview' - - 'PreviewLightDark' ignoreNamedArgument: true ignoreEnums: false ignoreRanges: false @@ -641,7 +699,7 @@ style: excludeImportStatements: true excludeCommentStatements: false excludeRawStrings: true - MayBeConst: + MayBeConstant: active: true ModifierOrder: active: true @@ -667,15 +725,17 @@ style: active: true OptionalUnit: active: true - PreferToOverPairSyntax: - active: true ProtectedMemberInFinalClass: active: true + RangeUntilInsteadOfRangeTo: + active: true + RedundantConstructorKeyword: + active: true RedundantExplicitType: active: true RedundantHigherOrderMapUsage: active: true - RedundantVisibilityModifierRule: + RedundantVisibilityModifier: active: false ReturnCount: active: true @@ -684,12 +744,12 @@ style: - 'equals' excludeLabeled: false excludeReturnFromLambda: true - excludeGuardClauses: true + excludeGuardClauses: false SafeCast: active: true SerialVersionUIDInSerializableClass: active: true - SpacingBetweenPackageAndImports: + SpacingAfterPackageAndImports: active: false StringShouldBeRawString: active: false @@ -698,7 +758,7 @@ style: ThrowsCount: active: true max: 2 - excludeGuardClauses: true + excludeGuardClauses: false TrailingWhitespace: active: false TrimMultilineRawString: @@ -710,9 +770,7 @@ style: active: false acceptableLength: 4 allowNonStandardGrouping: false - UnnecessaryAbstractClass: - active: true - UnnecessaryAnnotationUseSiteTarget: + UnnecessaryAny: active: true UnnecessaryApply: active: true @@ -722,6 +780,8 @@ style: active: true UnnecessaryFilter: active: true + UnnecessaryFullyQualifiedName: + active: true UnnecessaryInheritance: active: true UnnecessaryInnerClass: @@ -731,23 +791,31 @@ style: UnnecessaryParentheses: active: false allowForUnclearPrecedence: false - UntilInsteadOfRangeTo: + UnnecessaryReversed: active: true - UnusedImports: - active: false + UnusedImport: + active: true + additionalOperatorSet: [] UnusedParameter: active: true + aliases: ['UNUSED_PARAMETER', 'unused'] allowedNames: 'ignored|expected' UnusedPrivateClass: active: true - UnusedPrivateMember: + aliases: ['unused'] + UnusedPrivateFunction: active: true + aliases: ['unused'] allowedNames: '' + ignoreAnnotated: ['Preview'] UnusedPrivateProperty: active: true - allowedNames: '_|ignored|expected|serialVersionUID' - ignoreAnnotated: - - 'Preview' + aliases: ['unused'] + allowedNames: 'ignored|expected|serialVersionUID' + UnusedVariable: + active: true + aliases: ['UNUSED_VARIABLE', 'unused'] + allowedNames: 'ignored|_' UseAnyOrNoneInsteadOfFind: active: true UseArrayLiteralsInAnnotations: @@ -784,6 +852,7 @@ style: active: true VarCouldBeVal: active: true + aliases: ['CanBeVal'] ignoreLateinitVar: false WildcardImport: active: true @@ -791,10 +860,20 @@ style: - 'java.util.*' Compose: + ComposableAnnotationNaming: + active: true + ComposableNaming: + active: true + ComposableNestingDepth: + active: false + ## TODO: disabled as we need to understand the use cases better + ComposableParamOrder: + active: true CompositionLocalAllowlist: active: true allowedCompositionLocals: [ LocalColors, + LocalDateTimeConfiguration, LocalElevations, LocalImages, LocalShapes, @@ -806,14 +885,41 @@ Compose: LocalThemeShapes, LocalThemeSizes, LocalThemeSpacings, - LocalThemeTypography + LocalThemeTypography, ] + CompositionLocalNaming: + active: true + ConditionHoist: + active: true ContentEmitterReturningValues: active: true - ModifierComposable: + ContentTrailingLambda: + active: true + ContentSlotReused: + active: true + DefaultsVisibility: + active: true + InvalidReadOnlyComposable: + active: true + LambdaParameterEventTrailing: + active: true + LambdaParameterInRestartableEffect: + active: false + # TODO: disabled as we need to understand the use cases better + Material2: + active: true + MissingReadOnlyComposable: + active: true + ModifierClickableOrder: + active: true + ModifierComposed: active: true ModifierMissing: active: true + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true ModifierReused: active: true ModifierWithoutDefault: @@ -822,18 +928,33 @@ Compose: active: true MutableParams: active: true - ComposableNaming: + MutableStateAutoboxing: active: true - ComposableParamOrder: + MutableStateParam: + active: false + ## TODO: disabled as we need to understand the use cases better + ParameterNaming: active: true PreviewAnnotationNaming: active: true + PreviewNaming: + active: true PreviewPublic: active: true + RememberContentMissing: + active: true RememberMissing: active: true + StaleRememberUpdatedStateInRemember: + active: true + StateParam: + active: true + UnnecessaryComposable: + active: true UnstableCollections: active: true + VarsWithoutStateBacking: + active: true ViewModelForwarding: active: true ViewModelInjection: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7403e8c..49b2ab8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ androidXActivity = "1.13.0" androidXCompose = "1.11.3" assertk = "0.28.1" dependencyCheckPlugin = "0.54.0" -detektPlugin = "1.23.8" +detektPlugin = "2.0.0-alpha.5" detektPluginCompose = "0.6.2" dokkaPlugin = "2.2.0" gradle = "9.5.1" @@ -42,7 +42,7 @@ android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinBom" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } dependency-check = { id = "com.github.ben-manes.versions", version.ref = "dependencyCheckPlugin" } -detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detektPlugin" } +detekt = { id = "dev.detekt", version.ref = "detektPlugin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaPlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinBom" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinBom" }