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/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/increment-guard.yml b/.github/workflows/increment-guard.yml new file mode 100644 index 0000000..029b4d8 --- /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 :gradle-plugin:checkVersionIncrement --stacktrace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5c8748f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,89 @@ +name: Publish + +on: + push: + branches: + - master + +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 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: + distribution: temurin + java-version: 25 + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Check Plugin Version + shell: bash + run: ./gradlew :gradle-plugin:checkVersionIncrement --stacktrace + + - name: Check Build + shell: bash + run: >- + ./gradlew + -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME" + :buildSrc:check check + + - name: Decrypt Gradle Plugin Portal Credentials + shell: bash + 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" + umask 077 + 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: Validate Plugin Portal Publication + shell: bash + run: ./gradlew :gradle-plugin:publishPlugins --validate-only + + - 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 1238bcc..4eadb03 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. @@ -40,6 +40,12 @@ 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. +- `.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. @@ -104,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. @@ -115,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/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..f4e1841 --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrement.kt @@ -0,0 +1,133 @@ +/* + * 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 +import org.gradle.api.tasks.UntrackedTask + +/** + * 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. + */ +@UntrackedTask(because = "The result depends on live Gradle Plugin Portal state.") +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..d8f8698 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/embedcode/gradle/publish/CheckVersionIncrementSpec.kt @@ -0,0 +1,169 @@ +/* + * 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.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 + +@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.baseUrl).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.baseUrl).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.baseUrl).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.baseUrl).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, + ) + } + } + + @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) + .get() + .apply { + pluginId.set("io.spine.embed-code") + pluginVersion.set("0.1.1") + this.portalBaseUrl.set(portalBaseUrl) + } + } + + 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" + } +} diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 5b1bf07..ee10e48 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,38 @@ tasks.withType().configureEach { } } +val embedCodePlugin = + gradlePlugin.plugins.create("embedCodeGradlePlugin") { + id = "io.spine.embed-code" + implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" + displayName = "Embed Code Gradle Plugin" + description = + "Embeds code snippets from source files into documentation and verifies that " + + "they are up to date." + 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 - } - } - } - } +} + +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 { @@ -259,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 {