-
Notifications
You must be signed in to change notification settings - Fork 0
Provide plugin publishing workflow #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,4 @@ gradlew text eol=lf | |
| pom.xml text eol=lf | ||
| *.bat text eol=crlf | ||
| *.jar binary | ||
| *.gpg binary | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should fix — the decrypted credentials are world-readable until this
PROJECT.md lists this as a trust boundary: "restrict the decrypted Gradle properties file to its owner". The current sequence satisfies the end state but not the invariant. Create the file restricted rather than fixing it up — either run |
||
|
|
||
| - 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> | ||
|
|
||
| /** Plugin version whose marker artifact is checked. */ | ||
| @get:Input | ||
| public abstract val pluginVersion: Property<String> | ||
|
|
||
| /** Base URL of the Gradle Plugin Portal. */ | ||
| @get:Input | ||
| public abstract val portalBaseUrl: Property<String> | ||
|
|
||
| /** 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Must fix —
.gitattributeshas no rule for*.gpg, so Git normalizes line endings inside the credential ciphertext.git check-attr -a .github/keys/gradle-plugin-portal.secret.properties.gpgreturns nothing, so the blob falls back to Git's text auto-detection. Checking the committed bytes: the file is 179 bytes ofGPG symmetrically encrypted data (AES256 cipher)and contains no NUL byte, so Git's heuristic classifies it as text. It does contain a loneLFat offset 171 (and a loneCRat offset 1).On a checkout with
core.autocrlf=true— the default on GitHub'swindows-latestrunners, whichcheck.ymlalready uses, and on many Windows workstations — Git rewrites that loneLFtoCRLF. The working-tree file becomes 180 bytes, so the OpenPGP symmetric-encrypted-data packet no longer matches its declared payload and thisgpg --decryptcannot succeed. (I verified the byte layout and the missing attribute directly;gpgis not installed here, so the decrypt failure itself is inferred from the byte change rather than observed.)PROJECT.md states the cross-platform invariant this breaks: "Preserve cross-platform paths, permissions, line endings, and process behavior."
.gitattributesalready declares*.jar binary. Add the same rule for the key:Then confirm with
git check-attr -a .github/keys/gradle-plugin-portal.secret.properties.gpgthat it resolves totext: unset.