From 7d1902680b3614a9ff0d89e0498b9f18d60b9dbc Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 28 Jul 2026 17:14:24 +0200 Subject: [PATCH 1/4] Verify plugin publish flow. --- ...gradle-plugin-portal.secret.properties.gpg | 2 + .github/workflows/publish.yml | 71 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 .github/keys/gradle-plugin-portal.secret.properties.gpg create mode 100644 .github/workflows/publish.yml diff --git a/.github/keys/gradle-plugin-portal.secret.properties.gpg b/.github/keys/gradle-plugin-portal.secret.properties.gpg new file mode 100644 index 0000000..8ec9a8a --- /dev/null +++ b/.github/keys/gradle-plugin-portal.secret.properties.gpg @@ -0,0 +1,2 @@ +  2.y:ҢF`J1$9 D~ze!yuGBU2K0! q =˂p@zeqB>R#qj?1m굘U^2*kJ 3wq84}. +2M< \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bdf3b68 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,71 @@ +name: Publish + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: gradle-plugin-portal + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Set Up Java 25 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Decrypt Gradle Plugin Portal Credentials + env: + GRADLE_PORTAL_CREDENTIALS_KEY: ${{ secrets.GRADLE_PORTAL_CREDENTIALS_KEY }} + run: | + if [[ -z "$GRADLE_PORTAL_CREDENTIALS_KEY" ]]; then + echo "::error::The GRADLE_PORTAL_CREDENTIALS_KEY secret is unavailable." \ + "Grant this repository access to the organization secret." + exit 1 + fi + + mkdir -p "$HOME/.gradle" + printf '%s' "$GRADLE_PORTAL_CREDENTIALS_KEY" | + gpg \ + --quiet \ + --batch \ + --yes \ + --pinentry-mode loopback \ + --passphrase-fd 0 \ + --output "$HOME/.gradle/gradle.properties" \ + --decrypt .github/keys/gradle-plugin-portal.secret.properties.gpg + chmod 600 "$HOME/.gradle/gradle.properties" + + - name: Require Master for Publication + if: ${{ inputs.publish == true }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then + echo "::error::Run publication from the master branch." + exit 1 + fi + + - name: Validate Plugin Portal Publication + if: ${{ inputs.publish == false }} + run: ./gradlew :gradle-plugin:publishPlugins --validate-only + +# - name: Publish to Gradle Plugin Portal +# if: ${{ inputs.publish == true }} +# run: ./gradlew :gradle-plugin:publishPlugins + + - name: Remove Decrypted Credentials + if: ${{ always() }} + run: rm -f "$HOME/.gradle/gradle.properties" From 168ea1bd2177b8e0eedfb3612c9ae9c37b53f013 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 28 Jul 2026 17:43:14 +0200 Subject: [PATCH 2/4] Provide version increment check. --- .github/workflows/increment-guard.yml | 33 ++++ .github/workflows/publish.yml | 4 +- PROJECT.md | 2 + build.gradle.kts | 10 ++ .../gradle/publish/CheckVersionIncrement.kt | 131 +++++++++++++++ .../publish/CheckVersionIncrementSpec.kt | 150 ++++++++++++++++++ 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/increment-guard.yml create mode 100644 buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt diff --git a/.github/workflows/increment-guard.yml b/.github/workflows/increment-guard.yml new file mode 100644 index 0000000..6618714 --- /dev/null +++ b/.github/workflows/increment-guard.yml @@ -0,0 +1,33 @@ +name: Check Version Increment + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-version: + name: Check Version Increment + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Set Up Java 25 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Check Plugin Version + run: ./gradlew checkVersionIncrement --stacktrace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bdf3b68..2139e1c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,9 @@ name: Publish on: - pull_request: + push: + branches: + - master permissions: contents: read diff --git a/PROJECT.md b/PROJECT.md index 1238bcc..506f9e8 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -40,6 +40,8 @@ Embed Code configuration file and do not need to install the executable or Kotli - `.github/workflows/check.yml`: agent configuration and Ubuntu and Windows build verification. - `.github/workflows/coverage.yml`: JaCoCo report generation and Codecov upload, using the patch target defined in `codecov.yml`. +- `.github/workflows/increment-guard.yml`: Plugin Portal version availability verification for + pull requests. - `.claude/commands/`: thin Claude slash-command entry points for deliberate workflows. - `.agents/guidelines/`: shared writing, English-language, and project-ownership rules. - `.agents/skills/`: repository engineering, test, writing, review, and security workflows. diff --git a/build.gradle.kts b/build.gradle.kts index 099ca19..6707fdd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,8 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.publish.CheckVersionIncrement + plugins { base } @@ -41,3 +43,11 @@ allprojects { group = "io.spine.tools" version = embedCodePluginVersion } + +tasks.register("checkVersionIncrement") { + description = "Checks that the plugin version is not already published." + group = LifecycleBasePlugin.VERIFICATION_GROUP + pluginId.set("io.spine.embed-code") + pluginVersion.set(embedCodePluginVersion) + portalBaseUrl.set("https://plugins.gradle.org") +} diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt new file mode 100644 index 0000000..6856bf0 --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt @@ -0,0 +1,131 @@ +/* + * 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.embedcode.gradle.publish + +import java.io.IOException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpClient.Redirect.NORMAL +import java.net.http.HttpRequest +import java.net.http.HttpResponse.BodyHandlers.discarding +import java.time.Duration +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +/** + * Checks that the current plugin version is not published to the Gradle Plugin Portal. + * + * The task checks the exact plugin-marker POM. It succeeds when the Portal returns HTTP 404 and + * fails closed for an existing artifact, an unexpected response, or a network failure. + */ +public abstract class CheckVersionIncrement : DefaultTask() { + + /** Plugin ID whose marker artifact is checked. */ + @get:Input + public abstract val pluginId: Property + + /** Plugin version whose marker artifact is checked. */ + @get:Input + public abstract val pluginVersion: Property + + /** Base URL of the Gradle Plugin Portal. */ + @get:Input + public abstract val portalBaseUrl: Property + + /** Verifies that the current version can be published. */ + @TaskAction + public fun verifyVersion() { + val markerUri = markerUri() + when (val status = request(markerUri)) { + HTTP_OK -> + throw GradleException( + "Plugin `${pluginId.get()}` version `${pluginVersion.get()}` is already " + + "published to the Gradle Plugin Portal. Increment " + + "`embedCodePluginVersion` in `version.gradle.kts`.", + ) + + HTTP_NOT_FOUND -> Unit + else -> + throw GradleException( + "Could not verify whether plugin `${pluginId.get()}` version " + + "`${pluginVersion.get()}` is already published. The Gradle Plugin Portal " + + "returned HTTP $status for `$markerUri`. Retry before publishing.", + ) + } + } + + private fun markerUri(): URI { + val id = pluginId.get() + val version = pluginVersion.get() + val markerArtifact = "$id.gradle.plugin" + val groupPath = id.replace('.', '/') + return URI.create( + "${portalBaseUrl.get().trimEnd('/')}/m2/$groupPath/$markerArtifact/" + + "$version/$markerArtifact-$version.pom", + ) + } + + private fun request(markerUri: URI): Int { + val request = + HttpRequest + .newBuilder(markerUri) + .timeout(REQUEST_TIMEOUT) + .GET() + .build() + val client = + HttpClient + .newBuilder() + .connectTimeout(REQUEST_TIMEOUT) + .followRedirects(NORMAL) + .build() + try { + return client.send(request, discarding()).statusCode() + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw requestFailure(markerUri, error) + } catch (error: IOException) { + throw requestFailure(markerUri, error) + } + } + + private fun requestFailure(markerUri: URI, cause: Exception): GradleException = + GradleException( + "Could not verify whether plugin `${pluginId.get()}` version " + + "`${pluginVersion.get()}` is already published. The Gradle Plugin Portal " + + "request to `$markerUri` failed. Retry before publishing.", + cause, + ) + + private companion object { + const val HTTP_OK = 200 + const val HTTP_NOT_FOUND = 404 + val REQUEST_TIMEOUT: Duration = Duration.ofSeconds(30) + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt b/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt new file mode 100644 index 0000000..afce78a --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt @@ -0,0 +1,150 @@ +/* + * 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.embedcode.gradle.publish + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.util.Collections.synchronizedList +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Plugin version increment check should") +internal class CheckVersionIncrementSpec { + + @Test + fun `allow a version whose marker artifact is missing`() { + val requests = synchronizedList(mutableListOf()) + withServer { server -> + server.createContext("/") { exchange -> + requests.add(exchange.requestURI.path) + exchange.respond(404) + } + + createTask(server).verifyVersion() + } + + assertEquals(listOf(EXPECTED_MARKER_PATH), requests) + } + + @Test + fun `follow Portal redirects before accepting a missing version`() { + val requests = synchronizedList(mutableListOf()) + withServer { server -> + server.createContext("/") { exchange -> + val path = exchange.requestURI.path + requests.add(path) + if (path == EXPECTED_MARKER_PATH) { + exchange.responseHeaders.add("Location", "/missing") + exchange.respond(303) + } else { + exchange.respond(404) + } + } + + createTask(server).verifyVersion() + } + + assertEquals(listOf(EXPECTED_MARKER_PATH, "/missing"), requests) + } + + @Test + fun `reject a version whose marker artifact exists`() { + val error = + withServer { server -> + server.createContext("/") { exchange -> exchange.respond(200) } + + assertThrows(GradleException::class.java) { + createTask(server).verifyVersion() + } + } + + assertEquals( + "Plugin `io.spine.embed-code` version `0.1.1` is already published to the Gradle " + + "Plugin Portal. Increment `embedCodePluginVersion` in `version.gradle.kts`.", + error.message, + ) + } + + @Test + fun `fail closed when the Portal cannot verify the version`() { + withServer { server -> + server.createContext("/") { exchange -> exchange.respond(503) } + + val error = assertThrows(GradleException::class.java) { + createTask(server).verifyVersion() + } + + assertEquals( + "Could not verify whether plugin `io.spine.embed-code` version `0.1.1` is " + + "already published. The Gradle Plugin Portal returned HTTP 503 for " + + "`${server.baseUrl}$EXPECTED_MARKER_PATH`. Retry before publishing.", + error.message, + ) + } + } + + private fun createTask(server: HttpServer): CheckVersionIncrement { + val project = ProjectBuilder.builder().build() + return project.tasks + .register("checkVersionIncrement", CheckVersionIncrement::class.java) + .get() + .apply { + pluginId.set("io.spine.embed-code") + pluginVersion.set("0.1.1") + portalBaseUrl.set(server.baseUrl) + } + } + + private fun withServer(action: (HttpServer) -> T): T { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.start() + return try { + action(server) + } finally { + server.stop(0) + } + } + + private val HttpServer.baseUrl: String + get() = "http://127.0.0.1:${address.port}" + + private fun HttpExchange.respond(status: Int) { + sendResponseHeaders(status, -1) + close() + } + + private companion object { + const val EXPECTED_MARKER_PATH = + "/m2/io/spine/embed-code/io.spine.embed-code.gradle.plugin/0.1.1/" + + "io.spine.embed-code.gradle.plugin-0.1.1.pom" + } +} From f37c5005ef720a2718853d595686888d386b9b64 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 28 Jul 2026 18:12:24 +0200 Subject: [PATCH 3/4] Improve plugin publishment. --- .github/workflows/publish.yml | 26 +++++++------- PROJECT.md | 12 +++++-- build.gradle.kts | 1 - .../gradle/publish/CheckVersionIncrement.kt | 2 ++ .../publish/CheckVersionIncrementSpec.kt | 31 ++++++++++++---- gradle-plugin/build.gradle.kts | 35 +++++++++++-------- 6 files changed, 71 insertions(+), 36 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2139e1c..54bde7a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,16 @@ jobs: - name: Set Up Gradle uses: gradle/actions/setup-gradle@v6 + - name: Check Plugin Version + shell: bash + run: ./gradlew checkVersionIncrement --stacktrace + + - name: Check Build + shell: bash + run: ./gradlew :buildSrc:check check + - name: Decrypt Gradle Plugin Portal Credentials + shell: bash env: GRADLE_PORTAL_CREDENTIALS_KEY: ${{ secrets.GRADLE_PORTAL_CREDENTIALS_KEY }} run: | @@ -52,22 +61,15 @@ jobs: --decrypt .github/keys/gradle-plugin-portal.secret.properties.gpg chmod 600 "$HOME/.gradle/gradle.properties" - - name: Require Master for Publication - if: ${{ inputs.publish == true }} - run: | - if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then - echo "::error::Run publication from the master branch." - exit 1 - fi - - name: Validate Plugin Portal Publication - if: ${{ inputs.publish == false }} + shell: bash run: ./gradlew :gradle-plugin:publishPlugins --validate-only -# - name: Publish to Gradle Plugin Portal -# if: ${{ inputs.publish == true }} -# run: ./gradlew :gradle-plugin:publishPlugins + - name: Publish to Gradle Plugin Portal + shell: bash + run: ./gradlew :gradle-plugin:publishPlugins - name: Remove Decrypted Credentials if: ${{ always() }} + shell: bash run: rm -f "$HOME/.gradle/gradle.properties" diff --git a/PROJECT.md b/PROJECT.md index 506f9e8..45f4fee 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -14,7 +14,7 @@ Embed Code configuration file and do not need to install the executable or Kotli ## Project map -- `build.gradle.kts`: root group and version wiring. +- `build.gradle.kts`: root group and version wiring, plus the Plugin Portal version guard. - `pom.xml`: the generated inventory of first-level dependencies from every plugin-module configuration. It is not a Maven build descriptor. - `dependencies.md`: the generated license inventory for the plugin module's production, test, @@ -42,6 +42,10 @@ Embed Code configuration file and do not need to install the executable or Kotli target defined in `codecov.yml`. - `.github/workflows/increment-guard.yml`: Plugin Portal version availability verification for pull requests. +- `.github/workflows/publish.yml`: build verification, Portal validation, and publication after + changes reach `master`. +- `.github/keys/`: encrypted publication credentials that the publish workflow decrypts with an + organization secret. - `.claude/commands/`: thin Claude slash-command entry points for deliberate workflows. - `.agents/guidelines/`: shared writing, English-language, and project-ownership rules. - `.agents/skills/`: repository engineering, test, writing, review, and security workflows. @@ -106,7 +110,7 @@ version numbers into agent guidance where a durable source path is sufficient. ## Trust boundaries -Treat executable acquisition and reuse as security-sensitive: +Treat executable acquisition, reuse, and publication credentials as security-sensitive: - Authenticate release assets before extraction or execution. - Bind cached metadata to the release source, exact tag, platform asset, and digest. @@ -117,6 +121,10 @@ Treat executable acquisition and reuse as security-sensitive: - Reject symbolic-link or junction paths that can redirect writes outside that root. - Keep tokens explicit and secret; exclude them from logs, task inputs, cache keys, and metadata. - Use unpredictable temporary files and safe replacement when installing assets. +- Treat the committed Plugin Portal credential ciphertext as public. Keep its decryption key in + the organization secret, never print the key or plaintext, restrict the decrypted Gradle + properties file to its owner, and remove that file after every publication attempt. +- Rotate the Plugin Portal credentials and encryption key if the decryption key is compromised. Apply `gradle-engineer` and `security-engineer` to work spanning Gradle and security boundaries. diff --git a/build.gradle.kts b/build.gradle.kts index 6707fdd..184d337 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,7 +47,6 @@ allprojects { tasks.register("checkVersionIncrement") { description = "Checks that the plugin version is not already published." group = LifecycleBasePlugin.VERIFICATION_GROUP - pluginId.set("io.spine.embed-code") pluginVersion.set(embedCodePluginVersion) portalBaseUrl.set("https://plugins.gradle.org") } diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt index 6856bf0..f4e1841 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt @@ -38,6 +38,7 @@ import org.gradle.api.GradleException import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.UntrackedTask /** * Checks that the current plugin version is not published to the Gradle Plugin Portal. @@ -45,6 +46,7 @@ import org.gradle.api.tasks.TaskAction * The task checks the exact plugin-marker POM. It succeeds when the Portal returns HTTP 404 and * fails closed for an existing artifact, an unexpected response, or a network failure. */ +@UntrackedTask(because = "The result depends on live Gradle Plugin Portal state.") public abstract class CheckVersionIncrement : DefaultTask() { /** Plugin ID whose marker artifact is checked. */ diff --git a/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt b/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt index afce78a..d8f8698 100644 --- a/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt @@ -28,12 +28,14 @@ package io.spine.embedcode.gradle.publish import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer +import java.io.IOException import java.net.InetSocketAddress import java.util.Collections.synchronizedList import org.gradle.api.GradleException import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -49,7 +51,7 @@ internal class CheckVersionIncrementSpec { exchange.respond(404) } - createTask(server).verifyVersion() + createTask(server.baseUrl).verifyVersion() } assertEquals(listOf(EXPECTED_MARKER_PATH), requests) @@ -70,7 +72,7 @@ internal class CheckVersionIncrementSpec { } } - createTask(server).verifyVersion() + createTask(server.baseUrl).verifyVersion() } assertEquals(listOf(EXPECTED_MARKER_PATH, "/missing"), requests) @@ -83,7 +85,7 @@ internal class CheckVersionIncrementSpec { server.createContext("/") { exchange -> exchange.respond(200) } assertThrows(GradleException::class.java) { - createTask(server).verifyVersion() + createTask(server.baseUrl).verifyVersion() } } @@ -100,7 +102,7 @@ internal class CheckVersionIncrementSpec { server.createContext("/") { exchange -> exchange.respond(503) } val error = assertThrows(GradleException::class.java) { - createTask(server).verifyVersion() + createTask(server.baseUrl).verifyVersion() } assertEquals( @@ -112,7 +114,24 @@ internal class CheckVersionIncrementSpec { } } - private fun createTask(server: HttpServer): CheckVersionIncrement { + @Test + fun `fail closed when the Portal connection fails`() { + val portalBaseUrl = "http://127.0.0.1:0" + + val error = assertThrows(GradleException::class.java) { + createTask(portalBaseUrl).verifyVersion() + } + + assertEquals( + "Could not verify whether plugin `io.spine.embed-code` version `0.1.1` is already " + + "published. The Gradle Plugin Portal request to " + + "`$portalBaseUrl$EXPECTED_MARKER_PATH` failed. Retry before publishing.", + error.message, + ) + assertTrue(error.cause is IOException) + } + + private fun createTask(portalBaseUrl: String): CheckVersionIncrement { val project = ProjectBuilder.builder().build() return project.tasks .register("checkVersionIncrement", CheckVersionIncrement::class.java) @@ -120,7 +139,7 @@ internal class CheckVersionIncrementSpec { .apply { pluginId.set("io.spine.embed-code") pluginVersion.set("0.1.1") - portalBaseUrl.set(server.baseUrl) + this.portalBaseUrl.set(portalBaseUrl) } } diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 5b1bf07..8bd7089 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -30,6 +30,7 @@ import com.github.jk1.license.render.ReportRenderer import com.github.jk1.license.task.ReportTask import io.spine.embedcode.gradle.BuildSettings import io.spine.embedcode.gradle.dependency.LicenseReport +import io.spine.embedcode.gradle.publish.CheckVersionIncrement import io.spine.embedcode.gradle.report.DependencyMarkdownReportRenderer import io.spine.embedcode.gradle.report.GenerateDependencyPom import io.spine.embedcode.gradle.report.UpdateDependencyReports @@ -230,25 +231,29 @@ tasks.withType().configureEach { } } +val embedCodePlugin = + gradlePlugin.plugins.create("embedCode") { + id = "io.spine.embed-code" + implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" + displayName = "Embed Code Gradle Plugin" + description = + "Runs Embed Code from Gradle without a separately installed executable." + tags.set(listOf("documentation", "code-samples")) + compatibility { + features { + configurationCache = true + } + } + } + gradlePlugin { testSourceSets(functionalTestSourceSet) website.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") vcsUrl.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") - plugins { - create("embedCode") { - id = "io.spine.embed-code" - implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" - displayName = "Embed Code Gradle Plugin" - description = - "Runs Embed Code from Gradle without a separately installed executable." - tags.set(listOf("documentation", "code-samples")) - compatibility { - features { - configurationCache = true - } - } - } - } +} + +rootProject.tasks.named("checkVersionIncrement") { + pluginId.set(embedCodePlugin.id) } publishing { From 71c21b32a54732d73a60e57f9895ca4f39c66bb0 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Wed, 29 Jul 2026 13:10:22 +0200 Subject: [PATCH 4/4] Improve readability. --- .gitattributes | 1 + .github/workflows/increment-guard.yml | 2 +- .github/workflows/publish.yml | 18 ++++++++++++++++-- PROJECT.md | 6 +++--- build.gradle.kts | 9 --------- gradle-plugin/build.gradle.kts | 18 ++++++++++++++---- 6 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.gitattributes b/.gitattributes index 1074702..270461e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ gradlew text eol=lf pom.xml text eol=lf *.bat text eol=crlf *.jar binary +*.gpg binary diff --git a/.github/workflows/increment-guard.yml b/.github/workflows/increment-guard.yml index 6618714..029b4d8 100644 --- a/.github/workflows/increment-guard.yml +++ b/.github/workflows/increment-guard.yml @@ -30,4 +30,4 @@ jobs: uses: gradle/actions/setup-gradle@v6 - name: Check Plugin Version - run: ./gradlew checkVersionIncrement --stacktrace + run: ./gradlew :gradle-plugin:checkVersionIncrement --stacktrace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 54bde7a..5c8748f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,16 @@ jobs: - name: Checkout Repository uses: actions/checkout@v7 + - name: Set Up Java 17 for Compatibility Tests + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Save Java 17 Toolchain + shell: bash + run: echo "EMBED_CODE_JAVA_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + - name: Set Up Java 25 uses: actions/setup-java@v5 with: @@ -32,11 +42,14 @@ jobs: - name: Check Plugin Version shell: bash - run: ./gradlew checkVersionIncrement --stacktrace + run: ./gradlew :gradle-plugin:checkVersionIncrement --stacktrace - name: Check Build shell: bash - run: ./gradlew :buildSrc:check check + run: >- + ./gradlew + -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME" + :buildSrc:check check - name: Decrypt Gradle Plugin Portal Credentials shell: bash @@ -50,6 +63,7 @@ jobs: fi mkdir -p "$HOME/.gradle" + umask 077 printf '%s' "$GRADLE_PORTAL_CREDENTIALS_KEY" | gpg \ --quiet \ diff --git a/PROJECT.md b/PROJECT.md index 45f4fee..4eadb03 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -14,7 +14,7 @@ Embed Code configuration file and do not need to install the executable or Kotli ## Project map -- `build.gradle.kts`: root group and version wiring, plus the Plugin Portal version guard. +- `build.gradle.kts`: root group and version wiring. - `pom.xml`: the generated inventory of first-level dependencies from every plugin-module configuration. It is not a Maven build descriptor. - `dependencies.md`: the generated license inventory for the plugin module's production, test, @@ -28,8 +28,8 @@ Embed Code configuration file and do not need to install the executable or Kotli conventions. Its focused unit tests cover custom report logic, and its own Detekt configuration analyzes the build logic. - `gradle-plugin/build.gradle.kts`: plugin declaration, generated version source, functional - test source set, Dokka API documentation, publication metadata, and Plugin Portal - configuration. + test source set, Dokka API documentation, publication metadata, Plugin Portal version guard, + and publication configuration. - `gradle-plugin/src/main/kotlin/`: extension, plugin, task, platform, version, JSON, checksum, download, installation, and execution logic. - `gradle-plugin/src/main/templates/`: generated default-version source template. diff --git a/build.gradle.kts b/build.gradle.kts index 184d337..099ca19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,8 +24,6 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import io.spine.embedcode.gradle.publish.CheckVersionIncrement - plugins { base } @@ -43,10 +41,3 @@ allprojects { group = "io.spine.tools" version = embedCodePluginVersion } - -tasks.register("checkVersionIncrement") { - description = "Checks that the plugin version is not already published." - group = LifecycleBasePlugin.VERIFICATION_GROUP - pluginVersion.set(embedCodePluginVersion) - portalBaseUrl.set("https://plugins.gradle.org") -} diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 8bd7089..ee10e48 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -232,12 +232,13 @@ tasks.withType().configureEach { } val embedCodePlugin = - gradlePlugin.plugins.create("embedCode") { + gradlePlugin.plugins.create("embedCodeGradlePlugin") { id = "io.spine.embed-code" implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" displayName = "Embed Code Gradle Plugin" description = - "Runs Embed Code from Gradle without a separately installed executable." + "Embeds code snippets from source files into documentation and verifies that " + + "they are up to date." tags.set(listOf("documentation", "code-samples")) compatibility { features { @@ -252,8 +253,16 @@ gradlePlugin { vcsUrl.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") } -rootProject.tasks.named("checkVersionIncrement") { +val checkVersionIncrement = tasks.register("checkVersionIncrement") { + description = "Checks that the plugin version is not already published." + group = LifecycleBasePlugin.VERIFICATION_GROUP pluginId.set(embedCodePlugin.id) + pluginVersion.set(provider { project.version.toString() }) + portalBaseUrl.set("https://plugins.gradle.org") +} + +tasks.named("publishPlugins") { + dependsOn(checkVersionIncrement) } publishing { @@ -264,7 +273,8 @@ publishing { pom { name.set("Embed Code Gradle Plugin") description.set( - "Runs Embed Code from Gradle without a separately installed executable.", + "Embeds code snippets from source files into documentation and verifies that " + + "they are up to date.", ) url.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") licenses {