From 185a8510980e2d9fd6c86ce867a54af271a0b60c Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Mon, 13 Jul 2026 10:39:08 +0200 Subject: [PATCH 1/7] Provide gradle plugin. --- PROJECT.md | 7 +- README.md | 11 + gradle-plugin/.gitignore | 3 + gradle-plugin/README.md | 142 ++++++++++ gradle-plugin/build.gradle.kts | 55 ++++ gradle-plugin/gradle.properties | 4 + gradle-plugin/gradle/libs.versions.toml | 13 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + gradle-plugin/gradlew | 248 +++++++++++++++++ gradle-plugin/gradlew.bat | 82 ++++++ gradle-plugin/settings.gradle.kts | 15 + .../embedcode/gradle/EmbedCodeExtension.java | 90 ++++++ .../embedcode/gradle/EmbedCodePlatform.java | 88 ++++++ .../embedcode/gradle/EmbedCodePlugin.java | 105 +++++++ .../spine/embedcode/gradle/EmbedCodeTask.java | 258 ++++++++++++++++++ .../gradle/InstallEmbedCodeTask.java | 187 +++++++++++++ .../embedcode/gradle/EmbedCodePlatformSpec.kt | 45 +++ .../embedcode/gradle/EmbedCodePluginSpec.kt | 205 ++++++++++++++ 19 files changed, 1566 insertions(+), 1 deletion(-) create mode 100644 gradle-plugin/.gitignore create mode 100644 gradle-plugin/README.md create mode 100644 gradle-plugin/build.gradle.kts create mode 100644 gradle-plugin/gradle.properties create mode 100644 gradle-plugin/gradle/libs.versions.toml create mode 100644 gradle-plugin/gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle-plugin/gradle/wrapper/gradle-wrapper.properties create mode 100755 gradle-plugin/gradlew create mode 100644 gradle-plugin/gradlew.bat create mode 100644 gradle-plugin/settings.gradle.kts create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java create mode 100644 gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt create mode 100644 gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt diff --git a/PROJECT.md b/PROJECT.md index 76886b56..a1ee13be 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -23,6 +23,8 @@ them inside code fences, and checks whether existing snippets are up-to-date. - `embedding/commentfilter/`: language-aware comment-retention filtering. - `fragmentation/`: whole-file, named-fragment, and line-pattern extraction; source lookup; partition assembly; encoding checks; and caches. +- `gradle-plugin/`: Gradle plugin build, automatic release-binary installation, + check/embed tasks, Kotlin DSL configuration, and TestKit tests. - `files/`: filesystem validation and file helpers. - `indent/`: shared indentation measurement and removal. - `logging/`: `slog` handler, clickable file references, panic reporting, and @@ -39,6 +41,8 @@ them inside code fences, and checks whether existing snippets are up-to-date. - `README.md`: project entry point, short run/build instructions, and links to the complete guide. +- `gradle-plugin/README.md`: Gradle plugin application, configuration, task, + compatibility, and development guide. - `showcase/README.md`: complete user guide entry point and runnable workflow. - `showcase/configuration/README.md`: command-line flags, YAML configuration, source roots, include/exclude patterns, and multiple embedding targets. @@ -56,7 +60,8 @@ this file. Keep the root README short. This repository is configured with these GitHub workflows: - `check`: runs linting, the normal Go test suite, and the showcase end-to-end - tests across supported platforms. + tests across supported platforms. It also verifies the Gradle plugin on each + supported runner platform and against the minimum Gradle version. - `coverage`: uploads Go coverage to Codecov, where `codecov.yml` requires 90% patch coverage for pull requests. The workflow also runs on pushes to `master` to populate base coverage for later pull-request diffs and the diff --git a/README.md b/README.md index ff149d76..78838d43 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Use `-mode=embed` when documentation should be rewritten with current source content. See the [configuration guide](showcase/configuration/README.md) for all command-line flags and YAML options. +Gradle projects can use the [Embed Code Gradle plugin](gradle-plugin/README.md) +to download the platform binary automatically and configure both modes in +`build.gradle.kts` without an additional YAML file. + ## Build Use Go `1.26.4`. @@ -81,6 +85,13 @@ Run the executable showcase: go test -tags showcase ./showcase ``` +Run the Gradle plugin checks: + +```bash +cd gradle-plugin +./gradlew check +``` + ## Documentation The main user guide is the [showcase](showcase/README.md). diff --git a/gradle-plugin/.gitignore b/gradle-plugin/.gitignore new file mode 100644 index 00000000..2352321c --- /dev/null +++ b/gradle-plugin/.gitignore @@ -0,0 +1,3 @@ +/.gradle/ +/.kotlin/ +/build/ diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md new file mode 100644 index 00000000..5a19f3b9 --- /dev/null +++ b/gradle-plugin/README.md @@ -0,0 +1,142 @@ +# Embed Code Gradle Plugin + +The `io.spine.embed-code` plugin runs Embed Code without requiring developers +or CI jobs to download an executable manually. It selects the released binary +for the current platform, installs it under the project's `build/` directory, +and exposes separate check and embed tasks. + +## Apply and Configure + +After the plugin is published, apply its released version: + +```kotlin +plugins { + id("io.spine.embed-code") version "0.1.0" +} +``` + +Until then, test the plugin directly from this checkout by adding its build to +the consuming project's `settings.gradle.kts`: + +```kotlin +pluginManagement { + includeBuild("../embed-code-go/gradle-plugin") +} +``` + +The consuming `build.gradle.kts` can then apply `id("io.spine.embed-code")` +without a version while using that included build. + +Configure Embed Code directly in `build.gradle.kts`; no `embed-code.yml` file +is required: + +```kotlin +embedCode { + version.set("1.2.4") + codePath.set(layout.projectDirectory.dir("src/main/java")) + docsPath.set(layout.projectDirectory.dir("docs")) + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + separator.set("...") + info.set(false) + stacktrace.set(false) +} +``` + +`version` and `docsPath` are required. Configure either one unnamed `codePath` +or one or more named sources. The other properties use the same defaults as +the Embed Code command-line application. + +| Property | Default | Purpose | +|---|---|---| +| `version` | Required | Selects the GitHub release tag and executable version. | +| `codePath` | Required without named sources | Sets one unnamed source root. | +| `namedSource(name, directory)` | Required without `codePath` | Adds a source root selected with `$name/`. | +| `docsPath` | Required | Sets the documentation root to scan. | +| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | +| `docExcludes` | Empty | Skips matching documentation files. | +| `separator` | `...` | Separates joined fragment parts. | +| `info` | `false` | Enables informational logging. | +| `stacktrace` | `false` | Prints stack traces after panics. | +| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | + +### Named Source Roots + +Use `namedSource` when documentation embeds code from multiple modules: + +```kotlin +embedCode { + version.set("1.2.4") + namedSource( + "company-site", + layout.projectDirectory.dir("company-site"), + ) + namedSource( + "jxbrowser", + layout.projectDirectory.dir("browser"), + ) + docsPath.set(layout.projectDirectory) +} +``` + +Embedding instructions select these roots with `$company-site/` and +`$jxbrowser/`. The plugin writes the corresponding Embed Code configuration +into the Gradle task's temporary directory and passes it to the executable; +the project does not need an `embed-code.yml` file. + +`codePath` and `namedSource(...)` are mutually exclusive. Multiple independent +documentation targets are not exposed by this Gradle DSL. + +## Run + +Check that documentation already contains current source snippets: + +```bash +./gradlew checkEmbedCode +``` + +Update documentation in place: + +```bash +./gradlew embedCode +``` + +`installEmbedCode` is an internal preparation task. Gradle runs it +automatically before either execution task and reuses its output until the +requested version, platform, download URL, or build directory changes. + +The plugin supports the platforms for which Embed Code currently publishes +release assets: + +- Linux AMD64. +- Windows AMD64. +- macOS AMD64 and ARM64. + +## Compatibility + +The published plugin implementation targets Java 8 bytecode. The tested range +is Gradle 7.6.3 through 9.5.0. The JVM used to run Gradle must also satisfy the +selected Gradle version's own Java compatibility requirements. + +The plugin build uses Kotlin DSL and Kotlin tests, while its published classes +are Java. Keeping Kotlin 2.x off the consumer plugin classpath allows older +Gradle Kotlin DSL compilers to load the plugin. + +## Develop + +Run compilation, plugin validation, unit tests, and TestKit functional tests: + +```bash +cd gradle-plugin +./gradlew check +``` + +The functional tests create local fake release assets. They do not download or +execute a real GitHub release. + +Publish the current plugin version to the local Maven repository when testing +it from another checkout: + +```bash +./gradlew publishToMavenLocal +``` diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts new file mode 100644 index 00000000..ab30b791 --- /dev/null +++ b/gradle-plugin/build.gradle.kts @@ -0,0 +1,55 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +plugins { + alias(libs.plugins.kotlin.jvm) + `java-gradle-plugin` + `maven-publish` +} + +group = "io.spine.tools" +version = "0.1.0" + +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +tasks.named("compileJava") { + options.release.set(8) +} + +tasks.named("compileTestKotlin") { + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) +} + +dependencies { + testImplementation(libs.junit.jupiter) + testImplementation(libs.kotest.assertions.core) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} + +gradlePlugin { + plugins { + create("embedCode") { + id = "io.spine.embed-code" + implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" + displayName = "Embed Code" + description = "Runs Embed Code from Gradle without a separately installed executable" + tags.set(listOf("documentation", "code-samples")) + } + } +} diff --git a/gradle-plugin/gradle.properties b/gradle-plugin/gradle.properties new file mode 100644 index 00000000..ae839e5e --- /dev/null +++ b/gradle-plugin/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +kotlin.code.style=official +kotlin.stdlib.default.dependency=false diff --git a/gradle-plugin/gradle/libs.versions.toml b/gradle-plugin/gradle/libs.versions.toml new file mode 100644 index 00000000..94c94b33 --- /dev/null +++ b/gradle-plugin/gradle/libs.versions.toml @@ -0,0 +1,13 @@ +[versions] +junit = "5.14.4" +junit-platform = "1.14.4" +kotest = "6.2.2" +kotlin = "2.4.0" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.jar b/gradle-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b52fb7e7 --- /dev/null +++ b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradle-plugin/gradlew b/gradle-plugin/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/gradle-plugin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradle-plugin/gradlew.bat b/gradle-plugin/gradlew.bat new file mode 100644 index 00000000..24c62d56 --- /dev/null +++ b/gradle-plugin/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts new file mode 100644 index 00000000..01ffe2c3 --- /dev/null +++ b/gradle-plugin/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "embed-code-gradle-plugin" diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java new file mode 100644 index 00000000..77441a4b --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java @@ -0,0 +1,90 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.Directory; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; + +/** + * Configures Embed Code for a Gradle project. + * + *

The extension maps directly to Embed Code command-line options and does + * not create or require a YAML configuration file.

+ */ +public abstract class EmbedCodeExtension { + + /** Returns the Embed Code release version to download and run. */ + public abstract Property getVersion(); + + /** Returns the root directory containing source files used by embedding instructions. */ + public abstract DirectoryProperty getCodePath(); + + /** Returns named source roots keyed by the name used in embedding instructions. */ + public abstract MapProperty getNamedSources(); + + /** Returns named source directories while preserving their task dependencies. */ + public abstract ConfigurableFileCollection getNamedSourceDirectories(); + + /** + * Adds a named source root. + * + * @param name the name referenced as {@code $name} in an embedding instruction + * @param directory the source root directory + */ + public void namedSource(String name, Directory directory) { + String normalizedName = name.trim(); + if (normalizedName.isEmpty()) { + throw new InvalidUserDataException("An Embed Code source name must not be empty."); + } + getNamedSources().put(normalizedName, directory.getAsFile().getAbsolutePath()); + getNamedSourceDirectories().from(directory); + } + + /** + * Adds a named source root supplied by another Gradle provider. + * + * @param name the name referenced as {@code $name} in an embedding instruction + * @param directory the source root provider, including its task dependency + */ + public void namedSource(String name, Provider directory) { + String normalizedName = name.trim(); + if (normalizedName.isEmpty()) { + throw new InvalidUserDataException("An Embed Code source name must not be empty."); + } + getNamedSources().put( + normalizedName, + directory.map(value -> value.getAsFile().getAbsolutePath()) + ); + getNamedSourceDirectories().from(directory); + } + + /** Returns the root directory containing Markdown or HTML documentation. */ + public abstract DirectoryProperty getDocsPath(); + + /** Returns glob patterns selecting documentation files to process. */ + public abstract ListProperty getDocIncludes(); + + /** Returns glob patterns selecting documentation files to skip. */ + public abstract ListProperty getDocExcludes(); + + /** Returns text inserted between joined fragment parts. */ + public abstract Property getSeparator(); + + /** Returns whether Embed Code should print informational log messages. */ + public abstract Property getInfo(); + + /** Returns whether Embed Code should print stack traces after panics. */ + public abstract Property getStacktrace(); + + /** + * Returns the base URL containing versioned Embed Code release directories. + * + *

The plugin appends {@code /v/} to this URL. + * This property primarily supports release mirrors and functional testing.

+ */ + public abstract Property getDownloadBaseUrl(); +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java new file mode 100644 index 00000000..1dbfe527 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java @@ -0,0 +1,88 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.GradleException; + +import java.util.Locale; +import java.util.Objects; + +/** A released executable selected for an operating system and architecture. */ +final class EmbedCodePlatform { + + private final String assetName; + private final String executableName; + + EmbedCodePlatform(String assetName, String executableName) { + this.assetName = assetName; + this.executableName = executableName; + } + + /** Returns the platform-specific release asset name. */ + String getAssetName() { + return assetName; + } + + /** Returns the executable name after extraction. */ + String getExecutableName() { + return executableName; + } + + /** Selects the release asset for {@code osName} and {@code architecture}. */ + static EmbedCodePlatform detect(String osName, String architecture) { + String os = osName.toLowerCase(Locale.ROOT); + String arch = architecture.toLowerCase(Locale.ROOT); + boolean isAmd64 = arch.equals("amd64") || arch.equals("x86_64"); + boolean isArm64 = arch.equals("aarch64") || arch.equals("arm64"); + + if (os.contains("mac") && isArm64) { + return new EmbedCodePlatform( + "embed-code-macos-arm64.zip", + "embed-code-macos-arm64" + ); + } + if (os.contains("mac") && isAmd64) { + return new EmbedCodePlatform( + "embed-code-macos-x64.zip", + "embed-code-macos-x64" + ); + } + if (os.contains("linux") && isAmd64) { + return new EmbedCodePlatform("embed-code-linux", "embed-code-linux"); + } + if (os.contains("windows") && isAmd64) { + return new EmbedCodePlatform( + "embed-code-windows.exe", + "embed-code-windows.exe" + ); + } + throw new GradleException( + "Embed Code does not publish a binary for operating system `" + osName + + "` and architecture `" + architecture + "`." + ); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof EmbedCodePlatform)) { + return false; + } + EmbedCodePlatform that = (EmbedCodePlatform) other; + return assetName.equals(that.assetName) + && executableName.equals(that.executableName); + } + + @Override + public int hashCode() { + return Objects.hash(assetName, executableName); + } + + @Override + public String toString() { + return "EmbedCodePlatform{" + + "assetName='" + assetName + '\'' + + ", executableName='" + executableName + '\'' + + '}'; + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java new file mode 100644 index 00000000..32e8c217 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -0,0 +1,105 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.TaskProvider; + +import java.util.Arrays; +import java.util.Collections; + +/** Registers automatic installation and execution tasks for Embed Code. */ +public final class EmbedCodePlugin implements Plugin { + + private static final String DEFAULT_DOWNLOAD_BASE_URL = + "https://github.com/SpineEventEngine/embed-code-go/releases/download"; + private static final String SPINE_TASK_GROUP = "spine"; + + /** Applies the plugin to {@code project}. */ + @Override + public void apply(Project project) { + EmbedCodeExtension extension = project.getExtensions().create( + "embedCode", + EmbedCodeExtension.class + ); + extension.getDocIncludes().convention(Arrays.asList("**/*.md", "**/*.html")); + extension.getDocExcludes().convention(Collections.emptyList()); + extension.getNamedSources().convention(Collections.emptyMap()); + extension.getSeparator().convention("..."); + extension.getInfo().convention(false); + extension.getStacktrace().convention(false); + extension.getDownloadBaseUrl().convention(DEFAULT_DOWNLOAD_BASE_URL); + + Provider platform = project.getProviders() + .systemProperty("os.name") + .zip( + project.getProviders().systemProperty("os.arch"), + EmbedCodePlatform::detect + ); + TaskProvider installTask = project.getTasks().register( + "installEmbedCode", + InstallEmbedCodeTask.class, + task -> { + task.setGroup(SPINE_TASK_GROUP); + task.setDescription("Installs the requested Embed Code executable"); + task.getVersion().set(extension.getVersion()); + task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); + task.getAssetName().set(platform.map(EmbedCodePlatform::getAssetName)); + task.getExecutableName().set(platform.map(EmbedCodePlatform::getExecutableName)); + task.getExecutableFile().set( + project.getLayout().getBuildDirectory().file( + extension.getVersion().zip( + platform, + (version, selectedPlatform) -> "embed-code/" + version + + '/' + selectedPlatform.getExecutableName() + ) + ) + ); + } + ); + + registerExecutionTask( + project, + extension, + installTask, + "checkEmbedCode", + "Checks embedded code snippets are up to date", + "check" + ); + registerExecutionTask( + project, + extension, + installTask, + "embedCode", + "Updates embedded code snippets from source files", + "embed" + ); + } + + /** Registers one mode-specific execution task backed by {@code installTask}. */ + private static void registerExecutionTask( + Project project, + EmbedCodeExtension extension, + TaskProvider installTask, + String name, + String description, + String mode + ) { + project.getTasks().register(name, EmbedCodeTask.class, task -> { + task.setGroup(SPINE_TASK_GROUP); + task.setDescription(description); + task.getMode().set(mode); + task.getCodePath().set(extension.getCodePath()); + task.getNamedSources().set(extension.getNamedSources()); + task.getNamedSourceDirectories().from(extension.getNamedSourceDirectories()); + task.getDocsPath().set(extension.getDocsPath()); + task.getDocIncludes().set(extension.getDocIncludes()); + task.getDocExcludes().set(extension.getDocExcludes()); + task.getSeparator().set(extension.getSeparator()); + task.getInfo().set(extension.getInfo()); + task.getStacktrace().set(extension.getStacktrace()); + task.getExecutableFile().set(installTask.flatMap(InstallEmbedCodeTask::getExecutableFile)); + task.getWorkingDirectory().set(project.getLayout().getProjectDirectory()); + }); + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java new file mode 100644 index 00000000..b1cf740a --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java @@ -0,0 +1,258 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; +import org.gradle.work.DisableCachingByDefault; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +/** Runs Embed Code in either check or embed mode. */ +@DisableCachingByDefault(because = "Embed Code checks or updates documentation files in place") +public abstract class EmbedCodeTask extends DefaultTask { + + /** Returns process execution without project access at execution time. */ + @Inject + protected abstract ExecOperations getExecOperations(); + + /** Returns the execution mode assigned by the plugin. */ + @Input + public abstract Property getMode(); + + /** Returns the source root passed to {@code -code-path}. */ + @InputDirectory + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getCodePath(); + + /** Returns named source roots included in an internally generated configuration. */ + @Input + public abstract MapProperty getNamedSources(); + + /** Returns named source directories with their producing task dependencies. */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getNamedSourceDirectories(); + + /** Returns the documentation root passed to {@code -docs-path}. */ + @InputDirectory + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getDocsPath(); + + /** Returns documentation include patterns passed to {@code -doc-includes}. */ + @Input + public abstract ListProperty getDocIncludes(); + + /** Returns documentation exclude patterns passed to {@code -doc-excludes}. */ + @Input + public abstract ListProperty getDocExcludes(); + + /** Returns the fragment separator passed to {@code -separator}. */ + @Input + public abstract Property getSeparator(); + + /** Returns whether informational logging is enabled. */ + @Input + public abstract Property getInfo(); + + /** Returns whether panic stack traces are enabled. */ + @Input + public abstract Property getStacktrace(); + + /** Returns the installed platform executable. */ + @InputFile + @PathSensitive(PathSensitivity.NONE) + public abstract RegularFileProperty getExecutableFile(); + + /** Returns the process working directory. */ + @Internal + public abstract DirectoryProperty getWorkingDirectory(); + + /** Executes Embed Code with arguments derived from the Gradle extension. */ + @TaskAction + public void runEmbedCode() { + Map namedSources = new TreeMap<>(getNamedSources().get()); + boolean hasDirectSource = getCodePath().isPresent(); + boolean hasNamedSources = !namedSources.isEmpty(); + if (hasDirectSource == hasNamedSources) { + throw new GradleException( + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." + ); + } + + List arguments = new ArrayList<>(); + arguments.add("-mode=" + getMode().get()); + if (hasNamedSources) { + arguments.add("-config-path=" + writeNamedSourceConfiguration(namedSources)); + } else { + arguments.add("-code-path=" + getCodePath().get().getAsFile().getAbsolutePath()); + arguments.add("-docs-path=" + getDocsPath().get().getAsFile().getAbsolutePath()); + if (!getDocIncludes().get().isEmpty()) { + arguments.add("-doc-includes=" + String.join(",", getDocIncludes().get())); + } + if (!getDocExcludes().get().isEmpty()) { + arguments.add("-doc-excludes=" + String.join(",", getDocExcludes().get())); + } + arguments.add("-separator=" + getSeparator().get()); + arguments.add("-info=" + getInfo().get()); + arguments.add("-stacktrace=" + getStacktrace().get()); + } + + getExecOperations().exec(spec -> { + spec.executable(getExecutableFile().get().getAsFile()); + spec.args(arguments); + spec.setWorkingDir(getWorkingDirectory().get().getAsFile()); + }); + } + + /** Writes the generated configuration used when named source roots are configured. */ + private Path writeNamedSourceConfiguration(Map namedSources) { + Map normalizedSources = new TreeMap<>(); + for (Map.Entry source : namedSources.entrySet()) { + Path path = Paths.get(source.getValue()); + if (!path.isAbsolute()) { + path = getWorkingDirectory().get().getAsFile().toPath().resolve(path); + } + path = path.normalize().toAbsolutePath(); + if (!Files.isDirectory(path)) { + throw new GradleException( + "Embed Code source `" + source.getKey() + "` is not a directory: " + path + ); + } + normalizedSources.put(source.getKey(), path.toString()); + } + + String json = createConfigurationJson( + normalizedSources, + getDocsPath().get().getAsFile().getAbsolutePath(), + getDocIncludes().get(), + getDocExcludes().get(), + getSeparator().get(), + getInfo().get(), + getStacktrace().get() + ); + Path configuration = getTemporaryDir().toPath().resolve("embed-code.json"); + try { + Files.write(configuration, json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new GradleException( + "Could not write the generated Embed Code configuration to " + configuration + '.', + exception + ); + } + return configuration; + } + + /** Creates a JSON document accepted by Embed Code's YAML configuration parser. */ + static String createConfigurationJson( + Map namedSources, + String docsPath, + List docIncludes, + List docExcludes, + String separator, + boolean info, + boolean stacktrace + ) { + StringBuilder json = new StringBuilder(); + json.append("{\n \"code-path\": [\n"); + int index = 0; + for (Map.Entry source : namedSources.entrySet()) { + if (index > 0) { + json.append(",\n"); + } + json.append(" {\"name\": "); + appendJsonString(json, source.getKey()); + json.append(", \"path\": "); + appendJsonString(json, source.getValue()); + json.append('}'); + index++; + } + json.append("\n ],\n \"docs-path\": "); + appendJsonString(json, docsPath); + json.append(",\n \"doc-includes\": "); + appendJsonArray(json, docIncludes); + json.append(",\n \"doc-excludes\": "); + appendJsonArray(json, docExcludes); + json.append(",\n \"separator\": "); + appendJsonString(json, separator); + json.append(",\n \"info\": ").append(info); + json.append(",\n \"stacktrace\": ").append(stacktrace); + json.append("\n}\n"); + return json.toString(); + } + + /** Appends a JSON array containing {@code values}. */ + private static void appendJsonArray(StringBuilder json, List values) { + json.append('['); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + json.append(", "); + } + appendJsonString(json, values.get(i)); + } + json.append(']'); + } + + /** Appends {@code value} as an escaped JSON string. */ + private static void appendJsonString(StringBuilder json, String value) { + json.append('"'); + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + switch (character) { + case '"': + json.append("\\\""); + break; + case '\\': + json.append("\\\\"); + break; + case '\b': + json.append("\\b"); + break; + case '\f': + json.append("\\f"); + break; + case '\n': + json.append("\\n"); + break; + case '\r': + json.append("\\r"); + break; + case '\t': + json.append("\\t"); + break; + default: + if (character < 0x20) { + json.append(String.format(Locale.ROOT, "\\u%04x", (int) character)); + } else { + json.append(character); + } + } + } + json.append('"'); + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java new file mode 100644 index 00000000..01ffed8e --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java @@ -0,0 +1,187 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLConnection; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Downloads and prepares the Embed Code executable selected for the host. + * + *

The output file gives Gradle normal up-to-date behavior, so a successfully + * installed version is reused by later invocations.

+ */ +@DisableCachingByDefault(because = "The downloaded release asset is already reused as a task output") +public abstract class InstallEmbedCodeTask extends DefaultTask { + + private static final int CONNECT_TIMEOUT_MILLIS = 30_000; + private static final int READ_TIMEOUT_MILLIS = 120_000; + + /** Returns the Embed Code release version. */ + @Input + public abstract Property getVersion(); + + /** Returns the base URL containing versioned release directories. */ + @Input + public abstract Property getDownloadBaseUrl(); + + /** Returns the platform-specific release asset name. */ + @Input + public abstract Property getAssetName(); + + /** Returns the executable name expected inside an archive or used directly. */ + @Input + public abstract Property getExecutableName(); + + /** Returns the installed executable used by Embed Code execution tasks. */ + @OutputFile + public abstract RegularFileProperty getExecutableFile(); + + /** Downloads, extracts when necessary, and marks the executable runnable. */ + @TaskAction + public void install() { + String requestedVersion = getVersion().get().trim(); + if (requestedVersion.isEmpty()) { + throw new GradleException("Embed Code version must not be empty."); + } + + String releaseTag = requestedVersion.startsWith("v") + ? requestedVersion + : "v" + requestedVersion; + String asset = getAssetName().get(); + String baseUrl = trimTrailingSlashes(getDownloadBaseUrl().get()); + URI source = URI.create(baseUrl + '/' + releaseTag + '/' + asset); + Path destination = getExecutableFile().get().getAsFile().toPath(); + Path download = getTemporaryDir().toPath().resolve(asset); + Path preparedExecutable = getTemporaryDir().toPath().resolve(getExecutableName().get()); + + try { + Files.createDirectories(destination.getParent()); + getLogger().lifecycle("Downloading Embed Code {} from {}", requestedVersion, source); + download(source, download); + + if (asset.endsWith(".zip")) { + extractExecutable(download, getExecutableName().get(), preparedExecutable); + } else { + Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING); + } + + if (!preparedExecutable.toFile().setExecutable(true, false)) { + throw new GradleException( + "Could not make `" + preparedExecutable + "` executable." + ); + } + moveAtomically(preparedExecutable, destination); + } catch (IOException exception) { + throw new GradleException("Could not install Embed Code from " + source + '.', exception); + } + } + + /** Downloads {@code source} into {@code destination}, reporting HTTP failures clearly. */ + private static void download(URI source, Path destination) { + URLConnection connection = null; + try { + connection = source.toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + + if (connection instanceof HttpURLConnection) { + HttpURLConnection http = (HttpURLConnection) connection; + http.setInstanceFollowRedirects(true); + int status = http.getResponseCode(); + if (status < 200 || status > 299) { + throw new GradleException( + "Could not download Embed Code: HTTP " + status + " from " + source + '.' + ); + } + } + + try (InputStream input = connection.getInputStream(); + OutputStream output = Files.newOutputStream(destination)) { + copy(input, output); + } + } catch (IOException exception) { + throw new GradleException("Could not download Embed Code from " + source + '.', exception); + } finally { + if (connection instanceof HttpURLConnection) { + ((HttpURLConnection) connection).disconnect(); + } + } + } + + /** Extracts {@code entryName} from {@code archive} into {@code destination}. */ + private static void extractExecutable(Path archive, String entryName, Path destination) + throws IOException { + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry = zip.getNextEntry(); + while (entry != null) { + String fileName = entry.getName(); + int slash = fileName.lastIndexOf('/'); + if (slash >= 0) { + fileName = fileName.substring(slash + 1); + } + if (!entry.isDirectory() && fileName.equals(entryName)) { + try (OutputStream output = Files.newOutputStream(destination)) { + copy(zip, output); + } + return; + } + zip.closeEntry(); + entry = zip.getNextEntry(); + } + } + throw new GradleException( + "Archive `" + archive + "` does not contain `" + entryName + "`." + ); + } + + /** Copies all bytes from {@code input} into {@code output}. */ + private static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[8_192]; + int count = input.read(buffer); + while (count >= 0) { + output.write(buffer, 0, count); + count = input.read(buffer); + } + } + + /** Moves {@code source} to {@code destination}, atomically when supported. */ + private static void moveAtomically(Path source, Path destination) throws IOException { + try { + Files.move( + source, + destination, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** Removes trailing slashes without changing a URL scheme. */ + private static String trimTrailingSlashes(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end--; + } + return value.substring(0, end); + } +} diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt new file mode 100644 index 00000000..af9c2273 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt @@ -0,0 +1,45 @@ +package io.spine.embedcode.gradle + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.gradle.api.GradleException +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`EmbedCodePlatform` should") +internal class EmbedCodePlatformSpec { + + @Test + fun `select Apple silicon asset`() { + EmbedCodePlatform.detect("Mac OS X", "aarch64") shouldBe + EmbedCodePlatform("embed-code-macos-arm64.zip", "embed-code-macos-arm64") + } + + @Test + fun `select Intel macOS asset`() { + EmbedCodePlatform.detect("Mac OS X", "x86_64") shouldBe + EmbedCodePlatform("embed-code-macos-x64.zip", "embed-code-macos-x64") + } + + @Test + fun `select Linux asset`() { + EmbedCodePlatform.detect("Linux", "amd64") shouldBe + EmbedCodePlatform("embed-code-linux", "embed-code-linux") + } + + @Test + fun `select Windows asset`() { + EmbedCodePlatform.detect("Windows 11", "amd64") shouldBe + EmbedCodePlatform("embed-code-windows.exe", "embed-code-windows.exe") + } + + @Test + fun `reject platform without release binary`() { + val error = shouldThrow { + EmbedCodePlatform.detect("Linux", "aarch64") + } + + error.message shouldBe + "Embed Code does not publish a binary for operating system `Linux` and architecture `aarch64`." + } +} diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt new file mode 100644 index 00000000..e08436e3 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -0,0 +1,205 @@ +package io.spine.embedcode.gradle + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@DisplayName("`EmbedCodePlugin` should") +@EnabledOnOs(OS.LINUX, OS.MAC) +internal class EmbedCodePluginSpec { + + @TempDir + private lateinit var projectDirectory: Path + + private lateinit var releaseDirectory: Path + + @BeforeEach + fun setUp() { + Files.createDirectories(projectDirectory.resolve("code")) + Files.createDirectories(projectDirectory.resolve("docs")) + Files.writeString(projectDirectory.resolve("settings.gradle.kts"), "rootProject.name = \"test-project\"\n") + + releaseDirectory = projectDirectory.resolve("releases") + createFakeRelease(releaseDirectory) + writeBuildFile() + } + + @Test + fun `run check mode with Gradle configuration`() { + val result = runner("checkEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + + val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) + arguments shouldContain "-mode=check" + arguments shouldContain "-code-path=${projectDirectory.resolve("code").toRealPath()}" + arguments shouldContain "-docs-path=${projectDirectory.resolve("docs").toRealPath()}" + arguments shouldContain "-doc-includes=**/*.md,**/*.html" + arguments shouldContain "-doc-excludes=drafts/**,generated/**" + arguments shouldContain "-separator=---" + arguments shouldContain "-info=true" + arguments shouldContain "-stacktrace=true" + } + + @Test + fun `reuse installation when running embed mode`() { + runner("checkEmbedCode").build() + releaseDirectory.toFile().deleteRecursively() + + val result = runner("embedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.UP_TO_DATE + result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" + } + + @Test + fun `run with named source roots and generated configuration`() { + Files.createDirectories(projectDirectory.resolve("company-site")) + Files.createDirectories(projectDirectory.resolve("browser")) + writeNamedSourcesBuildFile() + + val result = runner("checkEmbedCode").build() + + result.task(":checkEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) + arguments shouldContain "-mode=check" + arguments.single { it.startsWith("-config-path=") } + + val configuration = Files.readString(projectDirectory.resolve("generated-config.json")) + configuration shouldContain "\"name\": \"company-site\"" + configuration shouldContain "\"path\": \"${projectDirectory.resolve("company-site").toRealPath()}\"" + configuration shouldContain "\"name\": \"jxbrowser\"" + configuration shouldContain "\"path\": \"${projectDirectory.resolve("browser").toRealPath()}\"" + configuration shouldContain "\"docs-path\": \"${projectDirectory.toRealPath()}\"" + } + + @Test + fun `reject direct and named source roots together`() { + Files.createDirectories(projectDirectory.resolve("browser")) + writeNamedSourcesBuildFile(includeDirectSource = true) + + val result = runner("checkEmbedCode").buildAndFail() + + result.output shouldContain + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." + } + + @Test + fun `report missing release asset`() { + releaseDirectory.toFile().deleteRecursively() + + val result = runner("checkEmbedCode").buildAndFail() + + result.output shouldContain "Could not download Embed Code" + } + + /** Creates a runner using the plugin-under-test classpath. */ + private fun runner(vararg arguments: String): GradleRunner = GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withArguments(*arguments, "--configuration-cache", "--stacktrace") + .withPluginClasspath() + + /** Writes a consuming build configured entirely through the plugin extension. */ + private fun writeBuildFile() { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + version.set("1.2.4") + downloadBaseUrl.set("$baseUrl") + codePath.set(layout.projectDirectory.dir("code")) + docsPath.set(layout.projectDirectory.dir("docs")) + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + separator.set("---") + info.set(true) + stacktrace.set(true) + } + """.trimIndent(), + ) + } + + /** Writes a consuming build with two named source roots and no YAML file. */ + private fun writeNamedSourcesBuildFile(includeDirectSource: Boolean = false) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val directSource = if (includeDirectSource) { + "codePath.set(layout.projectDirectory.dir(\"code\"))" + } else { + "" + } + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + version.set("1.2.4") + downloadBaseUrl.set("$baseUrl") + $directSource + namedSource("company-site", layout.projectDirectory.dir("company-site")) + namedSource("jxbrowser", layout.projectDirectory.dir("browser")) + docsPath.set(layout.projectDirectory) + } + """.trimIndent(), + ) + } + + /** Creates a host-specific fake release asset that records received arguments. */ + private fun createFakeRelease(root: Path) { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val versionDirectory = root.resolve("v1.2.4") + Files.createDirectories(versionDirectory) + val executable = projectDirectory.resolve(platform.executableName) + Files.writeString( + executable, + """ + #!/bin/sh + : > arguments.txt + for argument in "${'$'}@"; do + printf '%s\n' "${'$'}argument" >> arguments.txt + case "${'$'}argument" in + -mode=check) printf 'check\n' > mode.txt ;; + -mode=embed) printf 'embed\n' > mode.txt ;; + -config-path=*) cp "${'$'}{argument#-config-path=}" generated-config.json ;; + esac + done + """.trimIndent() + "\n", + ) + + val asset = versionDirectory.resolve(platform.assetName) + if (platform.assetName.endsWith(".zip")) { + ZipOutputStream(Files.newOutputStream(asset)).use { zip -> + zip.putNextEntry(ZipEntry(platform.executableName)) + Files.newInputStream(executable).use { it.copyTo(zip) } + zip.closeEntry() + } + } else { + Files.copy(executable, asset) + } + } +} From 79053036f1438f5f747d1a20c0bced3c546c4718 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Mon, 13 Jul 2026 18:03:13 +0200 Subject: [PATCH 2/7] Add Gradle 6.9.4 support. --- gradle-plugin/README.md | 2 +- .../embedcode/gradle/EmbedCodePlugin.java | 22 +++++------ .../embedcode/gradle/EmbedCodePluginSpec.kt | 37 +++++++++++++++++-- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md index 5a19f3b9..3dc14770 100644 --- a/gradle-plugin/README.md +++ b/gradle-plugin/README.md @@ -115,7 +115,7 @@ release assets: ## Compatibility The published plugin implementation targets Java 8 bytecode. The tested range -is Gradle 7.6.3 through 9.5.0. The JVM used to run Gradle must also satisfy the +is Gradle 6.9.4 through 9.5.0. The JVM used to run Gradle must also satisfy the selected Gradle version's own Java compatibility requirements. The plugin build uses Kotlin DSL and Kotlin tests, while its published classes diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java index 32e8c217..7cac0acd 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -2,7 +2,6 @@ import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.provider.Provider; import org.gradle.api.tasks.TaskProvider; import java.util.Arrays; @@ -30,12 +29,10 @@ public void apply(Project project) { extension.getStacktrace().convention(false); extension.getDownloadBaseUrl().convention(DEFAULT_DOWNLOAD_BASE_URL); - Provider platform = project.getProviders() - .systemProperty("os.name") - .zip( - project.getProviders().systemProperty("os.arch"), - EmbedCodePlatform::detect - ); + EmbedCodePlatform platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch") + ); TaskProvider installTask = project.getTasks().register( "installEmbedCode", InstallEmbedCodeTask.class, @@ -44,14 +41,13 @@ public void apply(Project project) { task.setDescription("Installs the requested Embed Code executable"); task.getVersion().set(extension.getVersion()); task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); - task.getAssetName().set(platform.map(EmbedCodePlatform::getAssetName)); - task.getExecutableName().set(platform.map(EmbedCodePlatform::getExecutableName)); + task.getAssetName().set(platform.getAssetName()); + task.getExecutableName().set(platform.getExecutableName()); task.getExecutableFile().set( project.getLayout().getBuildDirectory().file( - extension.getVersion().zip( - platform, - (version, selectedPlatform) -> "embed-code/" + version - + '/' + selectedPlatform.getExecutableName() + extension.getVersion().map( + version -> "embed-code/" + version + + '/' + platform.getExecutableName() ) ) ); diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index e08436e3..91d0631e 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -8,6 +8,7 @@ import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.condition.EnabledOnOs import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.io.TempDir @@ -55,6 +56,24 @@ internal class EmbedCodePluginSpec { arguments shouldContain "-stacktrace=true" } + @Test + fun `run check mode with Gradle 6_9_4`() { + val javaHome = System.getenv("EMBED_CODE_GRADLE_6_JAVA_HOME") + assumeTrue( + !javaHome.isNullOrBlank(), + "Set EMBED_CODE_GRADLE_6_JAVA_HOME to a JDK supported by Gradle 6.9.4.", + ) + + val result = runner("checkEmbedCode", useConfigurationCache = false) + .withGradleVersion("6.9.4") + .withEnvironment(System.getenv() + ("JAVA_HOME" to javaHome)) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + @Test fun `reuse installation when running embed mode`() { runner("checkEmbedCode").build() @@ -109,10 +128,20 @@ internal class EmbedCodePluginSpec { } /** Creates a runner using the plugin-under-test classpath. */ - private fun runner(vararg arguments: String): GradleRunner = GradleRunner.create() - .withProjectDir(projectDirectory.toFile()) - .withArguments(*arguments, "--configuration-cache", "--stacktrace") - .withPluginClasspath() + private fun runner( + vararg arguments: String, + useConfigurationCache: Boolean = true, + ): GradleRunner { + val gradleArguments = arguments.toMutableList() + if (useConfigurationCache) { + gradleArguments.add("--configuration-cache") + } + gradleArguments.add("--stacktrace") + return GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withArguments(gradleArguments) + .withPluginClasspath() + } /** Writes a consuming build configured entirely through the plugin extension. */ private fun writeBuildFile() { From 1c79612413afaf9b450a21b92c1b07f0b4949350 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 01:25:03 +0200 Subject: [PATCH 3/7] Improve tasks names. --- gradle-plugin/README.md | 19 +++-- .../embedcode/gradle/EmbedCodePlugin.java | 20 ++++- .../embedcode/gradle/EmbedCodePluginSpec.kt | 73 ++++++++++++++++--- 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md index 3dc14770..00d284e6 100644 --- a/gradle-plugin/README.md +++ b/gradle-plugin/README.md @@ -3,7 +3,7 @@ The `io.spine.embed-code` plugin runs Embed Code without requiring developers or CI jobs to download an executable manually. It selects the released binary for the current platform, installs it under the project's `build/` directory, -and exposes separate check and embed tasks. +and exposes separate `checkEmbedding` and `embedCode` tasks. ## Apply and Configure @@ -92,18 +92,25 @@ documentation targets are not exposed by this Gradle DSL. Check that documentation already contains current source snippets: ```bash -./gradlew checkEmbedCode +./gradlew :checkEmbedding ``` Update documentation in place: ```bash -./gradlew embedCode +./gradlew :embedCode ``` -`installEmbedCode` is an internal preparation task. Gradle runs it -automatically before either execution task and reuses its output until the -requested version, platform, download URL, or build directory changes. +Both tasks belong to the `embed code` group. `installEmbedCode` is an ungrouped +internal preparation task, so it is hidden from the normal `tasks` report but +remains visible with `tasks --all`. Gradle runs it automatically before either +execution task and reuses its output until the requested version, platform, +download URL, or build directory changes. + +The plugin owns the `checkEmbedding` and `embedCode` task names. It rejects an +existing task instead of replacing another plugin's task. The leading `:` in +the commands above selects the root task explicitly; without it, a +multi-project build may also run every subproject task with the same name. The plugin supports the platforms for which Embed Code currently publishes release assets: diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java index 7cac0acd..d86271a8 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -1,5 +1,6 @@ package io.spine.embedcode.gradle; +import org.gradle.api.GradleException; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.tasks.TaskProvider; @@ -12,11 +13,13 @@ public final class EmbedCodePlugin implements Plugin { private static final String DEFAULT_DOWNLOAD_BASE_URL = "https://github.com/SpineEventEngine/embed-code-go/releases/download"; - private static final String SPINE_TASK_GROUP = "spine"; + private static final String TASK_GROUP = "embed code"; /** Applies the plugin to {@code project}. */ @Override public void apply(Project project) { + ensureTaskNameIsAvailable(project, "checkEmbedding"); + ensureTaskNameIsAvailable(project, "embedCode"); EmbedCodeExtension extension = project.getExtensions().create( "embedCode", EmbedCodeExtension.class @@ -37,7 +40,6 @@ public void apply(Project project) { "installEmbedCode", InstallEmbedCodeTask.class, task -> { - task.setGroup(SPINE_TASK_GROUP); task.setDescription("Installs the requested Embed Code executable"); task.getVersion().set(extension.getVersion()); task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); @@ -58,7 +60,7 @@ public void apply(Project project) { project, extension, installTask, - "checkEmbedCode", + "checkEmbedding", "Checks embedded code snippets are up to date", "check" ); @@ -82,7 +84,7 @@ private static void registerExecutionTask( String mode ) { project.getTasks().register(name, EmbedCodeTask.class, task -> { - task.setGroup(SPINE_TASK_GROUP); + task.setGroup(TASK_GROUP); task.setDescription(description); task.getMode().set(mode); task.getCodePath().set(extension.getCodePath()); @@ -98,4 +100,14 @@ private static void registerExecutionTask( task.getWorkingDirectory().set(project.getLayout().getProjectDirectory()); }); } + + /** Rejects a project whose existing task would be replaced by the plugin. */ + private static void ensureTaskNameIsAvailable(Project project, String name) { + if (project.getTasks().findByName(name) != null) { + throw new GradleException( + "Cannot apply `io.spine.embed-code`: task `" + name + "` already exists. " + + "Apply the plugin to a project without that task." + ); + } + } } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 91d0631e..8d7b36e6 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -3,6 +3,7 @@ package io.spine.embedcode.gradle import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.BeforeEach @@ -39,10 +40,10 @@ internal class EmbedCodePluginSpec { @Test fun `run check mode with Gradle configuration`() { - val result = runner("checkEmbedCode").build() + val result = runner(":checkEmbedding").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.task(":checkEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) @@ -64,22 +65,22 @@ internal class EmbedCodePluginSpec { "Set EMBED_CODE_GRADLE_6_JAVA_HOME to a JDK supported by Gradle 6.9.4.", ) - val result = runner("checkEmbedCode", useConfigurationCache = false) + val result = runner(":checkEmbedding", useConfigurationCache = false) .withGradleVersion("6.9.4") .withEnvironment(System.getenv() + ("JAVA_HOME" to javaHome)) .build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.task(":checkEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" } @Test fun `reuse installation when running embed mode`() { - runner("checkEmbedCode").build() + runner(":checkEmbedding").build() releaseDirectory.toFile().deleteRecursively() - val result = runner("embedCode").build() + val result = runner(":embedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.UP_TO_DATE result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS @@ -92,9 +93,9 @@ internal class EmbedCodePluginSpec { Files.createDirectories(projectDirectory.resolve("browser")) writeNamedSourcesBuildFile() - val result = runner("checkEmbedCode").build() + val result = runner(":checkEmbedding").build() - result.task(":checkEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) arguments shouldContain "-mode=check" arguments.single { it.startsWith("-config-path=") } @@ -112,7 +113,7 @@ internal class EmbedCodePluginSpec { Files.createDirectories(projectDirectory.resolve("browser")) writeNamedSourcesBuildFile(includeDirectSource = true) - val result = runner("checkEmbedCode").buildAndFail() + val result = runner(":checkEmbedding").buildAndFail() result.output shouldContain "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." @@ -122,11 +123,63 @@ internal class EmbedCodePluginSpec { fun `report missing release asset`() { releaseDirectory.toFile().deleteRecursively() - val result = runner("checkEmbedCode").buildAndFail() + val result = runner(":checkEmbedding").buildAndFail() result.output shouldContain "Could not download Embed Code" } + @Test + fun `list only execution tasks under the Embed Code group`() { + val result = runner("tasks").build() + + result.output shouldContain "Embed code tasks" + result.output shouldContain "checkEmbedding - Checks embedded code snippets are up to date" + result.output shouldContain "embedCode - Updates embedded code snippets from source files" + result.output shouldNotContain "installEmbedCode" + + val allTasks = runner("tasks", "--all").build() + allTasks.output shouldContain + "installEmbedCode - Installs the requested Embed Code executable" + } + + @Test + fun `reject a project with an existing checkEmbedding task`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("checkEmbedding") + } + """.trimIndent(), + ) + + val result = runner("tasks").buildAndFail() + + result.output shouldContain + "Cannot apply `io.spine.embed-code`: task `checkEmbedding` already exists." + } + + @Test + fun `reject a project with an existing embedCode task`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("embedCode") + } + """.trimIndent(), + ) + + val result = runner("tasks").buildAndFail() + + result.output shouldContain + "Cannot apply `io.spine.embed-code`: task `embedCode` already exists." + } + /** Creates a runner using the plugin-under-test classpath. */ private fun runner( vararg arguments: String, From 77c8c4c194a567326271fa648687389b5f653716 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 01:36:12 +0200 Subject: [PATCH 4/7] Resolve name collision issue. --- gradle-plugin/README.md | 10 +++++---- .../embedcode/gradle/EmbedCodePlugin.java | 22 +++++++++---------- .../embedcode/gradle/EmbedCodePluginSpec.kt | 18 ++++++++------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md index 00d284e6..07dab927 100644 --- a/gradle-plugin/README.md +++ b/gradle-plugin/README.md @@ -107,10 +107,12 @@ remains visible with `tasks --all`. Gradle runs it automatically before either execution task and reuses its output until the requested version, platform, download URL, or build directory changes. -The plugin owns the `checkEmbedding` and `embedCode` task names. It rejects an -existing task instead of replacing another plugin's task. The leading `:` in -the commands above selects the root task explicitly; without it, a -multi-project build may also run every subproject task with the same name. +The plugin prefers the `checkEmbedding` and `embedCode` task names. If one is +already occupied, it prepends underscores until it finds an available name, for +example `_checkEmbedding` or `__checkEmbedding`. Existing tasks are unchanged; +use the `tasks` report to see the selected names. The leading `:` in the +commands above selects the root task explicitly; without it, a multi-project +build may also run every subproject task with the same name. The plugin supports the platforms for which Embed Code currently publishes release assets: diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java index d86271a8..d47f8da7 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -1,6 +1,5 @@ package io.spine.embedcode.gradle; -import org.gradle.api.GradleException; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.tasks.TaskProvider; @@ -18,8 +17,8 @@ public final class EmbedCodePlugin implements Plugin { /** Applies the plugin to {@code project}. */ @Override public void apply(Project project) { - ensureTaskNameIsAvailable(project, "checkEmbedding"); - ensureTaskNameIsAvailable(project, "embedCode"); + String checkTaskName = availableTaskName(project, "checkEmbedding"); + String embedTaskName = availableTaskName(project, "embedCode"); EmbedCodeExtension extension = project.getExtensions().create( "embedCode", EmbedCodeExtension.class @@ -60,7 +59,7 @@ public void apply(Project project) { project, extension, installTask, - "checkEmbedding", + checkTaskName, "Checks embedded code snippets are up to date", "check" ); @@ -68,7 +67,7 @@ public void apply(Project project) { project, extension, installTask, - "embedCode", + embedTaskName, "Updates embedded code snippets from source files", "embed" ); @@ -101,13 +100,12 @@ private static void registerExecutionTask( }); } - /** Rejects a project whose existing task would be replaced by the plugin. */ - private static void ensureTaskNameIsAvailable(Project project, String name) { - if (project.getTasks().findByName(name) != null) { - throw new GradleException( - "Cannot apply `io.spine.embed-code`: task `" + name + "` already exists. " - + "Apply the plugin to a project without that task." - ); + /** Returns {@code preferredName}, prepending underscores until the task name is unused. */ + private static String availableTaskName(Project project, String preferredName) { + String candidate = preferredName; + while (project.getTasks().getNames().contains(candidate)) { + candidate = '_' + candidate; } + return candidate; } } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 8d7b36e6..daa5ecc5 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -143,7 +143,7 @@ internal class EmbedCodePluginSpec { } @Test - fun `reject a project with an existing checkEmbedding task`() { + fun `prepend underscores to an occupied checkEmbedding task name`() { Files.writeString( projectDirectory.resolve("settings.gradle.kts"), """ @@ -151,18 +151,19 @@ internal class EmbedCodePluginSpec { gradle.beforeProject { tasks.register("checkEmbedding") + tasks.register("_checkEmbedding") } """.trimIndent(), ) - val result = runner("tasks").buildAndFail() + val result = runner(":__checkEmbedding").build() - result.output shouldContain - "Cannot apply `io.spine.embed-code`: task `checkEmbedding` already exists." + result.task(":__checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" } @Test - fun `reject a project with an existing embedCode task`() { + fun `prepend underscores to an occupied embedCode task name`() { Files.writeString( projectDirectory.resolve("settings.gradle.kts"), """ @@ -170,14 +171,15 @@ internal class EmbedCodePluginSpec { gradle.beforeProject { tasks.register("embedCode") + tasks.register("_embedCode") } """.trimIndent(), ) - val result = runner("tasks").buildAndFail() + val result = runner(":__embedCode").build() - result.output shouldContain - "Cannot apply `io.spine.embed-code`: task `embedCode` already exists." + result.task(":__embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" } /** Creates a runner using the plugin-under-test classpath. */ From 1af60d75e64e670220894eccc026c339922f6a35 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 01:59:21 +0200 Subject: [PATCH 5/7] Use project version. --- gradle-plugin/README.md | 49 ++++++++++++------- gradle-plugin/build.gradle.kts | 18 ++++++- .../embedcode/gradle/EmbedCodeExtension.java | 4 +- .../embedcode/gradle/EmbedCodePlugin.java | 28 +++++++++++ .../spine/embedcode/gradle/version.properties | 1 + .../embedcode/gradle/EmbedCodePluginSpec.kt | 41 ++++++++++++++-- 6 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md index 07dab927..5f13c5b5 100644 --- a/gradle-plugin/README.md +++ b/gradle-plugin/README.md @@ -11,7 +11,7 @@ After the plugin is published, apply its released version: ```kotlin plugins { - id("io.spine.embed-code") version "0.1.0" + id("io.spine.embed-code") version "1.2.4" } ``` @@ -32,7 +32,6 @@ is required: ```kotlin embedCode { - version.set("1.2.4") codePath.set(layout.projectDirectory.dir("src/main/java")) docsPath.set(layout.projectDirectory.dir("docs")) docIncludes.set(listOf("**/*.md", "**/*.html")) @@ -43,22 +42,32 @@ embedCode { } ``` -`version` and `docsPath` are required. Configure either one unnamed `codePath` -or one or more named sources. The other properties use the same defaults as -the Embed Code command-line application. - -| Property | Default | Purpose | -|---|---|---| -| `version` | Required | Selects the GitHub release tag and executable version. | -| `codePath` | Required without named sources | Sets one unnamed source root. | -| `namedSource(name, directory)` | Required without `codePath` | Adds a source root selected with `$name/`. | -| `docsPath` | Required | Sets the documentation root to scan. | -| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | -| `docExcludes` | Empty | Skips matching documentation files. | -| `separator` | `...` | Separates joined fragment parts. | -| `info` | `false` | Enables informational logging. | -| `stacktrace` | `false` | Prints stack traces after panics. | -| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | +`docsPath` is required. Configure either one unnamed `codePath` or one or more +named sources. By default, the plugin downloads the Embed Code release with the +same version as the plugin. The other properties use the same defaults as the +Embed Code command-line application. + +| Property | Default | Purpose | +|--------------------------------|--------------------------------|--------------------------------------------------------| +| `version` | Applied plugin version | Overrides the GitHub release tag and executable version. | +| `codePath` | Required without named sources | Sets one unnamed source root. | +| `namedSource(name, directory)` | Required without `codePath` | Adds a source root selected with `$name/`. | +| `docsPath` | Required | Sets the documentation root to scan. | +| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | +| `docExcludes` | Empty | Skips matching documentation files. | +| `separator` | `...` | Separates joined fragment parts. | +| `info` | `false` | Enables informational logging. | +| `stacktrace` | `false` | Prints stack traces after panics. | +| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | + +If a matching CLI release has a problem, override only the executable version +while keeping the applied plugin version unchanged: + +```kotlin +embedCode { + version.set("1.2.3") +} +``` ### Named Source Roots @@ -66,7 +75,6 @@ Use `namedSource` when documentation embeds code from multiple modules: ```kotlin embedCode { - version.set("1.2.4") namedSource( "company-site", layout.projectDirectory.dir("company-site"), @@ -149,3 +157,6 @@ it from another checkout: ```bash ./gradlew publishToMavenLocal ``` + +The plugin publication version and its default Embed Code executable version +are both read from the repository's root `VERSION` file. diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index ab30b791..34939f6d 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -7,8 +7,16 @@ plugins { `maven-publish` } +val versionFile = layout.projectDirectory.file("../VERSION") +val embedCodeVersion = providers.fileContents( + providers.provider { versionFile }, +).asText.map { it.trim() }.get() +require(embedCodeVersion.isNotEmpty()) { + "The Embed Code version in ../VERSION must not be empty." +} + group = "io.spine.tools" -version = "0.1.0" +version = embedCodeVersion kotlin { jvmToolchain(17) @@ -42,6 +50,14 @@ tasks.test { useJUnitPlatform() } +tasks.processResources { + val versionProperties = mapOf("embedCodeVersion" to project.version.toString()) + inputs.properties(versionProperties) + filesMatching("**/version.properties") { + expand(versionProperties) + } +} + gradlePlugin { plugins { create("embedCode") { diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java index 77441a4b..40bf3337 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java @@ -17,7 +17,9 @@ */ public abstract class EmbedCodeExtension { - /** Returns the Embed Code release version to download and run. */ + /** + * Returns the Embed Code release version to download and run, defaulting to the plugin version. + */ public abstract Property getVersion(); /** Returns the root directory containing source files used by embedding instructions. */ diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java index d47f8da7..0f170894 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -1,17 +1,23 @@ package io.spine.embedcode.gradle; +import org.gradle.api.GradleException; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.tasks.TaskProvider; +import java.io.IOException; +import java.io.InputStream; import java.util.Arrays; import java.util.Collections; +import java.util.Properties; /** Registers automatic installation and execution tasks for Embed Code. */ public final class EmbedCodePlugin implements Plugin { private static final String DEFAULT_DOWNLOAD_BASE_URL = "https://github.com/SpineEventEngine/embed-code-go/releases/download"; + private static final String VERSION_RESOURCE = + "/io/spine/embedcode/gradle/version.properties"; private static final String TASK_GROUP = "embed code"; /** Applies the plugin to {@code project}. */ @@ -23,6 +29,7 @@ public void apply(Project project) { "embedCode", EmbedCodeExtension.class ); + extension.getVersion().convention(pluginVersion()); extension.getDocIncludes().convention(Arrays.asList("**/*.md", "**/*.html")); extension.getDocExcludes().convention(Collections.emptyList()); extension.getNamedSources().convention(Collections.emptyMap()); @@ -108,4 +115,25 @@ private static String availableTaskName(Project project, String preferredName) { } return candidate; } + + /** Returns the Embed Code version packaged into the plugin at build time. */ + private static String pluginVersion() { + Properties properties = new Properties(); + try (InputStream resource = EmbedCodePlugin.class.getResourceAsStream(VERSION_RESOURCE)) { + if (resource == null) { + throw new GradleException( + "Embed Code plugin version resource is missing: " + VERSION_RESOURCE + ); + } + properties.load(resource); + } catch (IOException error) { + throw new GradleException("Could not read the Embed Code plugin version.", error); + } + + String version = properties.getProperty("version", "").trim(); + if (version.isEmpty()) { + throw new GradleException("The Embed Code plugin version must not be empty."); + } + return version; + } } diff --git a/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties b/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties new file mode 100644 index 00000000..d5e9750f --- /dev/null +++ b/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties @@ -0,0 +1 @@ +version=${embedCodeVersion} diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index daa5ecc5..5fd87c60 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -15,6 +15,7 @@ import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path +import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -57,6 +58,24 @@ internal class EmbedCodePluginSpec { arguments shouldContain "-stacktrace=true" } + @Test + fun `allow overriding the bundled Embed Code version`() { + val overrideVersion = "0.0.0-test" + createFakeRelease(releaseDirectory, overrideVersion) + writeBuildFile(overrideVersion) + + val result = runner(":checkEmbedding").build() + + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + val executableName = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ).executableName + Files.exists( + projectDirectory.resolve("build/embed-code/$overrideVersion/$executableName"), + ) shouldBe true + } + @Test fun `run check mode with Gradle 6_9_4`() { val javaHome = System.getenv("EMBED_CODE_GRADLE_6_JAVA_HOME") @@ -199,8 +218,9 @@ internal class EmbedCodePluginSpec { } /** Writes a consuming build configured entirely through the plugin extension. */ - private fun writeBuildFile() { + private fun writeBuildFile(version: String? = null) { val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -209,7 +229,7 @@ internal class EmbedCodePluginSpec { } embedCode { - version.set("1.2.4") + $versionConfiguration downloadBaseUrl.set("$baseUrl") codePath.set(layout.projectDirectory.dir("code")) docsPath.set(layout.projectDirectory.dir("docs")) @@ -239,7 +259,6 @@ internal class EmbedCodePluginSpec { } embedCode { - version.set("1.2.4") downloadBaseUrl.set("$baseUrl") $directSource namedSource("company-site", layout.projectDirectory.dir("company-site")) @@ -251,12 +270,12 @@ internal class EmbedCodePluginSpec { } /** Creates a host-specific fake release asset that records received arguments. */ - private fun createFakeRelease(root: Path) { + private fun createFakeRelease(root: Path, version: String = bundledVersion()) { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), System.getProperty("os.arch"), ) - val versionDirectory = root.resolve("v1.2.4") + val versionDirectory = root.resolve("v$version") Files.createDirectories(versionDirectory) val executable = projectDirectory.resolve(platform.executableName) Files.writeString( @@ -286,4 +305,16 @@ internal class EmbedCodePluginSpec { Files.copy(executable, asset) } } + + /** Returns the Embed Code version generated from the plugin project's VERSION file. */ + private fun bundledVersion(): String { + val properties = Properties() + val resource = requireNotNull( + EmbedCodePlugin::class.java.getResourceAsStream( + "/io/spine/embedcode/gradle/version.properties", + ), + ) + resource.use { properties.load(it) } + return requireNotNull(properties.getProperty("version")).trim() + } } From a63ba2095bee19b1d45937236833577030d1ddfe Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 11:46:26 +0200 Subject: [PATCH 6/7] Improve readability. --- gradle-plugin/README.md | 47 ++++++++++++++----- gradle-plugin/build.gradle.kts | 4 ++ .../gradle/wrapper/gradle-wrapper.properties | 1 + .../embedcode/gradle/EmbedCodeExtension.java | 10 ++-- .../embedcode/gradle/EmbedCodePlatform.java | 6 +++ .../embedcode/gradle/EmbedCodePlugin.java | 4 +- .../spine/embedcode/gradle/EmbedCodeTask.java | 3 +- .../gradle/InstallEmbedCodeTask.java | 20 ++++++-- .../embedcode/gradle/EmbedCodePlatformSpec.kt | 3 +- ...PluginSpec.kt => EmbedCodePluginIgTest.kt} | 36 ++++++++++++-- 10 files changed, 103 insertions(+), 31 deletions(-) rename gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/{EmbedCodePluginSpec.kt => EmbedCodePluginIgTest.kt} (90%) diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md index 5f13c5b5..f9d24858 100644 --- a/gradle-plugin/README.md +++ b/gradle-plugin/README.md @@ -47,18 +47,18 @@ named sources. By default, the plugin downloads the Embed Code release with the same version as the plugin. The other properties use the same defaults as the Embed Code command-line application. -| Property | Default | Purpose | -|--------------------------------|--------------------------------|--------------------------------------------------------| -| `version` | Applied plugin version | Overrides the GitHub release tag and executable version. | -| `codePath` | Required without named sources | Sets one unnamed source root. | -| `namedSource(name, directory)` | Required without `codePath` | Adds a source root selected with `$name/`. | -| `docsPath` | Required | Sets the documentation root to scan. | -| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | -| `docExcludes` | Empty | Skips matching documentation files. | -| `separator` | `...` | Separates joined fragment parts. | -| `info` | `false` | Enables informational logging. | -| `stacktrace` | `false` | Prints stack traces after panics. | -| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | +| Property | Default | Purpose | +|--------------------------------|--------------------------------|----------------------------------------------| +| `version` | Plugin version | Selects the executable release. | +| `codePath` | Required without named sources | Sets one unnamed source root. | +| `namedSource(name, directory)` | Required without `codePath` | Adds a `$name/` source root. | +| `docsPath` | Required | Sets the documentation root to scan. | +| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | +| `docExcludes` | Empty | Skips matching documentation files. | +| `separator` | `...` | Separates joined fragment parts. | +| `info` | `false` | Enables informational logging. | +| `stacktrace` | `false` | Prints stack traces after panics. | +| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | If a matching CLI release has a problem, override only the executable version while keeping the applied plugin version unchanged: @@ -158,5 +158,28 @@ it from another checkout: ./gradlew publishToMavenLocal ``` +Then make the local repository available to plugin resolution in the consuming +project's `settings.gradle.kts`: + +```kotlin +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} +``` + +The `mavenLocal()` declaration must be in `pluginManagement.repositories`. +Adding it only to the consuming project's regular `repositories` block does not +make locally published Gradle plugin markers available to the `plugins` block. +The consuming build can then apply the locally published version normally: + +```kotlin +plugins { + id("io.spine.embed-code") version "1.2.4" +} +``` + The plugin publication version and its default Embed Code executable version are both read from the repository's root `VERSION` file. diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 34939f6d..c1c403e6 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -48,6 +48,10 @@ dependencies { tasks.test { useJUnitPlatform() + inputs.property( + "embedCodeGradle6JavaHome", + providers.environmentVariable("EMBED_CODE_GRADLE_6_JAVA_HOME").orElse(""), + ) } tasks.processResources { diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties index b52fb7e7..31a44d20 100644 --- a/gradle-plugin/gradle/wrapper/gradle-wrapper.properties +++ b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java index 40bf3337..8056e184 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java @@ -17,9 +17,7 @@ */ public abstract class EmbedCodeExtension { - /** - * Returns the Embed Code release version to download and run, defaulting to the plugin version. - */ + /** Returns the release version to download and run, defaulting to the plugin version. */ public abstract Property getVersion(); /** Returns the root directory containing source files used by embedding instructions. */ @@ -28,7 +26,7 @@ public abstract class EmbedCodeExtension { /** Returns named source roots keyed by the name used in embedding instructions. */ public abstract MapProperty getNamedSources(); - /** Returns named source directories while preserving their task dependencies. */ + /** Returns named source directories with their task dependencies. */ public abstract ConfigurableFileCollection getNamedSourceDirectories(); /** @@ -83,10 +81,10 @@ public void namedSource(String name, Provider directory) { public abstract Property getStacktrace(); /** - * Returns the base URL containing versioned Embed Code release directories. - * *

The plugin appends {@code /v/} to this URL. * This property primarily supports release mirrors and functional testing.

+ * + * @return the base URL containing versioned Embed Code release directories */ public abstract Property getDownloadBaseUrl(); } diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java index 1dbfe527..1858ea28 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java @@ -11,6 +11,12 @@ final class EmbedCodePlatform { private final String assetName; private final String executableName; + /** + * Creates a platform description. + * + * @param assetName the release asset to download + * @param executableName the installed executable name + */ EmbedCodePlatform(String assetName, String executableName) { this.assetName = assetName; this.executableName = executableName; diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java index 0f170894..14a249b2 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -102,7 +102,9 @@ private static void registerExecutionTask( task.getSeparator().set(extension.getSeparator()); task.getInfo().set(extension.getInfo()); task.getStacktrace().set(extension.getStacktrace()); - task.getExecutableFile().set(installTask.flatMap(InstallEmbedCodeTask::getExecutableFile)); + task.getExecutableFile().set( + installTask.flatMap(InstallEmbedCodeTask::getExecutableFile) + ); task.getWorkingDirectory().set(project.getLayout().getProjectDirectory()); }); } diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java index b1cf740a..94a831c5 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java @@ -161,7 +161,8 @@ private Path writeNamedSourceConfiguration(Map namedSources) { Files.write(configuration, json.getBytes(StandardCharsets.UTF_8)); } catch (IOException exception) { throw new GradleException( - "Could not write the generated Embed Code configuration to " + configuration + '.', + "Could not write the generated Embed Code configuration to " + + configuration + '.', exception ); } diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java index 01ffed8e..7bdbe3a5 100644 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java @@ -28,7 +28,9 @@ *

The output file gives Gradle normal up-to-date behavior, so a successfully * installed version is reused by later invocations.

*/ -@DisableCachingByDefault(because = "The downloaded release asset is already reused as a task output") +@DisableCachingByDefault( + because = "The downloaded release asset is already reused as a task output" +) public abstract class InstallEmbedCodeTask extends DefaultTask { private static final int CONNECT_TIMEOUT_MILLIS = 30_000; @@ -70,7 +72,8 @@ public void install() { URI source = URI.create(baseUrl + '/' + releaseTag + '/' + asset); Path destination = getExecutableFile().get().getAsFile().toPath(); Path download = getTemporaryDir().toPath().resolve(asset); - Path preparedExecutable = getTemporaryDir().toPath().resolve(getExecutableName().get()); + Path preparedExecutable = getTemporaryDir().toPath() + .resolve(getExecutableName().get()); try { Files.createDirectories(destination.getParent()); @@ -90,7 +93,10 @@ public void install() { } moveAtomically(preparedExecutable, destination); } catch (IOException exception) { - throw new GradleException("Could not install Embed Code from " + source + '.', exception); + throw new GradleException( + "Could not install Embed Code from " + source + '.', + exception + ); } } @@ -108,7 +114,8 @@ private static void download(URI source, Path destination) { int status = http.getResponseCode(); if (status < 200 || status > 299) { throw new GradleException( - "Could not download Embed Code: HTTP " + status + " from " + source + '.' + "Could not download Embed Code: HTTP " + status + + " from " + source + '.' ); } } @@ -118,7 +125,10 @@ private static void download(URI source, Path destination) { copy(input, output); } } catch (IOException exception) { - throw new GradleException("Could not download Embed Code from " + source + '.', exception); + throw new GradleException( + "Could not download Embed Code from " + source + '.', + exception + ); } finally { if (connection instanceof HttpURLConnection) { ((HttpURLConnection) connection).disconnect(); diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt index af9c2273..a7deee78 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt @@ -40,6 +40,7 @@ internal class EmbedCodePlatformSpec { } error.message shouldBe - "Embed Code does not publish a binary for operating system `Linux` and architecture `aarch64`." + "Embed Code does not publish a binary for operating system `Linux`" + + " and architecture `aarch64`." } } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt similarity index 90% rename from gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt rename to gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt index 5fd87c60..76afc240 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt @@ -20,8 +20,7 @@ import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @DisplayName("`EmbedCodePlugin` should") -@EnabledOnOs(OS.LINUX, OS.MAC) -internal class EmbedCodePluginSpec { +internal class EmbedCodePluginIgTest { @TempDir private lateinit var projectDirectory: Path @@ -32,7 +31,10 @@ internal class EmbedCodePluginSpec { fun setUp() { Files.createDirectories(projectDirectory.resolve("code")) Files.createDirectories(projectDirectory.resolve("docs")) - Files.writeString(projectDirectory.resolve("settings.gradle.kts"), "rootProject.name = \"test-project\"\n") + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + "rootProject.name = \"test-project\"\n", + ) releaseDirectory = projectDirectory.resolve("releases") createFakeRelease(releaseDirectory) @@ -40,6 +42,7 @@ internal class EmbedCodePluginSpec { } @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `run check mode with Gradle configuration`() { val result = runner(":checkEmbedding").build() @@ -59,6 +62,22 @@ internal class EmbedCodePluginSpec { } @Test + fun `install platform release asset`() { + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ).executableName + val installedExecutable = projectDirectory.resolve( + "build/embed-code/${bundledVersion()}/$executableName", + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists(installedExecutable) shouldBe true + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `allow overriding the bundled Embed Code version`() { val overrideVersion = "0.0.0-test" createFakeRelease(releaseDirectory, overrideVersion) @@ -77,6 +96,7 @@ internal class EmbedCodePluginSpec { } @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `run check mode with Gradle 6_9_4`() { val javaHome = System.getenv("EMBED_CODE_GRADLE_6_JAVA_HOME") assumeTrue( @@ -95,6 +115,7 @@ internal class EmbedCodePluginSpec { } @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `reuse installation when running embed mode`() { runner(":checkEmbedding").build() releaseDirectory.toFile().deleteRecursively() @@ -107,6 +128,7 @@ internal class EmbedCodePluginSpec { } @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `run with named source roots and generated configuration`() { Files.createDirectories(projectDirectory.resolve("company-site")) Files.createDirectories(projectDirectory.resolve("browser")) @@ -121,9 +143,11 @@ internal class EmbedCodePluginSpec { val configuration = Files.readString(projectDirectory.resolve("generated-config.json")) configuration shouldContain "\"name\": \"company-site\"" - configuration shouldContain "\"path\": \"${projectDirectory.resolve("company-site").toRealPath()}\"" + val companySitePath = projectDirectory.resolve("company-site").toRealPath() + val browserPath = projectDirectory.resolve("browser").toRealPath() + configuration shouldContain "\"path\": \"$companySitePath\"" configuration shouldContain "\"name\": \"jxbrowser\"" - configuration shouldContain "\"path\": \"${projectDirectory.resolve("browser").toRealPath()}\"" + configuration shouldContain "\"path\": \"$browserPath\"" configuration shouldContain "\"docs-path\": \"${projectDirectory.toRealPath()}\"" } @@ -162,6 +186,7 @@ internal class EmbedCodePluginSpec { } @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `prepend underscores to an occupied checkEmbedding task name`() { Files.writeString( projectDirectory.resolve("settings.gradle.kts"), @@ -182,6 +207,7 @@ internal class EmbedCodePluginSpec { } @Test + @EnabledOnOs(OS.LINUX, OS.MAC) fun `prepend underscores to an occupied embedCode task name`() { Files.writeString( projectDirectory.resolve("settings.gradle.kts"), From d4573d430592d1f384a78630d11afd124340a4ff Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 12:46:20 +0200 Subject: [PATCH 7/7] Provide gradle plugin standards. --- PROJECT.md | 3 +- gradle-plugin/README.md | 63 ++++++++++++++---- gradle-plugin/build.gradle.kts | 66 ++++++++++++++++++- gradle-plugin/gradle.properties | 1 + gradle-plugin/gradle/libs.versions.toml | 6 +- .../embedcode/gradle/EmbedCodePluginIgTest.kt | 11 ++++ 6 files changed, 132 insertions(+), 18 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index a1ee13be..14c90158 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -61,7 +61,8 @@ This repository is configured with these GitHub workflows: - `check`: runs linting, the normal Go test suite, and the showcase end-to-end tests across supported platforms. It also verifies the Gradle plugin on each - supported runner platform and against the minimum Gradle version. + supported runner platform and against the minimum Gradle version, then + assembles its Maven publications on Linux. - `coverage`: uploads Go coverage to Codecov, where `codecov.yml` requires 90% patch coverage for pull requests. The workflow also runs on pushes to `master` to populate base coverage for later pull-request diffs and the diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md index f9d24858..61da5f91 100644 --- a/gradle-plugin/README.md +++ b/gradle-plugin/README.md @@ -47,18 +47,18 @@ named sources. By default, the plugin downloads the Embed Code release with the same version as the plugin. The other properties use the same defaults as the Embed Code command-line application. -| Property | Default | Purpose | -|--------------------------------|--------------------------------|----------------------------------------------| -| `version` | Plugin version | Selects the executable release. | -| `codePath` | Required without named sources | Sets one unnamed source root. | -| `namedSource(name, directory)` | Required without `codePath` | Adds a `$name/` source root. | -| `docsPath` | Required | Sets the documentation root to scan. | -| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | -| `docExcludes` | Empty | Skips matching documentation files. | -| `separator` | `...` | Separates joined fragment parts. | -| `info` | `false` | Enables informational logging. | -| `stacktrace` | `false` | Prints stack traces after panics. | -| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | +| Property | Default | Purpose | +| --- | --- | --- | +| `version` | Plugin version | Selects the executable release. | +| `codePath` | Required without named sources | Sets one unnamed source root. | +| `namedSource(name, directory)` | Required without `codePath` | Adds a `$name/` source root. | +| `docsPath` | Required | Sets the documentation root to scan. | +| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | +| `docExcludes` | Empty | Skips matching documentation files. | +| `separator` | `...` | Separates joined fragment parts. | +| `info` | `false` | Enables informational logging. | +| `stacktrace` | `false` | Prints stack traces after panics. | +| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | If a matching CLI release has a problem, override only the executable version while keeping the applied plugin version unchanged: @@ -139,6 +139,9 @@ The plugin build uses Kotlin DSL and Kotlin tests, while its published classes are Java. Keeping Kotlin 2.x off the consumer plugin classpath allows older Gradle Kotlin DSL compilers to load the plugin. +The plugin declares support for Gradle's configuration cache. Functional tests +run plugin tasks with `--configuration-cache` and verify cache reuse. + ## Develop Run compilation, plugin validation, unit tests, and TestKit functional tests: @@ -183,3 +186,39 @@ plugins { The plugin publication version and its default Embed Code executable version are both read from the repository's root `VERSION` file. + +## Publish + +The plugin is configured for the [Gradle Plugin Portal][plugin-portal]. Before +publishing, verify that the matching `v` GitHub release contains all +platform executables. The plugin uses its own version as the default executable +version, so publishing it before the binaries would leave new installations +without a downloadable asset. + +Request validation from the Plugin Portal without publishing a version: + +```bash +./gradlew publishPlugins --validate-only +``` + +The Portal task requires API credentials even in validation-only mode. Provide +them through `GRADLE_PUBLISH_KEY` and `GRADLE_PUBLISH_SECRET`. The regular CI +build uses `publishToMavenLocal` instead, which assembles the plugin marker, +implementation publication, POM metadata, sources, and Javadocs without +contacting the Portal. + +To publish after validation, run: + +```bash +./gradlew publishPlugins +``` + +The first publication of `io.spine.embed-code` requires manual Portal approval. +The publishing account must be able to establish ownership of the `io.spine` +namespace; this external approval cannot be validated by the local build. + +## License + +The plugin is available under the [Apache License 2.0](../LICENSE). + +[plugin-portal]: https://plugins.gradle.org/docs/publish-plugin diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index c1c403e6..356c8793 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -1,8 +1,12 @@ +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.external.javadoc.StandardJavadocDocletOptions +import org.gradle.plugin.compatibility.compatibility import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.gradle.plugin.publish) `java-gradle-plugin` `maven-publish` } @@ -40,10 +44,21 @@ tasks.named("compileTestKotlin") { compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } +tasks.named("jar") { + from(layout.projectDirectory.file("../LICENSE")) { + into("META-INF") + } +} + +// Getter docs use concise "Returns..." prose instead of duplicate `@return` tags. +tasks.withType().configureEach { + (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:-missing", true) +} + dependencies { testImplementation(libs.junit.jupiter) testImplementation(libs.kotest.assertions.core) - testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.launcher) } tasks.test { @@ -63,13 +78,58 @@ tasks.processResources { } gradlePlugin { + website.set( + "https://github.com/SpineEventEngine/embed-code-go/blob/master/gradle-plugin/README.md", + ) + vcsUrl.set("https://github.com/SpineEventEngine/embed-code-go") plugins { create("embedCode") { id = "io.spine.embed-code" implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" - displayName = "Embed Code" - description = "Runs Embed Code from Gradle without a separately installed executable" + 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 + } + } + } + } +} + +publishing { + publications.withType().configureEach { + pom { + name.set("Embed Code Gradle Plugin") + description.set( + "Runs Embed Code from Gradle without a separately installed executable.", + ) + url.set("https://github.com/SpineEventEngine/embed-code-go") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + developers { + developer { + id.set("SpineEventEngine") + name.set("Spine Event Engine") + url.set("https://github.com/SpineEventEngine") + } + } + scm { + url.set("https://github.com/SpineEventEngine/embed-code-go") + connection.set( + "scm:git:https://github.com/SpineEventEngine/embed-code-go.git", + ) + developerConnection.set( + "scm:git:ssh://git@github.com/SpineEventEngine/embed-code-go.git", + ) + } } } } diff --git a/gradle-plugin/gradle.properties b/gradle-plugin/gradle.properties index ae839e5e..60413e3a 100644 --- a/gradle-plugin/gradle.properties +++ b/gradle-plugin/gradle.properties @@ -1,4 +1,5 @@ org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m org.gradle.parallel=true kotlin.code.style=official kotlin.stdlib.default.dependency=false diff --git a/gradle-plugin/gradle/libs.versions.toml b/gradle-plugin/gradle/libs.versions.toml index 94c94b33..38432dde 100644 --- a/gradle-plugin/gradle/libs.versions.toml +++ b/gradle-plugin/gradle/libs.versions.toml @@ -1,13 +1,15 @@ [versions] junit = "5.14.4" -junit-platform = "1.14.4" kotest = "6.2.2" kotlin = "2.4.0" +platform = "1.14.4" +plugin-publish = "2.1.1" [libraries] junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } -junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } +junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "platform" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } [plugins] +gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "plugin-publish" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt index 76afc240..6f513c47 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt @@ -61,6 +61,17 @@ internal class EmbedCodePluginIgTest { arguments shouldContain "-stacktrace=true" } + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse the configuration cache`() { + runner(":checkEmbedding").build() + + val result = runner(":checkEmbedding").build() + + result.output shouldContain "Reusing configuration cache." + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + } + @Test fun `install platform release asset`() { val result = runner(":installEmbedCode").build()