diff --git a/.github/workflows/increment-guard.yml b/.github/workflows/increment-guard.yml index 9a6333f0f..56f300885 100644 --- a/.github/workflows/increment-guard.yml +++ b/.github/workflows/increment-guard.yml @@ -1,9 +1,13 @@ # Guards the project version by executing the Gradle `checkVersionIncrement` task, # which verifies that the version is both (a) strictly greater than the base branch # version in `version.gradle.kts` and (b) not already published. The result is -# published as the `Version Guard` commit status — the context required by branch -# protection and re-published by `revalidate-versions.yml` on the heads of other open -# PRs when the base branch advances (so a stale duplicate bump turns red before merge). +# published as the `Version bump` commit status, which `revalidate-versions.yml` also +# re-publishes on the heads of other open PRs when the base branch advances (so a stale +# duplicate bump turns red before merge). +# +# Branch protection must require the `Version bump` status, not the job's own +# `Check version increment` check run: only the status carries the revalidation verdict, +# and a job skipped by its `if` reports a passing check run. # # The check runs only for pull requests targeting a default (`master`/`main`) or # a release-line (e.g. `2.x-jdk8-master`) branch. It is the responsibility of a branch @@ -21,7 +25,7 @@ on: pull_request: # Beyond the default activity types (`opened`, `synchronize`, `reopened`), two more are # needed because they change what the guard must compare against without a new head SHA, - # which would otherwise leave a stale-green `Version Guard` status mergeable: + # which would otherwise leave a stale-green `Version bump` status mergeable: # * `ready_for_review` — a draft that went stale while in draft becomes ready; and # * `edited` — the base branch is retargeted (e.g. a release line -> `master`), so the # strict comparison must be recomputed against the new base. @@ -39,7 +43,7 @@ jobs: (endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main')) && (github.event.action != 'edited' || github.event.changes.base.ref.from != '') - # `statuses: write` lets the job publish the `Version Guard` commit status that + # `statuses: write` lets the job publish the `Version bump` commit status that # branch protection requires. permissions: contents: read @@ -76,7 +80,7 @@ jobs: VERSION_GUARD: "true" run: ./gradlew checkVersionIncrement --stacktrace - # Publish the verdict as the `Version Guard` commit status on the PR head. Posting it + # Publish the verdict as the `Version bump` commit status on the PR head. Posting it # on every run (success or failure) is what lets a later re-bump clear a failure that # `revalidate-versions.yml` set when the base branch advanced. # @@ -84,7 +88,7 @@ jobs: # posted (and a fork head SHA is not in this repo). The Spine agent workflow pushes PR # branches to the same repository; fork contributions are handled by a maintainer, who # owns the version bump. - - name: Report the Version Guard status + - name: Report the Version bump status if: always() && github.event.pull_request.head.repo.fork == false shell: bash env: @@ -96,5 +100,5 @@ jobs: fi gh api -X POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \ -f state="${state}" \ - -f context="Version Guard" \ + -f context="Version bump" \ -f description="Version increment check" diff --git a/.github/workflows/publish-api-docs.yml b/.github/workflows/publish-api-docs.yml new file mode 100644 index 000000000..1f6f17a18 --- /dev/null +++ b/.github/workflows/publish-api-docs.yml @@ -0,0 +1,100 @@ +name: Publish API docs + +# Publishes the API documentation of the published modules to the `gh-pages` +# branch, which GitHub Pages serves under `spine.io`. +# +# The job runs apart from `publish.yml`, and neither waits for the other: a +# failure here leaves the Maven publication intact, and the other way round. +# `:publishAllApiDocs` is registered in the root project by `spinePublishing { }`, +# so it exists in every repository that publishes, and does nothing where no +# module documents itself. It runs the `publishApiDocs` task of each published +# module. +# +# Only release versions are published. For a snapshot version, which most merges +# produce, the task of each module only says that it skips the documentation, +# and the run ends green without generating or pushing any. +# +# A failed run can be re-run as it is: each module commits its documentation +# even when nothing changed, and retries a failed push after rebasing onto +# `gh-pages`. +# +# Each module clones only the tip of `gh-pages` and checks out only its own most +# recent documentation and the folder of its version, so that a run does not +# fetch the documentation of every version of every module. +# +# This job does not ask whether `publish.yml` could publish the version, as that +# would couple the two again. When `publish.yml` stops because the version is +# already taken, which the version checks of pull requests normally prevent, +# this job still copies the documentation of the commit over that of the version. +# +# Unlike `publish.yml`, this file is not a caller of a reusable workflow hosted +# by `config`. That split exists to make `config` the signer of the attestation +# of the published artifacts, and the documentation is not attested. + +on: + push: + branches: [master] + +jobs: + publish-api-docs: + name: Publish API docs + runs-on: ubuntu-latest + + # One run at a time per repository, since every run pushes to `gh-pages`. + # `queue: max` keeps up to 100 runs waiting, instead of letting a newer run + # cancel the waiting one: each run also adds the documentation of its own + # version, which no later run would publish. The waiting runs may start in + # another order than their commits, so a run replaces the most recent + # documentation of a module only if no newer version is published already. + concurrency: + group: api-docs-publishing-${{ github.repository }} + queue: max + + # The push goes over SSH with the deploy key, not with `GITHUB_TOKEN`, which + # only reads the dependencies published to GitHub Packages. + permissions: + contents: read + packages: read + + steps: + - uses: actions/checkout@v6 + with: + submodules: 'true' + + - uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: zulu + + # The cache stays on, unlike in `publishing.yml`, which turns it off to keep + # its attestation trustworthy: nothing here is attested. A run on `master` + # reads only the caches written by the workflows that run on `master`. + - uses: gradle/actions/setup-gradle@v6 + + # The same step as in `build-on-ubuntu.yml`. For a release version, the build + # runs Dokka for every published module, in parallel, and the Linux runner + # ships with effectively no swap, so a memory peak would be an instant OOM + # kill. It goes before the deploy key is decrypted, so that the action never + # runs with the key on disk. + - name: Add swap space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 8 + + - name: Decrypt Git SSH credentials + run: ./config/scripts/decrypt.sh "$GIT_CREDENTIALS_KEY" ./.github/keys/deploy_key_rsa.gpg ./deploy_key_rsa + env: + GIT_CREDENTIALS_KEY: ${{ secrets.GIT_CREDENTIALS_KEY }} + + # The SSH agent does not accept a key that others can read. The build runs + # `register-ssh-key.sh` to hand the key to the agent. + - name: Set file system permissions + run: chmod 400 ./deploy_key_rsa && chmod +x ./config/scripts/register-ssh-key.sh + + - name: Publish API docs + run: ./gradlew :publishAllApiDocs --stacktrace + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FORMAL_GIT_HUB_PAGES_AUTHOR: developers@spine.io + # https://docs.github.com/en/actions/reference/environment-variables + REPO_SLUG: ${{ github.repository }} # e.g. SpineEventEngine/core-jvm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aacee780f..4e1479a82 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@604420cd7eab82b382c9a5c1d812be454552c27c + uses: SpineEventEngine/config/.github/workflows/publishing.yml@363fc1da3184d001361fa9c327929eb5e05fa673 permissions: contents: read packages: write diff --git a/.github/workflows/revalidate-versions.yml b/.github/workflows/revalidate-versions.yml index 2d2ecb9c6..59f81803c 100644 --- a/.github/workflows/revalidate-versions.yml +++ b/.github/workflows/revalidate-versions.yml @@ -5,7 +5,7 @@ # now stale: its publish would collide. GitHub does not re-run a PR's checks when its base # advances, so this workflow does it actively — for each other open PR whose # `version.gradle.kts` version is `<=` the new base version, it posts a failing -# `Version Guard` commit status on the PR head, blocking the merge until the author +# `Version bump` commit status on the PR head, blocking the merge until the author # re-bumps. The status self-clears: the re-bump push runs `increment-guard.yml`, which # posts a fresh `success` on the new head. # @@ -40,14 +40,14 @@ concurrency: jobs: revalidate: - name: Revalidate open PRs + name: Mark stale version bumps runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: submodules: 'true' - - name: Revalidate open PRs against the new base version + - name: Mark open PRs whose version is not above the new base shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/buildSrc/src/main/kotlin/BuildExtensions.kt b/buildSrc/src/main/kotlin/BuildExtensions.kt index a69dd1a04..cedacd258 100644 --- a/buildSrc/src/main/kotlin/BuildExtensions.kt +++ b/buildSrc/src/main/kotlin/BuildExtensions.kt @@ -27,7 +27,6 @@ import io.spine.dependency.test.Kotest import io.spine.dependency.test.Kover import io.spine.gradle.repo.standardToSpineSdk import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.artifacts.ModuleDependency import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.tasks.JavaExec @@ -168,7 +167,8 @@ fun Project.configureTaskDependencies() { * Creates a dependency between the Gradle task of *this* name * onto the task with `taskName`. * - * If either of the tasks does not exist in the enclosing `Project`, + * Neither task is created by this method. A task registered later is matched too. + * If either of the tasks never exists in the enclosing `Project`, * this method does nothing. * * This extension is kept local to the `configureTaskDependencies` extension @@ -176,9 +176,8 @@ fun Project.configureTaskDependencies() { */ fun String.dependOn(taskName: String) { val whoDepends = this - val dependOntoTask: Task? = tasks.findByName(taskName) - dependOntoTask?.let { - tasks.findByName(whoDepends)?.dependsOn(it) + tasks.named { it == whoDepends }.configureEach { + dependsOn(tasks.named { it == taskName }) } } @@ -267,19 +266,20 @@ fun JavaExec.remoteDebug(enabled: Boolean = true) { * * The port number is [5566][BuildSettings.REMOTE_DEBUG_PORT]. * + * The task is configured when Gradle creates it, so calling this function + * does not create the task. + * * @param enabled If `true` the task will be suspended. - * @throws IllegalStateException if the task with the given name is not found, or, - * if the task is not of [JavaExec] type. + * @throws IllegalStateException if the task with the given name is not found. + * @throws org.gradle.api.InvalidUserDataException if the task is not of [JavaExec] type. */ fun Project.setRemoteDebug(taskName: String, enabled: Boolean = true) { - val task = tasks.findByName(taskName) - check(task != null) { + check(taskName in tasks.names) { "Could not find a task named `$taskName` in the project `$name`." } - check(task is JavaExec) { - "The task `$taskName` is not of type `JavaExec`." + tasks.named(taskName, JavaExec::class.java).configure { + remoteDebug(enabled) } - task.remoteDebug(enabled) } /** diff --git a/buildSrc/src/main/kotlin/DokkaExts.kt b/buildSrc/src/main/kotlin/DokkaExts.kt index 5aeba8d5f..849fea6cb 100644 --- a/buildSrc/src/main/kotlin/DokkaExts.kt +++ b/buildSrc/src/main/kotlin/DokkaExts.kt @@ -25,6 +25,7 @@ import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.execution.TaskExecutionGraph import org.gradle.api.publish.maven.tasks.PublishToMavenLocal import org.gradle.api.publish.maven.tasks.PublishToMavenRepository +import org.gradle.api.tasks.TaskCollection import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.bundling.Jar @@ -183,14 +184,22 @@ fun DokkaExtension.configureForJava(project: Project, sourceLinkRemoteUrl: Strin } /** - * Finds the `dokkaGenerateHtml` Gradle task. + * Finds the `dokkaGeneratePublicationHtml` Gradle task without creating it. + * + * The returned collection is live: it holds the task once Dokka registers it, + * and stays empty in a project without Dokka. */ -fun TaskContainer.dokkaHtmlTask(): Task? = this.findByName("dokkaGeneratePublicationHtml") +fun TaskContainer.dokkaHtmlTask(): TaskCollection = + named { it == "dokkaGeneratePublicationHtml" } /** - * Finds the `dokkaGeneratePublicationJavadoc` Gradle task. + * Finds the `dokkaGeneratePublicationJavadoc` Gradle task without creating it. + * + * The returned collection is live: it holds the task once Dokka registers it, + * and stays empty in a project without Dokka. */ -fun TaskContainer.dokkaJavadocTask(): Task? = this.findByName("dokkaGeneratePublicationJavadoc") +fun TaskContainer.dokkaJavadocTask(): TaskCollection = + named { it == "dokkaGeneratePublicationJavadoc" } /** * Locates or creates the `htmlDocsJar` task in this [Project]. @@ -205,9 +214,7 @@ fun Project.htmlDocsJar(): TaskProvider = tasks.getOrCreate("htmlDocsJar") archiveClassifier.set("html-docs") from(files(dokkaHtmlOutput())) - tasks.dokkaHtmlTask()?.let { dokkaTask -> - this@getOrCreate.dependsOn(dokkaTask) - } + this@getOrCreate.dependsOn(tasks.dokkaHtmlTask()) } /** @@ -238,7 +245,7 @@ fun Task.isInPublishingGraph(): Boolean = * Such a build usually feeds integration tests, which need the published code, * but not its documentation. So the Dokka tasks are skipped when the execution graph * contains a [PublishToMavenLocal] task and no task that ships the documentation - * elsewhere: `publish`, a [PublishToMavenRepository] task, or `updateGitHubPages`. + * elsewhere: `publish`, a [PublishToMavenRepository] task, or `publishApiDocs`. * They still run if a task named on the command line selects a documentation task of * the graph: one that has `dokka` in its name, ignoring case, or a documentation JAR, * `javadocJar` or `htmlDocsJar`. The name may be the full one, e.g., `dokkaGenerate`, @@ -276,7 +283,7 @@ fun Project.skipDokkaWhenPublishingToMavenLocal() { /** * The names of the lifecycle tasks that ship the documentation beyond Maven Local. */ -private val docsShippingTaskNames = setOf("publish", TaskName.updateGitHubPages) +private val docsShippingTaskNames = setOf("publish", TaskName.publishApiDocs) /** * Tells if this graph publishes to Maven Local and does not contain a task that ships diff --git a/buildSrc/src/main/kotlin/dokka-setup.gradle.kts b/buildSrc/src/main/kotlin/dokka-setup.gradle.kts index c66e9f2f4..43eb56fa7 100644 --- a/buildSrc/src/main/kotlin/dokka-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/dokka-setup.gradle.kts @@ -41,10 +41,7 @@ afterEvaluate { DocumentationSettings.SourceLink.url(project) ) } - val kspKotlin = tasks.findByName("kspKotlin") - kspKotlin?.let { - tasks.withType().configureEach { - dependsOn(kspKotlin) - } + tasks.withType().configureEach { + dependsOn(tasks.named { it == "kspKotlin" }) } } diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt b/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt index b473fdcfa..63b3f4451 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/lib/OpenTelemetryKotlin.kt @@ -77,11 +77,31 @@ object OpenTelemetryKotlin : Dependency() { */ val compat = "$group:compat:$version" + /** + * The export machinery of the Kotlin SDK, including the `batchLogRecordProcessor` + * builder. + * + * Use it together with [implementation] and an exporter artifact, such as + * [exportersOtlp]. Like [core] and [implementation], it is for applications + * and tests. + */ + val exportersCore = "$group:exporters-core:$version" + + /** + * The OTLP exporters of the Kotlin SDK, such as `otlpHttpLogRecordExporter`, + * which send data over HTTP using Ktor. + * + * Use it together with [exportersCore]. + */ + val exportersOtlp = "$group:exporters-otlp:$version" + override val modules: List = listOf( "$group:api", "$group:noop", "$group:core", "$group:implementation", "$group:compat", + "$group:exporters-core", + "$group:exporters-otlp", ) } 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 baaf5c705..e58ab1415 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt @@ -25,7 +25,7 @@ package io.spine.dependency.local ) object Base { const val version = "2.0.0-SNAPSHOT.462" - const val versionForBuildScript = "2.0.0-SNAPSHOT.461" + const val versionForBuildScript = "2.0.0-SNAPSHOT.462" 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 e0a828061..5f08d0bb3 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/BaseTypes.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/BaseTypes.kt @@ -23,7 +23,7 @@ package io.spine.dependency.local "ConstPropertyName" /* We use a custom convention for artifact properties. */, ) object BaseTypes { - const val version = "2.0.0-SNAPSHOT.225" + const val version = "2.0.0-SNAPSHOT.227" const val group = Spine.group const val artifact = "spine-base-types" const val lib = "$group:$artifact:$version" 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 1f6ebe1c6..91864be35 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Change.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Change.kt @@ -23,7 +23,7 @@ package io.spine.dependency.local "ConstPropertyName" /* We use a custom convention for artifact properties. */, ) object Change { - const val version = "2.0.0-SNAPSHOT.208" + const val version = "2.0.0-SNAPSHOT.209" const val group = Spine.group const val artifact = "spine-change" const val lib = "$group:$artifact:$version" diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt index 137e83010..b75c0f1ef 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt @@ -60,7 +60,7 @@ object Compiler : Dependency() { * The version of the Compiler dependencies. */ override val version: String - private const val fallbackVersion = "2.0.0-SNAPSHOT.069" + private const val fallbackVersion = "2.0.0-SNAPSHOT.081" /** * The distinct version of the Compiler used by other build tools. @@ -69,7 +69,7 @@ object Compiler : Dependency() { * transitive dependencies, this is the version used to build the project itself. */ val dogfoodingVersion: String - private const val fallbackDfVersion = "2.0.0-SNAPSHOT.069" + private const val fallbackDfVersion = "2.0.0-SNAPSHOT.081" /** * The artifact for the Compiler Gradle plugin. 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 35246a779..94f3d9d68 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt @@ -30,7 +30,7 @@ typealias CoreJava = CoreJvm ) object CoreJvm { const val group = Spine.group - const val version = "2.0.0-SNAPSHOT.571" + const val version = "2.0.0-SNAPSHOT.577" 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 1af2694bb..8fc131eaa 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt @@ -34,12 +34,12 @@ object CoreJvmCompiler { /** * The version used in the build classpath. */ - const val dogfoodingVersion = "2.0.0-SNAPSHOT.097" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.099" /** * The version to be used for integration tests. */ - const val version = "2.0.0-SNAPSHOT.097" + const val version = "2.0.0-SNAPSHOT.099" /** * The ID of the Gradle plugin. 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 6026d49f5..4591fac50 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/DokkaTools.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/DokkaTools.kt @@ -25,7 +25,7 @@ package io.spine.dependency.local ) object DokkaTools { const val group = Spine.toolsGroup - const val version = "2.0.0-SNAPSHOT.10" + const val version = "2.0.0-SNAPSHOT.12" /** * The artifact dropped its `spine-` prefix in `2.0.0-SNAPSHOT.9`, to 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 fc2cc67e8..b84652a19 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.427" + const val version = "2.0.0-SNAPSHOT.428" const val group = Spine.group const val loggingArtifact = "spine-logging" @@ -42,7 +42,22 @@ object Logging { // Transitive dependencies. // Make `public` and use them to force a version in a particular repository, if needed. internal const val julBackend = "$group:spine-logging-jul-backend:$version" + + /** + * The former `middleware` module of Spine Logging. + * + * In `2.0.0-SNAPSHOT.270`, its code moved into the `logging` module, which is + * published as [lib]. The last published version of this artifact is + * `2.0.0-SNAPSHOT.263`, so these coordinates do not resolve at [version]. + * + * @deprecated Use [lib] instead. + */ + @Deprecated( + "The module was merged into `spine-logging` and is no longer published.", + ReplaceWith("lib"), + ) const val middleware = "$group:spine-logging-middleware:$version" + internal const val platformGenerator = "$group:spine-logging-platform-generator:$version" internal const val jvmDefaultPlatform = "$group:spine-logging-jvm-default-platform:$version" } 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 55154c696..b9e6f18ec 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.3" + const val version = "0.17.4" 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 c07fd2f71..da3e6edc5 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Reflect.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Reflect.kt @@ -23,7 +23,7 @@ package io.spine.dependency.local "ConstPropertyName" /* We use a custom convention for artifact properties. */, ) object Reflect { - const val version = "2.0.0-SNAPSHOT.202" + const val version = "2.0.0-SNAPSHOT.203" 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/Time.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt index 3e8e39938..d9221c3d7 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Time.kt @@ -28,7 +28,7 @@ import io.spine.dependency.Dependency ) object Time : Dependency() { override val group = Spine.group - override val version = "2.0.0-SNAPSHOT.251" + override val version = "2.0.0-SNAPSHOT.253" private const val infix = "spine-time" fun lib(version: String): String = "$group:$infix:$version" 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 2a8a255ac..905ab2d0d 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt @@ -32,8 +32,8 @@ package io.spine.dependency.local ) object ToolBase { const val group = Spine.toolsGroup - const val version = "2.0.0-SNAPSHOT.426" - const val dogfoodingVersion = "2.0.0-SNAPSHOT.426" + const val version = "2.0.0-SNAPSHOT.428" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.428" const val archive = "$group:archive:$version" const val code = "$group:code:$version" @@ -56,6 +56,20 @@ object ToolBase { const val rootGradlePlugins = "$group:root-gradle-plugins:$version" const val gradlePluginApi = "$group:gradle-plugin-api:$version" + + /** + * Stub Gradle plugins for the tests of the `gradle-plugin-api` module. + * + * The `gradle-plugin-api-test-fixtures` module that provides them is internal + * to `tool-base`, which never publishes it to a remote repository. Only + * `publishToMavenLocal` installs it, so these coordinates resolve only from + * a local `~/.m2` copy. + * + * @deprecated There is no replacement. Remove the usage. + */ + @Deprecated( + "The artifact is internal to `tool-base` and is not published remotely. Remove the usage." + ) const val gradlePluginApiTestFixtures = "$group:gradle-plugin-api-test-fixtures:$version" const val jvmTools = "$group:jvm-tools:$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 155cbbc60..1be9aff47 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt @@ -27,7 +27,7 @@ object Validation { /** * The version of the Validation library artifacts. */ - const val version = "2.0.0-SNAPSHOT.464" + const val version = "2.0.0-SNAPSHOT.469" const val group = Spine.toolsGroup private const val prefix = "validation" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt b/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt index 0e43f3f71..36ba14ebe 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt @@ -20,7 +20,6 @@ import java.io.File import java.io.InputStream import java.io.StringWriter import java.lang.ProcessBuilder.Redirect.PIPE -import java.util.* /** * Executes a process from Gradle code. @@ -32,20 +31,34 @@ class Cli(private val workingFolder: File) { /** * Executes the given terminal command and retrieves the command output. * - * [Executes][Runtime.exec] the given `String` array as a CLI command. + * Runs the given command, the program followed by its arguments, in + * the [workingFolder]. * * If the execution is successful, returns the command output. * Throws an [IllegalStateException] otherwise. * * @param command the command to execute. - * @return the command line output. + * @return the standard output as the command wrote it, line breaks included, + * decoded as UTF-8. * @throws IllegalStateException if the execution fails. */ - fun execute(vararg command: String): String { + fun execute(vararg command: String): String = execute(command.asList()) + + /** + * Executes the given terminal command and retrieves the command output. + * + * Works like the `vararg` overload, for a command assembled as a list. + * + * @param command the command to execute, the program followed by its arguments. + * @return the standard output as the command wrote it, line breaks included, + * decoded as UTF-8. + * @throws IllegalStateException if the execution fails. + */ + fun execute(command: List): String { val outWriter = StringWriter() val errWriter = StringWriter() - val process = ProcessBuilder(*command).apply { + val process = ProcessBuilder(command).apply { directory(workingFolder) redirectOutput(PIPE) redirectError(PIPE) @@ -66,22 +79,22 @@ class Cli(private val workingFolder: File) { val commandLine = command.joinToString(" ") val nl = System.lineSeparator() val errorMsg = "Command `$commandLine` finished with exit code $exitCode:" + - "$nl$errWriter" + - "$nl$outWriter." + "$nl${errWriter.toString().trimEnd()}" + + "$nl${outWriter.toString().trimEnd()}." throw IllegalStateException(errorMsg) } } } /** - * Starts a background thread that reads all lines from this [InputStream] and - * appends them to [dest], returning the thread so the caller can [join][Thread.join] - * it once the process has exited, ensuring the buffer holds the complete output. + * Starts a background thread that reads all of this [InputStream] and appends it + * to [dest], returning the thread so the caller can [join][Thread.join] it once + * the process has exited, ensuring the buffer holds the complete output. + * + * The text is appended as the process wrote it, line breaks included, so that + * a multi-line output can be split into lines. */ private fun InputStream.pourTo(dest: StringWriter): Thread = Thread { - val sc = Scanner(this) - while (sc.hasNextLine()) { - dest.append(sc.nextLine()) - } + bufferedReader().use { dest.append(it.readText()) } }.also { it.start() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/ProjectExtensions.kt b/buildSrc/src/main/kotlin/io/spine/gradle/ProjectExtensions.kt index 41cac8875..5c16bd566 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/ProjectExtensions.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/ProjectExtensions.kt @@ -59,7 +59,11 @@ fun Project.applyPlugin(cls: Class>) { * * The task must be present. Also, a caller is responsible for using the proper value of * the generic parameter `T`. + * + * Gradle creates the task if it is only registered so far, even in a build that never + * runs it. Prefer `tasks.named(name)`, which finds the task without creating it. */ +@Deprecated(message = "Please use `tasks.named(name)`, which does not create the task.") @Suppress("UNCHECKED_CAST") /* See the method docs. */ fun Project.getTask(name: String): T { val task = this.tasks.findByName(name) diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/TaskName.kt b/buildSrc/src/main/kotlin/io/spine/gradle/TaskName.kt index 0a595b3b4..763b9832b 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/TaskName.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/TaskName.kt @@ -17,6 +17,7 @@ package io.spine.gradle import kotlin.reflect.KClass import org.gradle.api.Task import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.register @@ -45,3 +46,22 @@ internal fun TaskContainer.named(name: TaskName) = named(name.valu */ internal fun TaskContainer.register(name: TaskName, init: T.() -> Unit) = register(name.value, name.clazz, init) + +/** + * Returns the task named [name] in this container, registering it with [init] + * if it is not there yet. + * + * Unlike [register][TaskContainer.register], this does not fail when the task is + * already registered, so it suits code that may reach the same task more than once. + * [init] applies only when this call registers the task; for an existing task it is + * ignored, so a setup needed in both cases goes to [TaskProvider.configure]. + */ +internal fun TaskContainer.getOrRegister( + name: String, + init: Task.() -> Unit = {} +): TaskProvider = + if (name in names) { + named(name) + } else { + register(name, init) + } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/checkstyle/CheckStyleConfig.kt b/buildSrc/src/main/kotlin/io/spine/gradle/checkstyle/CheckStyleConfig.kt index b8232683f..79351fbf1 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/checkstyle/CheckStyleConfig.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/checkstyle/CheckStyleConfig.kt @@ -64,7 +64,7 @@ object CheckStyleConfig { "checkstyleTest", "checkstyleTestFixtures" ).forEach { - task -> tasks.findByName(task)?.enabled = false + task -> tasks.named { it == task }.configureEach { enabled = false } } } } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt b/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt index 1b7e09abf..6630f985f 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt @@ -61,14 +61,33 @@ class Repository private constructor( repoExecute("git", "clone", sshUrl, ".") } + /** + * Clones the tip of the [branch], or of the default branch if it is `null`, + * into the [temporary folder][location]. + * + * The blobs are fetched only when the working tree needs them, and the working + * tree holds only the top-level files until [checkOutOnly] replaces them with + * the selected paths. + */ + private fun cloneSparsely(branch: String?) { + val options = listOf("--depth", "1", "--filter=blob:none", "--sparse", "--single-branch") + val branchOption = branch?.let { listOf("--branch", it) } ?: emptyList() + repoExecute(listOf("git", "clone") + options + branchOption + listOf("--", sshUrl, ".")) + } + /** * Executes a command in the [location]. */ - private fun repoExecute(vararg command: String): String { - val cmd = command.toList().joinToString(" ") + private fun repoExecute(vararg command: String): String = repoExecute(command.asList()) + + /** + * Executes a command, assembled as a list, in the [location]. + */ + private fun repoExecute(command: List): String { + val cmd = command.joinToString(" ") val msg = "[Repo (${project.path})] Executing command: `$cmd`." System.err.println(msg) - return Cli(location.toFile()).execute(*command) + return Cli(location.toFile()).execute(command) } /** @@ -125,12 +144,59 @@ class Repository private constructor( * match a namespaced branch such as `feature/$name`. Relies on `git ls-remote` * returning an empty output with a zero exit code when the branch is absent, * so the check does not raise an exception. + * + * @param name the name of the branch. + * @param remote the name of the remote or its URL; `origin` by default. */ - private fun remoteHasBranch(name: String): Boolean { - val output = repoExecute("git", "ls-remote", "--heads", "origin", "refs/heads/$name") + private fun remoteHasBranch(name: String, remote: String = "origin"): Boolean { + val output = repoExecute("git", "ls-remote", "--heads", remote, "refs/heads/$name") return output.isNotBlank() } + /** + * Checks out only the paths of the current branch that match the given [patterns], + * leaving the rest of the branch out of the working tree. + * + * The call replaces the paths checked out before, including the top-level files + * of a [sparse clone][Factory.cloneSparsely], so it has to be given every path + * to keep at once. + * + * The patterns follow the `.gitignore` syntax, so a folder can be checked out + * without some of its subfolders. The paths left out stay in the branch: committing + * [all changes][commitAllChanges] does not delete them. + * + * Leaving out some subfolders of a folder takes the non-cone mode of + * `git sparse-checkout`, which Git documents as deprecated, though Git 2.54 still + * supports it without a warning. The mode needs Git 2.35 or newer. + */ + fun checkOutOnly(patterns: List) { + repoExecute(listOf("git", "sparse-checkout", "set", "--no-cone") + patterns) + } + + /** + * Obtains the names of the folders in the given [folder] of the current commit, + * whether they are checked out or not. + * + * Reads the tree of the current commit, so the changes in the working tree that + * are not committed yet are not reflected. The files in the [folder] are skipped. + * + * @param folder the path of the folder relative to the repository root. + * @return the names of the folders in [folder], or an empty list if there is + * no such folder. + */ + fun listFolders(folder: String): List { + require(folder.isNotBlank()) { "The folder to list cannot be blank." } + // Each entry is ` \t`, the entries are separated + // by `NUL`, and the paths are not quoted. + return repoExecute("git", "ls-tree", "-z", "HEAD", "${folder.trimEnd('/')}/") + .split('\u0000') + .filter { it.isNotBlank() } + .mapNotNull { entry -> + val (info, path) = entry.split('\t', limit = 2) + path.substringAfterLast('/').takeIf { info.split(' ')[1] == "tree" } + } + } + /** * Creates the [branch] as an orphan branch seeded with [initialFiles] and * pushes it to the remote. @@ -173,7 +239,6 @@ class Repository private constructor( // If the branch now exists on the remote, another module won the // creation race and we adopt its branch; otherwise the failure is // genuine and is rethrown. - repoExecute("git", "fetch", "origin") if (!remoteHasBranch(branch)) { throw e } @@ -184,8 +249,14 @@ class Repository private constructor( /** * Discards the local orphan branch in favour of the same-named branch that * already exists on the remote, keeping the local branch in sync with it. + * + * The branch is added to the branches fetched from the remote first: a single-branch + * clone, which a shallow one is too, fetches no other branch, and Git does not + * accept a branch it does not fetch as the upstream of a local one. */ private fun adoptRemoteBranch(branch: String) { + repoExecute("git", "remote", "set-branches", "--add", "origin", branch) + repoExecute("git", "fetch", "origin", "+refs/heads/$branch:refs/remotes/origin/$branch") repoExecute("git", "reset", "--hard", "origin/$branch") repoExecute("git", "branch", "--set-upstream-to=origin/$branch", branch) } @@ -274,6 +345,53 @@ class Repository private constructor( return repo } + + /** + * Clones the tip of the [branch] of the repository with the provided SSH URL + * in a temporary folder, with only its top-level files checked out. + * + * Suits a branch with a large tip of which a change touches only a small part, + * such as the branch with the documentation of every published version. + * Further blobs are fetched only for the paths that [checkOutOnly] selects. + * The commits are made and [pushed][push] as in a full clone. + * + * A [branch] that does not exist yet is created and seeded with [initialFiles], + * which are top-level files. + * + * Configures the username and the email of the Git user. + * See [configureUser] documentation for more information. + * + * @throws IllegalArgumentException if SSH URL is a blank string. + */ + fun cloneSparsely( + project: Project, + sshUrl: String, + user: UserInfo, + branch: String, + initialFiles: Map = emptyMap(), + ): Repository { + require(sshUrl.isNotBlank()) { "SSH URL cannot be an empty string." } + + val repo = Repository(project, sshUrl = sshUrl, user = user, currentBranch = branch) + var cloned = false + try { + // Ask the remote first: an existing branch is cloned alone, while + // a missing one needs the default branch to start from. + val branchExists = repo.remoteHasBranch(branch, remote = sshUrl) + repo.cloneSparsely(branch.takeIf { branchExists }) + repo.configureUser(user) + if (!branchExists) { + repo.createOrphanBranch(branch, initialFiles) + } + cloned = true + return repo + } finally { + // The caller cannot close a repository it never gets. + if (!cloned) { + repo.close() + } + } + } } } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/AuthorEmail.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/AuthorEmail.kt index 39d16ac8f..cec12afdc 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/AuthorEmail.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/AuthorEmail.kt @@ -15,7 +15,8 @@ package io.spine.gradle.github.pages /** - * An author of updates to GitHub pages. + * The email of the author of the commits that publish the API documentation + * to GitHub Pages. */ class AuthorEmail(val value: String) { diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/DocsPublication.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/DocsPublication.kt new file mode 100644 index 000000000..11f37da3b --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/DocsPublication.kt @@ -0,0 +1,254 @@ +/* + * 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.github.pages + +import io.spine.gradle.VersionComparator +import io.spine.gradle.git.Repository +import java.io.File +import java.nio.file.Path +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.FileCollection +import org.gradle.api.logging.Logger + +/** + * Publishes the API documentation of the given [project] to GitHub Pages. + * + * The documentation in each format is copied into a clone of the `gh-pages` branch + * and committed there, and the commits are pushed. + * + * The clone has only the most recent documentation of the [project] and the folder of + * its version checked out, without the folders of the versions published earlier. + * The branch holds the documentation of every version of every module, which is too + * much to check out for a change to a single module. + */ +internal fun Task.publishToGitHubPages(project: Project) { + val plugin = project.plugins.getPlugin(PublishApiDocs::class.java) + + SshKey(plugin.rootFolder, logger).register() + + val repository = Repository.forPublishingDocumentation(project) + + val javadoc = JavadocPublication(project, plugin.javadocOutputFolder, repository, logger) + val html = HtmlPublication(project, plugin.htmlOutputFolder, repository, logger) + + repository.use { + repository.checkOutOnly(javadoc.checkoutPatterns() + html.checkoutPatterns()) + javadoc.publish() + html.publish() + repository.push() + } +} + +/** + * Publishes the documentation of a [project] in one format to the clone of + * the [repository] serving GitHub Pages. + * + * The documentation goes to the folder of its version, and also replaces the most + * recent documentation of the project, unless a newer version is already published. + */ +private abstract class DocsPublication( + private val project: Project, + private val docsSourceFolder: Path, + private val repository: Repository, + private val logger: Logger +) { + + /** + * The folder under the repository's root(`/`) for storing documentation. + * + * The value should not contain any leading or trailing file separators. + * + * The absolute path to the project's documentation is made by appending its + * name to the end, making `/docsDestinationFolder/project.name`. + */ + protected abstract val docsDestinationFolder: String + + /** + * The name of the format of the documentation to publish. + * + * This name will appear in logs as part of a message. + */ + protected abstract val formatName: String + + /** + * The path of the folder with the documentation of the [project] in this format, + * relative to the repository root. + */ + private val folder: String + get() = "$docsDestinationFolder/${project.name}" + + private val mostRecentFolder by lazy { + File("${repository.location}/$folder") + } + + /** + * The folder holding the documentation of each published version in a subfolder + * named after the version. + */ + private val versionsFolder by lazy { + File(mostRecentFolder, VERSIONS_FOLDER) + } + + private fun log(message: () -> String) { + if (logger.isDebugEnabled) { + logger.debug(message()) + } + } + + /** + * Obtains the patterns of the paths this publication writes, for + * [checking out][Repository.checkOutOnly] just those paths. + */ + fun checkoutPatterns(): List = + docsCheckoutPatterns(folder, project.version.toString()) + + /** + * Copies the documentation into the repository and commits it. + */ + fun publish() { + val module = project.name + log { "Publishing the `$formatName` documentation of the module `$module`." } + + val documentation = replaceMostRecentDocs() + copyIntoVersionDir(documentation) + + val version = project.version + val commitMessage = + "Publish the `$formatName` documentation of the module" + + " `$module` for the version `$version`." + repository.commitAllChanges(commitMessage) + + log { "Published the `$formatName` documentation of the module `$module`." } + } + + /** + * Replaces the most recent documentation with the one of this version, unless + * the documentation of a newer version is already published. + * + * The runs of `publish-api-docs.yml` are serialized, but not necessarily in the + * order of their commits, and a release of an older line may come after a newer + * one. Either way, the most recent documentation stays that of the newest version. + */ + private fun replaceMostRecentDocs(): ConfigurableFileCollection { + val generatedDocs = project.files(docsSourceFolder) + + val version = project.version.toString() + val newer = publishedVersions().newestAbove(version) + if (newer != null) { + logger.lifecycle( + "The most recent `$formatName` documentation of `${project.name}` stays" + + " that of `$newer`, which is newer than `$version`." + ) + return generatedDocs + } + + log { "Replacing the most recent `$formatName` documentation in `$mostRecentFolder`." } + copyDocs(generatedDocs, mostRecentFolder) + + return generatedDocs + } + + /** + * Obtains the versions whose documentation is published, as named by the folders + * under `v/`. + * + * The names are read from the tree of the current commit, as the folders of + * the other versions are not checked out. + */ + private fun publishedVersions(): List = + repository.listFolders("$folder/$VERSIONS_FOLDER") + + private fun copyDocs(source: FileCollection, destination: File) { + destination.mkdirs() + project.copy { + from(source) + into(destination) + } + } + + private fun copyIntoVersionDir(generatedDocs: ConfigurableFileCollection) { + val versionedDocDir = File(versionsFolder, "${project.version}") + + log { "Storing the new version of `$formatName` documentation in `${versionedDocDir}`." } + copyDocs(generatedDocs, versionedDocDir) + } +} + +private class JavadocPublication( + project: Project, + docsSourceFolder: Path, + repository: Repository, + logger: Logger +) : DocsPublication(project, docsSourceFolder, repository, logger) { + + override val docsDestinationFolder: String + get() = "javadoc" + override val formatName: String + get() = "javadoc" +} + +private class HtmlPublication( + project: Project, + docsSourceFolder: Path, + repository: Repository, + logger: Logger +) : DocsPublication(project, docsSourceFolder, repository, logger) { + + override val docsDestinationFolder: String + get() = "reference" + override val formatName: String + get() = "html" +} + +/** + * Returns the newest of these versions if it is newer than the given [version], + * or `null` otherwise. + * + * The versions are compared by [VersionComparator], so that `2.10.0` is newer than + * `2.9.0`, and a release is newer than the snapshots leading to it. + */ +internal fun Iterable.newestAbove(version: String): String? = + maxWithOrNull(VersionComparator)?.takeIf { VersionComparator.compare(it, version) > 0 } + +/** + * The name of the folder that holds the documentation of each published version + * in a subfolder named after the version. + */ +private const val VERSIONS_FOLDER = "v" + +/** + * Returns the patterns, in the `.gitignore` syntax, of the paths that a publication + * of the documentation in the given [folder] writes: the most recent documentation, + * and the folder of the given [version] under `v/`, but not the folders of the other + * versions. + * + * The folders under `v/` are excluded one by one, and the folder of the [version] + * is included again after them, as the last matching pattern wins. This is how + * `git sparse-checkout` lays out its patterns in cone mode. + * + * For example, for the `javadoc/core` folder and the version `1.2.0`, the patterns + * select everything in `javadoc/core`, exclude each folder in `javadoc/core/v`, and + * select `javadoc/core/v/1.2.0` again. + * + * @param folder the path of the documentation folder, relative to the repository root. + * @param version the version whose folder is included. + */ +internal fun docsCheckoutPatterns(folder: String, version: String): List = listOf( + "/$folder/*", + "!/$folder/$VERSIONS_FOLDER/*/", + "/$folder/$VERSIONS_FOLDER/$version/", +) diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishAllApiDocs.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishAllApiDocs.kt new file mode 100644 index 000000000..e610c5861 --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishAllApiDocs.kt @@ -0,0 +1,76 @@ +/* + * 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.github.pages + +import io.spine.gradle.SpineTaskGroup +import io.spine.gradle.getOrRegister +import io.spine.gradle.github.pages.TaskName.publishAllApiDocs +import io.spine.gradle.github.pages.TaskName.publishApiDocs +import org.gradle.api.Project + +/** + * Registers the [TaskName.publishAllApiDocs] task in the root project, which publishes + * the API documentation of all the published projects of the build. + * + * Usage: + * ``` + * PublishAllApiDocs.registerIn(project.rootProject, projectsToPublish) + * ``` + * + * `spinePublishing { }` makes this call, as it knows which projects are published. + * The task is not a part of the publication to Maven repositories: the + * `publish-api-docs.yml` workflow runs it apart from `publish`. + * + * The task is a lifecycle one: it has no action of its own, and publishes + * the documentation through the [TaskName.publishApiDocs] tasks it depends on, + * which the [PublishApiDocs] plugin registers in each documented project. + */ +internal object PublishAllApiDocs { + + /** + * Registers the [TaskName.publishAllApiDocs] task in the [root] project. + * The task publishes the API documentation of the [published] projects. + * + * The task depends on the [TaskName.publishApiDocs] task of each of + * the [published] projects that has one, that is, applies the [PublishApiDocs] + * plugin. This includes [root] when it is published itself, as in a single-project + * build. A project that applies the plugin, but is not published, such as a module + * of integration tests, is left out. The task of a project with a snapshot version + * publishes nothing. + * + * The task is registered even when none of the projects has the documentation + * to publish, and then does nothing. This lets the `publish-api-docs.yml` + * workflow run it in any repository that publishes: Gradle fails on a task + * that is missing from the build. + * + * The plugin registers the task of a project once that project is evaluated, + * so the tasks to depend on are looked up only when Gradle resolves + * the dependencies of this task. Repeated calls add their projects to the same + * task, as `spinePublishing { }` may run more than once in a build. + */ + fun registerIn(root: Project, published: Set) { + val task = root.tasks.getOrRegister(publishAllApiDocs) { + group = SpineTaskGroup.name + description = "Publishes the API documentation of all the published modules" + } + task.configure { + dependsOn(root.provider { + published + .filter { publishApiDocs in it.tasks.names } + .map { it.tasks.named(publishApiDocs) } + }) + } + } +} diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishApiDocs.kt similarity index 65% rename from buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt rename to buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishApiDocs.kt index e4960aaf7..f4ca18bc3 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishApiDocs.kt @@ -15,12 +15,12 @@ package io.spine.gradle.github.pages import dokkaHtmlTask -import dokkaJavadocTask import io.spine.gradle.SpineTaskGroup import io.spine.gradle.fs.LazyTempPath import io.spine.gradle.github.pages.TaskName.copyHtmlDocs import io.spine.gradle.github.pages.TaskName.copyJavadocDocs -import io.spine.gradle.github.pages.TaskName.updateGitHubPages +import io.spine.gradle.github.pages.TaskName.publishApiDocs +import io.spine.gradle.isSnapshot import java.io.File import org.gradle.api.Plugin import org.gradle.api.Project @@ -30,12 +30,18 @@ import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.TaskProvider /** - * Registers the `updateGitHubPages` task that performs the update of - * the GitHub Pages with the documentation generated in Javadoc and HTML format - * for a particular Gradle project. + * Registers the `publishApiDocs` task that publishes the API documentation + * of a particular Gradle project, generated in Javadoc and HTML format. * * The generated documentation is appended to the `spine.io` site - * via GitHub pages by pushing commits to the `gh-pages` branch. + * via GitHub Pages by pushing commits to the `gh-pages` branch. + * The documentation of a snapshot version is not published to the site. Its Javadoc and + * HTML archives are still built by Dokka and published to Maven repositories by `publish`. + * + * The task is not a part of the Maven publication. It is run by the `publish-api-docs.yml` + * workflow, apart from `publish.yml`, through the `publishAllApiDocs` task of the root + * project, which [publishes the documentation of the published modules that apply + * this plugin][PublishAllApiDocs]. * * In order to work, the script needs a `deploy_key_rsa` private RSA key file in the * repository root. It is recommended to encrypt it in the repository and then decrypt @@ -47,18 +53,18 @@ import org.gradle.api.tasks.TaskProvider * the SSH private (encrypted `deploy_key_rsa`) and the public * ("GitHub Pages publisher" on GitHub) keys. * - * Another requirement is an environment variable `REPO_SLUG`, which is set by the CI - * environment, such as `Publish` GitHub Actions workflow. It points to the repository - * for which the update is executed. E.g.: + * Another requirement is an environment variable `REPO_SLUG`, which a CI workflow sets, + * such as the `Publish API docs` GitHub Actions workflow. It points to + * the repository for which the documentation is published. E.g.: * * ``` * REPO_SLUG: SpineEventEngine/base * ``` * - * @see UpdateGitHubPagesExtension for the extension that is used to configure + * @see PublishApiDocsExtension for the extension that is used to configure * this plugin */ -class UpdateGitHubPages : Plugin { +class PublishApiDocs : Plugin { /** * Root folder of the repository, to which this `Project` belongs. @@ -74,13 +80,13 @@ class UpdateGitHubPages : Plugin { /** * Path to the temp folder used to gather the Javadoc format output (generated by Dokka) - * before submitting it to the GitHub Pages update. + * before publishing it. */ internal val javadocOutputFolder = LazyTempPath("javadoc") /** * Path to the temp folder used to gather the HTML documentation - * generated by Dokka before submitting it to the GitHub Pages update. + * generated by Dokka before publishing it. */ internal val htmlOutputFolder = LazyTempPath("html") @@ -94,41 +100,45 @@ class UpdateGitHubPages : Plugin { * when the project version changes to non-snapshot. */ override fun apply(project: Project) { - val extension = UpdateGitHubPagesExtension.createIn(project) + val extension = PublishApiDocsExtension.createIn(project) project.afterEvaluate { - registerTasks(extension) + if (version.toString().isSnapshot()) { + registerNoOpTask() + } else { + registerTasks(extension) + } } } /** - * Registers the `updateGitHubPages` task that performs no actual update, but prints - * the message telling the update is skipped, since the project is in - * its `SNAPSHOT` version. + * Registers the [TaskName.publishApiDocs] task that publishes nothing, but tells + * that the documentation is not published, since the project is in its `SNAPSHOT` + * version. */ - @Suppress("unused") private fun Project.registerNoOpTask() { - tasks.register(updateGitHubPages) { + val module = name + val snapshotVersion = version.toString() + tasks.register(publishApiDocs) { group = SpineTaskGroup.name - description = "Skips the GitHub Pages update for snapshot project versions" + description = "Skips publishing the API documentation of snapshot versions" doLast { - val project = this@registerNoOpTask - println( - "GitHub Pages update will be skipped since this project is a snapshot: " + - "`${project.name}-${project.version}`." + logger.lifecycle( + "The API documentation of `$module` is not published, since" + + " `$snapshotVersion` is a snapshot version." ) } } } - private fun Project.registerTasks(extension: UpdateGitHubPagesExtension) { + private fun Project.registerTasks(extension: PublishApiDocsExtension) { rootFolder = extension.rootFolder() includedInputs = extension.includedInputs() tasks.registerCopyJavadoc() tasks.registerCopyDokka() - val updatePagesTask = tasks.registerUpdateTask() - updatePagesTask.configure { + val publishTask = tasks.registerPublishTask() + publishTask.configure { dependsOn(copyJavadocDocs) dependsOn(copyHtmlDocs) } @@ -147,7 +157,8 @@ class UpdateGitHubPages : Plugin { private fun TaskContainer.composeJavadocInputs(): List { val inputs = mutableListOf() - inputs.add(dokkaJavadocTask()!!) + // Unlike the HTML docs, the Javadoc is required, so a missing task fails the build. + inputs.add(named("dokkaGeneratePublicationJavadoc")) inputs.addAll(includedInputs) return inputs } @@ -165,22 +176,19 @@ class UpdateGitHubPages : Plugin { private fun TaskContainer.composeDokkaInputs(): List { val inputs = mutableListOf() - - dokkaHtmlTask()?.let { - inputs.add(it) - } + inputs.add(dokkaHtmlTask()) inputs.addAll(includedInputs) return inputs } - private fun TaskContainer.registerUpdateTask(): TaskProvider { - return register(updateGitHubPages) { + private fun TaskContainer.registerPublishTask(): TaskProvider { + return register(publishApiDocs) { group = SpineTaskGroup.name - description = "Publishes the generated documentation to the `gh-pages` branch" + description = "Publishes the API documentation to the `gh-pages` branch" doLast { try { - updateGhPages(project) + publishToGitHubPages(project) } finally { cleanup() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPagesExtension.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishApiDocsExtension.kt similarity index 70% rename from buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPagesExtension.kt rename to buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishApiDocsExtension.kt index 5a7b1a88f..fbf0d9bc9 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPagesExtension.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/PublishApiDocsExtension.kt @@ -23,44 +23,44 @@ import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.property /** - * Configures the `updateGitHubPages` extension. + * Configures the `publishApiDocs` extension. */ @Suppress("unused") -fun Project.updateGitHubPages( - action: UpdateGitHubPagesExtension.() -> Unit +fun Project.publishApiDocs( + action: PublishApiDocsExtension.() -> Unit ) { - apply() + apply() - val extension = extensions.getByType(UpdateGitHubPagesExtension::class) + val extension = extensions.getByType(PublishApiDocsExtension::class) extension.action() } /** - * The extension for configuring the [UpdateGitHubPages] plugin. + * The extension for configuring the [PublishApiDocs] plugin. * - * @property rootFolder The root folder of the repository to which the updated `Project` belongs. + * @property rootFolder The root folder of the repository to which the documented `Project` belongs. * @property includeInputs The external inputs, which output should be included - * into the GitHub Pages update. The values are interpreted according to + * into the published documentation. The values are interpreted according to * [Copy.from][org.gradle.api.tasks.Copy.from] specification. * This property is optional. */ -class UpdateGitHubPagesExtension private constructor( - var rootFolder: Property, - var includeInputs: SetProperty +class PublishApiDocsExtension private constructor( + val rootFolder: Property, + val includeInputs: SetProperty ) { internal companion object { /** * The name of the extension. */ - const val name = "updateGitHubPages" + const val name = "publishApiDocs" /** * Creates a new extension and adds it to the passed project. */ - fun createIn(project: Project): UpdateGitHubPagesExtension { + fun createIn(project: Project): PublishApiDocsExtension { val factory = project.objects - val result = UpdateGitHubPagesExtension( + val result = PublishApiDocsExtension( rootFolder = factory.property(File::class), includeInputs = factory.setProperty(Any::class.java) ) @@ -76,8 +76,8 @@ class UpdateGitHubPagesExtension private constructor( fun rootFolder(): File = rootFolder.get() /** - * Returns the external inputs, which results should be included into the - * GitHub Pages update. + * Returns the external inputs, whose results should be included into the + * published documentation. */ fun includedInputs(): Set = includeInputs.get() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt index 0be661d2e..8a704cb94 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt @@ -21,17 +21,19 @@ import io.spine.gradle.repo.RepoSlug import org.gradle.api.Project /** - * Clones the current project repository with the branch dedicated to publishing - * documentation to GitHub Pages checked out. + * Clones the branch of the current project repository dedicated to publishing + * documentation to GitHub Pages. * * The repository's GitHub SSH URL is derived from the `REPO_SLUG` environment * variable. The [branch][Branch.documentation] dedicated to publishing documentation - * is automatically checked out in this repository, and created if it does not exist - * yet. A freshly created branch is seeded with a `CNAME` file so that GitHub Pages - * serves the documentation under the `spine.io` custom domain. Also, the username - * and the email of the git user are automatically configured. + * is cloned [sparsely][Repository.Factory.cloneSparsely]: its tip holds the documentation + * of every published version of every module, while a publication touches the folders + * of one module only; the caller [checks them out][Repository.checkOutOnly]. The branch + * is created if it does not exist yet. A freshly created branch is seeded with a `CNAME` + * file so that GitHub Pages serves the documentation under the `spine.io` custom domain. + * Also, the username and the email of the Git user are automatically configured. * - * The username is set to `"UpdateGitHubPages Plugin"`, and the email is derived from + * The username is set to `"PublishApiDocs Plugin"`, and the email is derived from * the `FORMAL_GIT_HUB_PAGES_AUTHOR` environment variable. * * @throws org.gradle.api.GradleException if any of the environment variables described above @@ -40,7 +42,7 @@ import org.gradle.api.Project internal fun Repository.Factory.forPublishingDocumentation(project: Project): Repository { val host = RepoSlug.fromVar().gitHost() - val username = "UpdateGitHubPages Plugin" + val username = "PublishApiDocs Plugin" val userEmail = AuthorEmail.fromVar().toString() val user = UserInfo(username, userEmail) @@ -51,5 +53,11 @@ internal fun Repository.Factory.forPublishingDocumentation(project: Project): Re // custom domain. val initialFiles = mapOf("CNAME" to "spine.io\n") - return clone(project, host, user, branch, initialFiles) + return cloneSparsely( + project, + sshUrl = host, + user = user, + branch = branch, + initialFiles = initialFiles + ) } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/TaskName.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/TaskName.kt index cc453f0bf..2d7d20de2 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/TaskName.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/TaskName.kt @@ -17,19 +17,25 @@ package io.spine.gradle.github.pages object TaskName { /** - * The name of the task that updates the GitHub Pages. + * The name of the task that publishes the API documentation of a project. */ - const val updateGitHubPages = "updateGitHubPages" + const val publishApiDocs = "publishApiDocs" /** - * The name of the helper task to gather the generated Javadoc format - * documentation generated by Dokka before updating GitHub Pages. + * The name of the task of the root project that publishes the API documentation + * of all the published projects of the build. + */ + const val publishAllApiDocs = "publishAllApiDocs" + + /** + * The name of the helper task to gather the Javadoc format documentation + * generated by Dokka before publishing it to GitHub Pages. */ const val copyJavadocDocs = "copyJavadocDocs" /** * The name of the helper task to gather HTML documentation - * generated by Dokka before updating GitHub Pages. + * generated by Dokka before publishing it to GitHub Pages. */ const val copyHtmlDocs = "copyHtmlDocs" } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/Update.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/Update.kt deleted file mode 100644 index 29896d159..000000000 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/Update.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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.github.pages - -import io.spine.gradle.git.Repository -import java.io.File -import java.nio.file.Path -import org.gradle.api.Project -import org.gradle.api.Task -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.FileCollection -import org.gradle.api.logging.Logger - -/** - * Performs the update of GitHub pages. - */ -fun Task.updateGhPages(project: Project) { - val plugin = project.plugins.getPlugin(UpdateGitHubPages::class.java) - - SshKey(plugin.rootFolder, logger).register() - - val repository = Repository.forPublishingDocumentation(project) - - val updateJavadocFormat = - UpdateJavadocFormat(project, plugin.javadocOutputFolder, repository, logger) - - val updateHtmlFormat = - UpdateHtmlFormat(project, plugin.htmlOutputFolder, repository, logger) - - repository.use { - updateJavadocFormat.run() - updateHtmlFormat.run() - repository.push() - } -} - -private abstract class UpdateDocumentation( - private val project: Project, - private val docsSourceFolder: Path, - private val repository: Repository, - private val logger: Logger -) { - - /** - * The folder under the repository's root(`/`) for storing documentation. - * - * The value should not contain any leading or trailing file separators. - * - * The absolute path to the project's documentation is made by appending its - * name to the end, making `/docsDestinationFolder/project.name`. - */ - protected abstract val docsDestinationFolder: String - - /** - * The name of the format of the documentation to update. - * - * This name will appear in logs as part of a message. - */ - protected abstract val formatName: String - - private val mostRecentFolder by lazy { - File("${repository.location}/${docsDestinationFolder}/${project.name}") - } - - private fun log(message: () -> String) { - if (logger.isDebugEnabled) { - logger.debug(message()) - } - } - - fun run() { - val module = project.name - log { "Update of the `$formatName` documentation for the module `$module` started." } - - val documentation = replaceMostRecentDocs() - copyIntoVersionDir(documentation) - - val version = project.version - val updateMessage = - "Update `$formatName` documentation for the module" + - " `$module` with the version `$version`." - repository.commitAllChanges(updateMessage) - - log { "Update of the `$formatName` documentation for `$module` successfully finished." } - } - - private fun replaceMostRecentDocs(): ConfigurableFileCollection { - val generatedDocs = project.files(docsSourceFolder) - - log { "Replacing the most recent `$formatName` documentation in `$mostRecentFolder`." } - copyDocs(generatedDocs, mostRecentFolder) - - return generatedDocs - } - - private fun copyDocs(source: FileCollection, destination: File) { - destination.mkdir() - project.copy { - from(source) - into(destination) - } - } - - private fun copyIntoVersionDir(generatedDocs: ConfigurableFileCollection) { - val versionedDocDir = File("$mostRecentFolder/v/${project.version}") - - log { "Storing the new version of `$formatName` documentation in `${versionedDocDir}`." } - copyDocs(generatedDocs, versionedDocDir) - } -} - -private class UpdateJavadocFormat( - project: Project, - docsSourceFolder: Path, - repository: Repository, - logger: Logger -) : UpdateDocumentation(project, docsSourceFolder, repository, logger) { - - override val docsDestinationFolder: String - get() = "javadoc" - override val formatName: String - get() = "javadoc" -} - -private class UpdateHtmlFormat( - project: Project, - docsSourceFolder: Path, - repository: Repository, - logger: Logger -) : UpdateDocumentation(project, docsSourceFolder, repository, logger) { - - override val docsDestinationFolder: String - get() = "reference" - override val formatName: String - get() = "html" -} diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/ExcludeInternalDoclet.kt b/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/ExcludeInternalDoclet.kt index 825d3254f..d34632853 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/ExcludeInternalDoclet.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/ExcludeInternalDoclet.kt @@ -89,7 +89,7 @@ private fun Project.appendCustomJavadocTask(excludeInternalDoclet: Configuration !it.absolutePath.contains("generated") }.asFileTree - classpath = javadocTask.classpath + classpath = javadocTask.get().classpath options { encoding = JavadocConfig.encoding.name diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/JavadocConfig.kt b/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/JavadocConfig.kt index cb6998de2..35ff1fbc3 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/JavadocConfig.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/javadoc/JavadocConfig.kt @@ -19,19 +19,25 @@ import java.io.File import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.javadoc.Javadoc import org.gradle.external.javadoc.StandardJavadocDocletOptions import productionModules /** - * Finds a [Javadoc] Gradle task by the passed name. + * Finds a [Javadoc] Gradle task by the passed name without creating it. + * + * The task must be registered. */ -fun TaskContainer.javadocTask(named: String) = this.getByName(named) as Javadoc +fun TaskContainer.javadocTask(named: String): TaskProvider = + this.named(named, Javadoc::class.java) /** - * Finds a default [Javadoc] Gradle task. + * Finds a default [Javadoc] Gradle task without creating it. + * + * The task must be registered. */ -fun TaskContainer.javadocTask() = this.getByName("javadoc") as Javadoc +fun TaskContainer.javadocTask(): TaskProvider = javadocTask("javadoc") /** * Javadoc processing settings. @@ -57,15 +63,23 @@ object JavadocConfig { val encoding = Encoding("UTF-8") + /** + * Applies the Javadoc settings to the `javadoc` task of the given [project], + * or disables the task if the project is not a production module. + * + * The task must be registered. It is configured when Gradle creates it, + * so calling this function does not create the task. + */ fun applyTo(project: Project) { - val javadocTask = project.tasks.javadocTask() - if (!isProductionModule(project)) { - javadocTask.enabled = false - return + project.tasks.javadocTask().configure { + if (!isProductionModule(project)) { + enabled = false + return@configure + } + discardJavaModulesInLinks(this) + val docletOptions = options as StandardJavadocDocletOptions + configureDoclet(docletOptions) } - discardJavaModulesInLinks(javadocTask) - val docletOptions = javadocTask.options as StandardJavadocDocletOptions - configureDoclet(docletOptions) } private fun isProductionModule(project: Project) = project.run { diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/CheckVersionIncrement.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/CheckVersionIncrement.kt index f43f01927..cfb0fa0fe 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/CheckVersionIncrement.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/CheckVersionIncrement.kt @@ -235,7 +235,7 @@ abstract class CheckVersionIncrement : DefaultTask() { */ private fun checkArtifactNotPublished(path: String) { val artifact = "$path/${MavenMetadata.FILE_NAME}" - val repositories = targetRepositories() + val repositories = repository.targets() var found = false for (repoUrl in repositories) { val metadata = fetch(repoUrl, artifact) ?: continue @@ -252,18 +252,6 @@ abstract class CheckVersionIncrement : DefaultTask() { } } - /** - * Returns the URLs of the target repositories: the `snapshots` one, and - * the `releases` one unless it is the same destination. - */ - private fun targetRepositories(): List { - val snapshots = repository.target(snapshots = true) - if (repository.hasOneTarget()) { - return listOf(snapshots) - } - return listOf(snapshots, repository.target(snapshots = false)) - } - /** * Throws if the [metadata] of the artifact at the given [path], fetched from [repoUrl], * lists the current [version]. diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt index 184c8218b..3bf8dd0c6 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt @@ -19,6 +19,7 @@ import io.spine.gradle.repo.Repository import io.spine.gradle.buildDirectory import net.lingala.zip4j.ZipFile import org.gradle.api.Project +import org.gradle.api.artifacts.repositories.MavenArtifactRepository /** * Maven repositories of Spine Event Engine projects hosted at GitHub Packages. @@ -26,19 +27,45 @@ import org.gradle.api.Project internal object GitHubPackages { /** - * Obtains an instance of the GitHub Packages repository with the given name. + * The name of the GitHub Packages repository. + * + * The Maven repository that a project publishes to is named after it: + * `GitHub-Packages` for releases, and `GitHub-Packages-snapshots` for snapshots + * (see `Repository.name(snapshots)`). */ - fun repository(repoName: String): Repository { + const val repositoryName = "GitHub-Packages" + + /** + * Obtains an instance of the GitHub Packages repository of the given GitHub repository. + * + * @param slug The name of the GitHub repository of Spine Event Engine, without + * the organization, such as `core-jvm`. + */ + fun repository(slug: String): Repository { val githubActor: String = actor() - val url = "https://maven.pkg.github.com/SpineEventEngine/$repoName" + val url = "https://maven.pkg.github.com/SpineEventEngine/$slug" return Repository( - name = "GitHub-Packages", + name = repositoryName, releases = url, snapshots = url ) { project -> project.credentialsWithToken(githubActor) } } - private fun actor(): String { + /** + * Tells whether the given Maven repository is a GitHub Packages one, as registered + * for publishing by [repository]. + * + * The name tells it, whichever repository at GitHub Packages the [candidate] is. + */ + fun isRepository(candidate: MavenArtifactRepository): Boolean = + candidate.name in listOf(false, true).map { snapshots -> + Repository.publishingName(name = repositoryName, snapshots = snapshots) + } + + /** + * Obtains the user to authenticate with at GitHub Packages, along with a token. + */ + fun actor(): String { var githubActor: String? = System.getenv("GITHUB_ACTOR") githubActor = if (githubActor.isNullOrEmpty()) { "developers@spine.io" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/IncrementGuard.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/IncrementGuard.kt index 121cc201a..52415b40f 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/IncrementGuard.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/IncrementGuard.kt @@ -21,7 +21,6 @@ import io.spine.gradle.SpineTaskGroup import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task -import org.gradle.api.publish.maven.MavenPublication import org.gradle.api.publish.maven.tasks.PublishToMavenLocal /** @@ -219,10 +218,3 @@ private fun Project.publishedArtifactPaths(): Set = mavenPublications() .map { it.repositoryPath } .toSet() - -/** - * The path to the artifact of this publication in a Maven repository, - * such as `io/spine/spine-base`. - */ -private val MavenPublication.repositoryPath: String - get() = "${groupId.replace('.', '/')}/$artifactId" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/MavenMetadata.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/MavenMetadata.kt index 532ee9e5e..2f9c779f6 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/MavenMetadata.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/MavenMetadata.kt @@ -16,7 +16,6 @@ package io.spine.gradle.publish import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES import com.fasterxml.jackson.dataformat.xml.XmlMapper -import java.io.FileNotFoundException import java.net.URL /** @@ -50,16 +49,16 @@ internal data class MavenMetadata(var versioning: Versioning = Versioning()) { * * If the document could not be found, assumes that the module was never * released and thus has no metadata. + * + * The document is read by [RemoteFile], which bounds the read with timeouts + * and retries it after a transient failure. + * + * @param url The URL of the document. + * @param authorization The value of the `Authorization` header to send, if any. */ - fun fetchAndParse(url: URL): MavenMetadata? { - return try { - url.openStream().use { stream -> - mapper.readValue(stream, MavenMetadata::class.java) - } - } catch (_: FileNotFoundException) { - null - } - } + fun fetchAndParse(url: URL, authorization: String? = null): MavenMetadata? = + RemoteFile.readText(url, authorization) + ?.let { mapper.readValue(it, MavenMetadata::class.java) } } } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtoExts.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtoExts.kt index fd5dba83f..0ade0727b 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtoExts.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/ProtoExts.kt @@ -20,6 +20,7 @@ import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.file.FileTreeElement import org.gradle.api.file.SourceDirectorySet +import org.gradle.api.tasks.TaskCollection import org.gradle.api.tasks.bundling.Jar /** @@ -67,15 +68,18 @@ internal fun FileTreeElement.isGoogleProtoSource(): Boolean { } /** - * The reference to the `generateProto` task of a `main` source set. + * The `generateProto` task of a `main` source set, found without creating it. + * + * The returned collection is live: it holds the task once it is registered, + * and stays empty in a project without Protobuf. */ -internal fun Project.generateProto(): Task? = tasks.findByName("generateProto") +internal fun Project.generateProto(): TaskCollection = + tasks.named { it == "generateProto" } /** - * Makes this [Jar] task depend on the [generateProto] task, if it exists in the same project. + * Makes this [Jar] task depend on the [generateProto] task, once that task is registered + * in the same project. */ internal fun Jar.dependOnGenerateProto() { - project.generateProto()?.let { - this.dependsOn(it) - } + this.dependsOn(project.generateProto()) } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationChecksums.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationChecksums.kt index a49f8a6bb..d083018b9 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationChecksums.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationChecksums.kt @@ -15,6 +15,7 @@ package io.spine.gradle.publish import io.spine.gradle.SpineTaskGroup +import io.spine.gradle.getOrRegister import java.io.File import java.security.MessageDigest import java.util.Locale @@ -98,6 +99,13 @@ internal object PublicationChecksums { * the root project of a multi-module build, which is where `publish.yml` * expects the manifest. A module configuring the extension for itself also * gets an aggregator of its own, describing only that module. + * + * The registration is idempotent. `spinePublishing { }` configures the extension + * anew on every call, and the same project can be reached by two such calls: + * the root extension lists a module in [SpinePublishing.modulesWithCustomPublishing], + * while the module itself opens the extension with `customPublishing = true` — + * which is what `uber-jar-module.gradle.kts` does. Plain registration would fail + * on the second call, during configuration. */ fun registerTasks(host: Project, published: Set) { val collectors = published.map { registerCollectorIn(it) } @@ -177,10 +185,10 @@ internal object PublicationChecksums { // build directory afterwards. // // The list belongs to the project rather than to this call, because the - // task is registered once while `registerTasks` may run several times — - // see `getOrRegister`. Were the action to close over one call's projects, - // a later call adding a module would register its collector and still - // leave it out of the manifest. + // task is registered once while `registerTasks` may run several times, as + // the documentation of `registerTasks` explains. Were the action to close + // over one call's projects, a later call adding a module would register its + // collector and still leave it out of the manifest. val sources = host.attestationSources() sources += published.map { collectorOutput(it) } val target = host.layout.buildDirectory.file(aggregatePath) @@ -256,27 +264,6 @@ internal object PublicationChecksums { } } -/** - * Returns the task named [name] in this container, registering it with [init] - * if it is not there yet. - * - * `spinePublishing { }` configures the extension anew on every call, and the - * same project can be reached by two such calls: the root extension lists a - * module in [SpinePublishing.modulesWithCustomPublishing], while the module - * itself opens the extension with `customPublishing = true` — which is what - * `uber-jar-module.gradle.kts` does. Plain registration fails on the second - * call, during configuration, so registration has to be idempotent. - */ -private fun TaskContainer.getOrRegister( - name: String, - init: Task.() -> Unit -): TaskProvider = - if (names.contains(name)) { - named(name) - } else { - register(name, init) - } - /** * Returns the `clean` task of this container, if it has one. * @@ -322,14 +309,19 @@ private fun Project.publishedArtifacts(): Map { mavenPublications().forEach { publication -> publication.rejectMavenSnapshot() - val base = "${publication.artifactId}-${publication.version}" publication.artifacts.forEach { artifact -> - val classifier = artifact.classifier?.takeIf { it.isNotEmpty() } - val suffix = classifier?.let { "-$it" } ?: "" - record("$base$suffix.${artifact.extension}", artifact.file) + val name = publication.publishedName( + classifier = artifact.classifier, + extension = artifact.extension + ) + record(name, artifact.file) + } + pomFileOf(publication)?.let { + record(publication.publishedName(classifier = null, extension = "pom"), it) + } + moduleFileOf(publication)?.let { + record(publication.publishedName(classifier = null, extension = "module"), it) } - pomFileOf(publication)?.let { record("$base.pom", it) } - moduleFileOf(publication)?.let { record("$base.module", it) } } return result } @@ -340,7 +332,7 @@ private fun Project.publishedArtifacts(): Map { */ private fun Project.pomFileOf(publication: MavenPublication): File? = tasks.withType(GenerateMavenPom::class.java) - .findByName("generatePomFileFor${publication.taskSuffix()}") + .findByName(publication.pomTaskName()) ?.destination ?.takeIf { it.exists() } @@ -385,6 +377,11 @@ private fun MavenPublication.rejectMavenSnapshot() { private fun MavenPublication.taskSuffix(): String = "${name.replaceFirstChar { it.titlecase(Locale.ROOT) }}Publication" +/** + * Returns the name of the task that writes the POM of this publication. + */ +internal fun MavenPublication.pomTaskName(): String = "generatePomFileFor${taskSuffix()}" + /** * Returns the SHA-256 digest of this file as a lowercase hexadecimal string. * diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationSbom.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationSbom.kt index b6d3817ac..64ea35ec7 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationSbom.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationSbom.kt @@ -110,7 +110,7 @@ internal object PublicationSbom { /** * The extension of a published SBOM, as in `-.spdx.json`. */ - private const val sbomExtension = "spdx.json" + const val sbomExtension = "spdx.json" /** * The [platform][KotlinTarget.platform] under which a JVM module looks up the modules @@ -154,7 +154,7 @@ internal object PublicationSbom { * Whether a project is a JVM or a multiplatform module is known only once it is * evaluated, so the registration waits until then. It is idempotent, because * `spinePublishing { }` may reach one project twice, as the documentation of - * `getOrRegister` in `PublicationChecksums.kt` explains. + * [PublicationChecksums.registerTasks] explains. */ fun registerTasks(host: Project, published: Set) { published.forEach { project -> diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationState.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationState.kt new file mode 100644 index 000000000..4384ea430 --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationState.kt @@ -0,0 +1,672 @@ +/* + * 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.spine.gradle.SpineTaskGroup +import java.net.URI +import java.net.URL +import java.util.Base64 +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.Future +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.artifacts.repositories.MavenArtifactRepository +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.UntrackedTask + +/** + * Tells whether the Maven publications of the build are already published. + * + * The `Publish` workflow runs this task before it builds anything. Every version is + * immutable in the registries, which reject any overwrite, so a run can upload only + * a version that nobody has uploaded before. The task looks for the files of each + * publication in each of its destinations — the remote repositories its project + * publishes to, such as Artifact Registry and GitHub Packages — and decides: + * + * - no file of any publication is there — `unpublished`, the run publishes; + * - every publication is complete in every destination, and every copy of the SBOMs + * published next to the artifacts names the [commit] — `published`: an earlier run of + * the same commit uploaded the version, e.g. a duplicate run of one push, and this + * run has nothing to do; + * - any other state fails the task, before a single upload. A gap anywhere — a file + * or a whole publication missing from a destination — is left over from a failed + * run, or from GitHub Packages pruning the version. A version published from another + * commit, in any destination, is a collision. A version with an SBOM copy naming no + * commit, or with no commit to compare, cannot be confirmed as this run's. No run can + * publish any of these. + * + * The state is written to the [stateFile] as `unpublished` or `published`. + * + * A publication is complete in a destination when the destination lists its version in + * `maven-metadata.xml`, and has each of its [files]. The Gradle module metadata + * (`.module`) is not among them, as no public API tells at configuration time whether + * a publication has it. It is required only to be in all destinations of a publication, + * or in none. + * + * Repositories not served over HTTP(S), such as a directory on disk, are not searched: + * a CI build starts with them empty, so they never hold a version published before. + * GitHub Packages is read with a token (see [gitHubPackages]); any other repository is + * read anonymously, so a repository that needs credentials to be read fails the task. + * + * An SBOM is the evidence of the commit because it records the revision it was built + * from (see [PublicationSbom]), and no other published file does. A publication without + * an SBOM, such as a Gradle plugin marker, takes no part in the decision about the commit. + */ +@UntrackedTask(because = "The state lives in remote repositories, not in local outputs.") +abstract class PublicationState : DefaultTask() { + + /** + * The versions of the publications to look up, keyed by the path to the artifact in + * a Maven repository, such as `io/spine/spine-base`. + */ + @get:Input + abstract val publications: MapProperty + + /** + * The names of the files of each publication, keyed by the path to the artifact. + * + * The names are those of the artifacts and the POM, such as + * `spine-base-2.0.0.jar` or `spine-base-2.0.0.pom`. + */ + @get:Input + abstract val files: MapProperty> + + /** + * The URLs of the destinations of each publication, keyed by the path to the artifact. + */ + @get:Input + abstract val destinations: MapProperty> + + /** + * The URLs of the [destinations] that are GitHub Packages repositories. + * + * GitHub Packages serves nothing without a token, so the task reads these with + * the one in the `GITHUB_TOKEN` environment variable, and sends it nowhere else. + */ + @get:Input + abstract val gitHubPackages: SetProperty + + /** + * The commit being published. + * + * Absent outside GitHub Actions, where `GITHUB_SHA` is not set. In that case, + * a version that is already published cannot be attributed to any commit, and + * fails the task. + */ + @get:Input + @get:Optional + abstract val commit: Property + + /** + * The file to write the state to. + * + * The task deletes the file when it starts, so a failed task leaves none. + */ + @get:OutputFile + abstract val stateFile: RegularFileProperty + + @TaskAction + fun lookUp() { + val file = stateFile.get().asFile + file.delete() + val versions = publications.get() + if (versions.isEmpty()) { + logger.warn( + "No project publishes to a remote repository," + + " so there is no publication to look up." + ) + } + val inspector = Inspector( + files = files.get(), + destinations = destinations.get(), + gitHubPackages = gitHubPackages.get(), + gitHubAuthorization = gitHubAuthorization() + ) + val findings = inspector.inspectAll(versions) + findings.forEach { logger.lifecycle(it.describe()) } + val currentCommit = commit.orNull + val decision = Verdict.of(findings = findings, commit = currentCommit) + val state = decision.state ?: throw GradleException( + explain(decision = decision, findings = findings, commit = currentCommit) + ) + file.parentFile.mkdirs() + file.writeText("$state\n") + logger.lifecycle("The publication state is `$state`.") + } + + /** + * Obtains the `Authorization` header for reading the [gitHubPackages], if there are + * any, with the token in the `GITHUB_TOKEN` environment variable. + * + * @throws GradleException if there is a GitHub Packages repository to read, but + * no token. + */ + private fun gitHubAuthorization(): String? { + val repositories = gitHubPackages.get() + if (repositories.isEmpty()) { + return null + } + val token = System.getenv("GITHUB_TOKEN") + if (token.isNullOrBlank()) { + throw GradleException( + "Reading the GitHub Packages repository " + + repositories.joinToString { "`$it`" } + + " requires a token in the `GITHUB_TOKEN` environment variable." + ) + } + val credentials = "${GitHubPackages.actor()}:$token" + return "Basic " + Base64.getEncoder().encodeToString(credentials.toByteArray()) + } + + private fun explain(decision: Verdict, findings: List, commit: String?): String { + val reason = when (decision) { + Verdict.PARTIAL -> explainPartial(findings) + Verdict.COLLISION -> { + val others = findings.flatMap { it.commits }.toSet() - commit + val from = if (others.size == 1) "another commit" else "other commits" + """ + The version is published from $from: ${others.joinToString { "`$it`" }}, + while this build publishes `$commit`. + """.trimIndent() + } + Verdict.UNCONFIRMED -> if (commit == null) { + "The version is published, but this build cannot tell which commit" + + " it publishes: `GITHUB_SHA` is not set." + } else { + "The version is published, but a published SBOM does not name the commit" + + " it was published from." + } + Verdict.UNPUBLISHED, Verdict.PUBLISHED -> + error("The verdict `$decision` needs no explanation.") + } + return reason + "\n" + + """ + The registries reject overwrites, so no run can publish this version. + Bump the version in `version.gradle.kts`: the run of that merge publishes + the new version. + """.trimIndent() + } + + private fun explainPartial(findings: List): String { + val incomplete = findings.filter { it.presence != Presence.COMPLETE } + val gaps = incomplete.flatMap { finding -> + when (finding.presence) { + Presence.ABSENT -> listOf("${finding.label}: the whole publication") + Presence.PARTIAL, Presence.COMPLETE -> + finding.gaps.map { "${finding.label}: $it" } + } + } + val gitHub = gitHubPackages.get() + val gitHubOnly = incomplete.all { finding -> + finding.presence == Presence.PARTIAL && finding.incomplete.all { it in gitHub } + } + val pruned = if (gitHubOnly) { + "\nEvery gap is in GitHub Packages, which keeps only the latest snapshot versions" + + " of a package. If the version is an old one, it was pruned from there," + + " and the failure needs no action; if it is the latest, follow the advice" + + " below." + } else { + "" + } + return "The version is partially published. Missing:\n" + + gaps.joinToString(separator = "\n") { " - $it" } + + pruned + } + + /** + * The files of a publication held by one of its destinations. + * + * @property destination The URL of the repository. + * @property listed Tells whether the repository lists the version of the publication. + * @property present The names of the files of the publication in the repository. + */ + internal data class Holding( + val destination: String, + val listed: Boolean, + val present: Set + ) + + /** + * How much of a publication is published. + */ + internal enum class Presence { + + /** + * Every destination lists the version and has every file, and the Gradle module + * metadata is in all of them or in none. + */ + COMPLETE, + + /** No destination lists the version or has any file. */ + ABSENT, + + /** + * Anything else: a file or the listing missing from some destination, or + * the module metadata in some destinations only. + */ + PARTIAL + } + + /** + * The published state of a Maven publication. + * + * @property path The path to the artifact in a Maven repository. + * @property version The version of the publication. + * @property presence How much of the publication is published. + * @property commits The commits named by the copies of the SBOM of the publication in + * its destinations, if it is [complete][Presence.COMPLETE]. Empty otherwise, or if it + * has no SBOM. + * @property gaps What is missing from each destination, if anything. + * @property incomplete The destinations missing anything of the publication. + * @property unconfirmed Tells whether the publication has an SBOM, and a copy of it in + * some destination names no commit, as one built outside GitHub Actions does. + */ + internal data class Finding( + val path: String, + val version: String, + val presence: Presence, + val commits: Set = emptySet(), + val gaps: List = emptyList(), + val incomplete: Set = emptySet(), + val unconfirmed: Boolean = false + ) { + + /** + * The coordinates of the publication for the messages of the task. + */ + val label: String + get() = "`$path:$version`" + + /** + * Describes the finding for the build log. + */ + fun describe(): String = when (presence) { + Presence.ABSENT -> "$label is not published." + Presence.PARTIAL -> "$label is partially published; missing: ${gaps.joinToString()}." + Presence.COMPLETE -> { + val from = commits.joinToString { "`$it`" } + when { + commits.isEmpty() -> "$label is published, with no SBOM naming its commit." + unconfirmed -> "$label is published from $from, but a copy of its SBOM" + + " names no commit." + else -> "$label is published from $from." + } + } + } + } + + /** + * The decision about the publications of a build. + * + * @property state The state written for the `Publish` workflow, or `null` if the + * version cannot be published, in which case the task fails. + */ + internal enum class Verdict(val state: String?) { + + /** No publication is published, or there is none to look up. */ + UNPUBLISHED("unpublished"), + + /** + * Every publication is complete in every destination, and every copy of the SBOMs + * names the commit being published. + */ + PUBLISHED("published"), + + /** + * Some publications, or some of their files, are missing from some destinations, + * and the rest is published. + */ + PARTIAL(null), + + /** Every publication is published, but a copy of an SBOM names another commit. */ + COLLISION(null), + + /** + * Every publication is published, but the commit cannot be compared: the commit + * being published is unknown, or a copy of an SBOM names no commit. + */ + UNCONFIRMED(null); + + companion object { + + /** + * Decides on the verdict for the [findings] of a build that publishes + * the given [commit]. + * + * @param findings The published states of all publications of the build. + * @param commit The commit being published, or `null` if it is unknown. + */ + fun of(findings: Collection, commit: String?): Verdict { + val commits = findings.flatMap { it.commits }.toSet() + return when { + findings.all { it.presence == Presence.ABSENT } -> UNPUBLISHED + findings.any { it.presence != Presence.COMPLETE } -> PARTIAL + commit == null || commits.isEmpty() -> UNCONFIRMED + commits != setOf(commit) -> COLLISION + findings.any { it.unconfirmed } -> UNCONFIRMED + else -> PUBLISHED + } + } + } + } + + companion object { + + /** + * The name of the task, registered in the root project. + */ + const val taskName = "publicationState" + + private const val stateFilePath = "publication/state.txt" + + /** + * Matches the revision in the source information that the SPDX Gradle Plugin + * writes for a module of this build: `git+@#[]`. + * + * The revision is a full commit hash: 40 hexadecimal digits of SHA-1, or 64 of SHA-256. + */ + private val scmRevision = Regex("""git\+[^"\s]+?@([0-9a-f]{64}|[0-9a-f]{40})#""") + + /** + * Obtains the revisions named in the source information of the given SBOM text. + * + * A revision that is not a full commit hash, such as the `unknown` written for + * a build outside GitHub Actions, is not matched. + */ + internal fun sbomRevisions(text: String): Set = + scmRevision.findAll(text).map { it.groupValues[1] }.toSet() + + /** + * Decides how much of a publication is published, from the [holdings] of its + * destinations, the [names] of its files, and the name of its [module] metadata. + */ + internal fun presenceOf( + holdings: List, + names: List, + module: String + ): Presence { + val modules = holdings.map { module in it.present }.toSet() + return when { + holdings.all { !it.listed && it.present.isEmpty() } -> Presence.ABSENT + holdings.all { it.listed && it.present.containsAll(names) } && + modules.size == 1 -> Presence.COMPLETE + else -> Presence.PARTIAL + } + } + + /** + * Describes what is missing from each of the [holdings] to complete + * the publication at the given [path]. + * + * @return the descriptions of the gaps keyed by the destination, for each + * destination missing anything. + */ + internal fun gapsOf( + holdings: List, + path: String, + names: List, + module: String + ): Map> { + val expected = if (holdings.any { module in it.present }) names + module else names + return holdings.associate { holding -> + val metadata = "${holding.destination}/$path/${MavenMetadata.FILE_NAME}" + val unlisted = if (holding.listed) { + emptyList() + } else { + listOf("the version in `$metadata`") + } + val absent = (expected - holding.present).map { + "`$it` in `${holding.destination}`" + } + holding.destination to unlisted + absent + }.filterValues { it.isNotEmpty() } + } + + /** + * Registers the task in the given [root] project, unless it is registered already. + * + * The publications are collected from every project of the build once all of + * them are evaluated, since the publishing handlers configure the coordinates and + * the artifacts in `afterEvaluate`. They are handed to the task as plain values, + * so that the task reads the state of no other project while it runs. + * + * Call it after [PublicationSbom.registerTasks]: both collect in `projectsEvaluated` + * callbacks, which run in the order of registration, and this task must see + * the SBOM artifacts that the other one adds to the publications. + */ + internal fun registerOn(root: Project) { + if (root.tasks.names.contains(taskName)) { + return + } + val task = root.tasks.register(taskName, PublicationState::class.java) { + group = SpineTaskGroup.name + description = "Tells whether the version of the Maven publications is published" + commit.set( + root.providers.environmentVariable("GITHUB_SHA").filter { it.isNotBlank() } + ) + stateFile.convention(root.layout.buildDirectory.file(stateFilePath)) + } + root.gradle.projectsEvaluated { + task.configure { + root.allprojects.forEach { collect(it) } + } + } + } + + /** + * Adds the publications of the given [project] to the inputs of this task, if + * the project publishes to a remote repository. + */ + private fun PublicationState.collect(project: Project) { + val repositories = project.remoteRepositories() + if (repositories.isEmpty()) { + return + } + project.mavenPublications().forEach { publication -> + val path = publication.repositoryPath + publications.put(path, publication.version) + files.put(path, publication.fileNames()) + destinations.put(path, repositories.map { it.location() }) + } + gitHubPackages.addAll( + repositories + .filter { GitHubPackages.isRepository(it) && it.carriesTokenSafely() } + .map { it.location() } + ) + } + } +} + +/** + * Looks up the files of publications in their destinations. + * + * Publications are looked up [in parallel][parallelism], as a build may have dozens of + * them, and each one takes a request per file per destination. + * + * @property files The names of the files of each publication. + * @property destinations The URLs of the destinations of each publication. + * @property gitHubPackages The destinations to read with the [gitHubAuthorization]. + * @property gitHubAuthorization The `Authorization` header for reading GitHub Packages. + */ +private class Inspector( + private val files: Map>, + private val destinations: Map>, + private val gitHubPackages: Set, + private val gitHubAuthorization: String? +) { + + /** + * Looks up the publications of the given versions, keyed by the paths to the artifacts. + */ + fun inspectAll(versions: Map): List { + val pool = Executors.newFixedThreadPool(parallelism) + try { + val lookups = versions.map { (path, version) -> + Callable { inspect(path = path, version = version) } + } + return pool.invokeAll(lookups).map { it.result() } + } finally { + pool.shutdownNow() + } + } + + private fun inspect(path: String, version: String): PublicationState.Finding { + val names = files[path].orEmpty() + val module = "${path.substringAfterLast('/')}-$version.module" + val holdings = destinations[path].orEmpty().map { destination -> + PublicationState.Holding( + destination = destination, + listed = lists(destination = destination, path = path, version = version), + present = (names + module) + .filter { has(destination = destination, file = "$path/$version/$it") } + .toSet() + ) + } + val presence = PublicationState.presenceOf( + holdings = holdings, + names = names, + module = module + ) + val sbomCopies = if (presence == PublicationState.Presence.COMPLETE) { + sbomCommits(holdings = holdings, path = path, version = version, names = names) + } else { + emptyList() + } + val gaps = PublicationState.gapsOf( + holdings = holdings, + path = path, + names = names, + module = module + ) + return PublicationState.Finding( + path = path, + version = version, + presence = presence, + commits = sbomCopies.flatten().toSet(), + gaps = gaps.values.flatten(), + incomplete = gaps.keys, + unconfirmed = sbomCopies.any { it.isEmpty() } + ) + } + + private fun lists(destination: String, path: String, version: String): Boolean { + val metadataUrl = url(destination, "$path/${MavenMetadata.FILE_NAME}") + val metadata = MavenMetadata.fetchAndParse(metadataUrl, authorization(destination)) + return metadata != null && version in metadata.versioning.versions + } + + private fun has(destination: String, file: String): Boolean = + RemoteFile.exists(url(destination, file), authorization(destination)) + + /** + * Obtains the commits named by each copy of the SBOM of a complete publication, one + * set for each of its [holdings]. + * + * Every copy is read, as each destination may hold a copy uploaded by another run. + * A copy naming no commit gives an empty set, and so does one that cannot be read. + * + * Returns an empty list if the publication has no SBOM. + */ + private fun sbomCommits( + holdings: List, + path: String, + version: String, + names: List + ): List> { + val sbom = names.firstOrNull { it.endsWith(".${PublicationSbom.sbomExtension}") } + ?: return emptyList() + return holdings.map { holding -> + val sbomUrl = url(holding.destination, "$path/$version/$sbom") + RemoteFile.readText(sbomUrl, authorization(holding.destination)) + ?.let(PublicationState::sbomRevisions) + ?: emptySet() + } + } + + private fun authorization(destination: String): String? = + gitHubAuthorization.takeIf { destination in gitHubPackages } + + private fun url(repository: String, path: String): URL = + URI.create("${repository.removeSuffix("/")}/$path").toURL() + + private fun Future.result(): T = + try { + get() + } catch (e: ExecutionException) { + throw e.cause ?: IllegalStateException(e) + } + + private companion object { + + /** + * The number of publications looked up at a time. + */ + const val parallelism = 4 + } +} + +/** + * The schemes of the URLs of remote repositories. + */ +private val remoteSchemes = setOf("http", "https") + +/** + * Obtains the remote Maven repositories this project publishes to. + * + * A repository with no URL is left out, as Gradle would fail to publish to it anyway. + */ +private fun Project.remoteRepositories(): List = + extensions.findByType(PublishingExtension::class.java) + ?.repositories + ?.withType(MavenArtifactRepository::class.java) + ?.filter { repository -> + // `getUrl()` returns `null` for an unset URL. + repository.url != null && repository.url.scheme in remoteSchemes + } + .orEmpty() + +/** + * Obtains the URL of this repository with no trailing slash. + */ +private fun MavenArtifactRepository.location(): String = url.toString().removeSuffix("/") + +/** + * Tells whether a token may be sent to this repository: over HTTPS, or to the loopback + * address of a test. + */ +private fun MavenArtifactRepository.carriesTokenSafely(): Boolean = + url.scheme == "https" || url.host in setOf("localhost", "127.0.0.1", "[::1]", "::1") + +/** + * Obtains the names of the files of this publication: its artifacts and the POM. + */ +private fun MavenPublication.fileNames(): List { + val artifactNames = artifacts.map { + publishedName(classifier = it.classifier, extension = it.extension) + } + return (artifactNames + publishedName(classifier = null, extension = "pom")).distinct() +} diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingExts.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingExts.kt index 5a876d213..d7b616b9e 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingExts.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingExts.kt @@ -69,6 +69,24 @@ internal fun Project.mavenPublications(): Collection { internal val MavenPublication.isPluginMarker: Boolean get() = name.endsWith("PluginMarkerMaven") +/** + * The path to the artifact of this publication in a Maven repository, + * such as `io/spine/spine-base`. + */ +internal val MavenPublication.repositoryPath: String + get() = "${groupId.replace('.', '/')}/$artifactId" + +/** + * Obtains the name under which a file of this publication with the given [classifier] + * and [extension] is published, such as `spine-base-2.0.0-javadoc.jar`. + * + * A `null` or empty [classifier] means the file has none, as the POM. + */ +internal fun MavenPublication.publishedName(classifier: String?, extension: String): String { + val suffix = classifier?.takeIf { it.isNotEmpty() }?.let { "-$it" } ?: "" + return "$artifactId-$version$suffix.$extension" +} + /** * Obtains an instance, if available, of [SpinePublishing] extension * applied to this project. diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingRepos.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingRepos.kt index 26c553326..aac49f8f5 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingRepos.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingRepos.kt @@ -24,7 +24,10 @@ object PublishingRepos { val cloudArtifactRegistry = CloudArtifactRegistry.repository /** - * Obtains a GitHub repository by the given name. + * Obtains the GitHub Packages repository of the given GitHub repository. + * + * @param slug The name of the GitHub repository of Spine Event Engine, without + * the organization, such as `core-jvm`. */ - fun gitHub(repoName: String): Repository = GitHubPackages.repository(repoName) + fun gitHub(slug: String): Repository = GitHubPackages.repository(slug) } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/RemoteFile.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/RemoteFile.kt new file mode 100644 index 000000000..e1e4e9adb --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/RemoteFile.kt @@ -0,0 +1,356 @@ +/* + * 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 java.io.FileNotFoundException +import java.io.IOException +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URI +import java.net.URISyntaxException +import java.net.URL +import java.net.URLConnection +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration + +/** + * Reads the files of a remote Maven repository, such as `maven-metadata.xml`. + * + * Each request is bounded by timeouts, so that an unresponsive registry fails the build + * instead of stalling it. A request that fails with an I/O error, a server error, + * a [transient] client error, or a rate-limit `403 Forbidden` is retried, up to three + * attempts in all, with the delay doubling from one second. A response with + * a `Retry-After` header makes the next attempt wait at least as long as it asks, + * up to a minute. A missing file is not an error, and a request the server refuses + * for good, such as one answered with `401 Unauthorized` or a plain `403 Forbidden`, + * is not retried. + * + * An `Authorization` header passed with a request is sent to the origin of the given URL + * only. A redirect to another origin, such as the storage GitHub Packages redirects + * a download to, is followed without it. A redirect is followed to an HTTP(S) URL only, + * and never from HTTPS to plain HTTP. + */ +internal object RemoteFile { + + private const val attempts = 3 + private const val maxRedirects = 5 + private val connectTimeout = 10.seconds + private val readTimeout = 30.seconds + private val initialBackoff = 1.seconds + + /** + * The longest wait before another attempt that a `Retry-After` header may impose. + */ + private val maxRetryAfter = 60.seconds + + /** + * `429 Too Many Requests`, which `HttpURLConnection` has no constant for. + */ + private const val tooManyRequests = 429 + + /** + * The HTTP statuses of successful responses, `2xx`. + */ + private val successes = HttpURLConnection.HTTP_OK.. withRetries(request: () -> T): T { + var backoff = initialBackoff + // All attempts but the last one, whose failure propagates. + repeat(attempts - 1) { + try { + return request() + } catch (e: IOException) { + val asked = (e as? RetryLaterException)?.delay ?: Duration.ZERO + Thread.sleep(maxOf(backoff, asked).inWholeMilliseconds) + backoff *= 2 + } + } + return request() + } + + private fun readOnce(url: URL, authorization: String?, redirectsLeft: Int): String? { + val connection = open(url, authorization) + return try { + if (connection is HttpURLConnection) { + connection.read(authorization = authorization, redirectsLeft = redirectsLeft) + } else { + connection.getInputStream().bufferedReader().use { it.readText() } + } + } catch (_: FileNotFoundException) { + // A `file:` URL to a missing file. + null + } finally { + (connection as? HttpURLConnection)?.errorStream?.close() + } + } + + /** + * Reads the response to this connection, following a redirect. + */ + private fun HttpURLConnection.read(authorization: String?, redirectsLeft: Int): String? = + when (status()) { + in missing -> null + in redirects -> { + check(redirectsLeft > 0) { "Too many redirects reading `${url.shown()}`." } + val next = target() + inputStream.close() + readOnce( + url = next, + authorization = authorization.takeIf { url.sameOrigin(next) }, + redirectsLeft = redirectsLeft - 1 + ) + } + else -> inputStream.bufferedReader().use { it.readText() } + } + + private fun existsOnce(url: URL, authorization: String?, redirectsLeft: Int): Boolean { + val connection = open(url, authorization) + return try { + if (connection is HttpURLConnection) { + connection.requestMethod = "HEAD" + connection.exists(authorization = authorization, redirectsLeft = redirectsLeft) + } else { + connection.getInputStream().close() + true + } + } catch (_: FileNotFoundException) { + // A `file:` URL to a missing file. + false + } finally { + (connection as? HttpURLConnection)?.errorStream?.close() + } + } + + /** + * Tells whether the response to this `HEAD` request finds a file, following + * a redirect with no `Authorization` header if it leads to another origin. + */ + private fun HttpURLConnection.exists(authorization: String?, redirectsLeft: Int): Boolean { + val status = status() + return when { + status in missing -> false + status in successes -> true + status in redirects -> { + check(redirectsLeft > 0) { "Too many redirects asking for `${url.shown()}`." } + val next = target() + existsOnce( + url = next, + authorization = authorization.takeIf { url.sameOrigin(next) }, + redirectsLeft = redirectsLeft - 1 + ) + } + else -> throw IOException("`${url.shown()}` answered with `$status`.") + } + } + + private fun open(url: URL, authorization: String?): URLConnection { + val connection = url.openConnection() + connection.connectTimeout = connectTimeout.inWholeMilliseconds.toInt() + connection.readTimeout = readTimeout.inWholeMilliseconds.toInt() + if (connection is HttpURLConnection) { + connection.instanceFollowRedirects = false + } + authorization?.let { connection.setRequestProperty("Authorization", it) } + return connection + } + + /** + * Obtains the status of the response if it is a success, a redirect, or a sign that + * there is no such file. + * + * @throws IOException for a server error, a [transient] client error, or a rate limit, + * which may go away on another attempt; a [RetryLaterException] if the response + * tells how long to wait before it. + * @throws IllegalStateException for any other client error. + */ + private fun HttpURLConnection.status(): Int { + val status = responseCode + if (status >= HttpURLConnection.HTTP_INTERNAL_ERROR || status in transient || + isRateLimited(status)) { + val message = "`${url.shown()}` answered with `$status $responseMessage`." + val delay = retryAfter(maxRetryAfter) + throw if (delay == null) IOException(message) else RetryLaterException(message, delay) + } + check(status !in clientErrors || status in missing) { + "Cannot read `${url.shown()}`: the server answered with `$status $responseMessage`." + } + return status + } + + /** + * Obtains the URL that this connection redirects to. + * + * @throws IllegalStateException if there is no such URL, or it may not be followed: + * a URL that is not HTTP(S), or a plain HTTP one redirected to from HTTPS. + */ + private fun HttpURLConnection.target(): URL { + val location = checkNotNull(getHeaderField("Location")) { + "`${url.shown()}` redirects with no `Location`." + } + // The cause is left out of the message: it would quote the location, whose query + // may carry a signature. + val target = resolve(location) + ?: error("`${url.shown()}` redirects to an invalid location.") + val allowed = target.protocol == url.protocol || + (url.protocol == "http" && target.protocol == "https") + check(allowed) { + "`${url.shown()}` redirects to `${target.shown()}`, which may not be followed." + } + return target + } +} + +/** + * Resolves the given redirect [location] against the URL of this connection. + * + * @return the resolved URL, or `null` if the location is not a valid URL. + */ +private fun HttpURLConnection.resolve(location: String): URL? = + try { + url.toURI().resolve(URI.create(location)).toURL() + } catch (_: IllegalArgumentException) { + null + } catch (_: URISyntaxException) { + null + } catch (_: MalformedURLException) { + null + } + +/** + * An I/O failure whose response asks to wait for the given [delay] before trying again. + */ +private class RetryLaterException(message: String, val delay: Duration) : IOException(message) + +/** + * Obtains how long this response asks to wait before trying again, by its `Retry-After` + * header in seconds or as an HTTP date, but no longer than the given [limit]. + * + * @return the delay, or `null` if the response has no such header, or it cannot be parsed. + */ +private fun HttpURLConnection.retryAfter(limit: Duration): Duration? { + val value = getHeaderField("Retry-After")?.trim() ?: return null + val delay = value.toLongOrNull()?.seconds ?: untilHttpDate(value) + return delay?.coerceIn(Duration.ZERO, limit) +} + +/** + * Obtains the time from now until the given HTTP date, such as + * `Wed, 21 Oct 2026 07:28:00 GMT`. + * + * @return the time until the date, or `null` if [value] is not an HTTP date. + */ +private fun untilHttpDate(value: String): Duration? = + try { + val date = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + java.time.Duration.between(Instant.now(), date.toInstant()).toKotlinDuration() + } catch (_: DateTimeParseException) { + null + } + +/** + * Tells whether this response with the given [status] refuses the request for exceeding + * a rate limit, as GitHub does with `403 Forbidden` and either a `Retry-After` header or + * an `X-RateLimit-Remaining: 0` header. + */ +private fun HttpURLConnection.isRateLimited(status: Int): Boolean = + status == HttpURLConnection.HTTP_FORBIDDEN && + (getHeaderField("Retry-After") != null || + getHeaderField("X-RateLimit-Remaining") == "0") + +/** + * Tells whether the given URL has the origin of this one: the protocol, the host, + * and the port. + */ +private fun URL.sameOrigin(other: URL): Boolean = + protocol == other.protocol && host.equals(other.host, ignoreCase = true) && + effectivePort() == other.effectivePort() + +private fun URL.effectivePort(): Int = if (port == -1) defaultPort else port + +/** + * Shows this URL with no query, which may carry a signature, as a redirect + * to a storage service does. + */ +private fun URL.shown(): String = "$protocol://$authority$path" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/SpinePublishing.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/SpinePublishing.kt index 033df0c5d..31a7a9fcd 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/SpinePublishing.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/SpinePublishing.kt @@ -17,6 +17,7 @@ package io.spine.gradle.publish import io.spine.dependency.local.Spine +import io.spine.gradle.github.pages.PublishAllApiDocs import io.spine.gradle.repo.Repository import java.util.Locale import org.gradle.api.Project @@ -329,6 +330,12 @@ open class SpinePublishing(private val project: Project) { } PublicationChecksums.registerTasks(project, projectsToPublish) PublicationSbom.registerTasks(project, projectsToPublish) + // After `PublicationSbom`: its `projectsEvaluated` callback adds the SBOM artifacts + // that the one of `PublicationState` collects. + PublicationState.registerOn(project.rootProject) + // `publishAllApiDocs` is registered here, as this is where the published modules + // are known. It is not tied to `publish`: neither task depends on the other. + PublishAllApiDocs.registerIn(project.rootProject, projectsToPublish) } /** diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/StandardJavaPublicationHandler.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/StandardJavaPublicationHandler.kt index f17170a07..030d03470 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/StandardJavaPublicationHandler.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/StandardJavaPublicationHandler.kt @@ -17,9 +17,11 @@ package io.spine.gradle.publish import io.spine.gradle.repo.Repository import org.gradle.api.Project import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.maven.tasks.GenerateMavenPom import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.bundling.Jar import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.withType /** * A publication for a typical Java project. @@ -58,6 +60,12 @@ class StandardJavaPublicationHandler private constructor( */ const val PUBLICATION_NAME = "mavenJava" + /** + * The packaging of a POM whose main artifact is a JAR, + * and the extension of that artifact. + */ + private const val JAR = "jar" + override fun create( project: Project, destinations: Set, @@ -108,6 +116,7 @@ class StandardJavaPublicationHandler private constructor( val javaComponent = project.components.findByName("java") javaComponent?.let { from(it) + declareJarPackaging() } /* @@ -118,4 +127,63 @@ class StandardJavaPublicationHandler private constructor( artifact(it) } } + + /** + * Sets the `jar` packaging of the POM, which Gradle would calculate from + * the JAR of the `java` component. + * + * Until the packaging is set, reading it makes Gradle calculate it from the artifacts + * of the publication, which creates the tasks producing them. [PublicationSbom] reads + * the packaging in every build, so that the SBOM it adds does not change it. + * + * Writing the POM fails if the publication turns out to have a main artifact other + * than one JAR, so that a misleading POM is not published. The artifacts are checked + * when the POM is written, so that the check neither creates the tasks producing them + * nor takes the artifacts of the component into the publication before it is final. + * The result is an input of the task, so an up-to-date task does not hide a mismatch. + */ + private fun MavenPublication.declareJarPackaging() { + pom.packaging = JAR + val publication = this + val pomTask = pomTaskName() + project.tasks.withType() + .named { it == pomTask } + .configureEach { + val mismatch = project.provider { publication.jarPackagingMismatch() } + inputs.property("jarPackagingMismatch", mismatch.orElse("")) + doFirst { + mismatch.orNull?.let { throw IllegalStateException(it) } + } + } + } + + /** + * Describes how this publication contradicts the `jar` packaging, or returns `null` + * if it does not. + * + * The publication contradicts it if it keeps the `jar` packaging, but does not have + * exactly one main artifact, a JAR, the SBOM not counted. Such is the case if + * the build gives the `jar` task a classifier, or adds an artifact without one. + */ + private fun MavenPublication.jarPackagingMismatch(): String? { + if (pom.packaging != JAR) { + // The build has set a packaging of its own. + return null + } + val mainArtifacts = artifacts.filter { + it.classifier.isNullOrEmpty() && it.extension != PublicationSbom.sbomExtension + } + return if (mainArtifacts.singleOrNull()?.extension == JAR) { + null + } else { + val found = if (mainArtifacts.isEmpty()) { + "no main artifact" + } else { + "the main artifacts ${mainArtifacts.map { it.file.name }}" + } + "The POM of the `$name` publication of `${project.path}` declares the `jar`" + + " packaging, but the publication has $found. Please keep the `jar`" + + " task without a classifier, or publish the module with custom publishing." + } + } } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repository.kt b/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repository.kt index 8e747f370..e2f80d2d6 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repository.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repository.kt @@ -49,6 +49,17 @@ data class Repository( * Serializes obtaining the credentials of all repositories (see [credentials]). */ private val credentialsLock = Any() + + /** + * Obtains the name used for publishing to a repository with the given [name]. + * + * @param snapshots If `true` the repository is used for publishing snapshots, + * and the suffix `-snapshots` is added to the given [name]. + * Otherwise, the function returns just [name]. + * @see Repository.name + */ + fun publishingName(name: String, snapshots: Boolean): String = + name + if (snapshots) "-snapshots" else "" } init { @@ -66,7 +77,7 @@ data class Repository( * and the suffix `-snapshots` will be added to the value of the [name] property. * Otherwise, the function returns just [name]. */ - fun name(snapshots: Boolean): String = name + if (snapshots) "-snapshots" else "" + fun name(snapshots: Boolean): String = publishingName(name, snapshots) /** * Obtains the target URL of the repository for publishing. @@ -79,6 +90,12 @@ data class Repository( */ fun hasOneTarget() = snapshots == releases + /** + * Obtains the URLs of the destinations of this repository: the `snapshots` one, and + * the `releases` one unless it is the same destination. + */ + fun targets(): List = listOf(snapshots, releases).distinct() + /** * Obtains the publishing password credentials to this repository. * diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt index 65d8d69a1..c2af41698 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt @@ -20,7 +20,6 @@ import com.github.jk1.license.LicenseReportPlugin import io.spine.dependency.local.Spine import io.spine.gradle.SpineTaskGroup import io.spine.gradle.applyPlugin -import io.spine.gradle.getTask import io.spine.gradle.report.license.Paths.outputFilename import io.spine.gradle.report.license.Paths.relativePath import java.io.File @@ -130,10 +129,13 @@ object LicenseReporter { group = SpineTaskGroup.name description = "Merges per-project license reports into a single repository-wide report" val consolidationTask = this - val assembleTask = project.getTask("assemble") + val assembleTask = project.tasks.named("assemble") val sourceProjects: Iterable = sourceProjects(rootProject) sourceProjects.forEach { - val perProjectTask = it.getTask(projectTaskName) + // Obtained rather than configured lazily: in a single-project build, this + // action belongs to the container that holds the task, and Gradle forbids + // configuring a task of a container from such an action. + val perProjectTask = it.tasks.named(projectTaskName).get() consolidationTask.dependsOn(perProjectTask) perProjectTask.dependsOn(assembleTask) } @@ -142,8 +144,9 @@ object LicenseReporter { } dependsOn(assembleTask) } - project.getTask("build") - .finalizedBy(mergeTask) + project.tasks.named("build").configure { + finalizedBy(mergeTask) + } } /** diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt index 479e4f5f2..234a3af44 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt @@ -100,11 +100,9 @@ object PomGenerator { writer.writeTo(pomFile) } - val assembleTask = project.tasks.findByName("assemble")!! - dependsOn(assembleTask) + dependsOn(project.tasks.named("assemble")) } - val buildTask = project.tasks.findByName("build")!! - buildTask.finalizedBy(task) + project.tasks.named("build").configure { finalizedBy(task) } } } diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 830335fbd..11777117a 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -26,7 +26,7 @@ import io.spine.dependency.local.Reflect import io.spine.dependency.test.Jacoco import io.spine.gradle.SpineTaskGroup import io.spine.gradle.checkstyle.CheckStyleConfig -import io.spine.gradle.github.pages.updateGitHubPages +import io.spine.gradle.github.pages.publishApiDocs import io.spine.gradle.javac.configureErrorProne import io.spine.gradle.javac.configureJavac import io.spine.gradle.javadoc.JavadocConfig @@ -58,7 +58,7 @@ project.run { val generatedDir = "$projectDir/generated" setTaskDependencies(generatedDir) - configureGitHubPages() + configureApiDocs() } typealias Module = Project @@ -150,19 +150,21 @@ fun Module.setTaskDependencies(generatedDir: String) { clean.configure { dependsOn(cleanGenerated) } - - project.afterEvaluate { - val publish = tasks.findByName("publish") - publish?.dependsOn("${project.path}:updateGitHubPages") - } } afterEvaluate { configureTaskDependencies() } } -fun Module.configureGitHubPages() { - updateGitHubPages { +/** + * Applies the plugin that registers the `publishApiDocs` task of this module. + * + * The `publish-api-docs.yml` workflow runs the task through `publishAllApiDocs` of + * the root project, which depends on it for the published modules only. It is not + * a part of `publish`. + */ +fun Module.configureApiDocs() { + publishApiDocs { rootFolder.set(rootDir) } } diff --git a/buildSrc/src/main/kotlin/module.gradle.kts b/buildSrc/src/main/kotlin/module.gradle.kts index 51a810716..db5c0717c 100644 --- a/buildSrc/src/main/kotlin/module.gradle.kts +++ b/buildSrc/src/main/kotlin/module.gradle.kts @@ -38,7 +38,7 @@ import io.spine.dependency.local.Reflect import io.spine.dependency.local.Validation import io.spine.dependency.test.JUnit import io.spine.gradle.checkstyle.CheckStyleConfig -import io.spine.gradle.github.pages.updateGitHubPages +import io.spine.gradle.github.pages.publishApiDocs import io.spine.gradle.javac.configureErrorProne import io.spine.gradle.javac.configureJavac import io.spine.gradle.javadoc.JavadocConfig @@ -77,7 +77,7 @@ project.run { applyGeneratedDirectories(generatedDir) configureTests() - configureGitHubPages() + configureApiDocs() configureTaskDependencies() tasks.clean.configure { @@ -155,8 +155,15 @@ fun Module.configureTests() { } } -fun Module.configureGitHubPages() { - updateGitHubPages { +/** + * Applies the plugin that registers the `publishApiDocs` task of this module. + * + * The `publish-api-docs.yml` workflow runs the task through `publishAllApiDocs` of + * the root project, which depends on it for the published modules only. It is not + * a part of `publish`. + */ +fun Module.configureApiDocs() { + publishApiDocs { rootFolder.set(rootDir) } } diff --git a/buildSrc/src/test/kotlin/BuildExtensionsSpec.kt b/buildSrc/src/test/kotlin/BuildExtensionsSpec.kt new file mode 100644 index 000000000..4ac7a5567 --- /dev/null +++ b/buildSrc/src/test/kotlin/BuildExtensionsSpec.kt @@ -0,0 +1,73 @@ +/* + * 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. + */ + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.gradle.api.DefaultTask +import org.gradle.api.InvalidUserDataException +import org.gradle.api.Project +import org.gradle.api.tasks.JavaExec +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("Build extensions should") +internal class BuildExtensionsSpec { + + private val project: Project = ProjectBuilder.builder().build() + + @Nested inner class + `set remote debug options` { + + @Test + fun `on a JavaExec task once it is created`() { + project.tasks.register("launch", JavaExec::class.java) + + project.setRemoteDebug("launch") + + val debug = project.tasks.named("launch", JavaExec::class.java).get().debugOptions + debug.enabled.get() shouldBe true + debug.port.get() shouldBe BuildSettings.REMOTE_DEBUG_PORT + } + + @Test + fun `without creating the task`() { + var created = false + project.tasks.register("launch", JavaExec::class.java) { created = true } + + project.setRemoteDebug("launch") + + created shouldBe false + } + + @Test + fun `failing at once for a missing task`() { + shouldThrow { + project.setRemoteDebug("missing") + } + } + + @Test + fun `failing at once for a task other than JavaExec`() { + var created = false + project.tasks.register("plain", DefaultTask::class.java) { created = true } + + shouldThrow { + project.setRemoteDebug("plain") + } + created shouldBe false + } + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/CliSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/CliSpec.kt new file mode 100644 index 000000000..69f03ba85 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/CliSpec.kt @@ -0,0 +1,51 @@ +/* + * 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.shouldBe +import io.kotest.matchers.string.shouldContain +import java.io.File +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +@DisplayName("`Cli` should") +internal class CliSpec { + + @TempDir + lateinit var workingFolder: File + + @Test + fun `return the output as the command wrote it, line breaks included`() { + // `printf` turns the `\n` sequences of its format into line breaks. + Cli(workingFolder).execute("printf", "one\\ntwo\\n") shouldBe "one\ntwo\n" + } + + @Test + fun `run a command assembled as a list`() { + Cli(workingFolder).execute(listOf("printf", "%s", "one")) shouldBe "one" + } + + @Test + fun `fail with the exit code and the error output of a failed command`() { + val error = shouldThrow { + Cli(workingFolder).execute("sh", "-c", "echo 'went wrong' >&2; exit 3") + } + + error.message shouldContain "exit code 3" + error.message shouldContain "went wrong" + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/docs/DokkaSetupIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/docs/DokkaSetupIgTest.kt index 6ec5effe9..150fe348f 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/docs/DokkaSetupIgTest.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/docs/DokkaSetupIgTest.kt @@ -52,7 +52,7 @@ private const val PROBE_PATH = ":dokka-sample:dokkaGeneratePublicationProbe" * The build publishes a `javadocJar` that depends on the probe, just as the `javadocJar` * that `spinePublishing` creates depends on `dokkaGeneratePublicationJavadoc`. The build * publishes to a file-based repository and to its own Maven Local repository, so it never - * touches `~/.m2`. The `updateGitHubPages` task of the build stands in for the one that + * touches `~/.m2`. The `publishApiDocs` task of the build stands in for the one that * copies the Dokka output to GitHub Pages. * * The project with the probe is named `dokka-sample`, like the modules of `dokka-tools`, @@ -105,10 +105,10 @@ internal class DokkaSetupIgTest { } @Test - fun `run a Dokka task when the build updates GitHub Pages and publishes to Maven Local`() { + fun `run a Dokka task when the build publishes the API docs and publishes to Maven Local`() { runStoringThenReusing( ":dokka-sample:publishToMavenLocal", - ":dokka-sample:updateGitHubPages" + ":dokka-sample:publishApiDocs" ) { it.probeOutcome shouldBe SUCCESS } @@ -227,7 +227,7 @@ internal class DokkaSetupIgTest { } } - tasks.register("updateGitHubPages") { + tasks.register("publishApiDocs") { dependsOn(docsProbe) } """.trimIndent() diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/git/RepositorySpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/git/RepositorySpec.kt new file mode 100644 index 000000000..e84919f55 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/git/RepositorySpec.kt @@ -0,0 +1,313 @@ +/* + * 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.git + +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.file.shouldExist +import io.kotest.matchers.file.shouldNotExist +import io.kotest.matchers.shouldBe +import io.spine.gradle.Cli +import io.spine.gradle.github.pages.docsCheckoutPatterns +import java.io.File +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +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 + +/** + * Verifies a [sparse clone][Repository.Factory.cloneSparsely] against a local bare + * repository, reached by a `file:` URL the way Git reaches a remote one: with a plain + * path, Git would ignore `--depth` and `--filter`. + * + * The `gh-pages` branch of the repository holds a top-level `CNAME`, the documentation + * of the module `m`, with the folders of its versions `1.0.0` and `1.1.0` and a file + * next to them, and the documentation of the module `other`. The tests check out + * the paths that [docsCheckoutPatterns] selects for `m`. + */ +@DisplayName("A sparse clone of a `Repository` should") +internal class RepositorySpec { + + @TempDir + lateinit var tempDir: File + + private lateinit var remote: File + + /** A full clone of the [remote], standing in for the publications of other modules. */ + private lateinit var seed: File + + private lateinit var project: Project + + private val user = UserInfo("Docs Publisher", "docs@example.com") + + @BeforeEach + fun createRemote() { + remote = tempDir.resolve("remote.git") + git(tempDir, "init", "--bare", "--initial-branch=master", remote.path) + // Allow partial clones, with the blobs fetched on demand, as GitHub does. + git(remote, "config", "uploadpack.allowFilter", "true") + git(remote, "config", "uploadpack.allowAnySHA1InWant", "true") + + seed = tempDir.resolve("seed") + git(tempDir, "clone", remote.path, seed.path) + git(seed, "config", "user.name", "Seed") + git(seed, "config", "user.email", "seed@example.com") + seed.write("README.md", "The sources.") + seed.commit("Add the sources.") + git(seed, "push", "origin", "HEAD:refs/heads/master") + + git(seed, "switch", "--orphan", "gh-pages") + seed.write("CNAME", "spine.io\n") + seed.write("javadoc/m/index.html", "m, 1.1.0") + seed.write("javadoc/m/v/1.0.0/index.html", "m, 1.0.0") + seed.write("javadoc/m/v/1.1.0/index.html", "m, 1.1.0") + seed.write("javadoc/m/v/notes.txt", "Not a version.") + seed.write("javadoc/other/index.html", "other, 1.1.0") + seed.commit("Publish the documentation.") + git(seed, "push", "origin", "gh-pages") + + project = ProjectBuilder.builder() + .withProjectDir(tempDir.resolve("project")) + .build() + } + + @Test + fun `check out only the top-level files`() { + cloneDocs().use { repo -> + val root = repo.location.toFile() + + root.resolve("CNAME").shouldExist() + root.resolve("javadoc").shouldNotExist() + } + } + + @Test + fun `check out only the paths of a publication`() { + cloneDocs().use { repo -> + repo.checkOutOnly(docsCheckoutPatterns("javadoc/m", "2.0.0")) + val root = repo.location.toFile() + + root.resolve("javadoc/m/index.html").shouldExist() + root.resolve("javadoc/m/v/1.0.0").shouldNotExist() + root.resolve("javadoc/other").shouldNotExist() + } + } + + /** + * Publishing a version again, as a re-run of a failed publication does, writes + * into the folder of that version, which is in the branch already. + */ + @Test + fun `check out and update the folder of a version published before`() { + cloneDocs().use { repo -> + repo.checkOutOnly(docsCheckoutPatterns("javadoc/m", "1.1.0")) + val root = repo.location.toFile() + + root.resolve("javadoc/m/v/1.1.0/index.html").shouldExist() + root.resolve("javadoc/m/v/1.0.0").shouldNotExist() + + root.write("javadoc/m/v/1.1.0/index.html", "m, 1.1.0, again") + repo.commitAllChanges("Publish `m` 1.1.0 again.") + + changedPaths(root) shouldContainExactly listOf("M\tjavadoc/m/v/1.1.0/index.html") + } + } + + @Test + fun `list the folders that are not checked out, without the files`() { + cloneDocs().use { repo -> + repo.listFolders("javadoc/m/v") shouldContainExactly listOf("1.0.0", "1.1.0") + repo.listFolders("javadoc/absent").shouldBeEmpty() + } + } + + @Test + fun `commit only the changed paths, keeping those not checked out`() { + cloneDocs().use { repo -> + repo.checkOutOnly(docsCheckoutPatterns("javadoc/m", "2.0.0")) + val root = repo.location.toFile() + root.write("javadoc/m/index.html", "m, 2.0.0") + root.write("javadoc/m/v/2.0.0/index.html", "m, 2.0.0") + + repo.commitAllChanges("Publish `m` 2.0.0.") + + changedPaths(root) shouldContainExactlyInAnyOrder listOf( + "M\tjavadoc/m/index.html", + "A\tjavadoc/m/v/2.0.0/index.html" + ) + } + } + + @Test + fun `push onto a commit that another publication pushed meanwhile`() { + cloneDocs().use { repo -> + repo.checkOutOnly(docsCheckoutPatterns("javadoc/m", "2.0.0")) + repo.location.toFile().write("javadoc/m/v/2.0.0/index.html", "m, 2.0.0") + repo.commitAllChanges("Publish `m` 2.0.0.") + + seed.write("javadoc/other/index.html", "other, 2.0.0") + seed.commit("Publish `other` 2.0.0.") + git(seed, "push", "origin", "gh-pages") + + repo.push() + } + + val log = git(remote, "log", "--format=%s", "gh-pages").lines() + log shouldContain "Publish `m` 2.0.0." + log shouldContain "Publish `other` 2.0.0." + git(remote, "show", "gh-pages:javadoc/m/v/2.0.0/index.html") shouldBe "m, 2.0.0" + git(remote, "show", "gh-pages:javadoc/other/index.html") shouldBe "other, 2.0.0" + git(remote, "show", "gh-pages:javadoc/m/v/1.0.0/index.html") shouldBe "m, 1.0.0" + } + + @Test + fun `create a branch that does not exist yet, and push to it`() { + val cname = "spine.io\n" + + cloneSite(initialFiles = mapOf("CNAME" to cname)).use { repo -> + git(remote, "ls-tree", "--name-only", "site").trim() shouldBe "CNAME" + git(remote, "show", "site:CNAME") shouldBe cname + + repo.checkOutOnly(docsCheckoutPatterns("javadoc/m", "1.0.0")) + repo.location.toFile().write("javadoc/m/index.html", "m, 1.0.0") + repo.commitAllChanges("Publish `m` 1.0.0.") + repo.push() + } + + git(remote, "show", "site:javadoc/m/index.html") shouldBe "m, 1.0.0" + } + + @Test + fun `adopt a branch that another publication created first, and push to it`() { + val preparedCommit = raceForSiteBranch() + + cloneSite(initialFiles = mapOf("CNAME" to "spine.io\n")).use { repo -> + val root = repo.location.toFile() + git(root, "rev-parse", "HEAD").trim() shouldBe preparedCommit + git(root, "rev-parse", "--abbrev-ref", "site@{upstream}").trim() shouldBe + "origin/site" + + raceHook.delete() + repo.checkOutOnly(docsCheckoutPatterns("javadoc/m", "1.0.0")) + root.write("javadoc/m/index.html", "m, 1.0.0") + repo.commitAllChanges("Publish `m` 1.0.0.") + repo.push() + } + + git(remote, "show", "site:javadoc/m/index.html") shouldBe "m, 1.0.0" + } + + /** + * The full [clone][Repository.Factory.clone] shares the adoption of a branch with + * the sparse one. + */ + @Test + fun `adopt a branch that another publication created first, in a full clone`() { + val preparedCommit = raceForSiteBranch() + + Repository.clone( + project, + sshUrl = url, + user = user, + branch = "site", + initialFiles = mapOf("CNAME" to "spine.io\n") + ).use { repo -> + val root = repo.location.toFile() + git(root, "rev-parse", "HEAD").trim() shouldBe preparedCommit + git(root, "rev-parse", "--abbrev-ref", "site@{upstream}").trim() shouldBe + "origin/site" + } + } + + /** + * Makes another publication create the `site` branch after this one has found it + * missing, and push first. + * + * The `pre-receive` hook of the [remote] stands in for that publication: it creates + * the branch from a prepared commit, and rejects the push of this publication. + * + * @return the prepared commit. + */ + private fun raceForSiteBranch(): String { + git(seed, "switch", "--orphan", "prepared") + seed.write("CNAME", "spine.io\n") + seed.commit("Initialize the `site` branch.") + git(seed, "push", "origin", "prepared:refs/heads/prepared-site") + // Git keeps the objects a push brings in a quarantine until the hook accepts + // them, and refuses to update a reference from there, so the hook leaves it. + raceHook.apply { + writeText( + """ + #!/bin/sh + env -u GIT_QUARANTINE_PATH -u GIT_OBJECT_DIRECTORY \ + -u GIT_ALTERNATE_OBJECT_DIRECTORIES \ + git update-ref refs/heads/site refs/heads/prepared-site + exit 1 + """.trimIndent() + ) + setExecutable(true) + } + return git(seed, "rev-parse", "HEAD").trim() + } + + private val raceHook: File + get() = remote.resolve("hooks/pre-receive") + + private val url: String + get() = remote.toPath().toUri().toString() + + /** + * Obtains the paths the last commit in the [root] of a clone changed, each + * with the letter of its change. + */ + private fun changedPaths(root: File): List = + git(root, "show", "--name-status", "--format=", "HEAD") + .lines() + .filter { it.isNotBlank() } + + private fun cloneDocs(): Repository = + Repository.cloneSparsely(project, sshUrl = url, user = user, branch = "gh-pages") + + /** + * Clones the `site` branch, which the [remote] does not have at first. + */ + private fun cloneSite(initialFiles: Map): Repository = + Repository.cloneSparsely( + project, + sshUrl = url, + user = user, + branch = "site", + initialFiles = initialFiles + ) +} + +private fun git(directory: File, vararg arguments: String): String = + Cli(directory).execute("git", *arguments) + +private fun File.write(path: String, content: String) { + resolve(path).apply { + parentFile.mkdirs() + writeText(content) + } +} + +private fun File.commit(message: String) { + git(this, "add", "--all") + git(this, "commit", "--message=$message") +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/github/pages/NewestAboveSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/github/pages/NewestAboveSpec.kt new file mode 100644 index 000000000..ad5f63170 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/github/pages/NewestAboveSpec.kt @@ -0,0 +1,55 @@ +/* + * 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.github.pages + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Verifies how the publication of the API documentation decides whether the most + * recent documentation of a module may be replaced by that of the published version. + */ +@DisplayName("`newestAbove` should") +internal class NewestAboveSpec { + + @Test + fun `find no newer version when nothing is published`() { + emptyList().newestAbove("2.0.0").shouldBeNull() + } + + @Test + fun `find no newer version for the newest one`() { + listOf("1.9.0", "2.0.0-SNAPSHOT.575", "2.0.0-SNAPSHOT.99") + .newestAbove("2.0.0").shouldBeNull() + } + + @Test + fun `find no newer version for a version published again`() { + listOf("1.9.0", "2.0.0").newestAbove("2.0.0").shouldBeNull() + } + + @Test + fun `find the newest version above an older one`() { + listOf("1.9.0", "2.1.0", "2.0.0").newestAbove("1.9.5") shouldBe "2.1.0" + } + + @Test + fun `compare versions by their meaning rather than as text`() { + listOf("2.9.0").newestAbove("2.10.0").shouldBeNull() + listOf("2.10.0").newestAbove("2.9.0") shouldBe "2.10.0" + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/github/pages/PublishApiDocsIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/github/pages/PublishApiDocsIgTest.kt new file mode 100644 index 000000000..9fc3a345f --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/github/pages/PublishApiDocsIgTest.kt @@ -0,0 +1,349 @@ +/* + * 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.github.pages + +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.io.File +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * Verifies which tasks the `publishAllApiDocs` task of the root project, registered by + * `spinePublishing { }`, brings into a build: the `publishApiDocs` tasks of the projects. + * + * The builds that would publish documentation run with `--dry-run`, so that nothing + * is pushed to GitHub Pages. The builds of a snapshot version run for real, as they + * publish nothing. + * + * In the multi-module build, `api` and `backend` are published and document + * themselves, `testing` documents itself, but is not published, as a module of + * integration tests, and `bare` is published, but does not document itself. + * + * No build applies Dokka. A task named like the Dokka one stands in for it, as + * the plugin and the `javadocJar` of `spinePublishing` look the task up by name. + */ +@DisplayName("`publishAllApiDocs` task should") +internal class PublishApiDocsIgTest { + + @TempDir + lateinit var projectDir: File + + @Test + fun `publish the docs of each published module that documents itself`() { + writeMultiModuleBuild(documented = setOf("api", "backend", "testing")) + + val planned = dryRun(":$aggregate") + + planned shouldContain ":$aggregate" + planned.docsTasks() shouldContainExactlyInAnyOrder listOf( + ":api:$taskName", + ":backend:$taskName" + ) + // `testing` has the task, so the aggregate left it out only for not being published. + dryRun(":testing:$taskName").docsTasks() shouldBe listOf(":testing:$taskName") + } + + /** + * `lib` opens `spinePublishing { }` on its own, so the second call reaches + * the task of the root project while `lib` is evaluated, with projects of its own. + */ + @Test + fun `publish the docs of a module that configures its own publishing`() { + file("settings.gradle.kts").writeText( + """ + rootProject.name = "own-publishing-sample" + include("api", "lib") + """.trimIndent() + ) + file("build.gradle.kts").writeText( + """ + ${buildscript()} + + import io.spine.gradle.github.pages.publishApiDocs + import io.spine.gradle.publish.spinePublishing + + allprojects { + group = "io.spine.sample" + version = "1.0.0" + } + + subprojects { + apply(plugin = "java-library") + $generatorStub + } + + project("api").publishApiDocs { + rootFolder.set(rootDir) + } + + spinePublishing { + modules = setOf("api") + destinations = emptySet() + } + """.trimIndent() + ) + file("lib/build.gradle.kts").apply { + parentFile.mkdirs() + writeText( + """ + import io.spine.gradle.github.pages.publishApiDocs + import io.spine.gradle.publish.spinePublishing + + publishApiDocs { + rootFolder.set(rootDir) + } + + spinePublishing { + destinations = emptySet() + } + """.trimIndent() + ) + } + file("api").mkdirs() + + dryRun(":$aggregate").docsTasks() shouldContainExactlyInAnyOrder listOf( + ":api:$taskName", + ":lib:$taskName" + ) + } + + @Test + fun `not be a part of the publication to Maven repositories`() { + writeMultiModuleBuild(documented = setOf("api", "backend", "testing")) + + val planned = dryRun("publish") + + planned shouldContain ":api:publish" + planned shouldNotContain ":$aggregate" + planned.docsTasks().shouldBeEmpty() + } + + @Test + fun `do nothing in a build that documents no module`() { + writeMultiModuleBuild(documented = emptySet()) + + val result = runner(":$aggregate").build() + + result.tasks.map { it.path } shouldBe listOf(":$aggregate") + result.task(":$aggregate")?.outcome shouldBe TaskOutcome.UP_TO_DATE + } + + @Test + fun `publish the docs of the project in a single-project build`() { + writeSingleProjectBuild(version = "1.0.0") + + val planned = dryRun(":$aggregate") + + planned.docsTasks() shouldBe listOf(":$taskName") + planned shouldContain ":${TaskName.copyJavadocDocs}" + } + + @Test + fun `publish no docs of a snapshot version`() { + writeMultiModuleBuild(documented = setOf("api", "backend", "testing"), version = snapshot) + + val result = runner(":$aggregate").build() + + val paths = result.tasks.map { it.path } + paths.docsTasks() shouldContainExactlyInAnyOrder listOf( + ":api:$taskName", + ":backend:$taskName" + ) + paths shouldNotContain ":api:${TaskName.copyJavadocDocs}" + result.output shouldContain "`$snapshot` is a snapshot version" + } + + /** + * Only the publication to GitHub Pages skips a snapshot version. The archives of + * its documentation, built by Dokka, are still published to Maven repositories. + */ + @Test + fun `leave the docs archives of a snapshot version to the Maven publication`() { + writeMultiModuleBuild(documented = setOf("api"), version = snapshot) + file("build.gradle.kts").appendText( + "\n" + """ + project("api") { + apply(plugin = "maven-publish") + the().repositories.maven { + name = "remote" + url = uri(rootDir.resolve("remote-repo")) + } + } + """.trimIndent() + ) + + val planned = dryRun(":api:publish") + + planned shouldContain ":api:javadocJar" + planned shouldContain ":api:dokkaGeneratePublicationJavadoc" + planned.docsTasks().shouldBeEmpty() + } + + @Test + fun `publish no docs of a snapshot version in a single-project build`() { + writeSingleProjectBuild(version = snapshot) + + val result = runner(":$aggregate").build() + + result.tasks.map { it.path } shouldContainExactlyInAnyOrder listOf( + ":$taskName", + ":$aggregate" + ) + result.output shouldContain "`$snapshot` is a snapshot version" + } + + private fun writeSingleProjectBuild(version: String) { + file("settings.gradle.kts").writeText("""rootProject.name = "single-sample"""") + file("build.gradle.kts").writeText( + """ + ${buildscript()} + + import io.spine.gradle.github.pages.publishApiDocs + import io.spine.gradle.publish.spinePublishing + + group = "io.spine.sample" + version = "$version" + + apply(plugin = "java-library") + $generatorStub + + publishApiDocs { + rootFolder.set(rootDir) + } + + spinePublishing { + destinations = emptySet() + } + """.trimIndent() + ) + } + + private fun writeMultiModuleBuild(documented: Set, version: String = "1.0.0") { + val modules = listOf("api", "backend", "bare", "testing") + modules.forEach { file(it).mkdirs() } + file("settings.gradle.kts").writeText( + """ + rootProject.name = "docs-sample" + include(${modules.joinToString { "\"$it\"" }}) + """.trimIndent() + ) + file("build.gradle.kts").writeText( + """ + ${buildscript()} + + import io.spine.gradle.github.pages.publishApiDocs + import io.spine.gradle.publish.spinePublishing + + allprojects { + group = "io.spine.sample" + version = "$version" + } + + subprojects { + apply(plugin = "java-library") + $generatorStub + } + + listOf(${documented.joinToString { "\"$it\"" }}).forEach { + project(it).publishApiDocs { + rootFolder.set(rootDir) + } + } + + spinePublishing { + modules = setOf("api", "backend", "bare") + destinations = emptySet() + } + """.trimIndent() + ) + } + + /** + * Runs the build with `--dry-run` and returns the paths of the tasks + * it would run. + * + * TestKit reports no tasks for a dry run, so they are read from the output, + * which lists each of them as ` SKIPPED`. + */ + private fun dryRun(task: String): List = + runner(task, "--dry-run").build().output.lines() + .filter { it.startsWith(":") && it.endsWith(" SKIPPED") } + .map { it.substringBefore(' ') } + + private fun runner(vararg args: String): GradleRunner = + GradleRunner.create() + .withProjectDir(projectDir) + .withArguments(*args, "--stacktrace") + + /** + * Selects the paths of the tasks that publish documentation. + */ + private fun List.docsTasks(): List = + filter { it.substringAfterLast(':') == taskName } + + private fun file(relativePath: String): File = projectDir.resolve(relativePath) + + private fun buildscript(): String = + """ + buildscript { + dependencies { + classpath(files( + ${buildSrcClasspath()} + )) + } + } + """.trimIndent() + + /** + * 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 { + + const val taskName = TaskName.publishApiDocs + + const val aggregate = TaskName.publishAllApiDocs + + /** A version in the format the Spine SDK publishes from every merge. */ + const val snapshot = "1.0.0-SNAPSHOT.1" + + /** + * A task standing in for the Dokka one whose output the plugin copies, and on + * which the `javadocJar` of `spinePublishing` depends. + */ + const val generatorStub = """tasks.register("dokkaGeneratePublicationJavadoc")""" + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/javadoc/JavadocConfigSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/javadoc/JavadocConfigSpec.kt new file mode 100644 index 000000000..19b9e9731 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/javadoc/JavadocConfigSpec.kt @@ -0,0 +1,45 @@ +/* + * 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.javadoc + +import io.kotest.matchers.shouldBe +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPlugin +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`JavadocConfig` should") +internal class JavadocConfigSpec { + + private val project: Project = ProjectBuilder.builder().build().also { + it.pluginManager.apply(JavaPlugin::class.java) + } + + /** + * The project of a build without subprojects is not a production module, + * so its `javadoc` task is disabled. + */ + @Test + fun `configure the javadoc task once Gradle creates it`() { + var created = false + project.tasks.named { it == "javadoc" }.configureEach { created = true } + + JavadocConfig.applyTo(project) + + created shouldBe false + project.tasks.javadocTask().get().enabled shouldBe false + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/ArtifactComponentsSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/ArtifactComponentsSpec.kt index e4302976a..8beb82745 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/publish/ArtifactComponentsSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/ArtifactComponentsSpec.kt @@ -35,8 +35,8 @@ import org.junit.jupiter.api.Test @DisplayName("`ArtifactComponents` should") internal class ArtifactComponentsSpec { - @Nested - inner class `describe a bundled package` { + @Nested inner class + `describe a bundled package` { @Test fun `as contained in the artifact rather than a dependency of it`() { @@ -157,8 +157,8 @@ internal class ArtifactComponentsSpec { } } - @Nested - inner class `keep` { + @Nested inner class + keep { @Test fun `the dependencies of the artifact as they are`() { @@ -192,8 +192,8 @@ internal class ArtifactComponentsSpec { } } - @Nested - inner class `leave out` { + @Nested inner class + `leave out` { @Test fun `a package the artifact neither depends on nor bundles`() { @@ -269,8 +269,8 @@ internal class ArtifactComponentsSpec { } } - @Nested - inner class `reject a document` { + @Nested inner class + `reject a document` { @Test fun `describing no package`() { diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/IncrementGuardTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/IncrementGuardTest.kt index 9bcbc4e4e..d315b5839 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/publish/IncrementGuardTest.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/IncrementGuardTest.kt @@ -43,8 +43,8 @@ import org.junit.jupiter.api.io.TempDir @DisplayName("`IncrementGuard` should") class IncrementGuardTest { - @Nested - inner class `require the version check` { + @Nested inner class + `require the version check` { @Test fun `for pull requests targeting default branches`() { @@ -59,8 +59,8 @@ class IncrementGuardTest { } } - @Nested - inner class `not require the version check` { + @Nested inner class + `not require the version check` { @Test fun `for pull requests targeting auxiliary branches`() { @@ -85,8 +85,8 @@ class IncrementGuardTest { } } - @Nested - inner class `actually run the check` { + @Nested inner class + `actually run the check` { @Test fun `on a CI pull request to a protected branch`() { @@ -99,8 +99,8 @@ class IncrementGuardTest { } } - @Nested - inner class `skip the check` { + @Nested inner class + `skip the check` { @Test fun `on a local build that does not publish`() { @@ -115,8 +115,8 @@ class IncrementGuardTest { } } - @Nested - inner class `compare against the base branch` { + @Nested inner class + `compare against the base branch` { @Test fun `inside the Version Guard workflow with a base branch`() { @@ -125,8 +125,8 @@ class IncrementGuardTest { } } - @Nested - inner class `not compare against the base branch` { + @Nested inner class + `not compare against the base branch` { @Test fun `outside the Version Guard workflow`() { @@ -142,8 +142,8 @@ class IncrementGuardTest { } } - @Nested - inner class `detect a Maven Local publish` { + @Nested inner class + `detect a Maven Local publish` { @Test fun `for the task's own project`() { @@ -180,8 +180,8 @@ class IncrementGuardTest { } } - @Nested - inner class `make 'checkVersionIncrement' a dependency of` { + @Nested inner class + `make 'checkVersionIncrement' a dependency of` { @Test fun `every Maven Local publishing task`() { @@ -195,8 +195,8 @@ class IncrementGuardTest { } } - @Nested - inner class `keep 'checkVersionIncrement' out of` { + @Nested inner class + `keep 'checkVersionIncrement' out of` { @Test fun `the 'check' lifecycle task`() { @@ -211,8 +211,8 @@ class IncrementGuardTest { } } - @Nested - inner class `configure 'checkVersionIncrement' with` { + @Nested inner class + `configure 'checkVersionIncrement' with` { @Test fun `the root directory of the build`() { @@ -321,8 +321,8 @@ class IncrementGuardTest { } } - @Nested - inner class `make 'checkVersionIncrement' fail` { + @Nested inner class + `make 'checkVersionIncrement' fail` { @TempDir lateinit var repoDir: File @@ -405,8 +405,8 @@ class IncrementGuardTest { } } - @Nested - inner class `make 'checkVersionIncrement' pass` { + @Nested inner class + `make 'checkVersionIncrement' pass` { @TempDir lateinit var repoDir: File diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtoExtsSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtoExtsSpec.kt new file mode 100644 index 000000000..3859d28ab --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/ProtoExtsSpec.kt @@ -0,0 +1,55 @@ +/* + * 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.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import org.gradle.api.Project +import org.gradle.api.plugins.BasePlugin +import org.gradle.api.tasks.bundling.Jar +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Proto extensions should") +internal class ProtoExtsSpec { + + private val project: Project = ProjectBuilder.builder().build().also { + it.pluginManager.apply(BasePlugin::class.java) + } + + private val jar: Jar = project.tasks.register("protoJar", Jar::class.java).get() + + @Test + fun `make a JAR depend on generateProto registered later, without creating it`() { + jar.dependOnGenerateProto() + var created = false + project.tasks.register("generateProto") { created = true } + + created shouldBe false + jar.dependencyNames() shouldContainExactly listOf("generateProto") + } + + @Test + fun `add no dependency to a JAR in a project without generateProto`() { + jar.dependOnGenerateProto() + + jar.dependencyNames().shouldBeEmpty() + } + + private fun Jar.dependencyNames(): List = + taskDependencies.getDependencies(this).map { it.name } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationSbomIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationSbomIgTest.kt index d64306ebb..60e0ff744 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationSbomIgTest.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationSbomIgTest.kt @@ -30,6 +30,7 @@ import io.kotest.matchers.file.shouldNotExist import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import java.io.File import java.util.jar.JarOutputStream @@ -199,6 +200,73 @@ internal class PublicationSbomIgTest { packagingOf("osgi", osgiBundle) shouldBe "bundle" } + /** + * Until the packaging of a POM is set, reading it creates the tasks that produce + * the artifacts of the publication. A standard publication sets its packaging + * as it is created, so a build that does not publish creates none of them. + * + * The init script reports the tasks created before the task graph is ready, which + * excludes those that the fixture creates later, to report the SBOMs. + */ + @Test + fun `create no archive task of a standard publication in a build that does not publish`() { + val initScript = file("report-created-tasks.gradle") + initScript.writeText( + """ + def graphReady = false + gradle.taskGraph.whenReady { graphReady = true } + allprojects { + tasks.configureEach { task -> + if (!graphReady) println("$createdPrefix${'$'}{task.path}") + } + } + """.trimIndent() + ) + val result = GradleRunner.create() + .withProjectDir(projectDir) + .withArguments("help", "--init-script", initScript.path, "--offline", "--stacktrace") + .build() + + val created = result.output.lines() + .filter { it.startsWith(createdPrefix) } + .map { it.removePrefix(createdPrefix) } + created shouldContain ":help" + created shouldNotContainAnyOf listOf("api", "impl").flatMap { module -> + listOf("jar", "sourcesJar", "javadocJar", "htmlDocsJar").map { ":$module:$it" } + } + } + + /** + * A standard publication declares the `jar` packaging before its artifacts are known, + * so writing its POM fails if the build gives the `jar` task a classifier, + * rather than publish a POM that names no main artifact. + * + * The case checks `StandardJavaPublicationHandler`, but shares the fixture of this + * class, whose `api` module is a standard publication with an SBOM. + * + * The build of the fixture has written the POM of `api` already. So the case also shows + * that the result of the check is an input of the task: without it, the task would be + * up to date, and skip the check. + */ + @Test + fun `fail to write the POM of a standard publication whose JAR is classified`() { + val script = file("api/build.gradle.kts") + val original = script.readText() + script.appendText("\ntasks.jar { archiveClassifier.set(\"plain\") }\n") + try { + val result = GradleRunner.create() + .withProjectDir(projectDir) + .withArguments(":api:${pomTaskOf("mavenJava")}", "--offline", "--stacktrace") + .buildAndFail() + + result.output shouldContain + "The POM of the `mavenJava` publication of `:api` declares the `jar`" + + " packaging, but the publication has no main artifact." + } finally { + script.writeText(original) + } + } + @Test fun `describe runtime dependencies only`() { val names = implSbom().packageNames() @@ -908,6 +976,9 @@ internal class PublicationSbomIgTest { /** Marks the lines in which the fixture reports the SBOMs of each publication. */ const val reportPrefix = "SBOM-REPORT " + /** Marks the lines in which an init script reports the tasks created. */ + const val createdPrefix = "CREATED-TASK " + /** The publication of a thin JAR with a hand-written POM. */ const val pluginJar = "pluginJar" diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateIgTest.kt new file mode 100644 index 000000000..cc627509a --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateIgTest.kt @@ -0,0 +1,531 @@ +/* + * 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.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.io.File +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.URI +import java.util.Base64 +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.io.TempDir + +/** + * Runs `publicationState` in a build that really publishes to a loopback registry. + * + * `api` and `backend` are the modules of `spinePublishing`. The [registry] has two remote + * repositories: one like Artifact Registry, which serves anyone, and one like GitHub + * Packages, which serves nothing without the token and redirects the downloads of files + * to storage of its own. `api` publishes to both and to a local repository, `backend` + * to GitHub Packages only, and `docs` to a local repository only. No local repository + * may be searched. + * + * The build publishes once for the whole suite, after the check of the unpublished + * version. Each test then looks at a copy of what the build published, so that it may + * take files away from it. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Timeout(value = 5, unit = TimeUnit.MINUTES) +@DisplayName("`publicationState` task should") +internal class PublicationStateIgTest { + + /** + * The directory of the suite, holding the project and the repositories. + */ + private lateinit var workDir: File + + private lateinit var registry: Registry + + /** + * The repositories as the build published them. + */ + private lateinit var published: File + + /** + * The state written before anything was published, and the output of that build. + */ + private lateinit var unpublished: Pair + + private val copies = AtomicInteger() + + @BeforeAll + fun publish(@TempDir dir: File) { + workDir = dir + registry = Registry(root = workDir.resolve("registry").apply { mkdirs() }, token = token) + writeBuild() + val output = runner().build().output + unpublished = stateFile.readText().trim() to output + runner(task = "publish").build() + published = registry.root + } + + @BeforeEach + fun serveCopy() { + val copy = workDir.resolve("served-${copies.incrementAndGet()}") + published.copyRecursively(copy) + registry.root = copy + registry.leakedTokens.set(0) + } + + @AfterAll + fun stopRegistry() { + if (::registry.isInitialized) { + registry.close() + } + } + + @Test + fun `report the version unpublished before anything is published`() { + val (state, output) = unpublished + + state shouldBe "unpublished" + output shouldContain "`$api:$version` is not published." + } + + @Test + fun `report the version published when its SBOMs name the commit being published`() { + val output = runner().build().output + + stateFile.readText().trim() shouldBe "published" + output shouldContain "`$backend:$version` is published from `$commit`." + registry.leakedTokens.get() shouldBe 0 + } + + @Test + fun `not look in local repositories`() { + val local = project.resolve("local-repo") + val aside = workDir.resolve("local-repo-aside") + check(local.renameTo(aside)) + try { + val output = runner().build().output + + stateFile.readText().trim() shouldBe "published" + output shouldNotContain docs + } finally { + check(aside.renameTo(local)) + } + } + + @Nested inner class + `fail when missing from a repository` { + + @Test + fun `a classified artifact`() { + val jar = "spine-backend-$version-sources.jar" + registry.root.resolve("github/sample/$backend/$version/$jar").delete() + + val output = runner().buildAndFail().output + + output shouldContain "The version is partially published" + output shouldContain "`$jar` in `${registry.url}/github/sample`" + } + + @Test + fun `the artifact of the component`() { + val jar = "spine-api-$version.jar" + registry.root.resolve("ar/snapshots/$api/$version/$jar").delete() + + val output = runner().buildAndFail().output + + output shouldContain "`$jar` in `${registry.url}/ar/snapshots`" + } + + @Test + fun `the SBOM`() { + val sbom = "spine-api-$version.spdx.json" + registry.root.resolve("ar/snapshots/$api/$version/$sbom").delete() + + val output = runner().buildAndFail().output + + output shouldContain "`$sbom` in `${registry.url}/ar/snapshots`" + } + + @Test + fun `the module metadata`() { + val module = "spine-api-$version.module" + registry.root.resolve("ar/snapshots/$api/$version/$module").delete() + + val output = runner().buildAndFail().output + + output shouldContain "`$module` in `${registry.url}/ar/snapshots`" + output shouldNotContain "pruned" + } + + @Test + fun `a whole publication, telling that GitHub Packages may have pruned it`() { + registry.root.resolve("github/sample/$api").deleteRecursively() + + val output = runner().buildAndFail().output + + output shouldContain "The version is partially published" + output shouldContain "`$api:$version`: the version in " + + "`${registry.url}/github/sample/$api/maven-metadata.xml`" + output shouldContain "it was pruned from there" + } + } + + @Test + fun `fail when the version is published from another commit`() { + val output = runner(commit = otherCommit).buildAndFail().output + + output shouldContain "The version is published from another commit" + output shouldContain "`$commit`" + } + + @Test + fun `fail when a copy of an SBOM in one repository names another commit`() { + rewriteSbom(repository = "github/sample", path = api, revision = otherCommit) + + val output = runner().buildAndFail().output + + output shouldContain "The version is published from another commit: `$otherCommit`" + } + + @Test + fun `fail when a copy of an SBOM names no commit`() { + rewriteSbom(repository = "github/sample", path = backend, revision = "unknown") + + val output = runner().buildAndFail().output + + output shouldContain "a published SBOM does not name the commit" + } + + @Test + fun `fail when the commit being published is unknown`() { + val output = runner(commit = null).buildAndFail().output + + output shouldContain "this build cannot tell which commit it publishes" + output shouldContain "`GITHUB_SHA` is not set" + } + + @Test + fun `fail when there is no token to read GitHub Packages with`() { + val output = runner(token = null).buildAndFail().output + + output shouldContain "requires a token in the `GITHUB_TOKEN` environment variable" + } + + @Test + fun `fail when GitHub Packages refuses the token`() { + val output = runner(token = "wrong-token").buildAndFail().output + + output shouldContain "the server answered with `401" + registry.leakedTokens.get() shouldBe 0 + } + + @Test + fun `look the state up again when reusing the configuration cache`() { + val publishedCopy = registry.root + registry.root = workDir.resolve("nothing").apply { mkdirs() } + runner().withArguments(runner().arguments + "--configuration-cache").build() + stateFile.readText().trim() shouldBe "unpublished" + registry.root = publishedCopy + + val output = runner() + .withArguments(runner().arguments + "--configuration-cache") + .build() + .output + + output shouldContain "Reusing configuration cache." + stateFile.readText().trim() shouldBe "published" + } + + /** + * Rewrites the copy of the SBOM of the artifact at the given [path] in the given + * [repository] of the registry, so that it names the given [revision] instead of + * the commit the build published. + */ + private fun rewriteSbom(repository: String, path: String, revision: String) { + val artifactId = path.substringAfterLast('/') + val sbom = registry.root.resolve( + "$repository/$path/$version/$artifactId-$version.spdx.json" + ) + val text = sbom.readText() + check(commit in text) { "The SBOM `$sbom` does not name the commit `$commit`." } + sbom.writeText(text.replace(commit, revision)) + } + + private val project: File + get() = workDir.resolve("project") + + private val stateFile: File + get() = project.resolve("build/publication/state.txt") + + /** + * Creates a runner of the given [task], with the given [commit] as `GITHUB_SHA` and + * the given [token] as `GITHUB_TOKEN`. A `null` value leaves the variable unset, + * as outside GitHub Actions. + */ + private fun runner( + task: String = ":${PublicationState.taskName}", + commit: String? = Companion.commit, + token: String? = Companion.token + ): GradleRunner { + val environment = System.getenv() - ciVariables - "GITHUB_SHA" - "GITHUB_TOKEN" + + listOfNotNull( + commit?.let { "GITHUB_SHA" to it }, + token?.let { "GITHUB_TOKEN" to it } + ) + return GradleRunner.create() + .withProjectDir(project) + .withEnvironment(environment) + .withArguments(task, "--stacktrace") + } + + private fun writeBuild() { + listOf("api", "backend", "docs").forEach { module -> + project.resolve("$module/src/main/java/sample/$module/Stub.java").apply { + parentFile.mkdirs() + writeText("package sample.$module;\npublic class Stub {}\n") + } + } + project.resolve("settings.gradle.kts").writeText( + """ + rootProject.name = "state-sample" + include("api", "backend", "docs") + """.trimIndent() + ) + project.resolve("build.gradle.kts").writeText( + """ + buildscript { + dependencies { + classpath(files( + ${buildSrcClasspath()} + )) + } + } + + import io.spine.gradle.publish.spinePublishing + + allprojects { + group = "io.spine.sample" + version = "$version" + } + + subprojects { + apply(plugin = "java-library") + apply(plugin = "maven-publish") + $generatorStub + } + + spinePublishing { + modules = setOf("api", "backend") + destinations = emptySet() + } + + fun PublishingExtension.toRegistry() = repositories.maven { + name = "CloudArtifactRegistry-snapshots" + url = uri("${registry.url}/ar/snapshots") + } + + fun PublishingExtension.toGitHub() = repositories.maven { + name = "GitHub-Packages-snapshots" + url = uri("${registry.url}/github/sample") + credentials { + username = "spine" + password = "$token" + } + } + + fun PublishingExtension.toLocal() = repositories.maven { + name = "local" + url = uri("${'$'}{rootDir}/local-repo") + } + + project("api").the().apply { + toRegistry() + toGitHub() + toLocal() + } + + // GitHub Packages only, so that the SBOM is read from there, through a redirect. + project("backend").the().apply { + toGitHub() + } + + project("docs").the().apply { + publications { + create("docs") { + from(project("docs").components["java"]) + } + } + toLocal() + } + """.trimIndent() + ) + } + + /** + * 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 { + + const val version = "2.0.0-SNAPSHOT.575" + const val commit = "35054d78dfce2a0ead2ca79347ad57f74508977c" + const val otherCommit = "2b98e1801a0b3c4d5e6f708192a3b4c5d6e7f809" + const val token = "test-token" + + /** The artifacts of `api` and `backend`, named with the default prefix. */ + const val api = "io/spine/sample/spine-api" + const val backend = "io/spine/sample/spine-backend" + + /** The artifact of `docs`, named after the project by `maven-publish`. */ + const val docs = "io/spine/sample/docs" + + /** + * A task standing in for the Dokka one that `javadocJar` of `spinePublishing` + * depends on; the fixture does not apply Dokka. + */ + const val generatorStub = """tasks.register("dokkaGeneratePublicationJavadoc")""" + } +} + +/** + * A loopback Maven registry storing the uploaded files under its [root]. + * + * The files under `/ar` are served to anyone. Like GitHub Packages, the registry serves + * the files under `/github` only to a request carrying the [token], and redirects + * the download of any of them but `maven-metadata.xml` to storage of another origin. + * A token sent to `/ar` or to the storage counts as a [leaked one][leakedTokens]. + */ +private class Registry(@Volatile var root: File, private val token: String) : AutoCloseable { + + private val server = loopbackServer() + + /** + * The storage that the downloads from `/github` are redirected to. + */ + private val storage = loopbackServer() + + /** + * The number of requests that carried a token to where it does not belong. + */ + val leakedTokens = AtomicInteger() + + /** + * The URL of this registry. + */ + val url: String + get() = server.url() + + init { + server.createContext("/") { exchange -> exchange.use { respond(it) } } + storage.createContext("/") { exchange -> + exchange.use { + it.authorization()?.let { leakedTokens.incrementAndGet() } + serve(it, root.resolve(it.requestURI.path.removePrefix("/"))) + } + } + server.start() + storage.start() + } + + private fun respond(exchange: HttpExchange) { + val path = exchange.requestURI.path + val authorization = exchange.authorization() + val file = root.resolve(path.removePrefix("/")) + val gitHub = path.startsWith("/github/") + if (!gitHub && authorization != null) { + leakedTokens.incrementAndGet() + } + when { + gitHub && !authorization.carriesToken() -> { + exchange.requestBody.readBytes() + exchange.responseHeaders.add("WWW-Authenticate", "Basic realm=\"github\"") + exchange.sendResponseHeaders(401, -1) + } + exchange.requestMethod == "PUT" -> { + file.parentFile.mkdirs() + file.writeBytes(exchange.requestBody.readBytes()) + exchange.sendResponseHeaders(201, -1) + } + exchange.requestMethod == "HEAD" -> + exchange.sendResponseHeaders(if (file.isFile) 200 else 404, -1) + gitHub && file.isFile && file.name != MavenMetadata.FILE_NAME -> { + exchange.responseHeaders.add("Location", "${storage.url()}$path") + exchange.sendResponseHeaders(302, -1) + } + else -> serve(exchange, file) + } + } + + private fun serve(exchange: HttpExchange, file: File) { + if (!file.isFile) { + exchange.sendResponseHeaders(404, -1) + return + } + val bytes = file.readBytes() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.write(bytes) + } + + private fun String?.carriesToken(): Boolean { + val credentials = this?.takeIf { it.startsWith("Basic ") }?.removePrefix("Basic ") + ?: return false + val decoded = try { + String(Base64.getDecoder().decode(credentials)) + } catch (_: IllegalArgumentException) { + return false + } + return decoded.endsWith(":$token") + } + + override fun close() { + server.stop(0) + storage.stop(0) + } +} + +private fun loopbackServer(): HttpServer = + HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + +/** + * Obtains the URL of this server. + * + * The loopback address may be an IPv6 one, which [URI] encloses in square brackets. + */ +private fun HttpServer.url(): String = + URI("http", null, address.hostString, address.port, null, null, null).toString() + +private fun HttpExchange.authorization(): String? = requestHeaders.getFirst("Authorization") diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateSpec.kt new file mode 100644 index 000000000..844d24555 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateSpec.kt @@ -0,0 +1,350 @@ +/* + * 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.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.spine.gradle.publish.PublicationState.Finding +import io.spine.gradle.publish.PublicationState.Holding +import io.spine.gradle.publish.PublicationState.Presence +import io.spine.gradle.publish.PublicationState.Verdict +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("`PublicationState` should") +internal class PublicationStateSpec { + + @Nested inner class + `find the version unpublished` { + + @Test + fun `when there is no publication to look up`() { + Verdict.of(emptyList(), commit) shouldBe Verdict.UNPUBLISHED + } + + @Test + fun `when no publication is published anywhere`() { + Verdict.of(listOf(absent(api), absent(backend)), commit) shouldBe + Verdict.UNPUBLISHED + } + } + + @Test + fun `find the version published when every SBOM names the commit being published`() { + val findings = listOf(complete(api, commit), complete(backend, commit), marker()) + + Verdict.of(findings, commit) shouldBe Verdict.PUBLISHED + } + + @Nested inner class + `find the version partially published` { + + @Test + fun `when some publications are not published`() { + Verdict.of(listOf(complete(api, commit), absent(backend)), commit) shouldBe + Verdict.PARTIAL + } + + @Test + fun `when a publication is partially published`() { + Verdict.of(listOf(complete(api, commit), partial(backend)), commit) shouldBe + Verdict.PARTIAL + } + + @Test + fun `even when the published ones name another commit`() { + val findings = listOf(complete(api, otherCommit), absent(backend)) + + Verdict.of(findings, commit) shouldBe Verdict.PARTIAL + } + + @Test + fun `even when the commit being published is unknown`() { + val findings = listOf(complete(api, commit), partial(backend)) + + Verdict.of(findings, commit = null) shouldBe Verdict.PARTIAL + } + } + + @Nested inner class + `find a collision` { + + @Test + fun `when an SBOM names another commit`() { + Verdict.of(listOf(complete(api, otherCommit)), commit) shouldBe Verdict.COLLISION + } + + @Test + fun `when SBOMs name both the commit being published and another one`() { + val findings = listOf(complete(api, commit), complete(backend, otherCommit)) + + Verdict.of(findings, commit) shouldBe Verdict.COLLISION + } + + @Test + fun `when the copies of one SBOM in its destinations name different commits`() { + val findings = listOf( + Finding( + path = api, + version = version, + presence = Presence.COMPLETE, + commits = setOf(commit, otherCommit) + ) + ) + + Verdict.of(findings, commit) shouldBe Verdict.COLLISION + } + } + + @Nested inner class + `leave the publisher unconfirmed` { + + @Test + fun `when no published SBOM names a commit`() { + Verdict.of(listOf(marker()), commit) shouldBe Verdict.UNCONFIRMED + } + + @Test + fun `when the commit being published is unknown`() { + Verdict.of(listOf(complete(api, commit)), commit = null) shouldBe + Verdict.UNCONFIRMED + } + + @Test + fun `when a copy of an SBOM names no commit, beside a confirmed publication`() { + val unknownRevision = Finding( + path = backend, + version = version, + presence = Presence.COMPLETE, + unconfirmed = true + ) + val findings = listOf(complete(api, commit), unknownRevision) + + Verdict.of(findings, commit) shouldBe Verdict.UNCONFIRMED + } + } + + @Nested inner class + `find a publication` { + + @Test + fun `absent when no repository has anything of it`() { + val holdings = listOf(holding(registry), holding(gitHub)) + + presence(holdings) shouldBe Presence.ABSENT + } + + @Test + fun `complete when every repository lists it and has every file`() { + val holdings = listOf( + holding(registry, listed = true, present = names + module), + holding(gitHub, listed = true, present = names + module) + ) + + presence(holdings) shouldBe Presence.COMPLETE + } + + @Test + fun `complete without module metadata in any repository`() { + val holdings = listOf( + holding(registry, listed = true, present = names), + holding(gitHub, listed = true, present = names) + ) + + presence(holdings) shouldBe Presence.COMPLETE + } + + @Test + fun `partial when a repository has nothing of it`() { + val holdings = listOf(holding(registry, listed = true, present = names), holding(gitHub)) + + presence(holdings) shouldBe Presence.PARTIAL + } + + @Test + fun `partial when a repository lacks a file`() { + val holdings = listOf( + holding(registry, listed = true, present = names), + holding(gitHub, listed = true, present = names - sources) + ) + + presence(holdings) shouldBe Presence.PARTIAL + } + + @Test + fun `partial when only some repositories have the module metadata`() { + val holdings = listOf( + holding(registry, listed = true, present = names + module), + holding(gitHub, listed = true, present = names) + ) + + presence(holdings) shouldBe Presence.PARTIAL + } + + @Test + fun `partial when a repository has its files but does not list it`() { + val holdings = listOf(holding(registry, listed = false, present = names)) + + presence(holdings) shouldBe Presence.PARTIAL + } + + @Test + fun `partial when a repository lists it but has none of its files`() { + val holdings = listOf(holding(registry, listed = true)) + + presence(holdings) shouldBe Presence.PARTIAL + } + } + + @Nested inner class + `tell what is missing from` { + + @Test + fun `each repository that lacks anything`() { + val holdings = listOf( + holding(registry, listed = true, present = names + module), + holding(gitHub, listed = false, present = names - sources) + ) + + val gaps = PublicationState.gapsOf( + holdings = holdings, + path = api, + names = names, + module = module + ) + + gaps.keys shouldContainExactly setOf(gitHub) + gaps.getValue(gitHub) shouldContainExactly listOf( + "the version in `$gitHub/$api/maven-metadata.xml`", + "`$sources` in `$gitHub`", + "`$module` in `$gitHub`" + ) + } + + @Test + fun `no repository when the publication has no module metadata anywhere`() { + val holdings = listOf( + holding(registry, listed = true, present = names), + holding(gitHub, listed = true, present = names) + ) + + val gaps = PublicationState.gapsOf( + holdings = holdings, + path = api, + names = names, + module = module + ) + + gaps.keys.shouldBeEmpty() + } + } + + @Nested inner class + `read the revisions of an SBOM` { + + @Test + fun `of every module of the build`() { + val text = """ + { "packages": [ + { "sourceInfo": "git+https://github.com/o/r@$commit#core[:core]" }, + { "sourceInfo": "git+https://github.com/o/r@$commit#client[:client]" } + ] } + """.trimIndent() + + PublicationState.sbomRevisions(text) shouldContainExactlyInAnyOrder setOf(commit) + } + + @Test + fun `from a URI with a user`() { + val text = """"sourceInfo": "git+ssh://git@github.com/o/r@$otherCommit#m[:m]"""" + + PublicationState.sbomRevisions(text) shouldContainExactlyInAnyOrder + setOf(otherCommit) + } + + @Test + fun `of SHA-256 commits`() { + val sha256 = "ab".repeat(32) + val text = """"sourceInfo": "git+https://github.com/o/r@$sha256#m[:m]"""" + + PublicationState.sbomRevisions(text) shouldContainExactlyInAnyOrder setOf(sha256) + } + + @Test + fun `ignoring a revision that is not a full commit hash`() { + val text = """ + { "sourceInfo": "git+https://github.com/o/r@unknown#m[:m]" } + { "sourceInfo": "git+https://github.com/o/r@35054d78#m[:m]" } + { "sourceInfo": "git+https://github.com/o/r@${commit}0#m[:m]" } + """.trimIndent() + + PublicationState.sbomRevisions(text).shouldBeEmpty() + } + } + + private companion object { + + const val version = "2.0.0-SNAPSHOT.575" + const val api = "io/spine/sample/api" + const val backend = "io/spine/sample/backend" + const val commit = "35054d78dfce2a0ead2ca79347ad57f74508977c" + const val otherCommit = "2b98e1801a0b3c4d5e6f708192a3b4c5d6e7f809" + + const val registry = "https://europe-maven.pkg.dev/spine-event-engine/snapshots" + const val gitHub = "https://maven.pkg.github.com/SpineEventEngine/sample" + const val sources = "api-$version-sources.jar" + const val module = "api-$version.module" + val names = listOf("api-$version.jar", sources, "api-$version.pom") + + fun absent(path: String) = + Finding(path = path, version = version, presence = Presence.ABSENT) + + fun partial(path: String) = + Finding(path = path, version = version, presence = Presence.PARTIAL) + + fun complete(path: String, sbomCommit: String) = + Finding( + path = path, + version = version, + presence = Presence.COMPLETE, + commits = setOf(sbomCommit) + ) + + /** + * The marker of a Gradle plugin, which is published with no SBOM. + */ + fun marker() = Finding( + path = "io/spine/sample/io.spine.sample.gradle.plugin", + version = version, + presence = Presence.COMPLETE + ) + + fun holding( + destination: String, + listed: Boolean = false, + present: Collection = emptyList() + ) = Holding(destination = destination, listed = listed, present = present.toSet()) + + /** + * Decides how much of the publication with the [names] and the [module] metadata + * the given [holdings] have. + */ + fun presence(holdings: List) = + PublicationState.presenceOf(holdings = holdings, names = names, module = module) + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/RemoteFileSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/RemoteFileSpec.kt new file mode 100644 index 000000000..ce6131c02 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/RemoteFileSpec.kt @@ -0,0 +1,405 @@ +/* + * 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.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.comparables.shouldBeGreaterThanOrEqualTo +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.io.File +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.URI +import java.net.URL +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.time.Duration.Companion.seconds +import kotlin.time.measureTime +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +@DisplayName("`RemoteFile` should") +internal class RemoteFileSpec { + + private val server = Server() + + /** + * Another origin, such as the storage that GitHub Packages redirects a download to. + */ + private val storage = Server() + + @AfterEach + fun stopServers() { + server.close() + storage.close() + } + + @Nested inner class + `read a file` { + + @Test + fun `answered with its text`() { + server.answer(200) + + RemoteFile.readText(server.url("file.txt")) shouldBe body + } + + @Test + fun `telling at once that there is no file`() { + server.answer(404) + + RemoteFile.readText(server.url("file.txt")).shouldBeNull() + server.requests.size shouldBe 1 + } + + @Test + fun `retrying after a server error`() { + server.answer(503, 200) + + RemoteFile.readText(server.url("file.txt")) shouldBe body + server.requests.size shouldBe 2 + } + + @Test + fun `retrying after throttling`() { + server.answer(429, 200) + + RemoteFile.readText(server.url("file.txt")) shouldBe body + server.requests.size shouldBe 2 + } + + @Test + fun `retrying after a rate limit`() { + server.rateLimit(then = 200) + + RemoteFile.readText(server.url("file.txt")) shouldBe body + server.requests.size shouldBe 2 + } + + @Test + fun `waiting before a retry as long as the server asks`() { + server.throttle(retryAfter = "2", then = 200) + + val elapsed = measureTime { + RemoteFile.readText(server.url("file.txt")) shouldBe body + } + + elapsed shouldBeGreaterThanOrEqualTo 2.seconds + server.requests.size shouldBe 2 + } + + @Test + fun `failing after the last attempt`() { + server.answer(500) + + shouldThrow { RemoteFile.readText(server.url("file.txt")) } + server.requests.size shouldBe 3 + } + + @Test + fun `not retrying a request the server refuses`() { + server.answer(403) + + val error = shouldThrow { + RemoteFile.readText(server.url("file.txt")) + } + + error.message shouldContain "403" + server.requests.size shouldBe 1 + } + + @Test + fun `on disk, telling that there is no such file`(@TempDir dir: File) { + val url = dir.resolve("missing.xml").toURI().toURL() + + RemoteFile.readText(url).shouldBeNull() + } + } + + @Nested inner class + `follow a redirect` { + + @Test + fun `to another origin, without the authorization`() { + server.redirect(storage.url("blob/file.txt").toString()) + storage.answer(200) + + RemoteFile.readText(server.url("file.txt"), authorization) shouldBe body + + server.requests.map { it.authorization } shouldContainExactly listOf(authorization) + storage.requests.map { it.authorization } shouldContainExactly listOf(null) + } + + @Test + fun `within the origin, with the authorization`() { + server.redirect("/moved/file.txt", then = 200) + + RemoteFile.readText(server.url("file.txt"), authorization) shouldBe body + + server.requests.map { it.path } shouldContainExactly + listOf("/file.txt", "/moved/file.txt") + server.requests.map { it.authorization } shouldContainExactly + listOf(authorization, authorization) + } + + @Test + fun `only a few times`() { + server.redirect("/file.txt") + + val error = shouldThrow { + RemoteFile.readText(server.url("file.txt")) + } + + error.message shouldContain "Too many redirects" + } + + @Test + fun `only to a location given`() { + server.redirect(location = null) + + val error = shouldThrow { + RemoteFile.readText(server.url("file.txt")) + } + + error.message shouldContain "no `Location`" + } + + @Test + fun `only to an HTTP location`() { + server.redirect("file:///etc/hosts") + + val error = shouldThrow { + RemoteFile.readText(server.url("file.txt")) + } + + error.message shouldContain "may not be followed" + } + + @Test + fun `showing no query in a message`() { + server.redirect("${storage.url("blob/file.txt")}?signature=secret") + storage.answer(403) + + val error = shouldThrow { + RemoteFile.readText(server.url("file.txt")) + } + + error.message shouldNotContain "secret" + } + } + + @Nested inner class + `tell whether there is a file` { + + @Test + fun `answered with a success`() { + server.answer(200) + + RemoteFile.exists(server.url("file.txt")) shouldBe true + server.requests.map { it.method } shouldContainExactly listOf("HEAD") + } + + @Test + fun `answered with no such file`() { + server.answer(404) + + RemoteFile.exists(server.url("file.txt")) shouldBe false + } + + @Test + fun `answered with a gone file`() { + server.answer(410) + + RemoteFile.exists(server.url("file.txt")) shouldBe false + } + + @Test + fun `redirected to another origin, following without the authorization`() { + server.redirect(storage.url("blob/file.txt").toString()) + storage.answer(200) + + RemoteFile.exists(server.url("file.txt"), authorization) shouldBe true + storage.requests shouldContainExactly + listOf(Request(method = "HEAD", path = "/blob/file.txt", authorization = null)) + } + + @Test + fun `redirected to another origin that has no such file`() { + server.redirect(storage.url("blob/file.txt").toString()) + storage.answer(404) + + RemoteFile.exists(server.url("file.txt")) shouldBe false + } + + @Test + fun `redirected within the origin, following`() { + server.redirect("/moved/file.txt", then = 404) + + RemoteFile.exists(server.url("file.txt")) shouldBe false + server.requests.size shouldBe 2 + } + + @Test + fun `retrying after a request timeout`() { + server.answer(408, 200) + + RemoteFile.exists(server.url("file.txt")) shouldBe true + server.requests.size shouldBe 2 + } + + @Test + fun `not retrying a request the server refuses`() { + server.answer(401) + + shouldThrow { RemoteFile.exists(server.url("file.txt")) } + server.requests.size shouldBe 1 + } + + @Test + fun `on disk`(@TempDir dir: File) { + val file = dir.resolve("file.txt").apply { writeText(body) } + + RemoteFile.exists(file.toURI().toURL()) shouldBe true + RemoteFile.exists(dir.resolve("missing.txt").toURI().toURL()) shouldBe false + } + } + + private companion object { + const val body = "" + const val authorization = "Bearer test-token" + } +} + +/** + * A request received by a [Server]. + */ +private data class Request(val method: String, val path: String, val authorization: String?) + +/** + * A loopback HTTP server, which answers each request as told, and records the requests. + */ +private class Server : AutoCloseable { + + private val server: HttpServer = + HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + + /** + * The requests received so far. + */ + val requests = CopyOnWriteArrayList() + + @Volatile + private var respond: (HttpExchange, Int) -> Unit = { exchange, _ -> exchange.reply(404) } + + init { + server.createContext("/") { exchange -> + exchange.use { + requests.add( + Request( + method = it.requestMethod, + path = it.requestURI.path, + authorization = it.requestHeaders.getFirst("Authorization") + ) + ) + respond(it, requests.size - 1) + } + } + server.start() + } + + /** + * Obtains the URL of the given [path] at this server. + * + * The loopback address may be an IPv6 one, which [URI] encloses in square brackets. + */ + fun url(path: String): URL { + val address = server.address + return URI("http", null, address.hostString, address.port, "/$path", null, null) + .toURL() + } + + /** + * Answers each request with the next of the given [statuses], repeating the last one. + */ + fun answer(vararg statuses: Int) { + respond = { exchange, index -> exchange.reply(statuses[minOf(index, statuses.lastIndex)]) } + } + + /** + * Redirects the first request to the given [location], or with no `Location` if it is + * `null`, and answers the next ones with the given status, or with the same redirect. + */ + fun redirect(location: String?, then: Int? = null) { + respond = { exchange, index -> + if (index == 0 || then == null) { + location?.let { exchange.responseHeaders.add("Location", it) } + exchange.sendResponseHeaders(302, -1) + } else { + exchange.reply(then) + } + } + } + + /** + * Refuses the first request for exceeding a rate limit, as GitHub does with + * `403 Forbidden` and a `Retry-After` header, and answers the next ones with + * the given status. + */ + fun rateLimit(then: Int) { + respond = { exchange, index -> + if (index == 0) { + exchange.responseHeaders.add("Retry-After", "1") + exchange.sendResponseHeaders(403, -1) + } else { + exchange.reply(then) + } + } + } + + /** + * Answers the first request with `429 Too Many Requests` and the given `Retry-After` + * header, and the next ones with the given status. + */ + fun throttle(retryAfter: String, then: Int) { + respond = { exchange, index -> + if (index == 0) { + exchange.responseHeaders.add("Retry-After", retryAfter) + exchange.sendResponseHeaders(429, -1) + } else { + exchange.reply(then) + } + } + } + + override fun close() = server.stop(0) + + private fun HttpExchange.reply(status: Int) { + val bytes = if (status == 200 && requestMethod != "HEAD") { + "".toByteArray() + } else { + byteArrayOf() + } + sendResponseHeaders(status, if (bytes.isEmpty()) -1 else bytes.size.toLong()) + responseBody.write(bytes) + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/publish/SpinePublishingTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/publish/SpinePublishingTest.kt index c97f30f59..3e40704d6 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/publish/SpinePublishingTest.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/publish/SpinePublishingTest.kt @@ -40,8 +40,8 @@ class SpinePublishingTest { extension = project.spinePublishing { } } - @Nested - inner class `calculate 'artifactId'` { + @Nested inner class + `calculate 'artifactId'` { @Test fun `with default prefix for non-tool projects`() { @@ -105,8 +105,8 @@ class SpinePublishingTest { } } - @Nested - inner class `validate configuration` { + @Nested inner class + `validate configuration` { @Test fun `ensuring 'testJar' inclusions are published`() { @@ -155,8 +155,8 @@ class SpinePublishingTest { } } - @Nested - inner class `identify 'projectsToPublish'` { + @Nested inner class + `identify 'projectsToPublish'` { @Test fun `as the project itself if no modules are specified`() { @@ -193,8 +193,8 @@ class SpinePublishingTest { } } - @Nested - inner class `resolve 'publishTo' repositories` { + @Nested inner class + `resolve 'publishTo' repositories` { @Test fun `from the extension itself if destinations are initialized`() { diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt index c457bfabc..68904112b 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt @@ -28,8 +28,8 @@ class FileExtensionsTest { @TempDir lateinit var sourceRoot: File - @Nested - inner class `for Java sources` { + @Nested inner class + `for Java sources` { @Test fun `return a single FQN`() { @@ -46,8 +46,8 @@ class FileExtensionsTest { } } - @Nested - inner class `for Kotlin sources` { + @Nested inner class + `for Kotlin sources` { @Test fun `return both the declared class and the synthetic file class`() { @@ -70,8 +70,8 @@ class FileExtensionsTest { } } - @Nested - inner class `for proto-file-scoped Kotlin sources` { + @Nested inner class + `for proto-file-scoped Kotlin sources` { @Test fun `strip the two-part 'proto-kt' suffix`() { @@ -84,8 +84,8 @@ class FileExtensionsTest { } } - @Nested - inner class `for unsupported inputs` { + @Nested inner class + `for unsupported inputs` { @Test fun `return an empty list for non-source files`() { diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/license/DependencyReportOutputTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/license/DependencyReportOutputTest.kt index a6213ab91..96f756d75 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/report/license/DependencyReportOutputTest.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/license/DependencyReportOutputTest.kt @@ -74,6 +74,29 @@ class DependencyReportOutputTest { reportFile.readText() shouldBe "license report" } + /** + * In a build of one project, the report to merge is that of the root project, whose + * task is in the same task container as the merging task. + */ + @Test + fun `merge the license report of a single-project build`() { + project.pluginManager.apply(BasePlugin::class.java) + LicenseReporter.generateReportIn(project) + val sourceReport = project.layout.buildDirectory.asFile.get() + .resolve(Paths.relativePath) + .resolve(Paths.outputFilename) + sourceReport.parentFile.mkdirs() + sourceReport.writeText("license report") + + LicenseReporter.mergeAllReports(project) + + project.tasks.named("mergeAllLicenseReports").get() + .executeActions() + + val reportFile = projectDir.resolve("docs/dependencies/dependencies.md") + reportFile.readText() shouldBe "license report" + } + private fun subproject(name: String): Project { val subprojectDir = projectDir.resolve(name) subprojectDir.mkdirs() diff --git a/config b/config index 604420cd7..363fc1da3 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 604420cd7eab82b382c9a5c1d812be454552c27c +Subproject commit 363fc1da3184d001361fa9c327929eb5e05fa673 diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index a286fb3f9..cfd05eaa4 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.tools:archive:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:archive:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -832,14 +832,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:classic-codegen:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:classic-codegen:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -1675,14 +1675,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:code:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:code:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -2498,14 +2498,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:fixtures:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:fixtures:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -3436,14 +3436,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:fs:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:fs:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4259,14 +4259,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:gradle-plugin-api:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:gradle-plugin-api:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.fasterxml.jackson.core. **Name** : jackson-annotations. **Version** : 2.22. @@ -5188,14 +5188,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:gradle-plugin-api-test-fixtures:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:gradle-plugin-api-test-fixtures:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.fasterxml.jackson.core. **Name** : jackson-annotations. **Version** : 2.22. @@ -5708,14 +5708,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:gradle-root-plugin:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:gradle-root-plugin:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -6579,14 +6579,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:intellij-platform:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:intellij-platform:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : be.cyberelf.nanoxml. **Name** : nanoxml. **Version** : 2.2.3. @@ -7683,14 +7683,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:intellij-platform-java:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:intellij-platform-java:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : be.cyberelf.nanoxml. **Name** : nanoxml. **Version** : 2.2.3. @@ -9562,14 +9562,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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). -# Dependencies of `io.spine.tools:java-code:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:java-code:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -10439,14 +10439,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:jvm-tool-plugins:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:jvm-tool-plugins:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -11302,14 +11302,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:jvm-tools:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:jvm-tools:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 26.1.0. @@ -12076,14 +12076,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:kotlin-code:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:kotlin-code:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -12899,14 +12899,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:14 WET 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). -# Dependencies of `io.spine.tools:plugin-base:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:plugin-base:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -13770,14 +13770,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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). -# Dependencies of `io.spine.tools:plugin-testlib:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:plugin-testlib:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.auto.value. **Name** : auto-value-annotations. **Version** : 1.11.1. @@ -14744,14 +14744,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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). -# Dependencies of `io.spine.tools:proto-code:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:proto-code:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -15637,14 +15637,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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). -# Dependencies of `io.spine.tools:protobuf-setup-plugins:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:protobuf-setup-plugins:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -16520,14 +16520,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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). -# Dependencies of `io.spine.tools:psi:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:psi:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : be.cyberelf.nanoxml. **Name** : nanoxml. **Version** : 2.2.3. @@ -17655,14 +17655,14 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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). -# Dependencies of `io.spine.tools:psi-java:2.0.0-SNAPSHOT.428` +# Dependencies of `io.spine.tools:psi-java:2.0.0-SNAPSHOT.430` ## Runtime 1. **Group** : be.cyberelf.nanoxml. **Name** : nanoxml. **Version** : 2.2.3. @@ -19581,6 +19581,6 @@ This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Oct 21 16:58:49 WEST 2026** using +This report was generated on **Thu Oct 29 19:20:15 WET 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 f168c61d3..f2517e7e5 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 tool-base -2.0.0-SNAPSHOT.428 +2.0.0-SNAPSHOT.430 2015 @@ -200,7 +200,7 @@ all modules and does not describe the project structure per-subproject. io.spine spine-logging - 2.0.0-SNAPSHOT.427 + 2.0.0-SNAPSHOT.428 compile @@ -374,7 +374,7 @@ all modules and does not describe the project structure per-subproject. io.spine.tools dokka-extensions - 2.0.0-SNAPSHOT.10 + 2.0.0-SNAPSHOT.12 net.sourceforge.pmd diff --git a/jvm-tool-plugins/src/main/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPlugin.kt b/jvm-tool-plugins/src/main/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPlugin.kt index 8f863bbcb..f3fc1b003 100644 --- a/jvm-tool-plugins/src/main/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPlugin.kt +++ b/jvm-tool-plugins/src/main/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPlugin.kt @@ -1,27 +1,15 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.jvm.plugin @@ -176,9 +164,11 @@ public class ArtifactMetaPlugin : Plugin { tasks.named(processResources.value()).configure { it.dependsOn(task) } - afterEvaluate { - val sourcesJarTask = tasks.findByName(sourcesJar.value()) - sourcesJarTask?.dependsOn(task) + // The `sourcesJar` task may be registered after this plugin is applied, + // e.g., via `java { withSourcesJar() }`. Unlike `named(String)`, `named(Spec)` + // includes such a task, and does not create it. + tasks.named { it == sourcesJar.value() }.configureEach { + it.dependsOn(task) } // Add the output directory to the resources diff --git a/jvm-tool-plugins/src/test/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPluginSpec.kt b/jvm-tool-plugins/src/test/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPluginSpec.kt index 720189aec..aa1b76d9a 100644 --- a/jvm-tool-plugins/src/test/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPluginSpec.kt +++ b/jvm-tool-plugins/src/test/kotlin/io/spine/tools/gradle/jvm/plugin/ArtifactMetaPluginSpec.kt @@ -1,31 +1,21 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.jvm.plugin +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.comparables.shouldBeGreaterThan import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -89,7 +79,7 @@ class ArtifactMetaPluginSpec { } @Test - fun `make 'processResources' depend on the 'ariteArtifactMeta' task`() { + fun `make 'processResources' depend on the 'writeArtifactMeta' task`() { project.pluginManager.apply(pluginClass) val processResources = project.tasks.getByName(processResources.value()) @@ -101,17 +91,21 @@ class ArtifactMetaPluginSpec { } @Test - fun `make 'sourcesJar' depend on the 'ariteArtifactMeta' task`() { + fun `make 'sourcesJar' depend on the 'writeArtifactMeta' task without creating it`() { project.pluginManager.apply(pluginClass) + // Registered after the plugin is applied, as a build script does it. project.extensions.findByType(JavaPluginExtension::class.java)?.withSourcesJar() + val created = mutableListOf() + project.tasks.configureEach { created += it.name } + // `configureEach` runs at once for the tasks created earlier, so start afresh. + created.clear() + (project as DefaultProject).evaluate() + created shouldNotContain sourcesJar.value() val sourcesJar = project.tasks.getByName(sourcesJar.value()) - val hasDependency = sourcesJar.dependsOn.any { - dep -> dep.toString().contains(WriteArtifactMeta.TASK_NAME) - } - - hasDependency shouldBe true + val dependencies = sourcesJar.taskDependencies.getDependencies(sourcesJar) + dependencies.map { it.name } shouldContain WriteArtifactMeta.TASK_NAME } /** @@ -163,14 +157,14 @@ class ArtifactMetaPluginSpec { id("java") id("${ArtifactMetaPlugin.id}") } - + group = "$group" version = "$version" - + repositories { mavenCentral() } - + dependencies { implementation("${dependencies[0]}") implementation("${dependencies[1]}") @@ -207,7 +201,7 @@ class ArtifactMetaPluginSpec { content shouldContain dependencies[0] content shouldContain dependencies[1] } - + @Test fun `filter test configurations by default`(@TempDir projectDir: File) { Gradle.buildFile.under(projectDir).writeText( @@ -389,20 +383,20 @@ class ArtifactMetaPluginSpec { id("java") id("io.spine.artifact-meta") } - + group = "test.group" version = "1.0.0" - + repositories { mavenCentral() } - + artifactMeta { excludeConfigurations { $clearStatement } } - + dependencies { implementation("${dependencies[0]}") implementation("${dependencies[1]}") diff --git a/plugin-base/src/main/java/io/spine/tools/gradle/task/GradleTask.java b/plugin-base/src/main/java/io/spine/tools/gradle/task/GradleTask.java index 6d24c7324..5a06092cc 100644 --- a/plugin-base/src/main/java/io/spine/tools/gradle/task/GradleTask.java +++ b/plugin-base/src/main/java/io/spine/tools/gradle/task/GradleTask.java @@ -1,32 +1,22 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.task; import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.spine.tools.gradle.project.ProjectHierarchy; import org.jspecify.annotations.Nullable; @@ -48,19 +38,31 @@ import static com.google.common.base.Preconditions.checkState; /** - * Utility wrapper around the Gradle tasks created. + * Utility wrapper around the Gradle tasks registered. * *

Instantiated via {@link Builder}, forces the new task to be added to * the Gradle build lifecycle. + * + *

The wrapper refers to the task via its {@link TaskProvider}. Therefore, Gradle creates + * the task only if the build needs it, or if the task is accessed via {@link #getTask()}, + * {@link #getGroup()}, or {@link #getDescription()}. + * + * @deprecated Please register the task via + * {@link org.gradle.api.tasks.TaskContainer#register(String, Action) + * TaskContainer.register()}, and make it depend on the previous task, or the following + * task depend on it, via the returned {@link TaskProvider}, for example, + * {@code tasks.named("clean").configure(clean -> clean.dependsOn(newTask))}. + * The Gradle API does the same lazily without the wrapper. */ +@Deprecated @SuppressWarnings("PMD.MissingStaticMethodInNonInstantiatableClass") /* Instantiated via Builder. */ public final class GradleTask { - private final Task task; + private final TaskProvider task; private final TaskName name; private final Project project; - private GradleTask(Task task, TaskName name, Project project) { + private GradleTask(TaskProvider task, TaskName name, Project project) { this.task = task; this.name = name; this.project = project; @@ -87,13 +89,27 @@ public static GradleTask from(Task task) { checkNotNull(task); TaskName taskName = new DynamicTaskName(task.getName()); var project = task.getProject(); - return new GradleTask(task, taskName, project); + var provider = project.getTasks() + .named(task.getName()); + return new GradleTask(provider, taskName, project); } /** * Obtains the Gradle task itself. + * + *

Gradle creates the task, if it is not created yet. + * Use {@link #getTaskProvider()} to configure the task without creating it. */ public Task getTask() { + return task.get(); + } + + /** + * Obtains the provider of the Gradle task. + * + *

Unlike {@link #getTask()}, this method does not create the task. + */ + public TaskProvider getTaskProvider() { return task; } @@ -113,16 +129,20 @@ public Project getProject() { /** * Obtains the group of the task, or {@code null} if the group is not set. + * + *

Gradle creates the task, if it is not created yet. */ public @Nullable String getGroup() { - return task.getGroup(); + return getTask().getGroup(); } /** * Obtains the description of the task, or {@code null} if the description is not set. + * + *

Gradle creates the task, if it is not created yet. */ public @Nullable String getDescription() { - return task.getDescription(); + return getTask().getDescription(); } /** @@ -132,7 +152,10 @@ public Project getProject() { * this one provides {@link #applyNowTo(Project)} method instead of * {@code build(..)}. This is done to add some additional semantics to * such an irreversible action like this. + * + * @deprecated Please use the Gradle API instead, as described for {@link GradleTask}. */ + @Deprecated public static final class Builder { private final TaskName name; private final Action action; @@ -207,7 +230,9 @@ public Builder insertAfterTask(TaskName target) { * the root project and all the subprojects). * *

If a certain project does not have a task with the specified name, no action is - * performed for that project. + * performed for that project. Only the tasks registered by the time of + * the {@link #applyNowTo(Project) applyNowTo()} call are taken into account. + * These tasks are not created by the call. * *

This method does not guarantee that the task will be included in a standard * Gradle build. @@ -331,15 +356,22 @@ public Builder withOutputFiles(FileCollection outputs) { } /** - * Builds an instance of {@link GradleTask} and inserts it to the project, - * if the task with the given name does not exist, specifying the task + * Registers the task with the given name in the project, specifying the task * lifecycle according to the "before" and "after" tasks specified in the builder. * + *

The method does not create the task, nor the tasks it depends on. + * Gradle creates the new task only if the build needs it, and configures it at + * that time according to the state of this builder at the moment of this call. + * Later changes to the builder do not affect the registered task. + * * @param project * the target Gradle project - * @return the newly created Gradle task - * @throws org.gradle.api.InvalidUserDataException + * @return the newly registered Gradle task + * @throws IllegalStateException * if the task with the {@linkplain #getName() name} already exists + * @throws org.gradle.api.UnknownTaskException + * if the {@linkplain #insertBeforeTask(TaskName) following task} + * is not registered in the project */ @CanIgnoreReturnValue public GradleTask applyNowTo(Project project) { @@ -348,26 +380,27 @@ public GradleTask applyNowTo(Project project) { var log = project.getLogger(); var projectName = project.getDisplayName(); var taskName = name.name(); - log.debug("Creating task `{}` in the project `{}`.", taskName, projectName); + log.debug("Registering task `{}` in the project `{}`.", taskName, projectName); + var setup = new TaskSetup(this, previousTasksOfAllProjects(project)); + // Obtain the following task before registering the new one, so that + // a missing following task does not leave the new task registered. + @Nullable TaskProvider following = followingTask != null + ? project.getTasks() + .named(followingTask.name()) + : null; TaskProvider newTask; try { newTask = project.getTasks() - .register(taskName, Task.class, task -> { - if (group != null) { - task.setGroup(group); - } - if (description != null) { - task.setDescription(description); - } - task.doLast(action); - }); + .register(taskName, Task.class, setup); } catch (@SuppressWarnings("OverlyBroadCatchBlock") Exception e) { - log.error("Failed to create task `{}` in the project `{}`.", taskName, projectName); + log.error("Failed to register task `{}` in the project `{}`.", + taskName, projectName); throw new IllegalStateException(e); } - dependTask(newTask.get(), project); - addTaskIO(newTask.get()); - var result = new GradleTask(newTask.get(), name, project); + if (following != null) { + following.configure(task -> task.dependsOn(newTask)); + } + var result = new GradleTask(newTask, name, project); return result; } @@ -389,35 +422,91 @@ private boolean dependenciesPresent() { || previousTaskOfAllProjects != null; } - private void dependTask(Task task, Project project) { - if (previousTask != null) { - task.dependsOn(previousTask.name()); + /** + * Obtains the providers of the tasks which have the name set via + * {@linkplain #insertAfterAllTasks(TaskName) insertAfterAllTasks()} in + * the root project of the given project and all its subprojects. + * + *

The method looks only at the names of the tasks registered by the time of + * this call, so it does not create the tasks. + * + * @param project + * the project whose root project and its subprojects to look at + * @return the providers, or an empty list if the previous task of all projects + * is not set + */ + private ImmutableList> previousTasksOfAllProjects(Project project) { + if (previousTaskOfAllProjects == null) { + return ImmutableList.of(); } - if (followingTask != null) { - var existingTasks = project.getTasks(); - existingTasks.getByName(followingTask.name()) - .dependsOn(task); + var prevTaskName = previousTaskOfAllProjects.name(); + var result = ImmutableList.>builder(); + ProjectHierarchy.applyToAll(project.getRootProject(), p -> { + var tasks = p.getTasks(); + if (tasks.getNames().contains(prevTaskName)) { + result.add(tasks.named(prevTaskName)); + } + }); + return result.build(); + } + } + + /** + * Configures a task registered by {@link Builder#applyNowTo(Project)}. + * + *

Gradle runs this action when it creates the task, which may happen long after + * the builder is applied, if at all. Therefore, the action holds a copy of + * the builder state rather than a reference to the builder. + */ + private static final class TaskSetup implements Action { + + private final Action action; + private final @Nullable String group; + private final @Nullable String description; + private final @Nullable TaskName previousTask; + private final ImmutableList> previousTasksOfAllProjects; + private final @Nullable ImmutableSet inputs; + private final @Nullable Map inputProperties; + private final @Nullable ImmutableSet outputs; + + private TaskSetup(Builder builder, + ImmutableList> previousTasksOfAllProjects) { + this.action = builder.action; + this.group = builder.group; + this.description = builder.description; + this.previousTask = builder.previousTask; + this.previousTasksOfAllProjects = previousTasksOfAllProjects; + this.inputs = builder.hasInputFiles ? ImmutableSet.copyOf(builder.inputs) : null; + this.inputProperties = builder.inputProperties != null + ? new HashMap<>(builder.inputProperties) + : null; + this.outputs = builder.hasOutputFiles ? ImmutableSet.copyOf(builder.outputs) : null; + } + + @Override + public void execute(Task task) { + if (group != null) { + task.setGroup(group); } - if (previousTaskOfAllProjects != null) { - var root = project.getRootProject(); - dependTaskOnAllProjects(task, root); + if (description != null) { + task.setDescription(description); } + task.doLast(action); + dependTask(task); + addTaskIO(task); } - private void dependTaskOnAllProjects(Task task, Project rootProject) { - checkNotNull(previousTaskOfAllProjects, "Previous task of all projects is not set."); - var prevTaskName = previousTaskOfAllProjects.name(); - ProjectHierarchy.applyToAll(rootProject, project -> { - var existingTask = project.getTasks() - .findByName(prevTaskName); - if (existingTask != null) { - task.dependsOn(existingTask); - } - }); + private void dependTask(Task task) { + if (previousTask != null) { + task.dependsOn(previousTask.name()); + } + for (var previous : previousTasksOfAllProjects) { + task.dependsOn(previous); + } } private void addTaskIO(Task task) { - if (hasInputFiles) { + if (inputs != null) { task.getInputs() .files(inputs) .skipWhenEmpty() @@ -427,7 +516,7 @@ private void addTaskIO(Task task) { task.getInputs() .properties(inputProperties); } - if (hasOutputFiles) { + if (outputs != null) { task.getOutputs() .files(outputs) .optional(); diff --git a/plugin-base/src/main/java/io/spine/tools/gradle/task/JavaCompileTasks.java b/plugin-base/src/main/java/io/spine/tools/gradle/task/JavaCompileTasks.java deleted file mode 100644 index 7ad27971d..000000000 --- a/plugin-base/src/main/java/io/spine/tools/gradle/task/JavaCompileTasks.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2022, TeamDev. All rights reserved. - * - * 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.tools.gradle.task; - -import org.gradle.api.Project; -import org.gradle.api.tasks.TaskCollection; -import org.gradle.api.tasks.compile.JavaCompile; - -import java.util.Arrays; -import java.util.Iterator; - -import static com.google.common.base.Preconditions.checkNotNull; - -/** - * A collection of {@link JavaCompile} tasks in a project. - */ -public final class JavaCompileTasks implements Iterable { - - private final TaskCollection tasks; - - private JavaCompileTasks(Project project) { - var allTasks = project.getTasks(); - this.tasks = allTasks.withType(JavaCompile.class); - } - - /** - * Creates a new instance for the given project. - */ - public static JavaCompileTasks of(Project project) { - checkNotNull(project); - return new JavaCompileTasks(project); - } - - /** - * Adds specified arguments to all {@code JavaCompile} tasks of the project. - */ - public void addArgs(String... arguments) { - checkNotNull(arguments); - for (var task : tasks) { - var taskOptions = task.getOptions(); - var compilerArgs = taskOptions.getCompilerArgs(); - compilerArgs.addAll(Arrays.asList(arguments)); - } - } - - @Override - public Iterator iterator() { - return tasks.iterator(); - } -} diff --git a/plugin-base/src/main/java/io/spine/tools/gradle/task/TaskDependencies.java b/plugin-base/src/main/java/io/spine/tools/gradle/task/TaskDependencies.java index ca032151a..d3036cf25 100644 --- a/plugin-base/src/main/java/io/spine/tools/gradle/task/TaskDependencies.java +++ b/plugin-base/src/main/java/io/spine/tools/gradle/task/TaskDependencies.java @@ -1,37 +1,37 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.task; import org.gradle.api.Task; +import org.gradle.api.tasks.TaskCollection; +import org.gradle.api.tasks.TaskProvider; import static com.google.common.base.Preconditions.checkNotNull; /** * Utilities for Gradle task dependencies management. + * + *

The utilities look at the dependencies declared via + * {@link Task#dependsOn(Object...) dependsOn()} as task names, task instances, + * task providers, and task collections. The utilities do not create tasks. + * + *

The dependencies which Gradle infers from task inputs, such as source directories + * {@linkplain org.gradle.api.file.ConfigurableFileCollection#builtBy(Object...) built by} + * another task, are not taken into account. Use + * {@link Task#getTaskDependencies() getTaskDependencies().getDependencies(task)} to obtain + * all the dependencies of a task. */ public final class TaskDependencies { @@ -74,6 +74,13 @@ private static boolean dependsOn(Task task, String ontoTaskWithName) { if (anObject instanceof Task objectAsTask) { contains = contains || ontoTaskWithName.equals(objectAsTask.getName()); } + if (anObject instanceof TaskProvider provider) { + contains = contains || ontoTaskWithName.equals(provider.getName()); + } + if (anObject instanceof TaskCollection collection) { + contains = contains || collection.getNames() + .contains(ontoTaskWithName); + } } return contains; } diff --git a/plugin-base/src/main/kotlin/io/spine/tools/gradle/project/ProjectExts.kt b/plugin-base/src/main/kotlin/io/spine/tools/gradle/project/ProjectExts.kt index 179c176b1..04ec167f3 100644 --- a/plugin-base/src/main/kotlin/io/spine/tools/gradle/project/ProjectExts.kt +++ b/plugin-base/src/main/kotlin/io/spine/tools/gradle/project/ProjectExts.kt @@ -26,10 +26,12 @@ import io.spine.tools.gradle.task.TaskName import io.spine.tools.meta.MavenArtifact import java.lang.reflect.Method import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.artifacts.Configuration import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskCollection import org.gradle.api.tasks.compile.JavaCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask @@ -173,9 +175,17 @@ public fun Project.hasCompileTask(language: Language): Boolean = public fun Project.hasJavaOrKotlin(): Boolean = hasJava() || hasKotlin() /** - * Attempts to obtain the [Java compilationGradle task][SourceSet.getCompileJavaTaskName] + * Attempts to obtain the [Java compilation Gradle task][SourceSet.getCompileJavaTaskName] * for the given source set. + * + * The call creates the task if it is registered but not yet created. + * + * @see javaCompileTasksFor */ +@Deprecated( + message = "Creates the task. Please use `javaCompileTasksFor()` and configure" + + " the task via `configureEach()` instead." +) public fun Project.findJavaCompileFor(sourceSet: SourceSet): JavaCompile? { val taskName = sourceSet.compileJavaTaskName return tasks.findByName(taskName) as JavaCompile? @@ -187,8 +197,75 @@ public fun Project.findJavaCompileFor(sourceSet: SourceSet): JavaCompile? { * Typically, the task is named by a pattern: `compileKotlin`, or just * `compileKotlin` if the source set name is `"main"`. * If the task does not fit this described pattern, this method will not find it. + * + * The call creates the task if it is registered but not yet created. + * + * @see kotlinCompileTasksFor */ +@Deprecated( + message = "Creates the task. Please use `kotlinCompileTasksFor()` and configure" + + " the task via `configureEach()` instead." +) public fun Project.findKotlinCompileFor(sourceSet: SourceSet): KotlinCompilationTask<*>? { val taskName = sourceSet.getCompileTaskName(Kotlin.name) return tasks.findByName(taskName) as KotlinCompilationTask<*>? } + +/** + * Obtains a live collection holding the + * [Java compilation task][SourceSet.getCompileJavaTaskName] for the given source set. + * + * The call does not create the task. The collection includes the task even if + * it is registered after the call, and remains empty if the project has no such task. + * + * Configure the task via [configureEach][TaskCollection.configureEach], so that + * the configuration runs only if the build needs the task: + * + * ```kotlin + * val myTask = project.tasks.register("myTask") { ... } + * project.javaCompileTasksFor(sourceSet).configureEach { + * it.dependsOn(myTask) + * } + * ``` + * + * Do not call `configureEach` from a configuration action of another task, such as + * the `register` block above: Gradle forbids configuring tasks lazily from there. + */ +public fun Project.javaCompileTasksFor(sourceSet: SourceSet): TaskCollection { + val taskName = sourceSet.compileJavaTaskName + return tasks.withType(JavaCompile::class.java).named { it == taskName } +} + +/** + * Obtains a live collection holding the Kotlin compilation task for the given source set. + * + * Typically, the task is named by a pattern: `compileKotlin`, or just + * `compileKotlin` if the source set name is `"main"`. + * If the task does not fit this described pattern, the collection does not include it. + * + * The call does not create the task. The collection includes the task even if + * it is registered after the call, e.g., when the Kotlin Gradle plugin is applied later. + * The collection remains empty if the project has no such task. + * + * Configure the task via [configureEach][TaskCollection.configureEach], so that + * the configuration runs only if the build needs the task: + * + * ```kotlin + * val myTask = project.tasks.register("myTask") { ... } + * project.kotlinCompileTasksFor(sourceSet).configureEach { + * it.dependsOn(myTask) + * } + * ``` + * + * Do not call `configureEach` from a configuration action of another task, such as + * the `register` block above: Gradle forbids configuring tasks lazily from there. + * + * The function matches the task by its name only, so that it works in a project which + * does not have the Kotlin Gradle plugin on its classpath. If the plugin is available, + * narrow the collection via `withType(KotlinCompilationTask::class.java)`, which + * does not create the task either. + */ +public fun Project.kotlinCompileTasksFor(sourceSet: SourceSet): TaskCollection { + val taskName = sourceSet.getCompileTaskName(Kotlin.name) + return tasks.named { it == taskName } +} diff --git a/plugin-base/src/test/java/io/spine/tools/gradle/given/ProjectConfigurations.java b/plugin-base/src/test/java/io/spine/tools/gradle/given/ProjectConfigurations.java deleted file mode 100644 index 42bdc9656..000000000 --- a/plugin-base/src/test/java/io/spine/tools/gradle/given/ProjectConfigurations.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2022, TeamDev. All rights reserved. - * - * 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.tools.gradle.given; - -import org.gradle.api.Project; -import org.gradle.api.tasks.compile.JavaCompile; - -import java.util.List; - -import static com.google.common.collect.testing.Helpers.assertEmpty; -import static com.google.common.truth.Truth.assertThat; -import static io.spine.tools.gradle.given.ProjectTasks.acquireJavaCompileTasks; -import static io.spine.tools.gradle.given.ProjectTasks.obtainCompilerArgs; - -/** - * A test helper providing various {@code assert...} methods related to the {@link Project} - * configurations, tasks and their arguments. - */ -public class ProjectConfigurations { - - /** Prevents instantiation of this utility class. */ - private ProjectConfigurations() { - } - - /** - * Asserts that the given project has no arguments for its tasks of type {@link JavaCompile}. - * - * @param project the project to check - * @throws AssertionError if the project contains any arguments for compile tasks - */ - public static void assertCompileTasksEmpty(Project project) { - var javaCompileTasks = acquireJavaCompileTasks(project); - for (var task : javaCompileTasks) { - var compilerArgs = obtainCompilerArgs(task); - assertEmpty(compilerArgs); - } - } - - /** - * Asserts that the given project's {@link JavaCompile} tasks contain the specified arguments. - * - * @param project - * the project to check - * @param args - * the arguments - * @throws AssertionError - * if any of {@link JavaCompile} tasks of the project does not contain - * any of the specified arguments - */ - public static void assertCompileTasksContain(Project project, String... args) { - var javaCompileTasks = acquireJavaCompileTasks(project); - for (var task : javaCompileTasks) { - var compilerArgs = obtainCompilerArgs(task); - assertHasAllArgs(compilerArgs, args); - } - } - - private static void assertHasAllArgs(List compilerArgs, String[] args) { - assertThat(compilerArgs).containsAtLeastElementsIn(args); - } -} diff --git a/plugin-base/src/test/java/io/spine/tools/gradle/given/ProjectTasks.java b/plugin-base/src/test/java/io/spine/tools/gradle/given/ProjectTasks.java deleted file mode 100644 index bd7f469c8..000000000 --- a/plugin-base/src/test/java/io/spine/tools/gradle/given/ProjectTasks.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2022, TeamDev. All rights reserved. - * - * 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.tools.gradle.given; - -import org.gradle.api.Project; -import org.gradle.api.internal.GradleInternal; -import org.gradle.api.tasks.TaskCollection; -import org.gradle.api.tasks.compile.JavaCompile; - -import java.util.List; - -/** - * An extractor for the {@link Project} {@linkplain org.gradle.api.Task tasks} and their arguments. - */ -class ProjectTasks { - - /** Prevents instantiation of this utility class. */ - private ProjectTasks() { - } - - /** - * Returns a list of project's tasks of type {@link JavaCompile}. - * - *

Evaluates the project in a process to trigger all tasks' arguments modifications. - * - * @param project the project to obtain tasks from - * @return the project {@link JavaCompile} tasks - */ - static TaskCollection acquireJavaCompileTasks(Project project) { - var gradle = (GradleInternal) project.getGradle(); - var buildListenerBroadcaster = gradle.getBuildListenerBroadcaster(); - buildListenerBroadcaster.projectsEvaluated(project.getGradle()); - var tasks = project.getTasks(); - var javaCompileTasks = tasks.withType(JavaCompile.class); - return javaCompileTasks; - } - - /** - * Returns compiler arguments from the given {@code JavaCompile} task. - * - * @param task the task to obtain the arguments from - * @return the {@code List} of the compiler arguments - */ - static List obtainCompilerArgs(JavaCompile task) { - var options = task.getOptions(); - var compilerArgs = options.getCompilerArgs(); - return compilerArgs; - } -} diff --git a/plugin-base/src/test/java/io/spine/tools/gradle/task/JavaCompileTasksTest.java b/plugin-base/src/test/java/io/spine/tools/gradle/task/JavaCompileTasksTest.java deleted file mode 100644 index a20da711f..000000000 --- a/plugin-base/src/test/java/io/spine/tools/gradle/task/JavaCompileTasksTest.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * 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 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.tools.gradle.task; - -import io.spine.tools.gradle.given.StubProject; -import org.gradle.api.Project; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static io.spine.tools.gradle.given.ProjectConfigurations.assertCompileTasksContain; -import static io.spine.tools.gradle.given.ProjectConfigurations.assertCompileTasksEmpty; -import static com.google.common.truth.Truth.assertThat; - -@DisplayName("`JavaCompileTasks` should") -class JavaCompileTasksTest { - - private Project project; - - @BeforeEach - void createProject() { - project = StubProject.createFor(getClass()).get(); - } - - @Test - @DisplayName("add arguments to Java compile tasks") - void someArgs() { - var firstArg = "firstArg"; - var secondArg = "secondArg"; - JavaCompileTasks.of(project).addArgs(firstArg, secondArg); - assertCompileTasksContain(project, firstArg, secondArg); - } - - @Test - @DisplayName("not add arguments if none is specified") - void noArgs() { - JavaCompileTasks.of(project).addArgs(); - assertCompileTasksEmpty(project); - } - - @Test - @DisplayName("iterate over Java compile tasks") - void iterable() { - var tasks = JavaCompileTasks.of(project); - var count = 0; - for (var task : tasks) { - count++; - } - assertThat(count).isGreaterThan(0); - } -} diff --git a/plugin-base/src/test/kotlin/io/spine/tools/gradle/project/ProjectExtsSpec.kt b/plugin-base/src/test/kotlin/io/spine/tools/gradle/project/ProjectExtsSpec.kt index fed219f24..2b835a9d3 100644 --- a/plugin-base/src/test/kotlin/io/spine/tools/gradle/project/ProjectExtsSpec.kt +++ b/plugin-base/src/test/kotlin/io/spine/tools/gradle/project/ProjectExtsSpec.kt @@ -15,6 +15,7 @@ package io.spine.tools.gradle.project import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -156,6 +157,7 @@ class ProjectExtsSpec { } @Test + @Suppress("DEPRECATION") // Covers the deprecated functions until they are removed. fun `find compile tasks for a source set`() { val mainSet = project.sourceSet(main) @@ -166,6 +168,59 @@ class ProjectExtsSpec { } } + @Nested inner class + `obtain compile tasks for a source set lazily` { + + private val created = mutableListOf() + + @BeforeEach + fun recordCreatedTasks() { + project.tasks.configureEach { created += it.name } + // `configureEach` runs at once for the tasks created earlier, so start afresh. + created.clear() + } + + @Test + fun `matching the task of the given source set`() { + project.let { + it.javaCompileTasksFor(it.sourceSet(main)).names shouldContainExactly + setOf("compileJava") + it.javaCompileTasksFor(it.sourceSet(test)).names shouldContainExactly + setOf("compileTestJava") + } + } + + @Test + fun `including a task registered later`() { + val kotlinCompile = project.kotlinCompileTasksFor(project.sourceSet(main)) + kotlinCompile.names.shouldBeEmpty() + + // No Kotlin plugin is applied, so register a task with the expected name. + project.tasks.register("compileKotlin") + + kotlinCompile.names shouldContainExactly setOf("compileKotlin") + } + + @Test + fun `allowing to make the tasks depend on another task without creating any`() { + val mainSet = project.sourceSet(main) + project.tasks.register("compileKotlin") + val generateCode = project.tasks.register("generateCode") + + project.javaCompileTasksFor(mainSet).configureEach { it.dependsOn(generateCode) } + project.kotlinCompileTasksFor(mainSet).configureEach { it.dependsOn(generateCode) } + + created.shouldBeEmpty() + + val compileJava = project.tasks.getByName("compileJava") + val compileKotlin = project.tasks.getByName("compileKotlin") + compileJava.taskDependencies.getDependencies(compileJava) shouldContain + generateCode.get() + compileKotlin.taskDependencies.getDependencies(compileKotlin) shouldContain + generateCode.get() + } + } + @Test fun `expose source set extensions`() { val mainSet = project.sourceSet(main) diff --git a/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskBuilderSpec.kt b/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskBuilderSpec.kt index 3789900a1..4727ea56c 100644 --- a/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskBuilderSpec.kt +++ b/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskBuilderSpec.kt @@ -1,35 +1,27 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.task import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.spine.testing.Assertions.assertIllegalState @@ -43,6 +35,7 @@ import io.spine.tools.gradle.testing.NoOp import java.io.File import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.UnknownTaskException import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -53,6 +46,7 @@ import org.junit.jupiter.api.Test * Tests [GradleTask.Builder]. */ @DisplayName("`GradleTask.Builder` should") +@Suppress("DEPRECATION") // Covers the deprecated `GradleTask` until it is removed. internal class GradleTaskBuilderSpec { private lateinit var project: Project @@ -75,11 +69,10 @@ internal class GradleTaskBuilderSpec { .insertAfterAllTasks(compileJava) .applyNowTo(subProject) val subProjectTasks = subProject.tasks - val newTask = subProjectTasks.findByName(task.name.name()) - newTask shouldNotBe null - val dependencies: Collection<*> = newTask!!.dependsOn - dependencies shouldContain subProjectTasks.findByName(compileJava.name()) - dependencies shouldContain project.tasks.findByName(compileJava.name()) + val newTask = subProjectTasks.getByName(task.name.name()) + val dependencies = newTask.dependencies() + dependencies shouldContain subProjectTasks.getByName(compileJava.name()) + dependencies shouldContain project.tasks.getByName(compileJava.name()) } @Test @@ -88,10 +81,11 @@ internal class GradleTaskBuilderSpec { .insertBeforeTask(classes) .applyNowTo(project) val tasks = project.tasks - val classes = tasks.findByName(classes.name()) - classes shouldNotBe null - val verifyModelTask = tasks.findByName(verifyModel.name) - classes!!.dependsOn shouldContain verifyModelTask + val classes = tasks.getByName(classes.name()) + val verifyModelTask = tasks.getByName(verifyModel.name) + classes.dependencies() shouldContain verifyModelTask + // The dependency is declared via a task provider, which the utility recognizes. + TaskDependencies.dependsOn(classes, verifyModel) shouldBe true } @Test @@ -118,8 +112,7 @@ internal class GradleTaskBuilderSpec { val generateProtoTask = tasks.findByName(generateProto.name()) generateProtoTask shouldBe null - val generateTestProtoTask = tasks.findByName(generateTestProto.name()) - generateTestProtoTask shouldNotBe null + tasks.getByName(generateTestProto.name()).dependencies().shouldBeEmpty() } @Test @@ -282,4 +275,98 @@ internal class GradleTaskBuilderSpec { task.name shouldBe taskName } + + @Nested + inner class `register the task lazily` { + + private val created = mutableListOf() + + @BeforeEach + fun recordCreatedTasks() { + project.tasks.configureEach { created += it.path } + // `configureEach` runs at once for the tasks created earlier, so start afresh. + created.clear() + } + + @Test + fun `creating neither the task nor the tasks it is inserted between`() { + val subProject = ProjectBuilder.builder() + .withParent(project) + .build() + subProject.pluginManager.apply(GradleProject.javaPlugin) + subProject.tasks.configureEach { created += it.path } + created.clear() + + GradleTask.newBuilder(preClean, NoOp.action()) + .insertBeforeTask(BaseTaskName.clean) + .insertAfterTask(compileJava) + .insertAfterAllTasks(classes) + .applyNowTo(subProject) + + created.shouldBeEmpty() + } + + @Test + fun `creating the task on demand`() { + val task = GradleTask.newBuilder(preClean, NoOp.action()) + .insertBeforeTask(BaseTaskName.clean) + .applyNowTo(project) + + task.taskProvider.name shouldBe preClean.name + created.shouldBeEmpty() + + task.task.name shouldBe preClean.name + created shouldContainExactly listOf(":${preClean.name}") + } + + @Test + fun `ignoring changes to the builder made after it is applied`() { + val builder = GradleTask.newBuilder(preClean, NoOp.action()) + .insertBeforeTask(BaseTaskName.clean) + .withDescription("Original description") + builder.applyNowTo(project) + + builder.withDescription("Changed description") + .withInputProperty("late", "value") + .withOutputFiles(project.layout.files(File(".").absoluteFile)) + + val task = project.tasks.getByName(preClean.name) + task.description shouldBe "Original description" + task.inputs.properties shouldNotContainKey "late" + task.outputs.files.shouldBeEmpty() + } + + @Test + fun `depending only on tasks of all projects registered before it`() { + val subProject = ProjectBuilder.builder() + .withParent(project) + .build() + val rootTask = project.tasks.register(verifyModel.name) + val task = GradleTask.newBuilder(annotateProto, NoOp.action()) + .insertAfterAllTasks(verifyModel) + .applyNowTo(subProject) + subProject.tasks.register(verifyModel.name) + + created.shouldBeEmpty() + task.task.dependencies() shouldContainExactly setOf(rootTask.get()) + } + + @Test + fun `not registering the task if the following task is not registered`() { + val builder = GradleTask.newBuilder(preClean, NoOp.action()) + .insertBeforeTask(TaskName.of("missingTask")) + + shouldThrow { + builder.applyNowTo(project) + } + project.tasks.names shouldNotContain preClean.name + } + } } + +/** + * Obtains the tasks this task depends on, resolving the dependencies + * declared via names, providers, and task collections. + */ +private fun Task.dependencies(): Set = + taskDependencies.getDependencies(this) diff --git a/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskTest.kt b/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskTest.kt index bfb583d6f..2673d06a9 100644 --- a/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskTest.kt +++ b/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/GradleTaskTest.kt @@ -1,27 +1,15 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.task @@ -35,6 +23,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @DisplayName("`GradleTask` should") +@Suppress("DEPRECATION") // Covers the deprecated `GradleTask` until it is removed. class GradleTaskTest { @Test @@ -51,6 +40,7 @@ class GradleTaskTest { gradleTask.let { it.task shouldBe task + it.taskProvider.get() shouldBe task it.name.name() shouldBe "existingTask" } } diff --git a/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/TaskDependenciesSpec.kt b/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/TaskDependenciesSpec.kt index 5e2892857..45b323e36 100644 --- a/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/TaskDependenciesSpec.kt +++ b/plugin-base/src/test/kotlin/io/spine/tools/gradle/task/TaskDependenciesSpec.kt @@ -1,31 +1,20 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.gradle.task +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe import io.spine.tools.gradle.task.TaskDependencies.dependsOn import org.gradle.api.Task @@ -65,4 +54,48 @@ internal class TaskDependenciesSpec { fun `return 'false' when there is no dependency`() { dependsOn(task, ontoTask) shouldBe false } + + @Test + fun `detect a dependency on a task provider without creating the task`() { + val project = task.project + val lazyTask = project.tasks.register("lazyTask") + val created = mutableListOf() + project.tasks.configureEach { created += it.name } + created.clear() + + task.dependsOn(lazyTask) + + dependsOn(task, TaskName.of("lazyTask")) shouldBe true + dependsOn(task, TaskName.of("otherTask")) shouldBe false + created.shouldBeEmpty() + } + + @Test + fun `detect a dependency on a task collection without creating the tasks`() { + val project = task.project + val lazyTasks = project.tasks.named { it.startsWith("lazy") } + project.tasks.register("lazyTask") + val created = mutableListOf() + project.tasks.configureEach { created += it.name } + created.clear() + + task.dependsOn(lazyTasks) + + dependsOn(task, TaskName.of("lazyTask")) shouldBe true + dependsOn(task, TaskName.of("otherTask")) shouldBe false + created.shouldBeEmpty() + } + + @Test + fun `detect a dependency declared in several ways at once`() { + val project = task.project + val lazyTask = project.tasks.register("lazyTask") + + // The name comes first, so the provider and the collection are checked + // after the dependency is already found. + task.dependsOn("lazyTask", lazyTask, project.tasks.named { it == "lazyTask" }) + + dependsOn(task, TaskName.of("lazyTask")) shouldBe true + dependsOn(task, TaskName.of("otherTask")) shouldBe false + } } diff --git a/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePlugin.kt b/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePlugin.kt index b1b06bd26..77d6143bb 100644 --- a/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePlugin.kt +++ b/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePlugin.kt @@ -1,27 +1,15 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.protobuf.gradle.plugin @@ -29,11 +17,8 @@ package io.spine.tools.protobuf.gradle.plugin import com.google.protobuf.gradle.GenerateProtoTask import io.spine.code.proto.DescriptorSetReferenceFile import io.spine.tools.code.SourceSetName -import io.spine.tools.gradle.task.JavaTaskName import io.spine.tools.protobuf.gradle.descriptorSetFile import java.io.File -import org.gradle.kotlin.dsl.withType -import org.gradle.language.jvm.tasks.ProcessResources /** * A Gradle project plugin that configures Protobuf generation tasks to produce @@ -76,7 +61,13 @@ public class DescriptorSetFilePlugin : ProtobufSetupPlugin() { // Add the `descriptors` directory to the resources so that // the descriptor set file, and the reference file which is created in // the `doLast` block below are packed together with the class files. - sourceSet.resources.srcDir(descriptorsDir.absolutePath) + // + // The directory is added as built by this task, so that `processResources`, + // and other tasks consuming the resources, depend on this task. + // The dependency cannot be set on `processResources` directly without creating + // the task: this function runs in the configuration action of `GenerateProtoTask`, + // where Gradle forbids configuring other tasks lazily. + sourceSet.resources.srcDir(project.files(descriptorsDir).builtBy(task)) // Create a `desc.ref` file pointing to the descriptor file name once the task finishes. task.doLast { @@ -84,8 +75,6 @@ public class DescriptorSetFilePlugin : ProtobufSetupPlugin() { } task.declareReferenceFileOutput(descriptorsDir) task.declareDescriptorSetNameInput(descriptorSetFile) - - task.dependOnProcessResourcesTask() } } @@ -134,16 +123,3 @@ private fun GenerateProtoTask.declareReferenceFileOutput(descriptorsDir: File) { private fun GenerateProtoTask.declareDescriptorSetNameInput(descriptorSetFile: File) { inputs.property(DescriptorSetFilePlugin.DESCRIPTOR_SET_NAME_PROPERTY, descriptorSetFile.name) } - -/** - * Make the `processResources` task depend on this `GenerateProtoTask`. - */ -private fun GenerateProtoTask.dependOnProcessResourcesTask() { - val ssn = SourceSetName(sourceSet.name) - val processResources = JavaTaskName.processResources(ssn).value() - // Find the task via iteration because the call to `named()` fails at - // this project configuration stage. - project.tasks.withType() - .filter { it.name == processResources } - .forEach { it.dependsOn(this) } -} diff --git a/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePlugin.kt b/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePlugin.kt index 208f31a7c..54d75203d 100644 --- a/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePlugin.kt +++ b/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePlugin.kt @@ -47,9 +47,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask * - makes the `GenerateProtoTask` copy generated sources into * `$projectDir/generated//{java,kotlin}`; * - excludes Protobuf plugin output directories from Kotlin/Java source sets and - * includes the copied ones; - * - makes Kotlin compilation and `processResources` depend on the corresponding - * `GenerateProtoTask` to avoid race conditions. + * includes the copied ones as built by the `GenerateProtoTask`, so that + * the compilation tasks depend on it without being created by the plugin. */ public class GeneratedSourcePlugin : ProtobufSetupPlugin(), GeneratedDirectoryContext { @@ -66,7 +65,6 @@ public class GeneratedSourcePlugin : ProtobufSetupPlugin(), GeneratedDirectoryCo configureSourceSetDirs() declareGeneratedDirOutput() copyGeneratedFilesWhenDone() - setupKotlinCompile() } /** @@ -240,9 +238,17 @@ private fun File.residesIn(directory: File): Boolean = canonicalFile.startsWith(directory.absolutePath) /** - * Ensure Kotlin compilation explicitly depends on this `GenerateProtoTask`. + * Ensures Kotlin compilation explicitly depends on this `GenerateProtoTask`. + * + * The function creates the Kotlin compilation task if it is registered but + * not yet created. Calling it is no longer needed: the compilation depends on + * this task via the generated source directories added by [configureSourceSetDirs]. */ @Internal +@Deprecated( + message = "No longer needed. Kotlin compilation depends on this task via" + + " the generated source directories added by `configureSourceSetDirs()`." +) public fun GenerateProtoTask.setupKotlinCompile() { val taskName = sourceSet.getCompileTaskName("Kotlin") try { diff --git a/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufSetupPlugin.kt b/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufSetupPlugin.kt index 70183d2d0..50bbcd1fb 100644 --- a/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufSetupPlugin.kt +++ b/protobuf-setup-plugins/src/main/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufSetupPlugin.kt @@ -1,33 +1,22 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.protobuf.gradle.plugin import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper import com.google.protobuf.gradle.GenerateProtoTask +import com.google.protobuf.gradle.ProtobufExtension.GenerateProtoTaskCollection import io.spine.tools.protobuf.gradle.protobufExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -38,7 +27,7 @@ import org.gradle.api.Project public abstract class ProtobufSetupPlugin : Plugin { /** - * Configures all `GenerateProtoTask`s to with the [setup] function. + * Configures all `GenerateProtoTask`s with the [setup] function. */ @OverridingMethodsMustInvokeSuper override fun apply(project: Project): Unit = with(project) { @@ -47,11 +36,19 @@ public abstract class ProtobufSetupPlugin : Plugin { generateProtoTasks.all().configureEach { task -> setup(task) } + // Force the creation of `GenerateProtoTask`s in the first action the Protobuf + // plugin runs once the project is evaluated. Otherwise, a later action may + // create the tasks while iterating over the source directories of + // the source sets, e.g., to add the generated directories to the IDE sources + // when the `idea` plugin is applied. As `setup()` modifies these source + // directories, the iteration would fail with `ConcurrentModificationException`. + generateProtoTasks { + it.createAll() + } afterEvaluate { - // "Actualize" the collection of `GenerateProtoTask`s that - // may not be brought to life yet because of lazy `configureEach()` - // behaviour of new (9.1.0) Gradle. - generateProtoTasks.all().size + // Create the `GenerateProtoTask`s registered after the actions of + // the Protobuf plugin ran, so that `setup()` runs for them too. + generateProtoTasks.createAll() } } } @@ -62,3 +59,12 @@ public abstract class ProtobufSetupPlugin : Plugin { */ protected abstract fun setup(task: GenerateProtoTask) } + +/** + * Creates the registered tasks of this collection, which runs their configuration actions. + * + * Querying the size of a task collection creates the tasks registered in it. + */ +private fun GenerateProtoTaskCollection.createAll() { + all().size +} diff --git a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/BuildCacheSpec.kt b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/BuildCacheSpec.kt index fb11593eb..5dfd7344c 100644 --- a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/BuildCacheSpec.kt +++ b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/BuildCacheSpec.kt @@ -1,27 +1,15 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.protobuf.gradle.plugin @@ -227,13 +215,7 @@ internal class BuildCacheSpec : ProtobufPluginTest() { """.trimIndent() ) - File(protoDir, "sample.proto").writeText( - """ - syntax = "proto3"; - package sample; - message Msg {} - """.trimIndent() - ) + writeSampleProto() val javaDir = File(projectDir, "src/main/java/sample") javaDir.mkdirs() diff --git a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePluginSpec.kt b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePluginSpec.kt index 53e79a477..15330663f 100644 --- a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePluginSpec.kt +++ b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/DescriptorSetFilePluginSpec.kt @@ -1,27 +1,15 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.protobuf.gradle.plugin @@ -47,14 +35,7 @@ class DescriptorSetFilePluginSpec : ProtobufPluginTest() { @Test fun `create desc ref and descriptor file for main source set`() { - // Create a minimal proto file. - File(protoDir, "sample.proto").writeText( - """ - syntax = "proto3"; - package sample; - message Msg {} - """.trimIndent() - ) + writeSampleProto() // Build file applying Protobuf plugin and our plugin. Gradle.buildFile.under(projectDir).writeText( @@ -102,6 +83,40 @@ class DescriptorSetFilePluginSpec : ProtobufPluginTest() { descriptor.exists() shouldBe true } + @Test + fun `create desc ref with the 'idea' plugin applied`() { + writeSampleProto() + + // Once the project is evaluated, the Protobuf plugin adds the generated + // directories to the IDE sources, reading the source directories of + // the source sets, which this plugin modifies. + Gradle.buildFile.under(projectDir).writeText( + """ + plugins { + id("java") + id("${ProtobufGradlePlugin.id}") version "${ProtobufGradlePlugin.version}" + id("${DescriptorSetFilePlugin.id}") + idea + } + + group = "$group" + version = "$version" + + repositories { mavenCentral() } + + protobuf { + protoc { artifact = "${ProtobufProtoc.dependency.artifact.coordinates}" } + } + """.trimIndent() + ) + + val task = ProtobufTaskName.generateProto + val result = runGradleBuild(projectDir, listOf(task.name()), debug = false) + + result.task(task.path())?.outcome shouldBe TaskOutcome.SUCCESS + File(projectDir, "build/descriptors/main/desc.ref").exists() shouldBe true + } + @Test fun `make processResources depend on generateProto`() { // Minimal proto to make `generateProto` do some work. diff --git a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePluginSpec.kt b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePluginSpec.kt index fab7c5c4b..3f3e1377a 100644 --- a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePluginSpec.kt +++ b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/GeneratedSourcePluginSpec.kt @@ -1,27 +1,15 @@ /* - * Copyright 2026, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.protobuf.gradle.plugin @@ -170,29 +158,35 @@ class GeneratedSourcePluginSpec : ProtobufPluginTest() { sampleJava.exists() shouldBe true } - /** - * Creates a minimal proto file in the [protoDir]. - */ - private fun writeSampleProto() { - File(protoDir, "sample.proto").writeText( - """ - syntax = "proto3"; - package sample; - message Msg {} - """.trimIndent() - ) + @Test + fun `copy generated sources with the 'idea' plugin applied`() { + writeSampleProto() + // Once the project is evaluated, the Protobuf plugin adds the generated + // directories to the IDE sources, reading the source directories of + // the source sets, which this plugin modifies. + writeJavaBuildFile(idea = true) + + val task = ProtobufTaskName.generateProto + val result = runGradleBuild(projectDir, listOf(task.name()), debug = false) + + result.task(task.path())?.outcome shouldBe TaskOutcome.SUCCESS + File(generatedJava, "sample/Sample.java").exists() shouldBe true } /** * Writes the build file applying the `java`, Protobuf, and our plugins. + * + * @param idea If `true`, the build also applies the `idea` plugin. */ - private fun writeJavaBuildFile() { + private fun writeJavaBuildFile(idea: Boolean = false) { + val ideaPlugin = if (idea) "idea" else "" Gradle.buildFile.under(projectDir).writeText( """ plugins { id("java") id("${ProtobufGradlePlugin.id}") version "${ProtobufGradlePlugin.version}" id("${GeneratedSourcePlugin.id}") + $ideaPlugin } group = "$group" diff --git a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/LazyTaskWiringSpec.kt b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/LazyTaskWiringSpec.kt new file mode 100644 index 000000000..864c728af --- /dev/null +++ b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/LazyTaskWiringSpec.kt @@ -0,0 +1,156 @@ +/* + * 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.tools.protobuf.gradle.plugin + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldNotContainAnyOf +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.string.shouldContain +import io.spine.tools.gradle.testing.Gradle +import io.spine.tools.gradle.testing.runGradleBuild +import io.spine.tools.gradle.testing.under +import io.spine.tools.protobuf.gradle.ProtobufDependencies +import org.gradle.api.Task +import org.gradle.api.internal.project.ProjectInternal +import org.gradle.api.plugins.JavaPlugin +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Verifies that the plugins of this module make the tasks consuming the output of + * a `GenerateProtoTask` depend on it without creating these tasks. + */ +@DisplayName("Protobuf setup plugins should") +internal class LazyTaskWiringSpec : ProtobufPluginTest() { + + override val group = "io.spine.tests" + override val version = "1.0.0" + + @Test + fun `make 'processResources' depend on 'generateProto' without creating it`() { + val project = ProjectBuilder.builder() + .withProjectDir(projectDir) + .build() + val created = mutableListOf() + with(project) { + pluginManager.apply(JavaPlugin::class.java) + group = this@LazyTaskWiringSpec.group + version = this@LazyTaskWiringSpec.version + pluginManager.apply(ProtobufDependencies.gradlePlugin.id) + pluginManager.apply(DescriptorSetFilePlugin::class.java) + tasks.configureEach { created += it.name } + } + // `configureEach` runs at once for the tasks created earlier, so start afresh. + created.clear() + + (project as ProjectInternal).evaluate() + + // `ProtobufSetupPlugin` creates the `GenerateProtoTask`s once the project is + // evaluated, which runs `DescriptorSetFilePlugin.setup()` for them. + created shouldContainAll listOf("generateProto", "generateTestProto") + created shouldNotContain "processResources" + created shouldNotContain "processTestResources" + + val tasks = project.tasks + tasks.getByName("processResources").dependencies() shouldContain + tasks.getByName("generateProto") + tasks.getByName("processTestResources").dependencies() shouldContain + tasks.getByName("generateTestProto") + } + + @Test + fun `create no compilation or resource processing tasks in a build not running them`() { + writeSampleProto() + writeKotlinBuildFile( + """ + tasks.configureEach { println("$CREATED" + name) } + """ + ) + + val result = runGradleBuild(projectDir, listOf("help"), debug = false) + + val created = result.output.lineSequence() + .filter { it.startsWith(CREATED) } + .map { it.removePrefix(CREATED) } + .toList() + // Proves that the plugins configured the `GenerateProtoTask`s. + created shouldContain "generateProto" + created shouldNotContainAnyOf listOf( + "compileJava", "compileTestJava", + "compileKotlin", "compileTestKotlin", + "processResources", "processTestResources", + ) + } + + @Test + fun `make Kotlin compilation depend on 'generateProto'`() { + writeSampleProto() + writeKotlinBuildFile() + + val result = runGradleBuild(projectDir, listOf("compileKotlin", "--dry-run")) + + result.output shouldContain ":generateProto SKIPPED" + } + + /** + * Writes the build file applying the Kotlin JVM, Protobuf, and both plugins + * of this module, followed by the given [extra] code. + * + * The build also applies the `idea` plugin, which makes the Protobuf plugin read + * the source directories once the project is evaluated. + */ + private fun writeKotlinBuildFile(extra: String = "") { + Gradle.buildFile.under(projectDir).writeText( + """ + plugins { + idea + // No version: resolved from the plugin-under-test classpath. + id("org.jetbrains.kotlin.jvm") + id("${ProtobufGradlePlugin.id}") version "${ProtobufGradlePlugin.version}" + id("${GeneratedSourcePlugin.id}") + id("${DescriptorSetFilePlugin.id}") + } + + group = "$group" + version = "$version" + + repositories { + mavenCentral() + } + + protobuf { + protoc { artifact = "${ProtobufProtoc.dependency.artifact.coordinates}" } + } + """.trimIndent() + "\n" + extra.trimIndent() + ) + } + + private companion object { + + /** + * The prefix of the lines printed by a build for each task it creates. + */ + const val CREATED = "CREATED_TASK " + } +} + +/** + * Obtains the tasks this task depends on, resolving the dependencies + * declared via its inputs, providers, and task collections. + */ +private fun Task.dependencies(): Set = + taskDependencies.getDependencies(this) diff --git a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufPluginTest.kt b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufPluginTest.kt index 51d257697..4cdc011b5 100644 --- a/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufPluginTest.kt +++ b/protobuf-setup-plugins/src/test/kotlin/io/spine/tools/protobuf/gradle/plugin/ProtobufPluginTest.kt @@ -1,27 +1,15 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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 + * 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 * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * 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.tools.protobuf.gradle.plugin @@ -56,4 +44,17 @@ abstract class ProtobufPluginTest { Gradle.settingsFile.under(projectDir).writeText("") protoDir.mkdirs() } + + /** + * Creates a minimal proto file in the [protoDir]. + */ + protected fun writeSampleProto() { + File(protoDir, "sample.proto").writeText( + """ + syntax = "proto3"; + package sample; + message Msg {} + """.trimIndent() + ) + } } diff --git a/version.gradle.kts b/version.gradle.kts index c64c1985d..019c84f2f 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.428") +extra.set("versionToPublish", "2.0.0-SNAPSHOT.430")