From 17a53c037e3cd7b08025138bb0359b4d7493d7ee Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Wed, 21 Oct 2026 16:35:10 +0100 Subject: [PATCH 1/4] Update shared agent tooling and config (./config/pull) --- .github/workflows/build-on-ubuntu.yml | 19 + .github/workflows/publish.yml | 2 +- AGENTS.md | 34 +- buildSrc/build.gradle.kts | 2 +- buildSrc/src/main/kotlin/DokkaExts.kt | 32 +- .../src/main/kotlin/config-tester.gradle.kts | 10 +- .../kotlin/io/spine/dependency/lib/Kotlin.kt | 4 +- .../io/spine/dependency/lib/KotlinPoet.kt | 2 +- .../dependency/lib/OpenTelemetryKotlin.kt | 87 +++ .../kotlin/io/spine/dependency/local/Base.kt | 9 +- .../io/spine/dependency/local/BaseTypes.kt | 4 +- .../io/spine/dependency/local/Change.kt | 4 +- .../io/spine/dependency/local/CoreJvm.kt | 7 +- .../spine/dependency/local/CoreJvmCompiler.kt | 2 +- .../io/spine/dependency/local/DokkaTools.kt | 1 - .../io/spine/dependency/local/Logging.kt | 5 +- .../io/spine/dependency/local/ProtoTap.kt | 2 +- .../io/spine/dependency/local/Reflect.kt | 6 +- .../kotlin/io/spine/dependency/local/Spine.kt | 4 +- .../io/spine/dependency/local/TestLib.kt | 6 +- .../kotlin/io/spine/dependency/local/Time.kt | 2 +- .../io/spine/dependency/local/ToolBase.kt | 9 +- .../io/spine/dependency/local/Validation.kt | 5 +- .../dependency/test/KotlinCompileTesting.kt | 2 +- .../kotlin/io/spine/gradle/ConfigTester.kt | 203 ++++++- .../spine/gradle/publish/ProtobufRuntime.kt | 173 ++++++ .../gradle/publish/PublicationHandler.kt | 27 + .../kotlin/io/spine/gradle/ClonedRepoSpec.kt | 158 ++++++ .../io/spine/gradle/GitRepositorySpec.kt | 158 ++++++ .../test/kotlin/io/spine/gradle/PathExts.kt | 29 + .../gradle/publish/ProtobufRuntimeIgTest.kt | 519 ++++++++++++++++++ .../publish/PublicationChecksumsIgTest.kt | 40 +- .../publish/PublicationHandlerIgTest.kt | 226 ++++++++ config | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- init-submodules | 168 +++++- 36 files changed, 1866 insertions(+), 99 deletions(-) create mode 100644 buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt create mode 100644 buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtobufRuntime.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtobufRuntimeIgTest.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationHandlerIgTest.kt 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 eabc0a1..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,7 +19,10 @@ 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.426" const val group = Spine.group 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/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` From 6adef1ad08e8d97221f7b95cb370e519d80dbc7c Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Wed, 21 Oct 2026 16:35:11 +0100 Subject: [PATCH 2/4] Update local dependency pins --- buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6d5f5b7..fc2cc67 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Logging.kt @@ -24,7 +24,7 @@ package io.spine.dependency.local "unused" /* Some subprojects do not use all Logging artifacts. */, ) object Logging { - const val version = "2.0.0-SNAPSHOT.426" + const val version = "2.0.0-SNAPSHOT.427" const val group = Spine.group const val loggingArtifact = "spine-logging" From 4603eced89fa44a6ce00687f8dc792cc2ca7012a Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Wed, 21 Oct 2026 16:35:12 +0100 Subject: [PATCH 3/4] Bump version -> `2.0.0-SNAPSHOT.216` --- version.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.gradle.kts b/version.gradle.kts index 0bee8be..c20ae88 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.215") +extra.set("versionToPublish", "2.0.0-SNAPSHOT.216") From b8018f38dc4cbdbe1322c91f97652ef535d779d1 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Wed, 21 Oct 2026 16:56:17 +0100 Subject: [PATCH 4/4] Update dependency reports --- docs/dependencies/dependencies.md | 52 +++++++++++++++---------------- docs/dependencies/pom.xml | 22 ++++++------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 5d21ffd..22d5365 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.tools:base-testlib:2.0.0-SNAPSHOT.215` +# Dependencies of `io.spine.tools:base-testlib:2.0.0-SNAPSHOT.216` ## Runtime 1. **Group** : com.google.auto.value. **Name** : auto-value-annotations. **Version** : 1.11.1. @@ -120,15 +120,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) @@ -668,23 +668,23 @@ * **Project URL:** [https://github.com/JetBrains/intellij-deps-trove4j](https://github.com/JetBrains/intellij-deps-trove4j) * **License:** [GNU LESSER GENERAL PUBLIC LICENSE 2.1](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html) -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-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) @@ -692,15 +692,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) @@ -708,11 +708,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) @@ -720,7 +720,7 @@ * **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-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) @@ -728,23 +728,23 @@ * **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) @@ -756,7 +756,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-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) @@ -764,7 +764,7 @@ * **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-stdlib-common. **Version** : 2.4.21. +1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib-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) @@ -772,7 +772,7 @@ * **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-stdlib-jdk7. **Version** : 2.4.21. +1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib-jdk7. **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) @@ -780,11 +780,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-stdlib-jdk8. **Version** : 2.4.21. +1. **Group** : org.jetbrains.kotlin. **Name** : kotlin-stdlib-jdk8. **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-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) @@ -956,6 +956,6 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu Oct 15 20:11:29 WEST 2026** using +This report was generated on **Wed Oct 21 16:56:11 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 7b9f624..6a734b7 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.tools testlib -2.0.0-SNAPSHOT.215 +2.0.0-SNAPSHOT.216 2015 @@ -86,19 +86,19 @@ all modules and does not describe the project structure per-subproject. io.spine spine-logging - 2.0.0-SNAPSHOT.426 + 2.0.0-SNAPSHOT.427 compile org.jetbrains.kotlin kotlin-bom - 2.4.21 + 2.4.10 compile org.jetbrains.kotlin kotlin-stdlib - 2.4.21 + 2.4.10 compile @@ -152,19 +152,19 @@ all modules and does not describe the project structure per-subproject. io.spine spine-logging-std-context - 2.0.0-SNAPSHOT.426 + 2.0.0-SNAPSHOT.427 test io.spine.tools base-testlib - 2.0.0-SNAPSHOT.214 + 2.0.0-SNAPSHOT.215 test io.spine.tools logging-testlib - 2.0.0-SNAPSHOT.426 + 2.0.0-SNAPSHOT.427 test @@ -263,22 +263,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