diff --git a/.github/workflows/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml
index b07bf0f..1f0540a 100644
--- a/.github/workflows/build-on-ubuntu.yml
+++ b/.github/workflows/build-on-ubuntu.yml
@@ -92,3 +92,22 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true
+
+ # The Codecov CLI ships a set of "file fixes" with every upload. For
+ # `.kt` files — never `.java` — they mark each line holding nothing
+ # but a brace or a parenthesis as non-code. JaCoCo, however, does
+ # measure one such line: it attributes a Kotlin lambda body's
+ # instructions to the body's closing `}`, through the synthetic
+ # `$lambda$N` that an `invokedynamic` lambda compiles to. The fixes
+ # thus discard roughly a tenth of the measured lines, which would be
+ # harmless if every ingestion applied them. Not every one does: when
+ # Codecov re-processes a stored upload, the second pass leaves them
+ # unapplied, so one and the same report can be ingested on either
+ # line basis. That switch moves the `src/main/**` project status by
+ # about 0.7 percentage points, fourteen times the threshold in
+ # `.codecov.yml`, and it has been seen on `master`, whose commits
+ # are the `target: auto` baselines — so it can shift a pull
+ # request's delta either way. Sending no fixes makes both ingestion
+ # paths agree, at the price of counting a lambda-closing brace as a
+ # coverable line.
+ disable_file_fixes: true
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 3bad11f..aacee78 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -30,7 +30,7 @@ on:
jobs:
publish:
name: Publish to Maven repositories
- uses: SpineEventEngine/config/.github/workflows/publishing.yml@a0cbbbfe66c12475224563439fb6deb3da71ba58
+ uses: SpineEventEngine/config/.github/workflows/publishing.yml@604420cd7eab82b382c9a5c1d812be454552c27c
permissions:
contents: read
packages: write
diff --git a/AGENTS.md b/AGENTS.md
index 8c5f619..dc6927f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,17 +20,29 @@ Shared skills, scripts, and guidelines come from the `.agents/shared` submodule
(and some shallow clones / cloud checkouts) start with NO submodules checked out, so
those symlinks dangle and no skills are found. Bootstrap such a tree with
**`./init-submodules`** — a root script that materializes the missing
-*config-managed* submodules at their pinned commits: `config` itself, plus every
-submodule that declares a tracked `branch` in `.gitmodules` (`.agents/shared`, and
-any shared submodule added later) — the same rule `./config/pull` uses to decide
-what it floats. Submodules the consumer owns (a Hugo theme, a vendored library,
-doc-example submodules, …) declare no tracked branch and are left untouched, so the
-automatic `SessionStart` run never tries to clone — or fail on credentials for — a
-submodule this project does not manage. It depends on no pre-existing `config`
-submodule, so it works before `./config/pull` (which lives inside the `config`
-submodule) can. Claude Code runs it automatically via a `SessionStart` hook; other
-agents and humans run it by hand, then `./config/pull` to float the shared submodules
-to their branch tips.
+*config-managed* submodules at the tip of their branch (`master` for `config`, the
+tracked `branch` for the rest): `config` itself, plus every submodule that declares
+a tracked `branch` in `.gitmodules` (`.agents/shared`, and any shared submodule
+added later) — the same rule `./config/pull` uses to decide what it floats. It
+deliberately does not stop at the commit the superproject pins for such a
+submodule: nothing keeps that pin current — `ignore = all` hides the submodule from
+`git status`, and `./config/pull` floats the on-disk copy without committing the
+moved gitlink — so materializing the pin as-is means months-old hook scripts,
+skills, and guidelines. If the float fails — a branch the remote does not have,
+say — a warning is printed and the submodule is left wherever the steps that
+did succeed put it, at worst the pin, so a session still starts with a usable
+tree; it is not retried on a later run, which is why that warning points at
+`./config/pull`. With no network at all the clone itself fails and the
+submodule is left uninitialized, as it was before. Submodules the consumer owns
+(a Hugo theme, a vendored library, doc-example submodules, …) declare no tracked
+branch and are left untouched, so the automatic `SessionStart` run never tries
+to clone — or fail on credentials for — a submodule this project does not
+manage. A submodule that is already checked out is left exactly as it is, which
+keeps repeated runs cheap and offline-safe and never rewinds a tree someone
+parked on a particular commit; run `./config/pull` to float those. It depends on
+no pre-existing `config` submodule, so it works before `./config/pull` (which
+lives inside the `config` submodule) can. Claude Code runs it automatically via
+a `SessionStart` hook; other agents and humans run it by hand.
## Commit and history safety
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index aeaa51d..f1666ed 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -77,7 +77,7 @@ val grGitVersion = "5.3.3"
* This version may change from the [version of Kotlin][io.spine.dependency.lib.Kotlin.version]
* used by the project.
*/
-val kotlinEmbeddedVersion = "2.4.21"
+val kotlinEmbeddedVersion = "2.4.10"
/**
* The version of Guava used in `buildSrc`.
diff --git a/buildSrc/src/main/kotlin/DokkaExts.kt b/buildSrc/src/main/kotlin/DokkaExts.kt
index d1483b6..5aeba8d 100644
--- a/buildSrc/src/main/kotlin/DokkaExts.kt
+++ b/buildSrc/src/main/kotlin/DokkaExts.kt
@@ -334,19 +334,45 @@ private fun abbreviationPattern(abbreviation: String): Regex {
private val wordStart = Regex("(?=\\p{javaUpperCase})")
/**
- * Disables Dokka and Javadoc tasks in this `Project`.
+ * Disables the Dokka and Javadoc tasks generating the documentation of this `Project`.
*
* This function could be useful to improve build speed when building subprojects containing
* test environments or integration test projects.
+ *
+ * The JARs packing the documentation stay enabled, as they do under
+ * [skipDokkaWhenPublishingToMavenLocal]. A publication declares one artifact
+ * file per JAR, and a JAR that a disabled task never writes would be missing
+ * both when publishing and when the published files are attested. With nothing
+ * generated to pack, such a JAR costs almost nothing: it contains only
+ * a manifest, or the output of an earlier Dokka run.
*/
@Suppress("unused")
fun Project.disableDocumentationTasks() {
gradle.taskGraph.whenReady {
tasks.forEach { task ->
- val lowercaseName = task.name.lowercase()
- if (lowercaseName.contains("dokka") || lowercaseName.contains("javadoc")) {
+ if (task.generatesDocumentation()) {
task.enabled = false
}
}
}
}
+
+/**
+ * Tells if this task generates documentation, rather than packing already generated
+ * documentation into a JAR.
+ *
+ * A [Jar] task is recognized by its type rather than by its name, so that
+ * `javadocJar` and `htmlDocsJar` are treated alike — as is any documentation JAR
+ * added later, whatever it is called. Any other task generates documentation if
+ * `dokka` or `javadoc` occurs in its name, ignoring case.
+ *
+ * [selectsDocsTask] keeps matching the same JARs by name, because the names it
+ * is given come from the command line rather than from the task graph.
+ */
+private fun Task.generatesDocumentation(): Boolean {
+ if (this is Jar) {
+ return false
+ }
+ return name.contains("dokka", ignoreCase = true)
+ || name.contains("javadoc", ignoreCase = true)
+}
diff --git a/buildSrc/src/main/kotlin/config-tester.gradle.kts b/buildSrc/src/main/kotlin/config-tester.gradle.kts
index 22b2d90..d7f3af9 100644
--- a/buildSrc/src/main/kotlin/config-tester.gradle.kts
+++ b/buildSrc/src/main/kotlin/config-tester.gradle.kts
@@ -17,17 +17,19 @@ import io.spine.gradle.SpineRepos
import io.spine.gradle.SpineTaskGroup
import io.spine.gradle.cleanFolder
import java.nio.file.Path
-import java.nio.file.Paths
// A reference to `config` to use along with the `ConfigTester`.
-val config: Path = Paths.get("./")
+//
+// Both paths below are resolved against the project directory, because a relative path
+// would be resolved against the working directory of the Gradle daemon instead.
+val config: Path = layout.projectDirectory.asFile.toPath()
// A temp folder to use to check out the sources of other repositories with the `ConfigTester`.
-val tempFolder = File("./tmp")
+val tempFolder = layout.projectDirectory.dir("tmp").asFile
// Creates a Gradle task that checks out and builds the selected Spine repositories
// with the local version of `config` and `config/buildSrc`.
-ConfigTester(config, tasks, tempFolder)
+ConfigTester(config = config, tasks = tasks, tempFolder = tempFolder)
.addRepo(SpineRepos.baseTypes) // Builds `base-types` at `master`.
.addRepo(SpineRepos.base) // Builds `base` at `master`.
.addRepo(SpineRepos.coreJvm) // Builds `core-jvm` at `master`.
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/lib/Kotlin.kt b/buildSrc/src/main/kotlin/io/spine/dependency/lib/Kotlin.kt
index 4b49e3a..5f63d1f 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/lib/Kotlin.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/lib/Kotlin.kt
@@ -26,7 +26,7 @@ object Kotlin : DependencyWithBom() {
* This is the version of Kotlin we use for writing code that does not depend on Gradle.
*/
@Suppress("MemberVisibilityCanBePrivate") // used directly from the outside.
- const val runtimeVersion = "2.4.21"
+ const val runtimeVersion = "2.4.10"
override val version = runtimeVersion
override val group = "org.jetbrains.kotlin"
@@ -43,7 +43,7 @@ object Kotlin : DependencyWithBom() {
* [embedded into Gradle](https://docs.gradle.org/current/userguide/compatibility.html#kotlin),
* which the Gradle version determines on its own.
*/
- const val embeddedVersion = "2.4.21"
+ const val embeddedVersion = "2.4.10"
/**
* The version of the JetBrains annotations library, which is a transitive
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/lib/KotlinPoet.kt b/buildSrc/src/main/kotlin/io/spine/dependency/lib/KotlinPoet.kt
index c7fcf35..9cd9664 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/lib/KotlinPoet.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/lib/KotlinPoet.kt
@@ -17,7 +17,7 @@ package io.spine.dependency.lib
// https://github.com/square/kotlinpoet
@Suppress("unused", "ConstPropertyName")
object KotlinPoet {
- private const val version = "2.4.0"
+ private const val version = "2.3.0"
const val lib = "com.squareup:kotlinpoet:$version"
const val ksp = "com.squareup:kotlinpoet-ksp:$version"
}
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt b/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt
new file mode 100644
index 0000000..b473fdc
--- /dev/null
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.dependency.lib
+
+import io.spine.dependency.Dependency
+
+/**
+ * The Kotlin Multiplatform API and SDK for OpenTelemetry.
+ *
+ * The project is currently in the alpha stage, and its API is marked
+ * with `@io.opentelemetry.kotlin.ExperimentalApi`. Modules using these artifacts
+ * must opt in to that marker.
+ *
+ * The project does not publish a BOM, so the version is embedded in each
+ * artifact coordinate below.
+ *
+ * @see GitHub project
+ * @see Kotlin documentation
+ */
+@Suppress("unused")
+object OpenTelemetryKotlin : Dependency() {
+
+ // https://central.sonatype.com/artifact/io.opentelemetry.kotlin/api
+ //
+ // The project is in the alpha stage, so bump this version only together
+ // with the code that consumes it.
+ override val version = "0.4.0"
+ override val group = "io.opentelemetry.kotlin"
+
+ /**
+ * The Kotlin Multiplatform API surface (traces, metrics, logs).
+ *
+ * This is the artifact production code compiles against; the SDK artifacts
+ * below are for applications and tests.
+ */
+ val api = "$group:api:$version"
+
+ /**
+ * A no-op implementation of the [api] artifact.
+ */
+ val noop = "$group:noop:$version"
+
+ /**
+ * The Kotlin SDK API: span processors, exporters, samplers, and the
+ * configuration DSL.
+ *
+ * This artifact exposes the SDK types on the compile classpath, while the
+ * `createOpenTelemetry { }` entry point that instantiates them lives in
+ * [implementation]. Applications and tests configure an `OpenTelemetry`
+ * instance using these two artifacts; production code needs only
+ * the [api] artifact.
+ */
+ val core = "$group:core:$version"
+
+ /**
+ * The Kotlin SDK implementation, including the `createOpenTelemetry { }`
+ * entry point.
+ *
+ * Use it together with [core].
+ */
+ val implementation = "$group:implementation:$version"
+
+ /**
+ * The compatibility bridge to the OpenTelemetry Java SDK.
+ */
+ val compat = "$group:compat:$version"
+
+ override val modules: List = listOf(
+ "$group:api",
+ "$group:noop",
+ "$group:core",
+ "$group:implementation",
+ "$group:compat",
+ )
+}
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt
index aa06886..45c1d2f 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt
@@ -19,10 +19,13 @@ package io.spine.dependency.local
*
* @see spine-base-libraries
*/
-@Suppress("ConstPropertyName", "unused")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+ "unused" /* Some subprojects do not use all Base artifacts. */,
+)
object Base {
- const val version = "2.0.0-SNAPSHOT.460"
- const val versionForBuildScript = "2.0.0-SNAPSHOT.460"
+ const val version = "2.0.0-SNAPSHOT.461"
+ const val versionForBuildScript = "2.0.0-SNAPSHOT.461"
const val group = Spine.group
private const val prefix = "spine"
const val libModule = "$prefix-base"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/BaseTypes.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/BaseTypes.kt
index 70fe77b..e0a8280 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/BaseTypes.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/BaseTypes.kt
@@ -19,7 +19,9 @@ package io.spine.dependency.local
*
* @see spine-base-types
*/
-@Suppress("ConstPropertyName")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+)
object BaseTypes {
const val version = "2.0.0-SNAPSHOT.225"
const val group = Spine.group
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Change.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Change.kt
index 228f96e..1f6ebe1 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Change.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Change.kt
@@ -19,7 +19,9 @@ package io.spine.dependency.local
*
* @see spine-change
*/
-@Suppress("ConstPropertyName")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+)
object Change {
const val version = "2.0.0-SNAPSHOT.208"
const val group = Spine.group
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt
index bef7362..35246a7 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt
@@ -24,10 +24,13 @@ typealias CoreJava = CoreJvm
*
* See [`SpineEventEngine/core-jvm`](https://github.com/SpineEventEngine/core-jvm/).
*/
-@Suppress("ConstPropertyName", "unused")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+ "unused" /* Some subprojects do not use all CoreJvm artifacts. */,
+)
object CoreJvm {
const val group = Spine.group
- const val version = "2.0.0-SNAPSHOT.567"
+ const val version = "2.0.0-SNAPSHOT.571"
const val coreArtifact = "spine-core"
const val clientArtifact = "spine-client"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt
index 5db1a81..1af2694 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt
@@ -22,7 +22,7 @@ package io.spine.dependency.local
@Suppress(
"MemberVisibilityCanBePrivate" /* The properties are used directly by other subprojects. */,
"ConstPropertyName" /* We use a custom convention for artifact properties. */,
- "unused"
+ "unused" /* Some subprojects do not use the CoreJvm Compiler directly. */,
)
object CoreJvmCompiler {
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/DokkaTools.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/DokkaTools.kt
index 5a64607..6026d49 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/DokkaTools.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/DokkaTools.kt
@@ -21,7 +21,6 @@ package io.spine.dependency.local
* See [`SpineEventEngine/dokka-tools`](https://github.com/SpineEventEngine/dokka-tools/).
*/
@Suppress(
- "unused" /* Some subprojects do not use the Dokka tools directly. */,
"ConstPropertyName" /* We use a custom convention for artifact properties. */,
)
object DokkaTools {
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt
index e5be8ed..6d5f5b7 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt
@@ -19,9 +19,12 @@ package io.spine.dependency.local
*
* @see spine-logging
*/
-@Suppress("ConstPropertyName", "unused")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+ "unused" /* Some subprojects do not use all Logging artifacts. */,
+)
object Logging {
- const val version = "2.0.0-SNAPSHOT.425"
+ const val version = "2.0.0-SNAPSHOT.426"
const val group = Spine.group
const val loggingArtifact = "spine-logging"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt
index 45e6792..55154c6 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt
@@ -26,7 +26,7 @@ package io.spine.dependency.local
)
object ProtoTap {
const val group = Spine.toolsGroup
- const val version = "0.17.2"
+ const val version = "0.17.3"
const val gradlePluginId = "io.spine.prototap"
const val api = "$group:prototap-api:$version"
const val gradlePlugin = "$group:prototap-gradle-plugin:$version"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Reflect.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Reflect.kt
index ae8e17f..c07fd2f 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Reflect.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Reflect.kt
@@ -19,9 +19,11 @@ package io.spine.dependency.local
*
* @see spine-reflect
*/
-@Suppress("ConstPropertyName")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+)
object Reflect {
- const val version = "2.0.0-SNAPSHOT.201"
+ const val version = "2.0.0-SNAPSHOT.202"
const val group = Spine.group
const val artifact = "spine-reflect"
const val lib = "$group:$artifact:$version"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Spine.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Spine.kt
index 6efbf78..c4e6c2d 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Spine.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Spine.kt
@@ -17,7 +17,9 @@ package io.spine.dependency.local
/**
* Defines Maven groups for the Spine SDK artifacts.
*/
-@Suppress("unused", "ConstPropertyName")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+)
object Spine {
const val group = "io.spine"
const val toolsGroup = "io.spine.tools"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/TestLib.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/TestLib.kt
index bf26e25..324a493 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/TestLib.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/TestLib.kt
@@ -19,9 +19,11 @@ package io.spine.dependency.local
*
* @see spine-testlib
*/
-@Suppress("ConstPropertyName")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+)
object TestLib {
- const val version = "2.0.0-SNAPSHOT.214"
+ const val version = "2.0.0-SNAPSHOT.215"
const val group = Spine.toolsGroup
const val artifact = "base-testlib"
const val lib = "$group:$artifact:$version"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt
index 942333f..3e8e399 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt
@@ -23,7 +23,7 @@ import io.spine.dependency.Dependency
*/
@Suppress(
"unused" /* Some subprojects do not use all Time artifacts. */,
- "ConstPropertyName" /* We use custom convention for artifact properties. */,
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
"MemberVisibilityCanBePrivate" /* The properties are used directly by other subprojects. */,
)
object Time : Dependency() {
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt
index 63ecfca..2a8a255 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt
@@ -26,11 +26,14 @@ package io.spine.dependency.local
*
* @see tool-base
*/
-@Suppress("ConstPropertyName", "unused")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+ "unused" /* Some subprojects do not use all ToolBase artifacts. */,
+)
object ToolBase {
const val group = Spine.toolsGroup
- const val version = "2.0.0-SNAPSHOT.425"
- const val dogfoodingVersion = "2.0.0-SNAPSHOT.425"
+ const val version = "2.0.0-SNAPSHOT.426"
+ const val dogfoodingVersion = "2.0.0-SNAPSHOT.426"
const val archive = "$group:archive:$version"
const val code = "$group:code:$version"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
index b1069ce..155cbbc 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
@@ -19,7 +19,10 @@ package io.spine.dependency.local
*
* See [`SpineEventEngine/validation`](https://github.com/SpineEventEngine/validation/).
*/
-@Suppress("ConstPropertyName", "unused")
+@Suppress(
+ "ConstPropertyName" /* We use a custom convention for artifact properties. */,
+ "unused" /* Some subprojects do not use all Validation artifacts. */,
+)
object Validation {
/**
* The version of the Validation library artifacts.
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/test/KotlinCompileTesting.kt b/buildSrc/src/main/kotlin/io/spine/dependency/test/KotlinCompileTesting.kt
index 1976493..4d54ce0 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/test/KotlinCompileTesting.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/test/KotlinCompileTesting.kt
@@ -21,7 +21,7 @@ package io.spine.dependency.test
*/
@Suppress("unused", "ConstPropertyName")
object KotlinCompileTesting {
- private const val version = "0.14.0"
+ private const val version = "0.13.0"
private const val group = "dev.zacsweers.kctfork"
const val libCore = "$group:core:$version"
const val libKsp = "$group:ksp:$version"
diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt b/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt
index f364ccc..de954af 100644
--- a/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt
+++ b/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt
@@ -18,17 +18,36 @@ package io.spine.gradle
import java.io.File
import java.net.URI
+import java.nio.file.FileVisitResult
+import java.nio.file.FileVisitResult.CONTINUE
+import java.nio.file.FileVisitResult.SKIP_SUBTREE
import java.nio.file.Files
import java.nio.file.Path
+import java.nio.file.SimpleFileVisitor
+import java.nio.file.attribute.BasicFileAttributes
+import kotlin.io.path.copyTo
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.name
+import kotlin.io.path.notExists
import org.ajoberstar.grgit.Grgit
import org.gradle.api.tasks.TaskContainer
+/**
+ * The name of the folder with the Gradle build logic of a project.
+ */
+private const val BUILD_SRC = "buildSrc"
+
/**
* A tool to execute the Gradle `build` task in selected Git repositories
* with the local version of [config] contents.
*
* Checks out the content of selected repositories into the specified [tempFolder].
- * The folder is created if it does not exist. By default, uses `./tmp` as a temp folder.
+ * The folder is created if it does not exist.
+ *
+ * Both [config] and [tempFolder] must be absolute paths, e.g. obtained from
+ * `layout.projectDirectory`. A relative path is resolved against the working directory
+ * of the Gradle daemon, which is unrelated to the project directory.
*
* Replaces the `config` and `buildSrc` folders in the checked-out repository by the local versions
* of code. If the repository-under-test already contains its own `buildSrc` or `config` folders,
@@ -46,7 +65,7 @@ import org.gradle.api.tasks.TaskContainer
class ConfigTester(
private val config: Path,
private val tasks: TaskContainer,
- private val tempFolder: File = File("./tmp")
+ private val tempFolder: File
) {
companion object {
@@ -57,7 +76,16 @@ class ConfigTester(
private const val BUILD_TIMEOUT_MINUTES = 30L
}
- private val buildSrc: Path = config.resolve("buildSrc")
+ init {
+ require(config.isAbsolute) {
+ "The path to `config` must be absolute. Encountered: `$config`."
+ }
+ require(tempFolder.isAbsolute) {
+ "The path to the temp folder must be absolute. Encountered: `$tempFolder`."
+ }
+ }
+
+ private val buildSrc: Path = config.resolve(BUILD_SRC)
/**
* Git repositories to test.
@@ -75,7 +103,8 @@ class ConfigTester(
}
/**
- * Adds a test.
+ * Adds a Git [repo] into the test build by its URI, to be checked out
+ * at the given [branch].
*/
fun addRepo(repo: URI, branch: Branch): ConfigTester {
repos.add(GitRepository(repo, branch))
@@ -159,7 +188,9 @@ class GitRepository(
private val uri: URI,
/**
- * A branch to checkout.
+ * A branch to check out.
+ *
+ * May be any branch of the repository, not only the default one.
*
* By default, points to `master`.
*/
@@ -193,6 +224,9 @@ class GitRepository(
* the `destinationFolder/foobar` folder.
*
* If the supplied folder does not exist, it is created.
+ *
+ * The [branch] is checked out as a local branch tracking its remote counterpart.
+ * A [branch] the remote does not advertise fails the checkout.
*/
fun checkout(destinationFolder: File): ClonedRepo {
val preparedFolder = prepareCheckout(destinationFolder).toPath()
@@ -201,16 +235,21 @@ class GitRepository(
"to `${preparedFolder.toAbsolutePath()}`."
)
+ // The branch is checked out by the `clone` itself, rather than by a subsequent
+ // `checkout`. A fresh clone has only the default branch among its local refs,
+ // so `checkout` cannot resolve any other branch by its plain name, while
+ // `refToCheckout` is resolved against the refs advertised by the remote,
+ // where every branch of the repository is present.
+ //
+ // Only the checked-out files are of interest further on, so the Git repository
+ // the clone opens is closed right away.
Grgit.clone(
mapOf(
"dir" to preparedFolder,
- "uri" to uri
+ "uri" to uri,
+ "refToCheckout" to branch.name
)
- ).checkout(
- mapOf(
- "branch" to branch.name
- )
- )
+ ).close()
return ClonedRepo(this, preparedFolder)
}
@@ -260,14 +299,37 @@ class ClonedRepo(
* Optionally, takes an [ignoredFolder] that will be excluded from the [source] paths
* when copying.
*
+ * Build output and Git metadata of the [source] are not copied. If the original
+ * `buildSrc` has its own `module.gradle.kts`, the script is preserved.
*
* Returns this instance of `ClonedRepo`, for call chaining.
*/
fun replaceBuildSrc(source: Path, ignoredFolder: Path?): ClonedRepo {
- replaceFolder("buildSrc", source, ignoredFolder)
+ replaceFolder(BUILD_SRC, source, ignoredFolder)?.let(::restoreModuleScript)
return this
}
+ /**
+ * Restores the `module.gradle.kts` script of this repository, if it has one.
+ *
+ * `config` ships this script as an empty template that each project replaces
+ * by its own. The `config/migrate` script preserves the script of a project
+ * when installing `buildSrc` into it, and so must the test build.
+ *
+ * @param originalBuildSrc
+ * the folder into which the original `buildSrc` of this repository was renamed.
+ */
+ private fun restoreModuleScript(originalBuildSrc: Path) {
+ val ownScript = originalBuildSrc.resolve(MODULE_SCRIPT_PATH)
+ if (!ownScript.exists()) {
+ return
+ }
+ println("Preserving the `$MODULE_SCRIPT_PATH` of `${repo.name}`.")
+ val destination = location.resolve(BUILD_SRC).resolve(MODULE_SCRIPT_PATH)
+ destination.parent.createDirectories()
+ ownScript.copyTo(destination, overwrite = true)
+ }
+
/**
* Replaces the `config` folder in this cloned repository by the contents
* of the folder defined by the [source].
@@ -280,6 +342,8 @@ class ClonedRepo(
* Optionally, takes an [ignoredFolder] that will be excluded from the [source] paths
* when copying.
*
+ * Build output and Git metadata of the [source] are not copied.
+ *
* Returns this instance of `ClonedRepo`, for call chaining.
*/
fun replaceConfig(source: Path, ignoredFolder: Path?): ClonedRepo {
@@ -287,19 +351,30 @@ class ClonedRepo(
return this
}
- private fun replaceFolder(folderName: String, source: Path, ignoredFolder: Path?) {
+ /**
+ * Replaces the [folderName] folder of this repository by the contents of the [source].
+ *
+ * The original folder, if any, is renamed into `-original`.
+ *
+ * Returns the path to which the original folder was renamed, or `null`
+ * if this repository has no such folder.
+ */
+ private fun replaceFolder(folderName: String, source: Path, ignoredFolder: Path?): Path? {
val folder = location.resolve(folderName)
val rawFolder = folder.toFile()
+ var renamedInto: Path? = null
if (rawFolder.exists() && rawFolder.isDirectory) {
- val toRenameInto = location.resolve(folderName + "-original")
+ val toRenameInto = location.resolve("$folderName-original")
println("Renaming ${folder.toAbsolutePath()} into ${toRenameInto.toAbsolutePath()}.")
- rawFolder.renameTo(toRenameInto.toFile())
+ Files.move(folder, toRenameInto)
+ renamedInto = toRenameInto
}
println(
"Copying the files from ${source.toAbsolutePath()} " +
"into ${folder.toAbsolutePath()}."
)
copyFolder(source, ignoredFolder, folder)
+ return renamedInto
}
@Suppress("TooGenericExceptionCaught")
@@ -313,22 +388,93 @@ class ClonedRepo(
}
}
+ /**
+ * Copies the [sourceFolder] into the [destinationFolder], skipping the [ignoredFolder]
+ * along with the build output and the Git metadata.
+ *
+ * A symbolic link to a folder is copied as an empty folder, as the tree is walked
+ * without following the links. A link that does not resolve is skipped: `config`
+ * has such links when its `.agents/shared` submodule is not checked out.
+ */
private fun copyRecursively(sourceFolder: Path, ignoredFolder: Path?, destinationFolder: Path) {
- fun Path.isIgnored(): Boolean = ignoredFolder
- ?.let { toAbsolutePath().startsWith(it.toAbsolutePath()) }
- ?: false
-
- val flattenedTree = Files.walk(sourceFolder).filter { it.isIgnored().not() }
- val filesToDestinations = flattenedTree.map { file ->
- val destination = destinationFolder.resolve(sourceFolder.relativize(file))
- file to destination
+ val ignored = ignoredFolder?.toAbsolutePath()
+
+ fun Path.isIgnored(): Boolean =
+ ignored?.let { toAbsolutePath().startsWith(it) } ?: false
+
+ fun Path.destination(): Path = destinationFolder.resolve(sourceFolder.relativize(this))
+
+ // The tree is walked rather than streamed, so that an entry which is not copied
+ // is not descended into either. During the test build the `tmp` folder alone holds
+ // the full clones of all the tested repositories.
+ Files.walkFileTree(sourceFolder, object : SimpleFileVisitor() {
+
+ override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
+ if (dir != sourceFolder && (dir.isIgnored() || dir.isNonSource())) {
+ return SKIP_SUBTREE
+ }
+ dir.destination().createDirectories()
+ return CONTINUE
+ }
+
+ override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+ val danglingLink = attrs.isSymbolicLink && file.notExists()
+ if (!danglingLink && !file.isIgnored() && !file.isNonSource()) {
+ file.copyTo(file.destination())
+ }
+ return CONTINUE
+ }
+ })
+ }
+
+ /**
+ * Tells if this entry holds the output of a build or the Git metadata,
+ * and hence must not be copied.
+ *
+ * The build output of the local `config` is unrelated to the repository under test.
+ * Git metadata additionally holds the IPC sockets of the Git file system monitor;
+ * these cannot be copied at all.
+ *
+ * A `build` folder is recognized only next to a Gradle build script, so that
+ * the source packages named `build` are copied as usual.
+ */
+ private fun Path.isNonSource(): Boolean {
+ if (name in NON_SOURCE_ENTRIES) {
+ return true
}
+ return name == BUILD_OUTPUT && parent?.holdsGradleBuild() == true
+ }
+
+ private fun Path.holdsGradleBuild(): Boolean = BUILD_SCRIPTS.any { resolve(it).exists() }
+
+ private companion object {
+
+ /**
+ * Names of the entries that never hold sources, at any depth of a project.
+ *
+ * @see ClonedRepo.isNonSource
+ */
+ val NON_SOURCE_ENTRIES = setOf(".git", ".gradle", ".kotlin")
+
+ /**
+ * The name of the folder that holds the output of a Gradle build.
+ *
+ * @see ClonedRepo.isNonSource
+ */
+ const val BUILD_OUTPUT = "build"
- val directories = filesToDestinations.filter { Files.isDirectory(it.first) }
- directories.forEach { Files.createDirectories(it.second) }
+ /**
+ * The names of the scripts by which a folder is recognized as a Gradle build.
+ */
+ val BUILD_SCRIPTS = setOf("build.gradle.kts", "settings.gradle.kts")
- val files = filesToDestinations.filter { Files.isDirectory(it.first).not() }
- files.forEach { Files.copy(it.first, it.second) }
+ /**
+ * The path, relative to `buildSrc`, to the script that each project
+ * defines for itself.
+ *
+ * @see ClonedRepo.restoreModuleScript
+ */
+ const val MODULE_SCRIPT_PATH = "src/main/kotlin/module.gradle.kts"
}
}
@@ -353,5 +499,8 @@ object SpineRepos {
/**
* A name of a Git branch.
+ *
+ * The [name] is a plain branch name, such as `master` or `grpc-concurrency-fixes`.
+ * Neither the `refs/heads/` prefix nor the name of a remote is expected.
*/
data class Branch(val name: String)
diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtobufRuntime.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtobufRuntime.kt
new file mode 100644
index 0000000..715a199
--- /dev/null
+++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtobufRuntime.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.gradle.publish
+
+import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar.Companion.SHADOW_JAR_TASK_NAME
+import groovy.util.Node
+import io.spine.dependency.lib.Protobuf
+import org.gradle.api.Project
+import org.gradle.api.publish.maven.MavenPublication
+import org.gradle.kotlin.dsl.add
+import org.gradle.kotlin.dsl.named
+
+/**
+ * Leaves the Protobuf runtime ([Protobuf.libs]) out of the fat JAR built by
+ * the `shadowJar` task of this project, declaring it as a `runtime` dependency of
+ * the published artifacts instead.
+ *
+ * A bundled copy of the runtime cannot be relocated, as the runtime depends on
+ * the names of its classes. Left unrelocated, it shadows the Protobuf runtime that
+ * a consumer resolves for its own classpath, and no `force(...)` can replace it:
+ * the copy is bytecode inside another artifact, not a dependency. Should the copy
+ * be older than the code generated for the consumer, loading that code fails with
+ * `ProtobufRuntimeVersionException`. Declared as a dependency, the runtime takes part
+ * in the dependency resolution of the consumer, so the version it resolves wins.
+ *
+ * The libraries are declared:
+ * 1. in the `protobufRuntime` configuration, which the `shadowRuntimeElements`
+ * configuration of the Shadow plugin extends, so that the Gradle module metadata
+ * of a publication of the shadowed variant, such as the publication of a Gradle
+ * plugin, lists them;
+ * 2. in the POM of the given [publication], which publishes the fat JAR with
+ * `artifact(...)` and so receives no dependencies from Gradle.
+ *
+ * All the libraries of [Protobuf.libs] are declared, whichever of them the fat JAR
+ * used to bundle. They are declared in the `runtime` scope, so they no longer reach
+ * the compile classpath of the consumers of the fat JAR, which suits the tools and
+ * plugins published as fat JARs.
+ *
+ * The `shadow` configuration is not used for this purpose: Shadow writes the files of
+ * that configuration into the `Class-Path` attribute of the JAR manifest. Such entries
+ * are relative to the JAR and carry the Protobuf version the build resolved, so in
+ * a directory holding the JAR along with other Protobuf JARs, they would put that
+ * version first again.
+ *
+ * Each library is declared non-transitively, so only the libraries themselves are
+ * declared, `protobuf-java` among them. The rest of their dependencies stays where
+ * it was: bundled into the fat JAR (Guava, Gson), or provided where the JAR runs,
+ * as Gradle provides the Kotlin standard library to plugins. So consumers get
+ * the same classes as before, except that those of the Protobuf runtime come from
+ * genuine artifacts.
+ *
+ * The libraries are excluded by the dependency filter of the `shadowJar` task rather
+ * than by the paths of their entries, so that everything they contribute, classes and
+ * resources alike, is left out, and the SBOM published with the fat JAR does not
+ * describe them as [bundled][SbomContent.bundled].
+ *
+ * Call this function after applying the Shadow and `maven-publish` plugins and
+ * creating the publication of the fat JAR, and after any code writing dependencies
+ * into the POM of the publication, so that the POM keeps a single `dependencies`
+ * element:
+ * ```kotlin
+ * unbundleProtobufRuntime() // The `fatJar` of `uber-jar-module`.
+ * unbundleProtobufRuntime(publication = "cliFatJar") // A publication of its own.
+ * ```
+ * A project publishing its fat JAR in several publications calls the function
+ * once for each of them.
+ *
+ * @param publication The name of the publication of the fat JAR. Defaults to
+ * `fatJar`, the publication created by `uber-jar-module`.
+ */
+fun Project.unbundleProtobufRuntime(publication: String = "fatJar") {
+ if (configurations.findByName(PROTOBUF_RUNTIME) == null) {
+ declareProtobufRuntime()
+ }
+ publications.named(publication) {
+ declareRuntimeDependencies(Protobuf.libs)
+ }
+}
+
+/**
+ * Declares the Protobuf runtime in the `protobufRuntime` configuration, which
+ * the `shadowRuntimeElements` configuration extends, and leaves it out of
+ * the fat JAR built by the `shadowJar` task.
+ */
+private fun Project.declareProtobufRuntime() {
+ val protobufRuntime = configurations.create(PROTOBUF_RUNTIME) {
+ description = "The Protobuf runtime, which the published artifacts declare" +
+ " rather than bundle."
+ isCanBeConsumed = false
+ isCanBeResolved = false
+ }
+ Protobuf.libs.forEach {
+ dependencies.add(protobufRuntime.name, it) {
+ isTransitive = false
+ }
+ }
+ configurations.named(ShadowJavaPlugin.SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) {
+ extendsFrom(protobufRuntime)
+ }
+ tasks.named(SHADOW_JAR_TASK_NAME) {
+ dependencies {
+ exclude { "${it.moduleGroup}:${it.moduleName}" in protobufRuntimeModules }
+ }
+ }
+}
+
+/**
+ * The name of the configuration holding the Protobuf runtime, which the published
+ * artifacts declare rather than bundle.
+ */
+private const val PROTOBUF_RUNTIME = "protobufRuntime"
+
+/**
+ * The `group:name` coordinates of the Protobuf runtime libraries.
+ */
+private val protobufRuntimeModules: Set =
+ Protobuf.libs.mapTo(mutableSetOf()) { it.substringBeforeLast(':') }
+
+/**
+ * Declares the dependencies on the given `group:name:version` [coordinates]
+ * in the POM of this publication, in the `runtime` scope and without
+ * transitive dependencies.
+ *
+ * The dependencies are added to the `dependencies` element of the POM,
+ * which is created if the POM has none.
+ */
+private fun MavenPublication.declareRuntimeDependencies(coordinates: List) {
+ pom.withXml {
+ val root = asNode()
+ val dependencies = root.children()
+ .filterIsInstance()
+ .firstOrNull { it.localName == "dependencies" }
+ ?: root.appendNode("dependencies")
+ coordinates.forEach {
+ // Prefixed with `lib`, as plain `name` and `version` would hide those
+ // of the publication.
+ val (libGroup, libName, libVersion) = it.split(':')
+ with(dependencies.appendNode("dependency")) {
+ appendNode("groupId", libGroup)
+ appendNode("artifactId", libName)
+ appendNode("version", libVersion)
+ appendNode("scope", "runtime")
+ with(appendNode("exclusions").appendNode("exclusion")) {
+ appendNode("groupId", "*")
+ appendNode("artifactId", "*")
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The name of this node without its namespace.
+ *
+ * The name is a `QName` for a node parsed with namespaces, as the nodes of a POM are.
+ * It renders as `{namespace}localName`.
+ */
+private val Node.localName: String
+ get() = name().toString().substringAfterLast('}')
diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationHandler.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationHandler.kt
index 1d4bb96..22d7d73 100644
--- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationHandler.kt
+++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationHandler.kt
@@ -38,6 +38,11 @@ import org.gradle.kotlin.dsl.withType
*/
private const val MAVEN_PUBLISH = "maven-publish"
+/**
+ * The name of the Gradle plugin that adds test fixtures to a project.
+ */
+private const val JAVA_TEST_FIXTURES = "java-test-fixtures"
+
/**
* Abstract base for handlers of publications in a project
* with [spinePublishing] settings declared.
@@ -95,6 +100,7 @@ sealed class PublicationHandler(
// is applied.
pluginManager.withPlugin(MAVEN_PUBLISH) {
handlePublications()
+ suppressTestFixturesPomWarnings()
registerDestinations()
configurePublishTask(destinations)
applied = true
@@ -109,6 +115,27 @@ sealed class PublicationHandler(
*/
abstract fun handlePublications()
+ /**
+ * Silences the warnings that Gradle prints about the test fixtures variants
+ * when it generates the POM of a Maven publication of the [project].
+ *
+ * The `java-test-fixtures` plugin adds the test fixtures to the `java` component
+ * as variants with a capability of their own. Gradle Module Metadata (`.module`)
+ * keeps them, while a POM cannot describe a capability, so Gradle leaves them
+ * out of it by design. Hence, the warnings carry no information.
+ *
+ * The publications added after this handler is applied, such as `pluginMaven`
+ * of `java-gradle-plugin`, are silenced as well.
+ */
+ private fun suppressTestFixturesPomWarnings() {
+ project.pluginManager.withPlugin(JAVA_TEST_FIXTURES) {
+ project.publications.withType().configureEach {
+ suppressPomMetadataWarningsFor("testFixturesApiElements")
+ suppressPomMetadataWarningsFor("testFixturesRuntimeElements")
+ }
+ }
+ }
+
/**
* Goes through the [destinations] and registers each as a repository for publishing
* in the given Gradle project.
diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt
new file mode 100644
index 0000000..c9f102c
--- /dev/null
+++ b/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.gradle
+
+import io.kotest.matchers.shouldBe
+import java.net.URI
+import java.nio.file.Path
+import kotlin.io.path.createSymbolicLinkPointingTo
+import kotlin.io.path.exists
+import kotlin.io.path.readText
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+@DisplayName("`ClonedRepo` should")
+internal class ClonedRepoSpec {
+
+ @TempDir
+ lateinit var tempDir: Path
+
+ /** The `buildSrc` of the local `config` to be copied. */
+ private lateinit var source: Path
+
+ /** The root of the repository under test. */
+ private lateinit var location: Path
+
+ @BeforeEach
+ fun prepareFolders() {
+ source = tempDir.resolve("config").resolve(BUILD_SRC)
+ location = tempDir.resolve("repo")
+ }
+
+ @Test
+ fun `copy the files, and not only the folders`() {
+ source.createFile("quality/detekt-config.yml", "The quality settings.")
+ source.createFile("src/main/kotlin/io/spine/dependency/build/Ksp.kt", "object Ksp")
+
+ replaceBuildSrc()
+
+ val copied = location.resolve(BUILD_SRC)
+ copied.resolve("quality/detekt-config.yml").readText() shouldBe "The quality settings."
+ copied.resolve("src/main/kotlin/io/spine/dependency/build/Ksp.kt")
+ .readText() shouldBe "object Ksp"
+ }
+
+ @Test
+ fun `skip the build output and the Git metadata`() {
+ source.createFile(BUILD_SCRIPT, "The build script.")
+ source.createFile("src/main/kotlin/Deps.kt", "object Deps")
+ source.createFile("build/classes/Deps.class", "The compiled code.")
+ source.createFile(".gradle/file-system.probe", "The probe.")
+ source.createFile(".kotlin/sessions/session.salive", "The session.")
+ source.createFile(".git/HEAD", "ref: refs/heads/master")
+
+ replaceBuildSrc()
+
+ val copied = location.resolve(BUILD_SRC)
+ copied.resolve("src/main/kotlin/Deps.kt").exists() shouldBe true
+ copied.resolve("build").exists() shouldBe false
+ copied.resolve(".gradle").exists() shouldBe false
+ copied.resolve(".kotlin").exists() shouldBe false
+ copied.resolve(".git").exists() shouldBe false
+ }
+
+ @Test
+ fun `skip the build output of a nested Gradle project`() {
+ val config = tempDir.resolve("config")
+ config.createFile(BUILD_SCRIPT, "The build script.")
+ config.createFile("$BUILD_SRC/settings.gradle.kts", "The settings of `buildSrc`.")
+ config.createFile("$BUILD_SRC/build/classes/Deps.class", "The compiled code.")
+ config.createFile("$BUILD_SRC/.gradle/file-system.probe", "The probe.")
+ config.createFile("$BUILD_SRC/src/main/kotlin/io/spine/dependency/build/Ksp.kt", "object Ksp")
+
+ val repo = GitRepository(URI("https://github.com/SpineEventEngine/base"))
+ ClonedRepo(repo, location).replaceConfig(config, null)
+
+ val copied = location.resolve("config")
+ copied.resolve("$BUILD_SRC/build").exists() shouldBe false
+ copied.resolve("$BUILD_SRC/.gradle").exists() shouldBe false
+ copied.resolve("$BUILD_SRC/src/main/kotlin/io/spine/dependency/build/Ksp.kt")
+ .readText() shouldBe "object Ksp"
+ }
+
+ @Test
+ fun `preserve the 'module gradle kts' of the repository under test`() {
+ source.createFile(MODULE_SCRIPT, TEMPLATE_MODULE_SCRIPT)
+ location.resolve(BUILD_SRC).createFile(MODULE_SCRIPT, OWN_MODULE_SCRIPT)
+
+ replaceBuildSrc()
+
+ location.resolve(BUILD_SRC).resolve(MODULE_SCRIPT)
+ .readText() shouldBe OWN_MODULE_SCRIPT
+ location.resolve("$BUILD_SRC-original").resolve(MODULE_SCRIPT)
+ .readText() shouldBe OWN_MODULE_SCRIPT
+ }
+
+ @Test
+ fun `take the template 'module gradle kts' when the repository has none`() {
+ source.createFile(MODULE_SCRIPT, TEMPLATE_MODULE_SCRIPT)
+
+ replaceBuildSrc()
+
+ location.resolve(BUILD_SRC).resolve(MODULE_SCRIPT)
+ .readText() shouldBe TEMPLATE_MODULE_SCRIPT
+ }
+
+ @Test
+ fun `skip a link that does not resolve`() {
+ source.createFile("src/main/kotlin/Deps.kt", "object Deps")
+ val link = source.resolve("guidelines")
+ link.createSymbolicLinkPointingTo(source.resolve("shared/guidelines"))
+
+ replaceBuildSrc()
+
+ val copied = location.resolve(BUILD_SRC)
+ copied.resolve("src/main/kotlin/Deps.kt").exists() shouldBe true
+ copied.resolve("guidelines").exists() shouldBe false
+ }
+
+ @Test
+ fun `skip the ignored folder`() {
+ source.createFile("src/main/kotlin/Deps.kt", "object Deps")
+ source.createFile("tmp/base/README.md", "The checked out sources.")
+
+ replaceBuildSrc(ignoredFolder = source.resolve("tmp"))
+
+ val copied = location.resolve(BUILD_SRC)
+ copied.resolve("src/main/kotlin/Deps.kt").exists() shouldBe true
+ copied.resolve("tmp").exists() shouldBe false
+ }
+
+ private fun replaceBuildSrc(ignoredFolder: Path? = null) {
+ val repo = GitRepository(URI("https://github.com/SpineEventEngine/base"))
+ ClonedRepo(repo, location).replaceBuildSrc(source, ignoredFolder)
+ }
+
+ private companion object {
+
+ const val BUILD_SRC = "buildSrc"
+ const val BUILD_SCRIPT = "build.gradle.kts"
+ const val MODULE_SCRIPT = "src/main/kotlin/module.gradle.kts"
+ const val TEMPLATE_MODULE_SCRIPT = "// A template to be defined by a project."
+ const val OWN_MODULE_SCRIPT = "plugins { `java-library` }"
+ }
+}
diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt
new file mode 100644
index 0000000..791a9ca
--- /dev/null
+++ b/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.gradle
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.paths.shouldNotExist
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import java.io.File
+import java.nio.file.Path
+import kotlin.io.path.createDirectories
+import kotlin.io.path.readText
+import org.ajoberstar.grgit.Grgit
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+@DisplayName("`GitRepository` should")
+internal class GitRepositorySpec {
+
+ @TempDir
+ lateinit var tempDir: Path
+
+ /** The repository to be cloned, playing the role of a remote. */
+ private lateinit var origin: Path
+
+ /** The folder the repository is checked out into. */
+ private lateinit var destination: File
+
+ /** The root of the checked-out repository. */
+ private val clone: Path
+ get() = destination.toPath().resolve(REPO_NAME)
+
+ /**
+ * Creates the repository to be cloned, with two branches.
+ *
+ * Both branches carry [SHARED_FILE]. Only [FEATURE] carries [FEATURE_FILE],
+ * which thus tells the branches apart in the clone.
+ *
+ * The created repository is left at [MASTER], so that this is the branch
+ * a clone checks out by default.
+ */
+ @BeforeEach
+ fun createOrigin() {
+ origin = tempDir.resolve(REPO_NAME).createDirectories()
+ destination = tempDir.resolve("checkout").toFile()
+ Grgit.init(mapOf("dir" to origin)).use { git ->
+ origin.createFile(SHARED_FILE, SHARED_TEXT)
+ git.commitAll("Add the file shared by all branches.")
+
+ // Git does not mandate the name of the branch a repository starts with,
+ // and JGit honors `init.defaultBranch`, so make sure `master` exists.
+ if (git.branch.current().name != MASTER) {
+ git.checkout(mapOf("branch" to MASTER, "createBranch" to true))
+ }
+
+ git.checkout(mapOf("branch" to FEATURE, "createBranch" to true))
+ origin.createFile(FEATURE_FILE, FEATURE_TEXT)
+ git.commitAll("Add the file of the feature branch.")
+
+ git.checkout(mapOf("branch" to MASTER))
+ }
+ }
+
+ @Test
+ fun `check out the default branch`() {
+ GitRepository(origin.toUri()).checkout(destination)
+
+ clone.resolve(SHARED_FILE).readText() shouldBe SHARED_TEXT
+ clone.resolve(FEATURE_FILE).shouldNotExist()
+ headOfClone() shouldBe "$MASTER -> origin/$MASTER"
+ }
+
+ /**
+ * Verifies that a branch is resolved by its plain name and is checked out
+ * as a local branch rather than as a detached `HEAD`.
+ *
+ * A fresh clone has only the default branch among its local refs, so the branch
+ * has to be resolved against the refs advertised by the remote. `HEAD` detached
+ * at `origin/` would hold the right files just as well, which is why
+ * the `HEAD` of the clone is asserted too.
+ */
+ @Test
+ fun `check out a branch other than the default one`() {
+ GitRepository(origin.toUri(), Branch(FEATURE)).checkout(destination)
+
+ clone.resolve(FEATURE_FILE).readText() shouldBe FEATURE_TEXT
+ headOfClone() shouldBe "$FEATURE -> origin/$FEATURE"
+ }
+
+ /**
+ * Verifies that a missing branch fails the checkout right away.
+ *
+ * Were the clone to succeed and leave an empty working tree behind, the Gradle
+ * build of the repository would fail later on with a far less obvious message.
+ */
+ @Test
+ fun `fail to check out a branch that does not exist`() {
+ val repo = GitRepository(origin.toUri(), Branch(MISSING))
+
+ val error = shouldThrow { repo.checkout(destination) }
+
+ // `GitAPIException` roots the whole JGit error hierarchy, so the branch
+ // has to be named for this to pin the branch resolution rather than,
+ // say, an unreadable `origin`.
+ error.message shouldContain MISSING
+ }
+
+ /**
+ * Returns the current branch of the clone, along with the branch it tracks,
+ * as ` -> `.
+ *
+ * A detached `HEAD` reads as `HEAD -> null`.
+ */
+ private fun headOfClone(): String =
+ Grgit.open(mapOf("dir" to clone)).use {
+ val head = it.branch.current()
+ "${head.name} -> ${head.trackingBranch?.name}"
+ }
+
+ private companion object {
+
+ /** The name of the folder the repository is cloned into. */
+ const val REPO_NAME = "acme-lib"
+
+ const val MASTER = "master"
+ const val FEATURE = "grpc-concurrency-fixes"
+ const val MISSING = "no-such-branch"
+
+ const val SHARED_FILE = "README.md"
+ const val SHARED_TEXT = "The file shared by all branches."
+
+ const val FEATURE_FILE = "src/main/kotlin/Concurrency.kt"
+ const val FEATURE_TEXT = "object Concurrency"
+
+ /**
+ * Commits all the files of this repository, including the new ones.
+ */
+ fun Grgit.commitAll(message: String) {
+ add(mapOf("patterns" to setOf(".")))
+ commit(mapOf("message" to message))
+ }
+ }
+}
diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt b/buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt
new file mode 100644
index 0000000..c0ae947
--- /dev/null
+++ b/buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.gradle
+
+import java.nio.file.Path
+import kotlin.io.path.createDirectories
+import kotlin.io.path.writeText
+
+/**
+ * Creates a file under this folder, along with the folders leading to it.
+ */
+internal fun Path.createFile(relativePath: String, content: String): Path {
+ val file = resolve(relativePath)
+ file.parent.createDirectories()
+ file.writeText(content)
+ return file
+}
diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtobufRuntimeIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtobufRuntimeIgTest.kt
new file mode 100644
index 0000000..f6a7d20
--- /dev/null
+++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtobufRuntimeIgTest.kt
@@ -0,0 +1,519 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.gradle.publish
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import io.kotest.inspectors.forAll
+import io.kotest.matchers.collections.shouldBeEmpty
+import io.kotest.matchers.collections.shouldBeSingleton
+import io.kotest.matchers.collections.shouldContainAll
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
+import io.kotest.matchers.collections.shouldNotContain
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.nulls.shouldNotBeNull
+import io.kotest.matchers.shouldBe
+import io.spine.dependency.lib.Protobuf
+import java.io.File
+import java.util.jar.JarFile
+import java.util.jar.JarOutputStream
+import java.util.jar.Manifest
+import java.util.zip.ZipEntry
+import javax.xml.parsers.DocumentBuilderFactory
+import org.gradle.testkit.runner.GradleRunner
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.io.TempDir
+import org.w3c.dom.Element
+
+/**
+ * Verifies [unbundleProtobufRuntime] in a real build of fat JARs, run via Gradle TestKit.
+ *
+ * The fixture is built once for the whole class, and each case inspects what
+ * the build left behind. The build runs with `--offline`, resolving from a local
+ * `file:` repository, which holds stand-ins for the Protobuf runtime libraries.
+ * `protobuf-java-util` depends on `com.example:gson`, as the genuine one depends
+ * on Gson. The build also runs with the configuration cache.
+ *
+ * All the modules of the fixture apply Shadow and depend on `com.example:lib`.
+ * The fixture has:
+ * - `fat` — a module depending on the Protobuf runtime. It publishes its fat JAR
+ * in the `cliFatJar` publication, with `artifact(...)`, as the Compiler CLI does,
+ * and its Java component, which carries the shadowed variant, in the `library`
+ * publication;
+ * - `component` — a module whose `library` publication of its Java component is
+ * given to the function, so that the POM the function adds to already has
+ * the `dependencies` element Gradle writes;
+ * - `twin` — a module depending on the Protobuf runtime. It publishes its fat JAR
+ * in two publications: `fatJar`, as `uber-jar-module` does, and `extra`, whose POM
+ * gets a `dependencies` element written by hand before the function adds to it.
+ * The function is called once for each publication.
+ */
+@DisplayName("`unbundleProtobufRuntime` should")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+internal class ProtobufRuntimeIgTest {
+
+ private lateinit var projectDir: File
+
+ @BeforeAll
+ fun buildFixture(@TempDir dir: File) {
+ projectDir = dir
+ writeRepository()
+ writeBuild()
+ GradleRunner.create()
+ .withProjectDir(projectDir)
+ .withArguments(
+ ":fat:shadowJar",
+ ":fat:${pomTaskOf(cliFatJar)}",
+ ":fat:generateMetadataFileForLibraryPublication",
+ ":fat:$listIncludedTask",
+ ":component:${pomTaskOf(library)}",
+ ":twin:${pomTaskOf(defaultFatJar)}",
+ ":twin:${pomTaskOf(extra)}",
+ "--offline",
+ "--configuration-cache",
+ "--stacktrace",
+ )
+ .build()
+ }
+
+ @Test
+ fun `leave the Protobuf runtime out of the fat JAR`() {
+ val entries = fatJarEntries()
+
+ entries.filter { it.startsWith("com/google/protobuf/") }.shouldBeEmpty()
+ entries shouldNotContain "META-INF/protobuf-kotlin.kotlin_module"
+ }
+
+ @Test
+ fun `keep bundling the other dependencies, including those of the runtime`() {
+ fatJarEntries() shouldContainAll listOf(
+ "com/example/Lib.class",
+ "com/example/gson/Gson.class",
+ )
+ }
+
+ @Test
+ fun `exclude the runtime by the dependency filter of the fat JAR`() {
+ val included = file("fat/build/included.txt").readLines()
+
+ included shouldContainExactlyInAnyOrder listOf("lib-1.0.jar", "gson-1.0.jar")
+ }
+
+ @Test
+ fun `not name the runtime in the manifest of the fat JAR`() {
+ JarFile(fatJarFile()).use { jar ->
+ val manifest = jar.manifest.shouldNotBeNull()
+ manifest.mainAttributes.getValue("Class-Path").shouldBeNull()
+ }
+ }
+
+ @Test
+ fun `declare the runtime in the POM of the fat JAR`() {
+ val dependencies = pomDependencies("fat", cliFatJar)
+ val artifactIds = dependencies.map { it.childText("artifactId") }
+
+ artifactIds shouldContainExactlyInAnyOrder runtimeLibraries
+ dependencies.forAll { it.shouldBeRuntimeLibrary() }
+ }
+
+ @Test
+ fun `declare the runtime in the shadowed variant`() {
+ val metadata = ObjectMapper().readTree(file("fat/build/publications/library/module.json"))
+ val variants = metadata["variants"].shouldNotBeNull()
+ .filter { it["name"]?.asText() == shadowedVariant }
+ variants.shouldBeSingleton()
+ val dependencies = variants.single()["dependencies"].shouldNotBeNull().toList()
+ val modules = dependencies.map { it["module"]?.asText() }
+
+ modules shouldContainExactlyInAnyOrder runtimeLibraries
+ dependencies.forAll {
+ it["group"]?.asText() shouldBe Protobuf.group
+ it["version"]?.get("requires")?.asText() shouldBe Protobuf.version
+ it.excludes() shouldContainExactly listOf("*" to "*")
+ }
+ }
+
+ @Test
+ fun `add to the dependencies which Gradle writes into the POM`() {
+ // The shadowed variant of the Java component adds the runtime as optional.
+ val dependencies = pomDependencies("component", library)
+ .filter { it.childTextOrNull("optional") == null }
+ val artifactIds = dependencies.map { it.childText("artifactId") }
+
+ artifactIds shouldContainExactly listOf("lib") + runtimeLibraries
+ dependencies.drop(1).forAll { it.shouldBeRuntimeLibrary() }
+ }
+
+ @Test
+ fun `add to the dependencies written into the POM by hand`() {
+ val dependencies = pomDependencies("twin", extra)
+ val artifactIds = dependencies.map { it.childText("artifactId") }
+
+ artifactIds shouldContainExactly listOf("lib") + runtimeLibraries
+ dependencies.drop(1).forAll { it.shouldBeRuntimeLibrary() }
+ }
+
+ @Test
+ fun `declare the runtime in each publication it is called for`() {
+ val dependencies = pomDependencies("twin", defaultFatJar)
+ val artifactIds = dependencies.map { it.childText("artifactId") }
+
+ artifactIds shouldContainExactlyInAnyOrder runtimeLibraries
+ dependencies.forAll { it.shouldBeRuntimeLibrary() }
+ }
+
+ private fun fatJarFile(): File = file("fat/build/libs/fat-$moduleVersion-all.jar")
+
+ private fun fatJarEntries(): List =
+ JarFile(fatJarFile()).use { jar -> jar.entries().toList().map { it.name } }
+
+ /**
+ * Returns the `dependency` elements of the POM of the given [publication] of
+ * the given [module], ensuring the POM has a single `dependencies` element.
+ */
+ private fun pomDependencies(module: String, publication: String): List {
+ val pom = file("$module/build/publications/$publication/pom-default.xml")
+ val project = DocumentBuilderFactory.newInstance()
+ .newDocumentBuilder()
+ .parse(pom)
+ .documentElement
+ val dependencies = project.childElements("dependencies")
+ dependencies.shouldBeSingleton()
+ return dependencies.single().childElements("dependency")
+ }
+
+ /**
+ * Writes the local repository with the stand-ins for the Protobuf runtime
+ * and the other libraries of the fixture.
+ */
+ private fun writeRepository() {
+ library(
+ Protobuf.javaLib,
+ entries = listOf("com/google/protobuf/RuntimeVersion.class"),
+ )
+ library(
+ Protobuf.javaUtil,
+ entries = listOf("com/google/protobuf/util/JsonFormat.class"),
+ dependencies = listOf(Protobuf.javaLib, gson),
+ )
+ library(
+ Protobuf.kotlin,
+ entries = listOf(
+ "com/google/protobuf/kotlin/DslList.class",
+ "META-INF/protobuf-kotlin.kotlin_module",
+ ),
+ dependencies = listOf(Protobuf.javaLib),
+ )
+ library(lib, entries = listOf("com/example/Lib.class"))
+ library(gson, entries = listOf("com/example/gson/Gson.class"))
+ }
+
+ /**
+ * Writes the POM and the JAR of the library with the given `group:name:version`
+ * [coordinates] into the local repository.
+ *
+ * @param entries The paths of the empty entries of the JAR.
+ * @param dependencies The `group:name:version` coordinates of the dependencies
+ * the POM declares.
+ */
+ private fun library(
+ coordinates: String,
+ entries: List,
+ dependencies: List = emptyList(),
+ ) {
+ val (groupId, artifactId, version) = coordinates.split(':')
+ val path = "repository/${groupId.replace('.', '/')}/$artifactId/$version"
+ val declared = dependencies.joinToString("\n") { dependency ->
+ val (depGroup, depArtifact, depVersion) = dependency.split(':')
+ """
+ |
+ | $depGroup
+ | $depArtifact
+ | $depVersion
+ |
+ """.trimMargin()
+ }
+ val dependenciesElement = if (dependencies.isEmpty()) "" else
+ " \n$declared\n "
+ write(
+ "$path/$artifactId-$version.pom",
+ """
+ |
+ |
+ | 4.0.0
+ | $groupId
+ | $artifactId
+ | $version
+ |$dependenciesElement
+ |
+ |""".trimMargin()
+ )
+ val jar = file("$path/$artifactId-$version.jar")
+ JarOutputStream(jar.outputStream(), Manifest()).use { out ->
+ entries.forEach {
+ out.putNextEntry(ZipEntry(it))
+ out.closeEntry()
+ }
+ }
+ }
+
+ private fun writeBuild() {
+ write(
+ "settings.gradle.kts",
+ """
+ rootProject.name = "protobuf-runtime-sample"
+ include("fat", "component", "twin")
+ """.trimIndent()
+ )
+ write(
+ "build.gradle.kts",
+ """
+ buildscript {
+ dependencies {
+ classpath(files(
+ ${buildSrcClasspath()}
+ ))
+ }
+ }
+
+ allprojects {
+ group = "io.spine.test"
+ version = "$moduleVersion"
+ repositories {
+ maven { url = uri("${'$'}{rootDir}/repository") }
+ }
+ }
+ """.trimIndent()
+ )
+ writeFatModule()
+ writeComponentModule()
+ writeTwinModule()
+ }
+
+ private fun writeFatModule() {
+ val body = """
+ |publishing {
+ | publications {
+ | create("$cliFatJar") {
+ | artifactId = "fat-all"
+ | artifact(tasks.shadowJar)
+ | }
+ | create("$library") {
+ | from(components["java"])
+ | }
+ | }
+ |}
+ |
+ |unbundleProtobufRuntime(publication = "$cliFatJar")
+ |
+ |// Lists the files of the dependencies the fat JAR bundles, which the SBOM
+ |// of a fat JAR describes as bundled.
+ |tasks.register("$listIncludedTask") {
+ | val included = files(tasks.shadowJar.map { it.includedDependencies })
+ | val listing = layout.buildDirectory.file("included.txt")
+ | inputs.files(included)
+ | outputs.file(listing)
+ | doLast {
+ | listing.get().asFile.writeText(included.files.joinToString("\n") { it.name })
+ | }
+ |}
+ |""".trimMargin()
+ writeModule("fat", body)
+ }
+
+ private fun writeComponentModule() {
+ val body = """
+ |publishing {
+ | publications {
+ | create("$library") {
+ | from(components["java"])
+ | }
+ | }
+ |}
+ |
+ |unbundleProtobufRuntime(publication = "$library")
+ |""".trimMargin()
+ writeModule("component", body, dependOnRuntime = false)
+ }
+
+ private fun writeTwinModule() {
+ val body = """
+ |publishing {
+ | publications {
+ | create("$defaultFatJar") {
+ | artifactId = "twin-all"
+ | artifact(tasks.shadowJar)
+ | }
+ | create("$extra") {
+ | artifactId = "twin-extra"
+ | artifact(tasks.shadowJar)
+ | pom.withXml {
+ | val dependency = asNode()
+ | .appendNode("dependencies")
+ | .appendNode("dependency")
+ | dependency.appendNode("groupId", "com.example")
+ | dependency.appendNode("artifactId", "lib")
+ | dependency.appendNode("version", "1.0")
+ | }
+ | }
+ | }
+ |}
+ |
+ |unbundleProtobufRuntime()
+ |unbundleProtobufRuntime(publication = "$extra")
+ |""".trimMargin()
+ writeModule("twin", body)
+ }
+
+ /**
+ * Writes the build script of the given [module], which applies Shadow,
+ * depends on `com.example:lib` and, unless told otherwise, on the Protobuf runtime,
+ * and ends with the given [body].
+ */
+ private fun writeModule(module: String, body: String, dependOnRuntime: Boolean = true) {
+ val libs = if (dependOnRuntime) Protobuf.libs else emptyList()
+ val runtime = libs.joinToString("\n") { "| implementation(\"$it\")" }
+ val head = """
+ |import io.spine.gradle.publish.unbundleProtobufRuntime
+ |
+ |plugins {
+ | `java-library`
+ | `maven-publish`
+ | id("com.gradleup.shadow")
+ |}
+ |
+ |dependencies {
+ | implementation("$lib")
+ $runtime
+ |}
+ |""".trimMargin()
+ write("$module/build.gradle.kts", "$head\n$body")
+ }
+
+ private fun file(relativePath: String): File = projectDir.resolve(relativePath)
+
+ private fun write(relativePath: String, text: String) {
+ file(relativePath).apply {
+ parentFile.mkdirs()
+ writeText(text)
+ }
+ }
+
+ /**
+ * Renders the classpath with the production classes of `buildSrc` as
+ * arguments of `files(...)`, for injection into the build script classpath
+ * of the generated build.
+ *
+ * The classpath comes from the `test` task in `buildSrc/build.gradle.kts`.
+ *
+ * Each entry after the first starts a line of the generated script, so each
+ * is prefixed with the indentation of the template it is inserted into, which
+ * `trimIndent()` then removes along with the rest.
+ */
+ private fun buildSrcClasspath(): String {
+ val classpath = requireNotNull(System.getProperty("buildSrc.classpath")) {
+ "The `buildSrc.classpath` system property is not set." +
+ " It is supplied by the `test` task in `buildSrc/build.gradle.kts`."
+ }
+ return classpath.split(File.pathSeparator).joinToString(",\n") {
+ " \"${File(it).invariantSeparatorsPath}\""
+ }
+ }
+
+ private companion object {
+
+ /** The version of the modules of the fixture. */
+ const val moduleVersion = "1.0.0"
+
+ /** The name of a publication of a fat JAR, other than the default one. */
+ const val cliFatJar = "cliFatJar"
+
+ /** The name of the publication of a fat JAR, which the function uses by default. */
+ const val defaultFatJar = "fatJar"
+
+ /** The name of the publication of a fat JAR whose POM declares a dependency. */
+ const val extra = "extra"
+
+ /** The name of a publication of a Java component. */
+ const val library = "library"
+
+ /** The name of the variant of the Java component published by the Shadow plugin. */
+ const val shadowedVariant = "shadowRuntimeElements"
+
+ /** The name of the task listing the dependencies the fat JAR bundles. */
+ const val listIncludedTask = "listIncluded"
+
+ /** The library the modules of the fixture bundle. */
+ const val lib = "com.example:lib:1.0"
+
+ /** The library `protobuf-java-util` depends on, as the genuine one depends on Gson. */
+ const val gson = "com.example:gson:1.0"
+
+ /** The artifact IDs of the Protobuf runtime libraries. */
+ val runtimeLibraries: List =
+ Protobuf.libs.map { it.substringBeforeLast(':').substringAfter(':') }
+
+ /** Returns the name of the task writing the POM of the given [publication]. */
+ fun pomTaskOf(publication: String): String =
+ "generatePomFileFor${publication.replaceFirstChar { it.uppercase() }}Publication"
+ }
+}
+
+/**
+ * Asserts that this `dependency` element of a POM declares a Protobuf runtime
+ * library in the `runtime` scope and without transitive dependencies.
+ */
+private fun Element.shouldBeRuntimeLibrary() {
+ childText("groupId") shouldBe Protobuf.group
+ childText("version") shouldBe Protobuf.version
+ childText("scope") shouldBe "runtime"
+ val exclusions = childElements("exclusions")
+ exclusions.shouldBeSingleton()
+ val exclusion = exclusions.single().childElements("exclusion")
+ exclusion.shouldBeSingleton()
+ exclusion.single().childText("groupId") shouldBe "*"
+ exclusion.single().childText("artifactId") shouldBe "*"
+}
+
+/** Returns the child elements of this one with the given [tag]. */
+private fun Element.childElements(tag: String): List {
+ val children = childNodes
+ return (0 until children.length)
+ .map { children.item(it) }
+ .filterIsInstance()
+ .filter { it.tagName == tag }
+}
+
+/** Returns the text of the child element of this one with the given [tag]. */
+private fun Element.childText(tag: String): String =
+ childTextOrNull(tag).shouldNotBeNull()
+
+/**
+ * Returns the text of the child element of this one with the given [tag],
+ * or `null` if there is none.
+ */
+private fun Element.childTextOrNull(tag: String): String? =
+ childElements(tag).firstOrNull()?.textContent
+
+/**
+ * Returns the `group` and `module` of each exclusion of this dependency of
+ * the Gradle module metadata.
+ */
+private fun JsonNode.excludes(): List> =
+ this["excludes"]?.map { it["group"]?.asText() to it["module"]?.asText() }.orEmpty()
diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationChecksumsIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationChecksumsIgTest.kt
index b27f6b5..48a1b66 100644
--- a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationChecksumsIgTest.kt
+++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationChecksumsIgTest.kt
@@ -79,7 +79,7 @@ internal class PublicationChecksumsIgTest {
// `javadocJar` of `spinePublishing` is wired to Dokka, which
// this fixture does not apply: the manifest is what is under
// test, not the documentation toolchain.
- tasks.register("dokkaGeneratePublicationJavadoc")
+ $generatorStub
}
spinePublishing {
@@ -166,6 +166,34 @@ internal class PublicationChecksumsIgTest {
names shouldContain "spine-api-$version-sources.jar"
}
+ /**
+ * A module that generates no documentation still publishes the JARs that
+ * would hold it: `disableDocumentationTasks()` stops the generators, not
+ * the JARs, so each is published containing only a manifest.
+ *
+ * Were a JAR stopped along with its generator, the publication would go on
+ * declaring a file that nothing writes. Publishing to Maven Local tolerates
+ * that today, so `collectPublicationChecksums` — the first task to read
+ * every declared file — is where such a module fails the build.
+ */
+ @Test
+ fun `cover a module that generates no documentation`() {
+ val script = file("build.gradle.kts")
+ script.writeText(
+ script.readText().replace(
+ generatorStub,
+ "$generatorStub\n disableDocumentationTasks()"
+ )
+ )
+
+ runGradle(aggregator, "publishAllPublicationsToStageRepository")
+
+ val names = manifestEntries().map { it.name }.sorted()
+ names shouldContain "spine-api-$version-javadoc.jar"
+ names shouldContain "spine-api-$version-html-docs.jar"
+ names shouldContainExactly publishedFileNames()
+ }
+
/**
* Maven deploys a version ending in `-SNAPSHOT` under a timestamped name
* assigned at upload time, so the names derived from the publication belong
@@ -272,6 +300,16 @@ internal class PublicationChecksumsIgTest {
const val version = "1.0.0"
+ /**
+ * The statement of the fixture that registers a stub of the Dokka task
+ * feeding `javadocJar`.
+ *
+ * It anchors the per-test additions to the configuration shared by
+ * the modules. The fixture is written from this constant too, so that
+ * an edit of one cannot leave the other matching nothing.
+ */
+ const val generatorStub = """tasks.register("dokkaGeneratePublicationJavadoc")"""
+
val sidecars = listOf(".md5", ".sha1", ".sha256", ".sha512")
}
}
diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationHandlerIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationHandlerIgTest.kt
new file mode 100644
index 0000000..67f5572
--- /dev/null
+++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationHandlerIgTest.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2026 CodeMatters, Lda.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package io.spine.gradle.publish
+
+import io.kotest.matchers.string.shouldContain
+import io.kotest.matchers.string.shouldNotContain
+import java.io.File
+import org.gradle.testkit.runner.GradleRunner
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.io.TempDir
+
+/**
+ * Verifies that [PublicationHandler] silences the warnings that Gradle prints when
+ * it generates the POMs of modules applying `java-test-fixtures`, in a real
+ * multi-module build run via Gradle TestKit.
+ *
+ * The fixture is built once for the whole class, and each case inspects the output
+ * of the build. All the modules of the fixture apply `java-test-fixtures`.
+ * The fixture has:
+ * - `library` — a module published in the standard way.
+ * - `plugin` — a module applying `java-gradle-plugin`, listed by the root project
+ * in `modulesWithCustomPublishing`. Its `pluginMaven` publication is created
+ * after the handler is applied.
+ * - `unhandled` — a module that `spinePublishing` does not publish. It declares
+ * a publication of the `java` component itself, so that the fixture shows
+ * the warnings the other modules would print.
+ */
+@DisplayName("`PublicationHandler` should")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+internal class PublicationHandlerIgTest {
+
+ private lateinit var projectDir: File
+ private lateinit var output: String
+
+ @BeforeAll
+ fun buildFixture(@TempDir dir: File) {
+ projectDir = dir
+ writeBuild()
+ output = GradleRunner.create()
+ .withProjectDir(projectDir)
+ .withArguments(
+ pomTask(library, standardPublication),
+ pomTask(pluginModule, pluginPublication),
+ pomTask(unhandled, unhandledPublication),
+ "--stacktrace",
+ )
+ .build()
+ .output
+ }
+
+ @Test
+ fun `silence the warnings about test fixtures for a standard publication`() {
+ output shouldNotContain warningsHeader(standardPublication)
+ }
+
+ @Test
+ fun `silence the warnings about test fixtures for a publication added by a plugin`() {
+ output shouldNotContain warningsHeader(pluginPublication)
+ }
+
+ @Test
+ fun `leave the warnings for a publication of a module it does not handle`() {
+ val header = warningsHeader(unhandledPublication)
+ output shouldContain header
+ val warnings = output.substringAfter(header)
+ warnings shouldContain "Variant testFixturesApiElements"
+ warnings shouldContain "Variant testFixturesRuntimeElements"
+ }
+
+ private fun writeBuild() {
+ write(
+ "settings.gradle.kts",
+ """
+ rootProject.name = "test-fixtures-sample"
+ include("$library", "$pluginModule", "$unhandled")
+ """.trimIndent()
+ )
+ write(
+ "build.gradle.kts",
+ """
+ buildscript {
+ dependencies {
+ classpath(files(
+ ${buildSrcClasspath()}
+ ))
+ }
+ }
+
+ import io.spine.gradle.publish.spinePublishing
+
+ allprojects {
+ group = "io.spine.test"
+ version = "1.0.0"
+ }
+
+ spinePublishing {
+ modules = setOf("$library")
+ modulesWithCustomPublishing = setOf("$pluginModule")
+ destinations = emptySet()
+ }
+ """.trimIndent()
+ )
+ write(
+ "$library/build.gradle.kts",
+ """
+ plugins {
+ `java-library`
+ `java-test-fixtures`
+ }
+
+ // `javadocJar` of `spinePublishing` is wired to Dokka, which
+ // this fixture does not apply: only the POMs are under test.
+ tasks.register("dokkaGeneratePublicationJavadoc")
+ """.trimIndent()
+ )
+ write(
+ "$pluginModule/build.gradle.kts",
+ """
+ plugins {
+ `java-gradle-plugin`
+ `maven-publish`
+ `java-test-fixtures`
+ }
+
+ gradlePlugin {
+ plugins {
+ create("sample") {
+ id = "io.spine.sample"
+ implementationClass = "io.spine.sample.SamplePlugin"
+ }
+ }
+ }
+ """.trimIndent()
+ )
+ write(
+ "$unhandled/build.gradle.kts",
+ """
+ plugins {
+ `java-library`
+ `maven-publish`
+ `java-test-fixtures`
+ }
+
+ publishing {
+ publications {
+ create("$unhandledPublication") {
+ from(components["java"])
+ }
+ }
+ }
+ """.trimIndent()
+ )
+ }
+
+ private fun file(relativePath: String): File = projectDir.resolve(relativePath)
+
+ private fun write(relativePath: String, text: String) {
+ file(relativePath).apply {
+ parentFile.mkdirs()
+ writeText(text)
+ }
+ }
+
+ /**
+ * Renders the classpath with the production classes of `buildSrc` as
+ * arguments of `files(...)`, for injection into the build script classpath
+ * of the generated build.
+ *
+ * The classpath comes from the `test` task in `buildSrc/build.gradle.kts`.
+ */
+ private fun buildSrcClasspath(): String {
+ val classpath = requireNotNull(System.getProperty("buildSrc.classpath")) {
+ "The `buildSrc.classpath` system property is not set." +
+ " It is supplied by the `test` task in `buildSrc/build.gradle.kts`."
+ }
+ return classpath.split(File.pathSeparator).joinToString(",\n") {
+ " \"${File(it).invariantSeparatorsPath}\""
+ }
+ }
+
+ private companion object {
+
+ /** The module published in the standard way. */
+ const val library = "library"
+
+ /** The module applying `java-gradle-plugin`, published in a custom way. */
+ const val pluginModule = "plugin"
+
+ /** The module not published by `spinePublishing`. */
+ const val unhandled = "unhandled"
+
+ /*
+ * The names of the publications, as given by the code that creates them.
+ */
+ const val standardPublication = StandardJavaPublicationHandler.PUBLICATION_NAME
+ const val pluginPublication = "pluginMaven"
+ const val unhandledPublication = "unhandled"
+
+ fun pomTask(module: String, publication: String): String {
+ val name = publication.replaceFirstChar { it.uppercase() }
+ return ":$module:generatePomFileFor${name}Publication"
+ }
+
+ /**
+ * Returns the line with which Gradle starts the POM warnings
+ * for the given [publication].
+ */
+ fun warningsHeader(publication: String): String =
+ "Maven publication '$publication' pom metadata warnings"
+ }
+}
diff --git a/config b/config
index a0cbbbf..604420c 160000
--- a/config
+++ b/config
@@ -1 +1 @@
-Subproject commit a0cbbbfe66c12475224563439fb6deb3da71ba58
+Subproject commit 604420cd7eab82b382c9a5c1d812be454552c27c
diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md
index a8ff08d..432d3e9 100644
--- a/docs/dependencies/dependencies.md
+++ b/docs/dependencies/dependencies.md
@@ -1,6 +1,6 @@
-# Dependencies of `io.spine:spine-reflect:2.0.0-SNAPSHOT.202`
+# Dependencies of `io.spine:spine-reflect:2.0.0-SNAPSHOT.203`
## Runtime
1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2.
@@ -52,15 +52,15 @@
* **Project URL:** [https://github.com/JetBrains/java-annotations](https://github.com/JetBrains/java-annotations)
* **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-bom. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-bom. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-reflect. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-reflect. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -532,23 +532,19 @@
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-bom. **Version** : 2.4.21.
- * **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
- * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt)
-
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-api. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-api. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-compat. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-compat. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-cri-impl. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-cri-impl. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-impl. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-build-tools-impl. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -556,15 +552,15 @@
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-compiler-embeddable. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-compiler-embeddable. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-compiler-runner. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-compiler-runner. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-daemon-client. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-daemon-client. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -572,11 +568,11 @@
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-daemon-embeddable. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-daemon-embeddable. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-klib-commonizer-embeddable. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-klib-commonizer-embeddable. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -588,31 +584,27 @@
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-reflect. **Version** : 2.4.21.
- * **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
- * **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-
1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-script-runtime. **Version** : 2.0.21.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-script-runtime. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-script-runtime. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-common. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-common. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-compiler-embeddable. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-compiler-embeddable. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-compiler-impl-embeddable. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-compiler-impl-embeddable. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-jvm. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-scripting-jvm. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -628,10 +620,6 @@
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib. **Version** : 2.4.21.
- * **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
- * **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-
1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib-common. **Version** : 2.0.21.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -656,7 +644,7 @@
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
-1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-tooling-core. **Version** : 2.4.21.
+1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-tooling-core. **Version** : 2.4.10.
* **Project URL:** [https://kotlinlang.org/](https://kotlinlang.org/)
* **License:** [Apache-2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
@@ -828,6 +816,6 @@
The dependencies distributed under several licenses, are used according their commercial-use-friendly license.
-This report was generated on **Thu Oct 15 20:10:06 WEST 2026** using
+This report was generated on **Wed Oct 21 16:43:17 WEST 2026** using
[Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under
[Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE).
\ No newline at end of file
diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml
index a889f6d..c28e53d 100644
--- a/docs/dependencies/pom.xml
+++ b/docs/dependencies/pom.xml
@@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject.
-->
io.spine
reflect
-2.0.0-SNAPSHOT.202
+2.0.0-SNAPSHOT.203
2015
@@ -50,19 +50,19 @@ all modules and does not describe the project structure per-subproject.
org.jetbrains.kotlin
kotlin-bom
- 2.4.21
+ 2.4.10
compile
org.jetbrains.kotlin
kotlin-reflect
- 2.4.21
+ 2.4.10
compile
org.jetbrains.kotlin
kotlin-stdlib
- 2.4.21
+ 2.4.10
compile
@@ -110,7 +110,7 @@ all modules and does not describe the project structure per-subproject.
io.spine.tools
base-testlib
- 2.0.0-SNAPSHOT.214
+ 2.0.0-SNAPSHOT.215
test
@@ -216,22 +216,22 @@ all modules and does not describe the project structure per-subproject.
org.jetbrains.kotlin
kotlin-build-tools-compat
- 2.4.21
+ 2.4.10
org.jetbrains.kotlin
kotlin-build-tools-impl
- 2.4.21
+ 2.4.10
org.jetbrains.kotlin
kotlin-klib-commonizer-embeddable
- 2.4.21
+ 2.4.10
org.jetbrains.kotlin
kotlin-scripting-compiler-embeddable
- 2.4.21
+ 2.4.10
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 2e7bb9f..91c4fc6 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
diff --git a/init-submodules b/init-submodules
index 95b916b..64bb085 100755
--- a/init-submodules
+++ b/init-submodules
@@ -17,10 +17,10 @@
# (distributed by `config`), so `git worktree add` always checks it out — it can
# therefore bring `config` itself into existence.
#
-# It initializes ONLY submodules that are BOTH:
+# It acts ONLY on submodules that are BOTH:
#
-# * not yet checked out — those `git submodule status` marks with a leading `-`,
-# at the commit the branch pins; and
+# * not yet checked out — those `git submodule status` marks with a leading
+# `-`; and
#
# * config-managed — `config` itself (the bootstrap target `pull` lives inside,
# which carries no tracked `branch` in a consumer's `.gitmodules`), plus every
@@ -28,6 +28,33 @@
# the rule `./config/pull` uses to decide what it floats, so the two scripts
# can never disagree about what is shared.
#
+# Such a submodule is initialized and then advanced to the tip of its tracked
+# branch, the way `./config/pull` floats it — NOT left at the commit the
+# superproject pins. That pin is not a version anyone maintains: `.gitmodules`
+# declares `ignore = all` for `.agents/shared`, which hides the submodule from
+# `git status` and `git diff`, and `./config/pull` floats the on-disk copy without
+# ever committing the moved gitlink. The pin therefore still holds the value it
+# had on the day the submodule was adopted; an audit of all 19 Spine consumer
+# repositories on 2026-10-16 found every one of them months stale. Materializing
+# it hands a fresh worktree, CI checkout, or cloud session hook scripts, skills,
+# and guidelines that are months out of date.
+#
+# A submodule may declare `branch = .`, which gitmodules(5) defines as the branch
+# the superproject is on rather than a branch name. It is resolved before it
+# reaches a refspec, because `git fetch origin .` is a `fatal: invalid refspec`.
+# Nothing in Spine declares it today — `.agents/shared` tracks `agents`' `master`
+# whatever branch the consumer is on — but it is valid in any `.gitmodules`.
+#
+# Neither the clone nor the float can fail the session start. With no network at
+# all the clone is what fails: the submodule is left uninitialized and the
+# symlinks keep dangling until a run that has a network, exactly as before this
+# script floated anything. A float that fails after a successful clone — a branch
+# the remote does not have, a fetch that dies midway, a local branch that is not
+# a fast-forward — warns and leaves the submodule wherever the steps that did
+# succeed put it: at worst the pin, a usable, if older, tree. The submodule is
+# checked out by then, so later runs no longer see a `-` line for it and never
+# retry the float — which is why the warning points at `./config/pull`.
+#
# Consumer-owned submodules (a Hugo theme, a vendored library, documentation
# examples, ...) declare no tracked branch and are deliberately left untouched.
# Because a `SessionStart` hook runs this script automatically on every session,
@@ -35,15 +62,14 @@
# a submodule this project does not manage, on every single start. They are
# skipped (noted on stderr).
#
-# Submodules already present are left exactly as they are, so a tree that floated
-# `config` / `.agents/shared` to a branch tip via `./config/pull` is never
-# silently rewound to the pin. That makes the script idempotent and safe to run on
-# every session start.
+# Submodules already present are left exactly as they are, neither rewound to the
+# pin nor advanced. That keeps the script idempotent, keeps the runs that find
+# nothing missing free of network access, and never disturbs a tree whose
+# submodules someone deliberately parked on a particular commit. Run
+# `./config/pull` to float those.
#
-# It does NOT float submodules to their branch tips — run `./config/pull`
-# afterwards for that. Unlike `pull`, it depends on no pre-existing `config`
-# submodule, so it can bootstrap a bare worktree where `./config/pull` does not
-# yet exist.
+# Unlike `pull`, this script depends on no pre-existing `config` submodule, so it
+# can bootstrap a bare worktree where `./config/pull` does not yet exist.
#
################################################################################
@@ -133,19 +159,29 @@ cd "$root" || exit 0
# Nothing to do in a repo without submodules.
[ -f .gitmodules ] || exit 0
-# The set of config-managed submodule paths: `config` itself (handled specially —
-# it carries no tracked branch, exactly as in `./config/pull`), plus every
-# submodule declaring a tracked `branch` in `.gitmodules`. Mirrors `pull`'s rule.
-config_managed_paths() {
- printf '%s\n' 'config'
+# The config-managed submodules, as `pathbranch` lines: every submodule that
+# declares a tracked `branch` in `.gitmodules`, plus `config` itself — which
+# carries no tracked branch in a consumer's `.gitmodules`, and which
+# `./config/pull` floats to `master` in its step 1. Mirrors `pull`'s rule. The
+# declared entries come first, so a `.gitmodules` that does give `config` a
+# tracked branch wins over that default.
+config_managed() {
git config -f .gitmodules --get-regexp '^submodule\..*\.branch$' 2>/dev/null \
- | while read -r key _branch; do
+ | while read -r key branch; do
name=${key#submodule.}; name=${name%.branch}
- git config -f .gitmodules --get "submodule.$name.path" 2>/dev/null
+ path=$(git config -f .gitmodules --get "submodule.$name.path" 2>/dev/null) || continue
+ printf '%s\t%s\n' "$path" "$branch"
done
+ printf '%s\t%s\n' 'config' 'master'
}
-managed=$(config_managed_paths | sort -u)
+managed=$(config_managed)
+
+# The branch the submodule at `$1` tracks; empty when that path is not
+# config-managed at all. The first match wins, as noted above.
+tracked_branch() {
+ printf '%s\n' "$managed" | awk -F'\t' -v path="$1" '$1 == path { print $2; exit }'
+}
# `git submodule status` prefixes each uninitialized submodule with `-`; an
# initialized one starts with a space (at the pinned commit) or `+` (ahead of it).
@@ -153,13 +189,97 @@ managed=$(config_managed_paths | sort -u)
# that path is config-managed.
git submodule status 2>/dev/null | awk '$1 ~ /^-/ { print $2 }' | while read -r path; do
[ -n "$path" ] || continue
- if printf '%s\n' "$managed" | grep -qxF -- "$path"; then
- echo "init-submodules: initializing '$path'"
- git submodule update --init --recursive -- "$path" \
- || echo "init-submodules: WARNING — could not initialize '$path' (offline?)." >&2
- else
+ branch=$(tracked_branch "$path")
+ if [ -z "$branch" ]; then
echo "init-submodules: skipping consumer-owned '$path' (not config-managed)." >&2
+ continue
+ fi
+ echo "init-submodules: initializing '$path'"
+ git submodule update --init --recursive -- "$path" &2
+ continue
+ }
+ # `.gitmodules` may declare `branch = .`, which gitmodules(5) defines as the
+ # branch the superproject itself is on. That is a sentinel, not a ref: passed
+ # through, it dies with `fatal: invalid refspec '.'`, and because the clone
+ # has already succeeded by then the submodule would keep the pin and never be
+ # retried. Resolve it to the current branch name. A detached superproject has
+ # no name to inherit, so the float is skipped and the pin kept — the one case
+ # where `./config/pull` is the only way forward, once on a branch.
+ if [ "$branch" = "." ]; then
+ # `git branch --show-current` prints the name exactly, and prints nothing
+ # when `HEAD` is detached. `git rev-parse --abbrev-ref HEAD` cannot stand
+ # in: it shortens to a *non-ambiguous* name, so a superproject carrying
+ # both a branch and a tag called `foo` yields `heads/foo`; the fetch
+ # then succeeds but the checkout fails, leaving this freshly
+ # initialized submodule stale and never retried. `./config/pull`
+ # resolves it the same way, for the same reason.
+ branch=$(git branch --show-current 2>/dev/null)
+ if [ -z "$branch" ]; then
+ echo "init-submodules: WARNING — '$path' tracks the superproject's" \
+ "branch ('.'), which is detached here; left at the pin. Run" \
+ "'./config/pull' from a branch to float it." >&2
+ continue
+ fi
fi
+ # What that just checked out is the superproject's pin, which nobody
+ # maintains (see the header). Advance the fresh checkout to the tip of its
+ # tracked branch, leaving it ON the branch rather than in a detached `HEAD` —
+ # the same three steps `./config/pull` takes in its step 2, both scripts
+ # naming the fetch destination and the checkout start point, for the two
+ # reasons below. A failure leaves the submodule wherever the steps that did
+ # succeed put it, so the warning promises no particular commit, as `pull`'s
+ # does not.
+ #
+ # The fetch names its destination. A bare `git fetch origin ` relies
+ # on the refspec configured for the remote to update
+ # `refs/remotes/origin/` opportunistically as well, which a full
+ # clone's does. But a submodule declaring `shallow = true` in `.gitmodules`
+ # is cloned single-branch (a shallow clone implies `--single-branch`), as
+ # `+refs/heads/:refs/remotes/origin/`, so any other tracked
+ # branch lands in `FETCH_HEAD` alone, `refs/remotes/origin/` never
+ # appears, and the checkout below fails. Spelling the refspec out makes the
+ # float work whatever refspec the clone carries, and the leading `+` matches
+ # what a default clone configures.
+ #
+ # The checkout asks whether the branch exists locally rather than letting Git
+ # work it out, because both answers are otherwise wrong in a reachable case.
+ # A fresh clone has a local branch only for the default branch, so `-b` alone
+ # would fail with `already exists` on the common path. And plain
+ # `git checkout ` is not a reliable substitute: with no local branch
+ # of that name it guesses one from a remote-tracking ref, considering only
+ # refs the remote's fetch refspec covers — so in the single-branch clone
+ # above it fails even though the explicit fetch did create
+ # `refs/remotes/origin/` — and if the submodule carries a *tag* of
+ # that name it quietly resolves the tag and detaches `HEAD` instead, which
+ # the `--ff-only` below then fast-forwards to the right commit on no branch
+ # at all. Naming the start point avoids both.
+ #
+ # `merge --ff-only` is kept after either path: a local branch that has
+ # diverged from `origin/` is reported rather than overwritten.
+ # Nothing here force-moves a ref except the remote-tracking ref the fetch
+ # owns, which a default clone force-updates anyway.
+ #
+ # It merges `refs/remotes/origin/`, not the `origin/`
+ # shorthand, for the reason the checkout above spells a ref out too.
+ # gitrevisions(7) resolves a bare name through `refs/tags/` BEFORE
+ # `refs/remotes/`, so a tag literally named `origin/` wins the
+ # shorthand — and then this merge would fast-forward the branch the
+ # checkout just placed exactly onto whatever that tag points at, reporting
+ # success. Every ref in the chain is therefore fully qualified.
+ echo "init-submodules: floating '$path' -> origin/$branch"
+ ( cd "$path" \
+ && git fetch --quiet origin \
+ "+refs/heads/$branch:refs/remotes/origin/$branch" \
+ && if git show-ref --verify --quiet "refs/heads/$branch"; then \
+ git checkout --quiet "$branch"; \
+ else \
+ git checkout --quiet -b "$branch" \
+ "refs/remotes/origin/$branch"; \
+ fi \
+ && git merge --quiet --ff-only "refs/remotes/origin/$branch" ) &2
done
# Route Git hooks to the shared hooks directory so the secret-scan `pre-commit`
diff --git a/version.gradle.kts b/version.gradle.kts
index bd66ba4..496d614 100644
--- a/version.gradle.kts
+++ b/version.gradle.kts
@@ -12,4 +12,4 @@
* and limitations under the License.
*/
-extra.set("versionToPublish", "2.0.0-SNAPSHOT.202")
+extra.set("versionToPublish", "2.0.0-SNAPSHOT.203")