Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/keys/gradle-plugin-portal.secret.properties.gpg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
� 2�.y�:���ҢF����`�J�1$�����9�������� D~�z�e��!yu��GBU��2�K�0�!�� ����q =���˂p�������@z����e�qB�>�R#�qj�?�1��m굘U��^�2��*���k��J� 3�w�q84}.
�2��M<�
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/increment-guard.yml
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 checkVersionIncrement --stacktrace
75 changes: 75 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 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 checkVersionIncrement --stacktrace

- name: Check Build
shell: bash
run: ./gradlew :buildSrc:check check

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix — this check runs without the Java 17 toolchain that CI is required to provision.

buildSrc/src/main/kotlin/jvm-module.gradle.kts:90 pins the test launcher to BuildSettings.bytecodeVersion (17), and check.yml handles that by installing Java 17 first, exporting EMBED_CODE_JAVA_17_HOME, and passing -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME". This job installs only Java 25, so test and functionalTest have no local JDK 17 and Gradle has to resolve one through api.foojay.io and download it on every release run.

PROJECT.md states the rule directly: "CI provisions its required JDKs before invoking Gradle. This build-time provisioning is outside the plugin's SHA-256 verification of Embed Code release assets." Routing the release build through an unpinned third-party toolchain download is exactly what that line exists to prevent, and it makes a foojay/CDN outage a release-blocking failure.

This step has also never actually executed in CI — the trial Publish run on this branch (run 30372596781) went straight from Set Up Gradle to Decrypt Gradle Plugin Portal Credentials, so the gap is untested.

Mirror check.yml: add the Set Up Java 17 for Compatibility Tests and Save Java 17 Toolchain steps before Set Up Java 25, and pass -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME" on this invocation.


- 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"
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix — .gitattributes has no rule for *.gpg, so Git normalizes line endings inside the credential ciphertext.

git check-attr -a .github/keys/gradle-plugin-portal.secret.properties.gpg returns nothing, so the blob falls back to Git's text auto-detection. Checking the committed bytes: the file is 179 bytes of GPG symmetrically encrypted data (AES256 cipher) and contains no NUL byte, so Git's heuristic classifies it as text. It does contain a lone LF at offset 171 (and a lone CR at offset 1).

On a checkout with core.autocrlf=true — the default on GitHub's windows-latest runners, which check.yml already uses, and on many Windows workstations — Git rewrites that lone LF to CRLF. The working-tree file becomes 180 bytes, so the OpenPGP symmetric-encrypted-data packet no longer matches its declared payload and this gpg --decrypt cannot succeed. (I verified the byte layout and the missing attribute directly; gpg is 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."

.gitattributes already declares *.jar binary. Add the same rule for the key:

*.gpg binary

Then confirm with git check-attr -a .github/keys/gradle-plugin-portal.secret.properties.gpg that it resolves to text: unset.

chmod 600 "$HOME/.gradle/gradle.properties"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix — the decrypted credentials are world-readable until this chmod runs.

gpg --output creates the plaintext file with 0666 & ~umask, i.e. mode 0644 under the runner's default umask 022. The chmod 600 only closes that window afterwards, so the Portal key and secret exist at 0644 for the duration of the decryption.

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 umask 077 immediately before the gpg invocation (keeping this chmod is harmless), or pre-create the target with install -m 600 /dev/null "$HOME/.gradle/gradle.properties" and let gpg --yes write into it.


- 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"
14 changes: 12 additions & 2 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -41,3 +43,10 @@ allprojects {
group = "io.spine.tools"
version = embedCodePluginVersion
}

tasks.register<CheckVersionIncrement>("checkVersionIncrement") {
description = "Checks that the plugin version is not already published."
group = LifecycleBasePlugin.VERIFICATION_GROUP
pluginVersion.set(embedCodePluginVersion)
portalBaseUrl.set("https://plugins.gradle.org")
}
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)
}
}
Loading