From 7c7093b5781a03270f595da3898fed7aa06681bd Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 18:12:41 +0200 Subject: [PATCH 01/14] Provide Gradle plugin. --- .github/workflows/check.yml | 6 +- .gitignore | 1 + README.md | 224 ++++++++++ buildSrc/build.gradle.kts | 12 + .../src/main/kotlin/jvm-module.gradle.kts | 2 + gradle-plugin/build.gradle.kts | 106 +++++ .../embedcode/gradle/EmbedCodeExtension.java | 116 +++++ .../embedcode/gradle/EmbedCodePlatform.java | 120 ++++++ .../embedcode/gradle/EmbedCodePlugin.java | 167 ++++++++ .../spine/embedcode/gradle/EmbedCodeTask.java | 285 +++++++++++++ .../gradle/InstallEmbedCodeTask.java | 223 ++++++++++ .../spine/embedcode/gradle/version.properties | 1 + .../embedcode/gradle/EmbedCodePlatformSpec.kt | 82 ++++ .../embedcode/gradle/EmbedCodePluginIgTest.kt | 399 ++++++++++++++++++ 14 files changed, 1742 insertions(+), 2 deletions(-) create mode 100644 README.md 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/main/resources/io/spine/embedcode/gradle/version.properties 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/EmbedCodePluginIgTest.kt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 047d4a6..7bced9d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,10 +15,12 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: 21 + java-version: | + 17 + 21 - name: Set Up Gradle uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew build + run: ./gradlew build :gradle-plugin:publishToMavenLocal diff --git a/.gitignore b/.gitignore index fe9cbe9..f29958d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gradle/ +.kotlin/ .idea/ *.iml **/build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5970d90 --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# 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 `checkEmbedding` and `embedCode` tasks. + +## Apply and Configure + +After the plugin is published, apply its released version: + +```kotlin +plugins { + id("io.spine.embed-code") version "" +} +``` + +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-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 { + 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) +} +``` + +`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` | 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: + +```kotlin +embedCode { + version.set("1.2.3") +} +``` + +### Named Source Roots + +Use `namedSource` when documentation embeds code from multiple modules: + +```kotlin +embedCode { + 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 :checkEmbedding +``` + +Update documentation in place: + +```bash +./gradlew :embedCode +``` + +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 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: + +- Linux AMD64. +- Windows AMD64. +- macOS AMD64 and ARM64. + +## Compatibility + +The published plugin implementation targets Java 8 bytecode. Compatibility is +tested with Gradle 7.6.3 and the current wrapper version, Gradle 9.6.1. 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. + +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: + +```bash +./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 :gradle-plugin: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 "" +} +``` + +The plugin publication version and its default Embed Code executable version +are both read from `version.gradle.kts`. + +## 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 :gradle-plugin: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 :gradle-plugin: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/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index faa9e6b..15ced43 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -36,8 +36,20 @@ plugins { */ val kotlinVersion = "2.4.0" +/** + * Version of the Gradle Plugin Publish plugin. + * + * `buildSrc` needs this version before its dependency objects are compiled. + * Keep in sync with `io.spine.embedcode.gradle.dependency.PluginPublish.version`. + */ +val pluginPublishVersion = "2.1.1" + dependencies { implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + implementation( + "com.gradle.plugin-publish:com.gradle.plugin-publish.gradle.plugin:" + + pluginPublishVersion, + ) } kotlin { diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 8471fdb..76d1b74 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -53,6 +53,8 @@ kotlin { tasks.named("compileJava") { options.release.set(BuildSettings.productionBytecodeVersion) + // Java 8 bytecode is intentional for the documented Gradle 7.6.3 floor. + options.compilerArgs.add("-Xlint:-options") } tasks.named("compileTestKotlin") { diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index bb7b934..df73505 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -24,6 +24,112 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.dependency.PluginPublish +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.external.javadoc.StandardJavadocDocletOptions +import org.gradle.plugin.compatibility.compatibility + plugins { id("jvm-module") + `java-gradle-plugin` + `maven-publish` +} + +apply(plugin = PluginPublish.id) + +base { + archivesName.set("embed-code-gradle-plugin") +} + +java { + withJavadocJar() + withSourcesJar() +} + +tasks.withType().configureEach { + from(rootProject.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) +} + +tasks.test { + inputs.property( + "embedCodeGradle7JavaHome", + providers.environmentVariable("EMBED_CODE_GRADLE_7_JAVA_HOME") + .orElse(providers.environmentVariable("JAVA_HOME_17_X64")) + .orElse(""), + ) +} + +tasks.processResources { + val versionProperties = mapOf("embedCodeVersion" to project.version.toString()) + inputs.properties(versionProperties) + filesMatching("**/version.properties") { + expand(versionProperties) + } +} + +gradlePlugin { + website.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") + vcsUrl.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") + plugins { + create("embedCode") { + id = "io.spine.embed-code" + implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" + displayName = "Embed Code Gradle Plugin" + description = + "Runs Embed Code from Gradle without a separately installed executable." + tags.set(listOf("documentation", "code-samples")) + compatibility { + features { + configurationCache = true + } + } + } + } +} + +publishing { + publications.withType().configureEach { + if (name == "pluginMaven") { + artifactId = "embed-code-gradle-plugin" + } + 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-gradle-plugin") + 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-gradle-plugin") + connection.set( + "scm:git:https://github.com/SpineEventEngine/" + + "embed-code-gradle-plugin.git", + ) + developerConnection.set( + "scm:git:ssh://git@github.com/SpineEventEngine/" + + "embed-code-gradle-plugin.git", + ) + } + } + } } 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 0000000..dbece38 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +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 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. */ + public abstract DirectoryProperty getCodePath(); + + /** Returns named source roots keyed by the name used in embedding instructions. */ + public abstract MapProperty getNamedSources(); + + /** Returns named source directories with 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(); + + /** + *

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 new file mode 100644 index 0000000..c0d0d18 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +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; + + /** + * 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; + } + + /** 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 0000000..2b74d0c --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -0,0 +1,167 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +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}. */ + @Override + public void apply(Project project) { + String checkTaskName = availableTaskName(project, "checkEmbedding"); + String embedTaskName = availableTaskName(project, "embedCode"); + EmbedCodeExtension extension = project.getExtensions().create( + "embedCode", + EmbedCodeExtension.class + ); + extension.getVersion().convention(pluginVersion()); + 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); + + EmbedCodePlatform platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch") + ); + TaskProvider installTask = project.getTasks().register( + "installEmbedCode", + InstallEmbedCodeTask.class, + task -> { + task.setDescription("Installs the requested Embed Code executable"); + task.getVersion().set(extension.getVersion()); + task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); + task.getAssetName().set(platform.getAssetName()); + task.getExecutableName().set(platform.getExecutableName()); + task.getExecutableFile().set( + project.getLayout().getBuildDirectory().file( + extension.getVersion().map( + version -> "embed-code/" + version + + '/' + platform.getExecutableName() + ) + ) + ); + } + ); + + registerExecutionTask( + project, + extension, + installTask, + checkTaskName, + "Checks embedded code snippets are up to date", + "check" + ); + registerExecutionTask( + project, + extension, + installTask, + embedTaskName, + "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(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()); + }); + } + + /** 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; + } + + /** 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/java/io/spine/embedcode/gradle/EmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java new file mode 100644 index 0000000..43cc072 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java @@ -0,0 +1,285 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +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 0000000..e8a4e7a --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java @@ -0,0 +1,223 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +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/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 0000000..d5e9750 --- /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/EmbedCodePlatformSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt new file mode 100644 index 0000000..607154d --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`EmbedCodePlatform` should") +internal class EmbedCodePlatformSpec { + + @Test + fun `select Apple silicon asset`() { + assertEquals( + EmbedCodePlatform("embed-code-macos-arm64.zip", "embed-code-macos-arm64"), + EmbedCodePlatform.detect("Mac OS X", "aarch64"), + ) + } + + @Test + fun `select Intel macOS asset`() { + assertEquals( + EmbedCodePlatform("embed-code-macos-x64.zip", "embed-code-macos-x64"), + EmbedCodePlatform.detect("Mac OS X", "x86_64"), + ) + } + + @Test + fun `select Linux asset`() { + assertEquals( + EmbedCodePlatform("embed-code-linux", "embed-code-linux"), + EmbedCodePlatform.detect("Linux", "amd64"), + ) + } + + @Test + fun `select Windows asset`() { + assertEquals( + EmbedCodePlatform("embed-code-windows.exe", "embed-code-windows.exe"), + EmbedCodePlatform.detect("Windows 11", "amd64"), + ) + } + + @Test + fun `reject platform without release binary`() { + val error = assertThrows(GradleException::class.java) { + EmbedCodePlatform.detect("Linux", "aarch64") + } + + assertEquals( + "Embed Code does not publish a binary for operating system `Linux`" + + " and architecture `aarch64`.", + error.message, + ) + } +} 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 new file mode 100644 index 0000000..11983ff --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +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 +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@DisplayName("`EmbedCodePlugin` should") +internal class EmbedCodePluginIgTest { + + @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 + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle configuration`() { + val result = runner(":checkEmbedding").build() + + result.task(":installEmbedCode")?.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")) + 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 + @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() + 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) + 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 + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle 7_6_3`() { + val javaHome = System.getenv("EMBED_CODE_GRADLE_7_JAVA_HOME") + ?: System.getenv("JAVA_HOME_17_X64") + assumeTrue( + !javaHome.isNullOrBlank(), + "Set EMBED_CODE_GRADLE_7_JAVA_HOME to a JDK supported by Gradle 7.6.3.", + ) + + val result = runner(":checkEmbedding", useConfigurationCache = false) + .withGradleVersion("7.6.3") + .withEnvironment(System.getenv() + ("JAVA_HOME" to javaHome)) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse installation when running embed mode`() { + runner(":checkEmbedding").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 + @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")) + writeNamedSourcesBuildFile() + + val result = runner(":checkEmbedding").build() + + 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=") } + + val configuration = Files.readString(projectDirectory.resolve("generated-config.json")) + configuration shouldContain "\"name\": \"company-site\"" + val companySitePath = projectDirectory.resolve("company-site").toRealPath() + val browserPath = projectDirectory.resolve("browser").toRealPath() + configuration shouldContain "\"path\": \"$companySitePath\"" + configuration shouldContain "\"name\": \"jxbrowser\"" + configuration shouldContain "\"path\": \"$browserPath\"" + 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(":checkEmbedding").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(":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 + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied checkEmbedding task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("checkEmbedding") + tasks.register("_checkEmbedding") + } + """.trimIndent(), + ) + + val result = runner(":__checkEmbedding").build() + + result.task(":__checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied embedCode task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("embedCode") + tasks.register("_embedCode") + } + """.trimIndent(), + ) + + val result = runner(":__embedCode").build() + + 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. */ + 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(version: String? = null) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + $versionConfiguration + 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 { + 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, version: String = bundledVersion()) { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val versionDirectory = root.resolve("v$version") + 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) + } + } + + /** Returns the Embed Code version bundled into the plugin resources. */ + 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() + } +} + +private infix fun T.shouldBe(expected: T) { + assertEquals(expected, this) +} + +private infix fun Iterable.shouldContain(expected: T) { + assertTrue(any { it == expected }, "Expected collection to contain <$expected>.") +} + +private infix fun String.shouldContain(expected: String) { + assertTrue(contains(expected), "Expected text to contain <$expected>.") +} + +private infix fun String.shouldNotContain(expected: String) { + assertFalse(contains(expected), "Expected text not to contain <$expected>.") +} From 0a16136c563572091fe03234d721fad0f15a09d0 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 10:12:29 +0200 Subject: [PATCH 02/14] Improve version control. --- README.md | 57 ++++++++++--------- gradle-plugin/build.gradle.kts | 8 --- .../embedcode/gradle/EmbedCodeExtension.java | 10 ++-- .../embedcode/gradle/EmbedCodePlugin.java | 32 ++--------- .../gradle/InstallEmbedCodeTask.java | 43 +++++++++----- .../spine/embedcode/gradle/version.properties | 1 - .../embedcode/gradle/EmbedCodePluginIgTest.kt | 31 +++++----- 7 files changed, 84 insertions(+), 98 deletions(-) delete mode 100644 gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties diff --git a/README.md b/README.md index 5970d90..3670c92 100644 --- a/README.md +++ b/README.md @@ -43,25 +43,26 @@ embedCode { ``` `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` | 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: +named sources. By default, the plugin downloads the latest Embed Code release +from GitHub Releases. Plugin and application versions are independent. The +other properties use the same defaults as the Embed Code command-line +application. + +| Property | Default | Purpose | +|--------------------------------|--------------------------------|----------------------------------------------| +| `version` | Latest GitHub release | Pins a specific executable release when set. | +| `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. | + +For reproducible builds, or if the latest CLI release has a problem, pin only +the executable version while keeping the applied plugin version unchanged: ```kotlin embedCode { @@ -112,8 +113,9 @@ Update documentation in place: 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. +execution task. Without an explicit `version`, it downloads the current latest +release on every invocation. A pinned version uses Gradle's normal up-to-date +behavior and reuses its installed executable. The plugin prefers the `checkEmbedding` and `embedCode` task names. If one is already occupied, it prepends underscores until it finds an available name, for @@ -184,16 +186,15 @@ plugins { } ``` -The plugin publication version and its default Embed Code executable version -are both read from `version.gradle.kts`. +The plugin publication version is configured in `version.gradle.kts`. Embed +Code application versions are resolved independently at execution time. ## 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. +The plugin is configured for the [Gradle Plugin Portal][plugin-portal]. Its +publication version does not need to match an Embed Code application version. +By default, every published plugin version follows the latest stable GitHub +release; consumers can pin an application version through the extension. Request validation from the Plugin Portal without publishing a version: diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index df73505..fe37699 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -66,14 +66,6 @@ tasks.test { ) } -tasks.processResources { - val versionProperties = mapOf("embedCodeVersion" to project.version.toString()) - inputs.properties(versionProperties) - filesMatching("**/version.properties") { - expand(versionProperties) - } -} - gradlePlugin { website.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") vcsUrl.set("https://github.com/SpineEventEngine/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 index dbece38..d1263fe 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 @@ -43,7 +43,7 @@ */ public abstract class EmbedCodeExtension { - /** Returns the release version to download and run, defaulting to the plugin version. */ + /** Returns an optional release version, with the latest release used when absent. */ public abstract Property getVersion(); /** Returns the root directory containing source files used by embedding instructions. */ @@ -107,10 +107,12 @@ public void namedSource(String name, Provider directory) { public abstract Property getStacktrace(); /** - *

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

+ *

The plugin appends {@code /latest/download/} when no + * version is configured, or {@code /download/v/} + * for an explicit version. This property primarily supports release + * mirrors and functional testing.

* - * @return the base URL containing versioned Embed Code release directories + * @return the base URL of the Embed Code releases */ public abstract Property getDownloadBaseUrl(); } 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 2b74d0c..6b6b50c 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 @@ -26,24 +26,18 @@ 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"; + "https://github.com/SpineEventEngine/embed-code-go/releases"; private static final String TASK_GROUP = "embed code"; /** Applies the plugin to {@code project}. */ @@ -55,7 +49,6 @@ 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()); @@ -82,9 +75,12 @@ public void apply(Project project) { extension.getVersion().map( version -> "embed-code/" + version + '/' + platform.getExecutableName() + ).orElse( + "embed-code/latest/" + platform.getExecutableName() ) ) ); + task.getOutputs().upToDateWhen(ignored -> extension.getVersion().isPresent()); } ); @@ -144,24 +140,4 @@ 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/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java index e8a4e7a..2e8be8e 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 @@ -31,6 +31,7 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.TaskAction; import org.gradle.work.DisableCachingByDefault; @@ -51,22 +52,24 @@ /** * 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.

+ *

An explicitly selected version is reused using Gradle's normal up-to-date + * behavior. The latest release is downloaded on every invocation so that it + * cannot remain stale behind an existing output.

*/ @DisableCachingByDefault( - because = "The downloaded release asset is already reused as a task output" + because = "Release assets come from external URLs that may change" ) 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. */ + /** Returns an optional Embed Code release version. */ @Input + @Optional public abstract Property getVersion(); - /** Returns the base URL containing versioned release directories. */ + /** Returns the base URL of the Embed Code releases. */ @Input public abstract Property getDownloadBaseUrl(); @@ -85,17 +88,17 @@ public abstract class InstallEmbedCodeTask extends DefaultTask { /** 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 requestedVersion = getVersion().getOrNull(); + boolean useLatest = requestedVersion == null; + if (!useLatest) { + requestedVersion = requestedVersion.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); + URI source = releaseAsset(baseUrl, requestedVersion, asset); Path destination = getExecutableFile().get().getAsFile().toPath(); Path download = getTemporaryDir().toPath().resolve(asset); Path preparedExecutable = getTemporaryDir().toPath() @@ -103,7 +106,8 @@ public void install() { try { Files.createDirectories(destination.getParent()); - getLogger().lifecycle("Downloading Embed Code {} from {}", requestedVersion, source); + String release = useLatest ? "latest release" : requestedVersion; + getLogger().lifecycle("Downloading Embed Code {} from {}", release, source); download(source, download); if (asset.endsWith(".zip")) { @@ -126,6 +130,17 @@ public void install() { } } + /** Returns the release asset URI for the latest or explicitly requested version. */ + private static URI releaseAsset(String baseUrl, String requestedVersion, String asset) { + if (requestedVersion == null) { + return URI.create(baseUrl + "/latest/download/" + asset); + } + String releaseTag = requestedVersion.startsWith("v") + ? requestedVersion + : "v" + requestedVersion; + return URI.create(baseUrl + "/download/" + releaseTag + '/' + asset); + } + /** Downloads {@code source} into {@code destination}, reporting HTTP failures clearly. */ private static void download(URI source, Path destination) { URLConnection connection = null; 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 deleted file mode 100644 index d5e9750..0000000 --- a/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties +++ /dev/null @@ -1 +0,0 @@ -version=${embedCodeVersion} 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 11983ff..9b0a9f6 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 @@ -40,7 +40,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.nio.file.StandardCopyOption import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -94,6 +94,7 @@ internal class EmbedCodePluginIgTest { val result = runner(":checkEmbedding").build() result.output shouldContain "Reusing configuration cache." + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS } @@ -105,7 +106,7 @@ internal class EmbedCodePluginIgTest { System.getProperty("os.arch"), ).executableName val installedExecutable = projectDirectory.resolve( - "build/embed-code/${bundledVersion()}/$executableName", + "build/embed-code/latest/$executableName", ) result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS @@ -114,7 +115,7 @@ internal class EmbedCodePluginIgTest { @Test @EnabledOnOs(OS.LINUX, OS.MAC) - fun `allow overriding the bundled Embed Code version`() { + fun `allow overriding the latest Embed Code version`() { val overrideVersion = "0.0.0-test" createFakeRelease(releaseDirectory, overrideVersion) writeBuildFile(overrideVersion) @@ -154,6 +155,7 @@ internal class EmbedCodePluginIgTest { @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `reuse installation when running embed mode`() { + writeBuildFile(TEST_RELEASE_VERSION) runner(":checkEmbedding").build() releaseDirectory.toFile().deleteRecursively() @@ -333,13 +335,15 @@ internal class EmbedCodePluginIgTest { } /** Creates a host-specific fake release asset that records received arguments. */ - private fun createFakeRelease(root: Path, version: String = bundledVersion()) { + private fun createFakeRelease(root: Path, version: String = TEST_RELEASE_VERSION) { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), System.getProperty("os.arch"), ) - val versionDirectory = root.resolve("v$version") + val versionDirectory = root.resolve("download/v$version") + val latestDirectory = root.resolve("latest/download") Files.createDirectories(versionDirectory) + Files.createDirectories(latestDirectory) val executable = projectDirectory.resolve(platform.executableName) Files.writeString( executable, @@ -367,18 +371,15 @@ internal class EmbedCodePluginIgTest { } else { Files.copy(executable, asset) } + Files.copy( + asset, + latestDirectory.resolve(platform.assetName), + StandardCopyOption.REPLACE_EXISTING, + ) } - /** Returns the Embed Code version bundled into the plugin resources. */ - 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() + private companion object { + const val TEST_RELEASE_VERSION = "1.2.4-test" } } From e14beab1b9e0494a06c6c0c150030fa428d3f426 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 11:29:07 +0200 Subject: [PATCH 03/14] Remove old Gradle versions support. --- .github/workflows/check.yml | 20 +- README.md | 25 +- .../spine/embedcode/gradle/BuildSettings.kt | 7 +- .../embedcode/gradle/dependency/JUnit.kt | 2 +- .../src/main/kotlin/jvm-module.gradle.kts | 27 +- gradle-plugin/build.gradle.kts | 22 +- .../embedcode/gradle/EmbedCodeExtension.java | 118 -------- .../embedcode/gradle/EmbedCodePlatform.java | 120 -------- .../embedcode/gradle/EmbedCodePlugin.java | 143 --------- .../spine/embedcode/gradle/EmbedCodeTask.java | 285 ------------------ .../gradle/InstallEmbedCodeTask.java | 238 --------------- .../embedcode/gradle/EmbedCodeExtension.kt | 120 ++++++++ .../embedcode/gradle/EmbedCodePlatform.kt | 75 +++++ .../spine/embedcode/gradle/EmbedCodePlugin.kt | 135 +++++++++ .../spine/embedcode/gradle/EmbedCodeTask.kt | 270 +++++++++++++++++ .../embedcode/gradle/InstallEmbedCodeTask.kt | 228 ++++++++++++++ .../embedcode/gradle/EmbedCodePluginIgTest.kt | 34 ++- 17 files changed, 904 insertions(+), 965 deletions(-) delete mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java delete mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java delete mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java delete mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java delete mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index cafb761..8238498 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,6 +1,14 @@ name: Check -on: pull_request +on: + pull_request: + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: @@ -9,9 +17,15 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 + + - name: Set Up Java 17 for Compatibility Tests + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 - - name: Set Up Java + - name: Set Up Java 25 uses: actions/setup-java@v5 with: distribution: temurin diff --git a/README.md b/README.md index 3670c92..90ff1e9 100644 --- a/README.md +++ b/README.md @@ -133,14 +133,19 @@ release assets: ## Compatibility -The published plugin implementation targets Java 8 bytecode. Compatibility is -tested with Gradle 7.6.3 and the current wrapper version, Gradle 9.6.1. The JVM -used to run Gradle must also satisfy the selected Gradle version's own Java -compatibility requirements. +The plugin requires Gradle 8.14.4 or newer. Its published classes require Java +17, and the JVM running the build must also be supported by the selected Gradle +version. Compatibility is tested with Gradle 8.14.4, Gradle 9.0.0, and the +current wrapper version, Gradle 9.6.1. -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 implementation, build scripts, and tests are written in Kotlin. +Consumers do not need to install Kotlin or apply a Kotlin plugin because Gradle +provides the Kotlin runtime. The project uses the Kotlin 2.4.10 compiler but +targets Kotlin 2.0 language and API levels because Gradle 8.14.4 embeds Kotlin +2.0.21. Published classes target Java 17 bytecode. + +The build uses a JDK 25 toolchain. TestKit runs on a Java 17 toolchain so that +the same suite can exercise the minimum Gradle version and Gradle 9.0.0. The plugin declares support for Gradle's configuration cache. Functional tests run plugin tasks with `--configuration-cache` and verify cache reuse. @@ -153,8 +158,10 @@ Run compilation, plugin validation, unit tests, and TestKit functional tests: ./gradlew check ``` -The functional tests create local fake release assets. They do not download or -execute a real GitHub release. +The functional tests create local fake release assets and run them with Gradle +8.14.4, Gradle 9.0.0, and the wrapper version. They do not download or execute +a real GitHub release. JDK 17 and JDK 25 must both be discoverable as Gradle +toolchains when running the complete suite locally. Publish the current plugin version to the local Maven repository when testing it from another checkout: diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt index 116ef5b..b535b0a 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt @@ -33,10 +33,9 @@ object BuildSettings { const val javaVersion = 25 /** - * JVM bytecode version produced for published code. + * JVM bytecode version produced by the project. * - * Java 8 bytecode keeps the plugin loadable by the minimum supported Gradle - * version, 7.6.3, while builds and tests use Java 25. + * Java 17 is supported by Gradle 8.14.4 and required by Gradle 9. */ - const val productionBytecodeVersion = 8 + const val bytecodeVersion = 17 } diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt index 123470c..a4d1a16 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt @@ -29,7 +29,7 @@ package io.spine.embedcode.gradle.dependency /** JUnit dependencies used by tests. */ object JUnit { - const val version = "6.1.1" + const val version = "6.1.2" private const val group = "org.junit.jupiter" // https://github.com/junit-team/junit5 diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 76d1b74..f9fd07c 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -27,16 +27,14 @@ import io.spine.embedcode.gradle.BuildSettings import io.spine.embedcode.gradle.dependency.JUnit import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { `java-library` kotlin("jvm") } -fun jvmTarget(version: Int): JvmTarget = JvmTarget.fromTarget( - if (version == 8) "1.$version" else version.toString(), -) +fun jvmTarget(version: Int): JvmTarget = JvmTarget.fromTarget(version.toString()) java { toolchain { @@ -46,19 +44,17 @@ java { kotlin { compilerOptions { - jvmTarget.set(jvmTarget(BuildSettings.productionBytecodeVersion)) + jvmTarget.set(jvmTarget(BuildSettings.bytecodeVersion)) + // Gradle 8.14.4 embeds Kotlin 2.0.21. Keep plugin metadata and + // standard-library API usage compatible with that runtime. + languageVersion.set(KotlinVersion.KOTLIN_2_0) + apiVersion.set(KotlinVersion.KOTLIN_2_0) freeCompilerArgs.add("-Xjsr305=strict") } } -tasks.named("compileJava") { - options.release.set(BuildSettings.productionBytecodeVersion) - // Java 8 bytecode is intentional for the documented Gradle 7.6.3 floor. - options.compilerArgs.add("-Xlint:-options") -} - -tasks.named("compileTestKotlin") { - compilerOptions.jvmTarget.set(jvmTarget(BuildSettings.javaVersion)) +tasks.withType().configureEach { + options.release.set(BuildSettings.bytecodeVersion) } dependencies { @@ -68,4 +64,9 @@ dependencies { tasks.test { useJUnitPlatform() + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(BuildSettings.bytecodeVersion)) + }, + ) } diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index fe37699..635d653 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -24,9 +24,9 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.dependency.Kotlin import io.spine.embedcode.gradle.dependency.PluginPublish import org.gradle.api.publish.maven.MavenPublication -import org.gradle.external.javadoc.StandardJavadocDocletOptions import org.gradle.plugin.compatibility.compatibility plugins { @@ -37,6 +37,12 @@ plugins { apply(plugin = PluginPublish.id) +dependencies { + // Gradle supplies Kotlin at runtime, so the plugin does not publish the standard library. + compileOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") + testCompileOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") +} + base { archivesName.set("embed-code-gradle-plugin") } @@ -52,20 +58,6 @@ tasks.withType().configureEach { } } -// Getter docs use concise "Returns..." prose instead of duplicate `@return` tags. -tasks.withType().configureEach { - (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:-missing", true) -} - -tasks.test { - inputs.property( - "embedCodeGradle7JavaHome", - providers.environmentVariable("EMBED_CODE_GRADLE_7_JAVA_HOME") - .orElse(providers.environmentVariable("JAVA_HOME_17_X64")) - .orElse(""), - ) -} - gradlePlugin { website.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") vcsUrl.set("https://github.com/SpineEventEngine/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 deleted file mode 100644 index d1263fe..0000000 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.embedcode.gradle; - -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 an optional release version, with the latest release used when absent. */ - 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 with 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(); - - /** - *

The plugin appends {@code /latest/download/} when no - * version is configured, or {@code /download/v/} - * for an explicit version. This property primarily supports release - * mirrors and functional testing.

- * - * @return the base URL of the Embed Code releases - */ - 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 deleted file mode 100644 index c0d0d18..0000000 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.embedcode.gradle; - -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; - - /** - * 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; - } - - /** 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 deleted file mode 100644 index 6b6b50c..0000000 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.embedcode.gradle; - -import org.gradle.api.Plugin; -import org.gradle.api.Project; -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"; - private static final String TASK_GROUP = "embed code"; - - /** Applies the plugin to {@code project}. */ - @Override - public void apply(Project project) { - String checkTaskName = availableTaskName(project, "checkEmbedding"); - String embedTaskName = availableTaskName(project, "embedCode"); - 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); - - EmbedCodePlatform platform = EmbedCodePlatform.detect( - System.getProperty("os.name"), - System.getProperty("os.arch") - ); - TaskProvider installTask = project.getTasks().register( - "installEmbedCode", - InstallEmbedCodeTask.class, - task -> { - task.setDescription("Installs the requested Embed Code executable"); - task.getVersion().set(extension.getVersion()); - task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); - task.getAssetName().set(platform.getAssetName()); - task.getExecutableName().set(platform.getExecutableName()); - task.getExecutableFile().set( - project.getLayout().getBuildDirectory().file( - extension.getVersion().map( - version -> "embed-code/" + version - + '/' + platform.getExecutableName() - ).orElse( - "embed-code/latest/" + platform.getExecutableName() - ) - ) - ); - task.getOutputs().upToDateWhen(ignored -> extension.getVersion().isPresent()); - } - ); - - registerExecutionTask( - project, - extension, - installTask, - checkTaskName, - "Checks embedded code snippets are up to date", - "check" - ); - registerExecutionTask( - project, - extension, - installTask, - embedTaskName, - "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(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()); - }); - } - - /** 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/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java deleted file mode 100644 index 43cc072..0000000 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.embedcode.gradle; - -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 deleted file mode 100644 index 2e8be8e..0000000 --- a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.embedcode.gradle; - -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.Optional; -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. - * - *

An explicitly selected version is reused using Gradle's normal up-to-date - * behavior. The latest release is downloaded on every invocation so that it - * cannot remain stale behind an existing output.

- */ -@DisableCachingByDefault( - because = "Release assets come from external URLs that may change" -) -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 an optional Embed Code release version. */ - @Input - @Optional - public abstract Property getVersion(); - - /** Returns the base URL of the Embed Code releases. */ - @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().getOrNull(); - boolean useLatest = requestedVersion == null; - if (!useLatest) { - requestedVersion = requestedVersion.trim(); - if (requestedVersion.isEmpty()) { - throw new GradleException("Embed Code version must not be empty."); - } - } - String asset = getAssetName().get(); - String baseUrl = trimTrailingSlashes(getDownloadBaseUrl().get()); - URI source = releaseAsset(baseUrl, requestedVersion, 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()); - String release = useLatest ? "latest release" : requestedVersion; - getLogger().lifecycle("Downloading Embed Code {} from {}", release, 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 - ); - } - } - - /** Returns the release asset URI for the latest or explicitly requested version. */ - private static URI releaseAsset(String baseUrl, String requestedVersion, String asset) { - if (requestedVersion == null) { - return URI.create(baseUrl + "/latest/download/" + asset); - } - String releaseTag = requestedVersion.startsWith("v") - ? requestedVersion - : "v" + requestedVersion; - return URI.create(baseUrl + "/download/" + releaseTag + '/' + asset); - } - - /** 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/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt new file mode 100644 index 0000000..b362a20 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +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 { + + /** An optional release version, with the latest release used when absent. */ + public abstract val version: Property + + /** The root directory containing source files used by embedding instructions. */ + public abstract val codePath: DirectoryProperty + + /** Named source roots keyed by the name used in embedding instructions. */ + public abstract val namedSources: MapProperty + + /** Named source directories with their task dependencies. */ + public abstract val namedSourceDirectories: ConfigurableFileCollection + + /** + * Adds a named source root. + * + * @param name the name referenced as `$name` in an embedding instruction + * @param directory the source root directory + */ + public fun namedSource(name: String, directory: Directory) { + val normalizedName = validateSourceName(name) + namedSources.put(normalizedName, directory.asFile.absolutePath) + namedSourceDirectories.from(directory) + } + + /** + * Adds a named source root supplied by another Gradle provider. + * + * @param name the name referenced as `$name` in an embedding instruction + * @param directory the source root provider, including its task dependency + */ + public fun namedSource(name: String, directory: Provider) { + val normalizedName = validateSourceName(name) + namedSources.put( + normalizedName, + directory.map { value -> value.asFile.absolutePath }, + ) + namedSourceDirectories.from(directory) + } + + /** The root directory containing Markdown or HTML documentation. */ + public abstract val docsPath: DirectoryProperty + + /** Glob patterns selecting documentation files to process. */ + public abstract val docIncludes: ListProperty + + /** Glob patterns selecting documentation files to skip. */ + public abstract val docExcludes: ListProperty + + /** Text inserted between joined fragment parts. */ + public abstract val separator: Property + + /** Whether Embed Code should print informational log messages. */ + public abstract val info: Property + + /** Whether Embed Code should print stack traces after panics. */ + public abstract val stacktrace: Property + + /** + * The base URL of the Embed Code releases. + * + * The plugin appends `/latest/download/` when no version is + * configured, or `/download/v/` for an explicit + * version. This property primarily supports release mirrors and functional + * testing. + */ + public abstract val downloadBaseUrl: Property + + private fun validateSourceName(name: String): String { + val normalizedName = name.trim() + if (normalizedName.isEmpty()) { + throw InvalidUserDataException("An Embed Code source name must not be empty.") + } + return normalizedName + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt new file mode 100644 index 0000000..0598e98 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.GradleException +import java.util.Locale + +/** A released executable selected for an operating system and architecture. */ +internal data class EmbedCodePlatform( + val assetName: String, + val executableName: String, +) { + + companion object { + + /** Selects the release asset for [osName] and [architecture]. */ + fun detect(osName: String, architecture: String): EmbedCodePlatform { + val os = osName.lowercase(Locale.ROOT) + val arch = architecture.lowercase(Locale.ROOT) + val isAmd64 = arch == "amd64" || arch == "x86_64" + val isArm64 = arch == "aarch64" || arch == "arm64" + + return when { + os.contains("mac") && isArm64 -> EmbedCodePlatform( + "embed-code-macos-arm64.zip", + "embed-code-macos-arm64", + ) + + os.contains("mac") && isAmd64 -> EmbedCodePlatform( + "embed-code-macos-x64.zip", + "embed-code-macos-x64", + ) + + os.contains("linux") && isAmd64 -> EmbedCodePlatform( + "embed-code-linux", + "embed-code-linux", + ) + + os.contains("windows") && isAmd64 -> EmbedCodePlatform( + "embed-code-windows.exe", + "embed-code-windows.exe", + ) + + else -> throw GradleException( + "Embed Code does not publish a binary for operating system `$osName` " + + "and architecture `$architecture`.", + ) + } + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt new file mode 100644 index 0000000..af765f7 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** Registers automatic installation and execution tasks for Embed Code. */ +public class EmbedCodePlugin : Plugin { + + /** Applies the plugin to [project]. */ + override fun apply(project: Project) { + val checkTaskName = availableTaskName(project, "checkEmbedding") + val embedTaskName = availableTaskName(project, "embedCode") + val extension = project.extensions.create( + "embedCode", + EmbedCodeExtension::class.java, + ) + extension.docIncludes.convention(listOf("**/*.md", "**/*.html")) + extension.docExcludes.convention(emptyList()) + extension.namedSources.convention(emptyMap()) + extension.separator.convention("...") + extension.info.convention(false) + extension.stacktrace.convention(false) + extension.downloadBaseUrl.convention(DEFAULT_DOWNLOAD_BASE_URL) + + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val installTask = project.tasks.register( + "installEmbedCode", + InstallEmbedCodeTask::class.java, + ) { task -> + task.description = "Installs the requested Embed Code executable" + task.version.set(extension.version) + task.downloadBaseUrl.set(extension.downloadBaseUrl) + task.assetName.set(platform.assetName) + task.executableName.set(platform.executableName) + task.executableFile.set( + project.layout.buildDirectory.file( + extension.version.map { version -> + "embed-code/$version/${platform.executableName}" + }.orElse("embed-code/latest/${platform.executableName}"), + ), + ) + task.outputs.upToDateWhen { extension.version.isPresent } + } + + registerExecutionTask( + project, + extension, + installTask, + checkTaskName, + "Checks embedded code snippets are up to date", + "check", + ) + registerExecutionTask( + project, + extension, + installTask, + embedTaskName, + "Updates embedded code snippets from source files", + "embed", + ) + } + + private companion object { + + const val DEFAULT_DOWNLOAD_BASE_URL = + "https://github.com/SpineEventEngine/embed-code-go/releases" + const val TASK_GROUP = "embed code" + + /** Registers one mode-specific execution task backed by [installTask]. */ + fun registerExecutionTask( + project: Project, + extension: EmbedCodeExtension, + installTask: TaskProvider, + name: String, + description: String, + mode: String, + ) { + project.tasks.register(name, EmbedCodeTask::class.java) { task -> + task.group = TASK_GROUP + task.description = description + task.mode.set(mode) + task.codePath.set(extension.codePath) + task.namedSources.set(extension.namedSources) + task.namedSourceDirectories.from(extension.namedSourceDirectories) + task.docsPath.set(extension.docsPath) + task.docIncludes.set(extension.docIncludes) + task.docExcludes.set(extension.docExcludes) + task.separator.set(extension.separator) + task.info.set(extension.info) + task.stacktrace.set(extension.stacktrace) + task.executableFile.set(installTask.flatMap { it.executableFile }) + task.workingDirectory.set(project.layout.projectDirectory) + } + } + + /** Returns [preferredName], prepending underscores until it is unused. */ + fun availableTaskName(project: Project, preferredName: String): String { + var candidate = preferredName + while (project.tasks.names.contains(candidate)) { + candidate = "_$candidate" + } + return candidate + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt new file mode 100644 index 0000000..41ca887 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -0,0 +1,270 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +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 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.Locale +import java.util.TreeMap +import javax.inject.Inject + +/** Runs Embed Code in either check or embed mode. */ +@DisableCachingByDefault(because = "Embed Code checks or updates documentation files in place") +public abstract class EmbedCodeTask : DefaultTask() { + + /** Process execution without project access at execution time. */ + @get:Inject + protected abstract val execOperations: ExecOperations + + /** The execution mode assigned by the plugin. */ + @get:Input + public abstract val mode: Property + + /** The source root passed to `-code-path`. */ + @get:InputDirectory + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val codePath: DirectoryProperty + + /** Named source roots included in an internally generated configuration. */ + @get:Input + public abstract val namedSources: MapProperty + + /** Named source directories with their producing task dependencies. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val namedSourceDirectories: ConfigurableFileCollection + + /** The documentation root passed to `-docs-path`. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val docsPath: DirectoryProperty + + /** Documentation include patterns passed to `-doc-includes`. */ + @get:Input + public abstract val docIncludes: ListProperty + + /** Documentation exclude patterns passed to `-doc-excludes`. */ + @get:Input + public abstract val docExcludes: ListProperty + + /** The fragment separator passed to `-separator`. */ + @get:Input + public abstract val separator: Property + + /** Whether informational logging is enabled. */ + @get:Input + public abstract val info: Property + + /** Whether panic stack traces are enabled. */ + @get:Input + public abstract val stacktrace: Property + + /** The installed platform executable. */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val executableFile: RegularFileProperty + + /** The process working directory. */ + @get:Internal + public abstract val workingDirectory: DirectoryProperty + + /** Executes Embed Code with arguments derived from the Gradle extension. */ + @TaskAction + public fun runEmbedCode() { + val configuredSources = TreeMap(namedSources.get()) + val hasDirectSource = codePath.isPresent + val hasNamedSources = configuredSources.isNotEmpty() + if (hasDirectSource == hasNamedSources) { + throw GradleException( + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code.", + ) + } + + val arguments = ArrayList() + arguments.add("-mode=${mode.get()}") + if (hasNamedSources) { + arguments.add("-config-path=${writeNamedSourceConfiguration(configuredSources)}") + } else { + arguments.add("-code-path=${codePath.get().asFile.absolutePath}") + arguments.add("-docs-path=${docsPath.get().asFile.absolutePath}") + if (docIncludes.get().isNotEmpty()) { + arguments.add("-doc-includes=${docIncludes.get().joinToString(",")}") + } + if (docExcludes.get().isNotEmpty()) { + arguments.add("-doc-excludes=${docExcludes.get().joinToString(",")}") + } + arguments.add("-separator=${separator.get()}") + arguments.add("-info=${info.get()}") + arguments.add("-stacktrace=${stacktrace.get()}") + } + + execOperations.exec { spec -> + spec.executable(executableFile.get().asFile) + spec.args(arguments) + spec.setWorkingDir(workingDirectory.get().asFile) + } + } + + /** Writes the generated configuration used when named source roots are configured. */ + private fun writeNamedSourceConfiguration(configuredSources: Map): Path { + val normalizedSources = TreeMap() + for (source in configuredSources.entries) { + var path = Paths.get(source.value) + if (!path.isAbsolute) { + path = workingDirectory.get().asFile.toPath().resolve(path) + } + path = path.normalize().toAbsolutePath() + if (!Files.isDirectory(path)) { + throw GradleException( + "Embed Code source `${source.key}` is not a directory: $path", + ) + } + normalizedSources[source.key] = path.toString() + } + + val json = createConfigurationJson( + normalizedSources, + docsPath.get().asFile.absolutePath, + docIncludes.get(), + docExcludes.get(), + separator.get(), + info.get(), + stacktrace.get(), + ) + val configuration = temporaryDir.toPath().resolve("embed-code.json") + try { + Files.write(configuration, json.toByteArray(StandardCharsets.UTF_8)) + } catch (exception: IOException) { + throw GradleException( + "Could not write the generated Embed Code configuration to $configuration.", + exception, + ) + } + return configuration + } + + private companion object { + + /** Creates a JSON document accepted by Embed Code's YAML configuration parser. */ + fun createConfigurationJson( + namedSources: Map, + docsPath: String, + docIncludes: List, + docExcludes: List, + separator: String, + info: Boolean, + stacktrace: Boolean, + ): String { + val json = StringBuilder() + json.append("{\n \"code-path\": [\n") + var index = 0 + for (source in namedSources.entries) { + if (index > 0) { + json.append(",\n") + } + json.append(" {\"name\": ") + appendJsonString(json, source.key) + json.append(", \"path\": ") + appendJsonString(json, source.value) + 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 [values]. */ + fun appendJsonArray(json: StringBuilder, values: List) { + json.append('[') + for (index in values.indices) { + if (index > 0) { + json.append(", ") + } + appendJsonString(json, values[index]) + } + json.append(']') + } + + /** Appends [value] as an escaped JSON string. */ + fun appendJsonString(json: StringBuilder, value: String) { + json.append('"') + for (character in value) { + when (character) { + '"' -> json.append("\\\"") + '\\' -> json.append("\\\\") + '\b' -> json.append("\\b") + '\u000C' -> json.append("\\f") + '\n' -> json.append("\\n") + '\r' -> json.append("\\r") + '\t' -> json.append("\\t") + else -> { + if (character < '\u0020') { + json.append(String.format(Locale.ROOT, "\\u%04x", character.code)) + } else { + json.append(character) + } + } + } + } + json.append('"') + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt new file mode 100644 index 0000000..f30dee4 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +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.Optional +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.ZipInputStream + +/** + * Downloads and prepares the Embed Code executable selected for the host. + * + * An explicitly selected version is reused using Gradle's normal up-to-date + * behavior. The latest release is downloaded on every invocation so that it + * cannot remain stale behind an existing output. + */ +@DisableCachingByDefault(because = "Release assets come from external URLs that may change") +public abstract class InstallEmbedCodeTask : DefaultTask() { + + /** An optional Embed Code release version. */ + @get:Input + @get:Optional + public abstract val version: Property + + /** The base URL of the Embed Code releases. */ + @get:Input + public abstract val downloadBaseUrl: Property + + /** The platform-specific release asset name. */ + @get:Input + public abstract val assetName: Property + + /** The executable name expected inside an archive or used directly. */ + @get:Input + public abstract val executableName: Property + + /** The installed executable used by Embed Code execution tasks. */ + @get:OutputFile + public abstract val executableFile: RegularFileProperty + + /** Downloads, extracts when necessary, and marks the executable runnable. */ + @TaskAction + public fun install() { + val requestedVersion = version.orNull?.trim() + if (requestedVersion != null && requestedVersion.isEmpty()) { + throw GradleException("Embed Code version must not be empty.") + } + val asset = assetName.get() + val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) + val source = releaseAsset(baseUrl, requestedVersion, asset) + val destination = executableFile.get().asFile.toPath() + val download = temporaryDir.toPath().resolve(asset) + val preparedExecutable = temporaryDir.toPath().resolve(executableName.get()) + + try { + Files.createDirectories(destination.parent) + val release = requestedVersion ?: "latest release" + logger.lifecycle("Downloading Embed Code {} from {}", release, source) + download(source, download) + + if (asset.endsWith(".zip")) { + extractExecutable(download, executableName.get(), preparedExecutable) + } else { + Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING) + } + + if (!preparedExecutable.toFile().setExecutable(true, false)) { + throw GradleException("Could not make `$preparedExecutable` executable.") + } + moveAtomically(preparedExecutable, destination) + } catch (exception: IOException) { + throw GradleException("Could not install Embed Code from $source.", exception) + } + } + + private companion object { + + const val CONNECT_TIMEOUT_MILLIS = 30_000 + const val READ_TIMEOUT_MILLIS = 120_000 + const val BUFFER_SIZE = 8_192 + + /** Returns the release asset URI for the latest or explicitly requested version. */ + fun releaseAsset(baseUrl: String, requestedVersion: String?, asset: String): URI { + if (requestedVersion == null) { + return URI.create("$baseUrl/latest/download/$asset") + } + val releaseTag = if (requestedVersion.startsWith("v")) { + requestedVersion + } else { + "v$requestedVersion" + } + return URI.create("$baseUrl/download/$releaseTag/$asset") + } + + /** Downloads [source] into [destination], reporting HTTP failures clearly. */ + fun download(source: URI, destination: Path) { + var connection: URLConnection? = null + try { + connection = source.toURL().openConnection() + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + + if (connection is HttpURLConnection) { + connection.instanceFollowRedirects = true + val status = connection.responseCode + if (status < 200 || status > 299) { + throw GradleException( + "Could not download Embed Code: HTTP $status from $source.", + ) + } + } + + connection.getInputStream().use { input -> + Files.newOutputStream(destination).use { output -> + copy(input, output) + } + } + } catch (exception: IOException) { + throw GradleException("Could not download Embed Code from $source.", exception) + } finally { + if (connection is HttpURLConnection) { + connection.disconnect() + } + } + } + + /** Extracts [entryName] from [archive] into [destination]. */ + @Throws(IOException::class) + fun extractExecutable(archive: Path, entryName: String, destination: Path) { + ZipInputStream(Files.newInputStream(archive)).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + val entryPath = entry.name + val slash = entryPath.lastIndexOf('/') + val fileName = if (slash >= 0) { + entryPath.substring(slash + 1) + } else { + entryPath + } + if (!entry.isDirectory && fileName == entryName) { + Files.newOutputStream(destination).use { output -> + copy(zip, output) + } + return + } + zip.closeEntry() + entry = zip.nextEntry + } + } + throw GradleException("Archive `$archive` does not contain `$entryName`.") + } + + /** Copies all bytes from [input] into [output]. */ + @Throws(IOException::class) + fun copy(input: InputStream, output: OutputStream) { + val buffer = ByteArray(BUFFER_SIZE) + var count = input.read(buffer) + while (count >= 0) { + output.write(buffer, 0, count) + count = input.read(buffer) + } + } + + /** Moves [source] to [destination], atomically when supported. */ + @Throws(IOException::class) + fun moveAtomically(source: Path, destination: Path) { + try { + Files.move( + source, + destination, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + + /** Removes trailing slashes without changing a URL scheme. */ + fun trimTrailingSlashes(value: String): String { + var end = value.length + while (end > 0 && value[end - 1] == '/') { + end-- + } + return value.substring(0, end) + } + } +} 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 9b0a9f6..8f1b097 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 @@ -34,7 +34,6 @@ 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 @@ -134,22 +133,14 @@ internal class EmbedCodePluginIgTest { @Test @EnabledOnOs(OS.LINUX, OS.MAC) - fun `run check mode with Gradle 7_6_3`() { - val javaHome = System.getenv("EMBED_CODE_GRADLE_7_JAVA_HOME") - ?: System.getenv("JAVA_HOME_17_X64") - assumeTrue( - !javaHome.isNullOrBlank(), - "Set EMBED_CODE_GRADLE_7_JAVA_HOME to a JDK supported by Gradle 7.6.3.", - ) - - val result = runner(":checkEmbedding", useConfigurationCache = false) - .withGradleVersion("7.6.3") - .withEnvironment(System.getenv() + ("JAVA_HOME" to javaHome)) - .build() + fun `run check mode with Gradle 8_14_4`() { + runCheckModeWithGradle("8.14.4") + } - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS - Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle 9_0_0`() { + runCheckModeWithGradle("9.0.0") } @Test @@ -282,6 +273,17 @@ internal class EmbedCodePluginIgTest { .withPluginClasspath() } + /** Runs check mode with [gradleVersion]. */ + private fun runCheckModeWithGradle(gradleVersion: String) { + val result = runner(":checkEmbedding") + .withGradleVersion(gradleVersion) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + /** Writes a consuming build configured entirely through the plugin extension. */ private fun writeBuildFile(version: String? = null) { val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') From 8123f76dca0c6e2e1c0e4af170a01b770496aceb Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 14:01:20 +0200 Subject: [PATCH 04/14] Improve multiplatform behavior. --- .github/workflows/check.yml | 25 ++- README.md | 33 ++-- .../embedcode/gradle/EmbedCodeExtension.kt | 13 +- .../embedcode/gradle/EmbedCodePlatform.kt | 8 + .../spine/embedcode/gradle/EmbedCodePlugin.kt | 17 +- .../spine/embedcode/gradle/EmbedCodeTask.kt | 3 +- .../embedcode/gradle/InstallEmbedCodeTask.kt | 18 +- .../embedcode/gradle/EmbedCodePlatformSpec.kt | 10 ++ .../embedcode/gradle/EmbedCodePluginIgTest.kt | 164 ++++++++++++++++-- 9 files changed, 240 insertions(+), 51 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8238498..3310d81 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,9 +2,6 @@ name: Check on: pull_request: - push: - branches: - - master concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -12,8 +9,14 @@ concurrency: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 15 steps: - name: Checkout Repository @@ -25,6 +28,10 @@ jobs: distribution: temurin java-version: 17 + - name: Save Java 17 Toolchain + shell: bash + run: echo "EMBED_CODE_JAVA_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + - name: Set Up Java 25 uses: actions/setup-java@v5 with: @@ -35,4 +42,10 @@ jobs: uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew build :gradle-plugin:publishToMavenLocal + shell: bash + env: + EMBED_CODE_REAL_TEST: ${{ github.event_name == 'workflow_dispatch' && matrix.os == 'ubuntu-latest' }} + run: >- + ./gradlew + -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME" + build :gradle-plugin:publishToMavenLocal diff --git a/README.md b/README.md index 90ff1e9..fbea672 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,19 @@ application. | `stacktrace` | `false` | Prints stack traces after panics. | | `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | -For reproducible builds, or if the latest CLI release has a problem, pin only -the executable version while keeping the applied plugin version unchanged: +For CI and reproducible builds, pin the executable version while keeping the +applied plugin version unchanged: ```kotlin embedCode { - version.set("1.2.3") + version.set("1.2.4") } ``` +Leaving `version` unset follows the latest release and requires a network +request on every invocation; this is convenient for local use but is not +recommended for CI. + ### Named Source Roots Use `namedSource` when documentation embeds code from multiple modules: @@ -120,12 +124,14 @@ behavior and reuses its installed executable. 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. +use the `tasks` report to see the selected names. This fallback covers tasks +registered before this plugin is applied. A build must not register either +preferred name later in the same project. 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: +release assets. Linux and Windows installation paths run in CI: - Linux AMD64. - Windows AMD64. @@ -158,10 +164,15 @@ Run compilation, plugin validation, unit tests, and TestKit functional tests: ./gradlew check ``` -The functional tests create local fake release assets and run them with Gradle -8.14.4, Gradle 9.0.0, and the wrapper version. They do not download or execute -a real GitHub release. JDK 17 and JDK 25 must both be discoverable as Gradle -toolchains when running the complete suite locally. +The regular functional tests create local fake release assets and run them with +Gradle 8.14.4, Gradle 9.0.0, and the wrapper version. JDK 17 and JDK 25 must +both be discoverable as Gradle toolchains when running the complete suite +locally. + +Manually dispatch the `Check` workflow to run an additional Linux smoke test +against the latest real release. That test exercises the real CLI flags and +verifies that Embed Code's YAML parser accepts the generated JSON configuration +used for named source roots. Publish the current plugin version to the local Maven repository when testing it from another checkout: diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt index b362a20..be3d61e 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt @@ -43,6 +43,8 @@ import org.gradle.api.provider.Provider */ public abstract class EmbedCodeExtension { + private val configuredSourceNames = mutableSetOf() + /** An optional release version, with the latest release used when absent. */ public abstract val version: Property @@ -62,7 +64,7 @@ public abstract class EmbedCodeExtension { * @param directory the source root directory */ public fun namedSource(name: String, directory: Directory) { - val normalizedName = validateSourceName(name) + val normalizedName = registerSourceName(name) namedSources.put(normalizedName, directory.asFile.absolutePath) namedSourceDirectories.from(directory) } @@ -74,7 +76,7 @@ public abstract class EmbedCodeExtension { * @param directory the source root provider, including its task dependency */ public fun namedSource(name: String, directory: Provider) { - val normalizedName = validateSourceName(name) + val normalizedName = registerSourceName(name) namedSources.put( normalizedName, directory.map { value -> value.asFile.absolutePath }, @@ -110,11 +112,16 @@ public abstract class EmbedCodeExtension { */ public abstract val downloadBaseUrl: Property - private fun validateSourceName(name: String): String { + private fun registerSourceName(name: String): String { val normalizedName = name.trim() if (normalizedName.isEmpty()) { throw InvalidUserDataException("An Embed Code source name must not be empty.") } + if (!configuredSourceNames.add(normalizedName)) { + throw InvalidUserDataException( + "Embed Code source `$normalizedName` is already configured.", + ) + } return normalizedName } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt index 0598e98..8dd09dc 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt @@ -37,6 +37,14 @@ internal data class EmbedCodePlatform( companion object { + /** Returns the stable installed executable name for [osName]. */ + fun installedExecutableName(osName: String): String = + if (osName.lowercase(Locale.ROOT).contains("windows")) { + "embed-code.exe" + } else { + "embed-code" + } + /** Selects the release asset for [osName] and [architecture]. */ fun detect(osName: String, architecture: String): EmbedCodePlatform { val os = osName.lowercase(Locale.ROOT) diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index af765f7..9d69888 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -49,10 +49,9 @@ public class EmbedCodePlugin : Plugin { extension.stacktrace.convention(false) extension.downloadBaseUrl.convention(DEFAULT_DOWNLOAD_BASE_URL) - val platform = EmbedCodePlatform.detect( - System.getProperty("os.name"), - System.getProperty("os.arch"), - ) + val operatingSystem = System.getProperty("os.name").orEmpty() + val architecture = System.getProperty("os.arch").orEmpty() + val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) val installTask = project.tasks.register( "installEmbedCode", InstallEmbedCodeTask::class.java, @@ -60,16 +59,16 @@ public class EmbedCodePlugin : Plugin { task.description = "Installs the requested Embed Code executable" task.version.set(extension.version) task.downloadBaseUrl.set(extension.downloadBaseUrl) - task.assetName.set(platform.assetName) - task.executableName.set(platform.executableName) + task.operatingSystem.set(operatingSystem) + task.architecture.set(architecture) task.executableFile.set( project.layout.buildDirectory.file( extension.version.map { version -> - "embed-code/$version/${platform.executableName}" - }.orElse("embed-code/latest/${platform.executableName}"), + "embed-code/$version/$installedExecutableName" + }.orElse("embed-code/latest/$installedExecutableName"), ), ) - task.outputs.upToDateWhen { extension.version.isPresent } + task.outputs.upToDateWhen { task.version.isPresent } } registerExecutionTask( diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt index 41ca887..8f8306d 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -50,7 +50,6 @@ 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.Locale import java.util.TreeMap import javax.inject.Inject @@ -128,7 +127,7 @@ public abstract class EmbedCodeTask : DefaultTask() { ) } - val arguments = ArrayList() + val arguments = mutableListOf() arguments.add("-mode=${mode.get()}") if (hasNamedSources) { arguments.add("-config-path=${writeNamedSourceConfiguration(configuredSources)}") diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index f30dee4..0e5c589 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -66,13 +66,13 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:Input public abstract val downloadBaseUrl: Property - /** The platform-specific release asset name. */ + /** The operating system used to select a release asset. */ @get:Input - public abstract val assetName: Property + public abstract val operatingSystem: Property - /** The executable name expected inside an archive or used directly. */ + /** The architecture used to select a release asset. */ @get:Input - public abstract val executableName: Property + public abstract val architecture: Property /** The installed executable used by Embed Code execution tasks. */ @get:OutputFile @@ -85,12 +85,16 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (requestedVersion != null && requestedVersion.isEmpty()) { throw GradleException("Embed Code version must not be empty.") } - val asset = assetName.get() + val platform = EmbedCodePlatform.detect( + operatingSystem.get(), + architecture.get(), + ) + val asset = platform.assetName val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) val source = releaseAsset(baseUrl, requestedVersion, asset) val destination = executableFile.get().asFile.toPath() val download = temporaryDir.toPath().resolve(asset) - val preparedExecutable = temporaryDir.toPath().resolve(executableName.get()) + val preparedExecutable = temporaryDir.toPath().resolve(platform.executableName) try { Files.createDirectories(destination.parent) @@ -99,7 +103,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { download(source, download) if (asset.endsWith(".zip")) { - extractExecutable(download, executableName.get(), preparedExecutable) + extractExecutable(download, platform.executableName, preparedExecutable) } else { Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING) } 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 607154d..80b53a4 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 @@ -67,6 +67,16 @@ internal class EmbedCodePlatformSpec { ) } + @Test + fun `use a stable executable name on Unix`() { + assertEquals("embed-code", EmbedCodePlatform.installedExecutableName("Linux")) + } + + @Test + fun `keep the executable suffix on Windows`() { + assertEquals("embed-code.exe", EmbedCodePlatform.installedExecutableName("Windows 11")) + } + @Test fun `reject platform without release binary`() { val error = assertThrows(GradleException::class.java) { 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 8f1b097..e06cdcd 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 @@ -26,17 +26,20 @@ package io.spine.embedcode.gradle +import com.sun.net.httpserver.HttpServer import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue -import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assumptions.assumeTrue 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.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption @@ -100,10 +103,9 @@ internal class EmbedCodePluginIgTest { @Test fun `install platform release asset`() { val result = runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.detect( + val executableName = EmbedCodePlatform.installedExecutableName( System.getProperty("os.name"), - System.getProperty("os.arch"), - ).executableName + ) val installedExecutable = projectDirectory.resolve( "build/embed-code/latest/$executableName", ) @@ -122,15 +124,71 @@ internal class EmbedCodePluginIgTest { val result = runner(":checkEmbedding").build() result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS - val executableName = EmbedCodePlatform.detect( + val executableName = EmbedCodePlatform.installedExecutableName( System.getProperty("os.name"), - System.getProperty("os.arch"), - ).executableName + ) Files.exists( projectDirectory.resolve("build/embed-code/$overrideVersion/$executableName"), ) shouldBe true } + @Test + fun `defer unsupported platform failure until installation`() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + tasks.named("installEmbedCode") { + operatingSystem.set("Linux") + architecture.set("aarch64") + } + """.trimIndent(), + ) + + runner("tasks").build() + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain + "Embed Code does not publish a binary for operating system `Linux`" + + " and architecture `aarch64`." + } + + @Test + fun `accept trailing slashes in the release base URL`() { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + "///" + writeBuildFile(downloadBaseUrl = baseUrl) + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + } + + @Test + fun `report an HTTP status returned for a release asset`() { + val server = HttpServer.create( + InetSocketAddress("127.0.0.1", 0), + 0, + ) + server.createContext("/") { exchange -> + exchange.sendResponseHeaders(503, -1) + exchange.close() + } + server.start() + try { + val baseUrl = "http://127.0.0.1:${server.address.port}/releases" + writeBuildFile(downloadBaseUrl = baseUrl) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "HTTP 503" + } finally { + server.stop(0) + } + } + @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `run check mode with Gradle 8_14_4`() { @@ -181,6 +239,57 @@ internal class EmbedCodePluginIgTest { configuration shouldContain "\"docs-path\": \"${projectDirectory.toRealPath()}\"" } + @Test + fun `reject an empty named source`() { + writeNamedSourcesBuildFile(firstSourceName = " ", includeSecondSource = false) + + val result = runner("tasks").buildAndFail() + + result.output shouldContain "An Embed Code source name must not be empty." + } + + @Test + fun `reject a duplicate named source`() { + writeNamedSourcesBuildFile(secondSourceName = "company-site") + + val result = runner("tasks").buildAndFail() + + result.output shouldContain "Embed Code source `company-site` is already configured." + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run a real Embed Code release with generated configuration`() { + assumeTrue( + System.getenv("EMBED_CODE_REAL_TEST").toBoolean(), + "Set EMBED_CODE_REAL_TEST=true to run this smoke test.", + ) + Files.writeString( + projectDirectory.resolve("code/Hello.java"), + "class Hello {\n static final String MESSAGE = \"Hello\";\n}\n", + ) + val documentation = projectDirectory.resolve("docs/example.md") + Files.writeString( + documentation, + """ + # Example + + + ```java + class Outdated {} + ``` + """.trimIndent() + "\n", + ) + writeRealReleaseBuildFile() + + val embedResult = runner(":embedCode").build() + val checkResult = runner(":checkEmbedding").build() + + embedResult.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + checkResult.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(documentation) shouldContain "static final String MESSAGE = \"Hello\";" + } + @Test fun `reject direct and named source roots together`() { Files.createDirectories(projectDirectory.resolve("browser")) @@ -285,8 +394,10 @@ internal class EmbedCodePluginIgTest { } /** Writes a consuming build configured entirely through the plugin extension. */ - private fun writeBuildFile(version: String? = null) { - val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + private fun writeBuildFile( + version: String? = null, + downloadBaseUrl: String = releaseDirectory.toUri().toString().trimEnd('/'), + ) { val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() Files.writeString( projectDirectory.resolve("build.gradle.kts"), @@ -297,7 +408,7 @@ internal class EmbedCodePluginIgTest { embedCode { $versionConfiguration - downloadBaseUrl.set("$baseUrl") + downloadBaseUrl.set("$downloadBaseUrl") codePath.set(layout.projectDirectory.dir("code")) docsPath.set(layout.projectDirectory.dir("docs")) docIncludes.set(listOf("**/*.md", "**/*.html")) @@ -311,13 +422,23 @@ internal class EmbedCodePluginIgTest { } /** Writes a consuming build with two named source roots and no YAML file. */ - private fun writeNamedSourcesBuildFile(includeDirectSource: Boolean = false) { + private fun writeNamedSourcesBuildFile( + includeDirectSource: Boolean = false, + firstSourceName: String = "company-site", + secondSourceName: String = "jxbrowser", + includeSecondSource: Boolean = true, + ) { val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') val directSource = if (includeDirectSource) { "codePath.set(layout.projectDirectory.dir(\"code\"))" } else { "" } + val secondSource = if (includeSecondSource) { + "namedSource(\"$secondSourceName\", layout.projectDirectory.dir(\"browser\"))" + } else { + "" + } Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -328,14 +449,31 @@ internal class EmbedCodePluginIgTest { embedCode { downloadBaseUrl.set("$baseUrl") $directSource - namedSource("company-site", layout.projectDirectory.dir("company-site")) - namedSource("jxbrowser", layout.projectDirectory.dir("browser")) + namedSource("$firstSourceName", layout.projectDirectory.dir("company-site")) + $secondSource docsPath.set(layout.projectDirectory) } """.trimIndent(), ) } + /** Writes a consuming build that exercises a real release and generated JSON config. */ + private fun writeRealReleaseBuildFile() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + namedSource("sample", layout.projectDirectory.dir("code")) + docsPath.set(layout.projectDirectory.dir("docs")) + } + """.trimIndent(), + ) + } + /** Creates a host-specific fake release asset that records received arguments. */ private fun createFakeRelease(root: Path, version: String = TEST_RELEASE_VERSION) { val platform = EmbedCodePlatform.detect( From 728cbbe58a26600731a53890f7b53a4ce25f1e7f Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 14:21:24 +0200 Subject: [PATCH 05/14] Improve readme. --- README.md | 255 ++++++++++++++++++------------------------------------ 1 file changed, 86 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index fbea672..e1c8ea6 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,160 @@ -# Embed Code Gradle Plugin +[![Build on Ubuntu and Windows][build-badge]][gh-actions] +[![license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](http://www.apache.org/licenses/LICENSE-2.0) -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 `checkEmbedding` and `embedCode` tasks. +# Embed Code Gradle plugin -## Apply and Configure +Gradle plugin for [Embed Code][embed-code], an application that keeps code +examples in Markdown and HTML synchronized with their source files. -After the plugin is published, apply its released version: +The plugin downloads the released Embed Code executable for the current +platform, so developers and CI jobs do not have to install it manually. It +adds two tasks: -```kotlin -plugins { - id("io.spine.embed-code") version "" -} -``` +- `checkEmbedding` checks that embedded code is up to date. +- `embedCode` updates embedded code in place. -Until then, test the plugin directly from this checkout by adding its build to -the consuming project's `settings.gradle.kts`: +## Requirements -```kotlin -pluginManagement { - includeBuild("../embed-code-gradle-plugin") -} -``` +- Java 17 or a newer version supported by the selected Gradle version. +- Gradle 8.14.4 or newer. +- Linux AMD64, Windows AMD64, or macOS AMD64/ARM64. + +Consumers do not need to install Kotlin or apply a Kotlin plugin. +The plugin is written in Kotlin, but uses the Kotlin runtime supplied by Gradle. + +## How to use -The consuming `build.gradle.kts` can then apply `id("io.spine.embed-code")` -without a version while using that included build. +This section describes, how to use plugin, if you are interested in how to use +Embed Code application at all, see it's [documentation][embed-code]. -Configure Embed Code directly in `build.gradle.kts`; no `embed-code.yml` file -is required: +Add the following configuration to the project's `build.gradle.kts`: ```kotlin +plugins { + id("io.spine.embed-code") version "0.1.0" // Specify the actual version here. +} + embedCode { + + // Specify the directory containing source files referenced by embedding instructions. + // + // This property is required unless `namedSource(...)` is used. + // codePath.set(layout.projectDirectory.dir("src/main/java")) + + // Specify the directory containing Markdown or HTML documentation. + // + // This property is required. + // docsPath.set(layout.projectDirectory.dir("docs")) + + // Configure documentation files to include and exclude. + // + // This section is optional. The default includes are `**/*.md` and + // `**/*.html`; the default excludes list is empty. + // docIncludes.set(listOf("**/*.md", "**/*.html")) docExcludes.set(listOf("drafts/**", "generated/**")) + + // Configure other Embed Code command-line options. + // + // This section is optional. The values below are the defaults. + // separator.set("...") info.set(false) stacktrace.set(false) } ``` -`docsPath` is required. Configure either one unnamed `codePath` or one or more -named sources. By default, the plugin downloads the latest Embed Code release -from GitHub Releases. Plugin and application versions are independent. The -other properties use the same defaults as the Embed Code command-line -application. - -| Property | Default | Purpose | -|--------------------------------|--------------------------------|----------------------------------------------| -| `version` | Latest GitHub release | Pins a specific executable release when set. | -| `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. | - -For CI and reproducible builds, pin the executable version while keeping the -applied plugin version unchanged: - -```kotlin -embedCode { - version.set("1.2.4") -} -``` - -Leaving `version` unset follows the latest release and requires a network -request on every invocation; this is convenient for local use but is not -recommended for CI. - -### Named Source Roots - -Use `namedSource` when documentation embeds code from multiple modules: +Use named source roots when documentation embeds code from multiple modules: ```kotlin embedCode { namedSource( - "company-site", - layout.projectDirectory.dir("company-site"), + "model", + layout.projectDirectory.dir("model"), ) namedSource( - "jxbrowser", - layout.projectDirectory.dir("browser"), + "database", + layout.projectDirectory.dir("database"), ) 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. +Embedding instructions refer to these roots with `$model/` and +`$database/`. `codePath` and `namedSource(...)` are mutually exclusive. -`codePath` and `namedSource(...)` are mutually exclusive. Multiple independent -documentation targets are not exposed by this Gradle DSL. +To use a specific Embed Code application release, add its version to the extension: -## Run +```kotlin +embedCode { + version.set("1.2.4") +} +``` -Check that documentation already contains current source snippets: +Check that documentation is up to date: ```bash ./gradlew :checkEmbedding ``` -Update documentation in place: +Update documentation: ```bash ./gradlew :embedCode ``` -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. Without an explicit `version`, it downloads the current latest -release on every invocation. A pinned version uses Gradle's normal up-to-date -behavior and reuses its installed executable. - -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. This fallback covers tasks -registered before this plugin is applied. A build must not register either -preferred name later in the same project. 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. Linux and Windows installation paths run in CI: - -- Linux AMD64. -- Windows AMD64. -- macOS AMD64 and ARM64. +The plugin prefers the `checkEmbedding` and `embedCode` task names. If a name +is already occupied when the plugin is applied, underscores are prepended until +an available name is found, for example `_embedCode` or `__embedCode`. +The fallback cannot account for a conflicting task registered later. -## Compatibility +## Development -The plugin requires Gradle 8.14.4 or newer. Its published classes require Java -17, and the JVM running the build must also be supported by the selected Gradle -version. Compatibility is tested with Gradle 8.14.4, Gradle 9.0.0, and the -current wrapper version, Gradle 9.6.1. - -The plugin implementation, build scripts, and tests are written in Kotlin. -Consumers do not need to install Kotlin or apply a Kotlin plugin because Gradle -provides the Kotlin runtime. The project uses the Kotlin 2.4.10 compiler but -targets Kotlin 2.0 language and API levels because Gradle 8.14.4 embeds Kotlin -2.0.21. Published classes target Java 17 bytecode. - -The build uses a JDK 25 toolchain. TestKit runs on a Java 17 toolchain so that -the same suite can exercise the minimum Gradle version and Gradle 9.0.0. - -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: +Run compilation, plugin validation, and the complete test suite: ```bash ./gradlew check ``` -The regular functional tests create local fake release assets and run them with -Gradle 8.14.4, Gradle 9.0.0, and the wrapper version. JDK 17 and JDK 25 must -both be discoverable as Gradle toolchains when running the complete suite -locally. - -Manually dispatch the `Check` workflow to run an additional Linux smoke test -against the latest real release. That test exercises the real CLI flags and -verifies that Embed Code's YAML parser accepts the generated JSON configuration -used for named source roots. - -Publish the current plugin version to the local Maven repository when testing -it from another checkout: +The build uses a JDK 25 toolchain. TestKit uses Java 17 to exercise Gradle +8.14.4, Gradle 9.0.0, and the current wrapper version. Manually dispatching the +`Check` workflow also runs a Linux smoke test against the latest real Embed +Code release. -```bash -./gradlew :gradle-plugin:publishToMavenLocal -``` - -Then make the local repository available to plugin resolution in the consuming -project's `settings.gradle.kts`: +To test the plugin from another checkout without publishing it, include this +build in the consuming project's `settings.gradle.kts`: ```kotlin pluginManagement { - repositories { - mavenLocal() - gradlePluginPortal() - } + includeBuild("../embed-code-gradle-plugin") } ``` -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: +The consuming project can then apply the plugin without a version: ```kotlin plugins { - id("io.spine.embed-code") version "" + id("io.spine.embed-code") } ``` -The plugin publication version is configured in `version.gradle.kts`. Embed -Code application versions are resolved independently at execution time. - -## Publish - -The plugin is configured for the [Gradle Plugin Portal][plugin-portal]. Its -publication version does not need to match an Embed Code application version. -By default, every published plugin version follows the latest stable GitHub -release; consumers can pin an application version through the extension. - -Request validation from the Plugin Portal without publishing a version: - -```bash -./gradlew :gradle-plugin: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: +Alternatively, publish the plugin to the local Maven repository: ```bash -./gradlew :gradle-plugin:publishPlugins +./gradlew :gradle-plugin:publishToMavenLocal ``` -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. +In this case, add `mavenLocal()` to `pluginManagement.repositories` in the +consuming project's `settings.gradle.kts`. Adding it only to the regular +`repositories` block does not make Gradle plugin markers available to the +`plugins` block. ## License The plugin is available under the [Apache License 2.0](LICENSE). -[plugin-portal]: https://plugins.gradle.org/docs/publish-plugin +[build-badge]: https://github.com/SpineEventEngine/embed-code-gradle-plugin/actions/workflows/check.yml/badge.svg +[embed-code]: https://github.com/SpineEventEngine/embed-code-go +[gh-actions]: https://github.com/SpineEventEngine/embed-code-gradle-plugin/actions From 0442ed535f0d6538a5df34b251faa3d6f3a92383 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 14:28:12 +0200 Subject: [PATCH 06/14] Remove redundant tests. --- .github/workflows/check.yml | 2 - README.md | 24 +-------- .../embedcode/gradle/EmbedCodePluginIgTest.kt | 51 ------------------- 3 files changed, 1 insertion(+), 76 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3310d81..e49ba76 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -43,8 +43,6 @@ jobs: - name: Build shell: bash - env: - EMBED_CODE_REAL_TEST: ${{ github.event_name == 'workflow_dispatch' && matrix.os == 'ubuntu-latest' }} run: >- ./gradlew -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME" diff --git a/README.md b/README.md index e1c8ea6..dbb7b11 100644 --- a/README.md +++ b/README.md @@ -118,29 +118,7 @@ Run compilation, plugin validation, and the complete test suite: ./gradlew check ``` -The build uses a JDK 25 toolchain. TestKit uses Java 17 to exercise Gradle -8.14.4, Gradle 9.0.0, and the current wrapper version. Manually dispatching the -`Check` workflow also runs a Linux smoke test against the latest real Embed -Code release. - -To test the plugin from another checkout without publishing it, include this -build in the consuming project's `settings.gradle.kts`: - -```kotlin -pluginManagement { - includeBuild("../embed-code-gradle-plugin") -} -``` - -The consuming project can then apply the plugin without a version: - -```kotlin -plugins { - id("io.spine.embed-code") -} -``` - -Alternatively, publish the plugin to the local Maven repository: +To test the plugin publish it to the local Maven repository: ```bash ./gradlew :gradle-plugin:publishToMavenLocal 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 e06cdcd..50f7ea3 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 @@ -32,7 +32,6 @@ import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -257,39 +256,6 @@ internal class EmbedCodePluginIgTest { result.output shouldContain "Embed Code source `company-site` is already configured." } - @Test - @EnabledOnOs(OS.LINUX, OS.MAC) - fun `run a real Embed Code release with generated configuration`() { - assumeTrue( - System.getenv("EMBED_CODE_REAL_TEST").toBoolean(), - "Set EMBED_CODE_REAL_TEST=true to run this smoke test.", - ) - Files.writeString( - projectDirectory.resolve("code/Hello.java"), - "class Hello {\n static final String MESSAGE = \"Hello\";\n}\n", - ) - val documentation = projectDirectory.resolve("docs/example.md") - Files.writeString( - documentation, - """ - # Example - - - ```java - class Outdated {} - ``` - """.trimIndent() + "\n", - ) - writeRealReleaseBuildFile() - - val embedResult = runner(":embedCode").build() - val checkResult = runner(":checkEmbedding").build() - - embedResult.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS - checkResult.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS - Files.readString(documentation) shouldContain "static final String MESSAGE = \"Hello\";" - } - @Test fun `reject direct and named source roots together`() { Files.createDirectories(projectDirectory.resolve("browser")) @@ -457,23 +423,6 @@ internal class EmbedCodePluginIgTest { ) } - /** Writes a consuming build that exercises a real release and generated JSON config. */ - private fun writeRealReleaseBuildFile() { - Files.writeString( - projectDirectory.resolve("build.gradle.kts"), - """ - plugins { - id("io.spine.embed-code") - } - - embedCode { - namedSource("sample", layout.projectDirectory.dir("code")) - docsPath.set(layout.projectDirectory.dir("docs")) - } - """.trimIndent(), - ) - } - /** Creates a host-specific fake release asset that records received arguments. */ private fun createFakeRelease(root: Path, version: String = TEST_RELEASE_VERSION) { val platform = EmbedCodePlatform.detect( From 0e1031ffb72d4bcc4c7b64ebb34f89b7744500e2 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 14:32:46 +0200 Subject: [PATCH 07/14] Enable explicitApi for Kotlin. --- buildSrc/src/main/kotlin/jvm-module.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index f9fd07c..5291c7a 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -43,6 +43,7 @@ java { } kotlin { + explicitApi() compilerOptions { jvmTarget.set(jvmTarget(BuildSettings.bytecodeVersion)) // Gradle 8.14.4 embeds Kotlin 2.0.21. Keep plugin metadata and From 181b3a3955995c6edd6aee6d4bed99ba5f28a8d2 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 14:39:17 +0200 Subject: [PATCH 08/14] Update doc style. --- .../spine/embedcode/gradle/BuildSettings.kt | 4 ++- .../embedcode/gradle/dependency/JUnit.kt | 4 ++- .../embedcode/gradle/dependency/Kotlin.kt | 4 ++- .../embedcode/gradle/EmbedCodePlatform.kt | 12 ++++++-- .../spine/embedcode/gradle/EmbedCodePlugin.kt | 16 ++++++++--- .../spine/embedcode/gradle/EmbedCodeTask.kt | 24 ++++++++++++---- .../embedcode/gradle/InstallEmbedCodeTask.kt | 28 ++++++++++++++----- .../embedcode/gradle/EmbedCodePluginIgTest.kt | 20 +++++++++---- 8 files changed, 84 insertions(+), 28 deletions(-) diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt index b535b0a..d4ddee0 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt @@ -26,7 +26,9 @@ package io.spine.embedcode.gradle -/** Build-wide Java and bytecode targets. */ +/** + * Build-wide Java and bytecode targets. + */ object BuildSettings { /** Java toolchain version used to build and test the project. */ diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt index a4d1a16..895cb24 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt @@ -26,7 +26,9 @@ package io.spine.embedcode.gradle.dependency -/** JUnit dependencies used by tests. */ +/** + * JUnit dependencies used by tests. + */ object JUnit { const val version = "6.1.2" diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt index 51e35c3..3df0da5 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt @@ -26,7 +26,9 @@ package io.spine.embedcode.gradle.dependency -/** Kotlin dependencies used by the project. */ +/** + * Kotlin dependencies used by the project. + */ object Kotlin { const val version = "2.4.10" diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt index 8dd09dc..818a319 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt @@ -29,7 +29,9 @@ package io.spine.embedcode.gradle import org.gradle.api.GradleException import java.util.Locale -/** A released executable selected for an operating system and architecture. */ +/** + * A released executable selected for an operating system and architecture. + */ internal data class EmbedCodePlatform( val assetName: String, val executableName: String, @@ -37,7 +39,9 @@ internal data class EmbedCodePlatform( companion object { - /** Returns the stable installed executable name for [osName]. */ + /** + * Returns the stable installed executable name for [osName]. + */ fun installedExecutableName(osName: String): String = if (osName.lowercase(Locale.ROOT).contains("windows")) { "embed-code.exe" @@ -45,7 +49,9 @@ internal data class EmbedCodePlatform( "embed-code" } - /** Selects the release asset for [osName] and [architecture]. */ + /** + * Selects the release asset for [osName] and [architecture]. + */ fun detect(osName: String, architecture: String): EmbedCodePlatform { val os = osName.lowercase(Locale.ROOT) val arch = architecture.lowercase(Locale.ROOT) diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index 9d69888..fa21f82 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -30,10 +30,14 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider -/** Registers automatic installation and execution tasks for Embed Code. */ +/** + * Registers automatic installation and execution tasks for Embed Code. + */ public class EmbedCodePlugin : Plugin { - /** Applies the plugin to [project]. */ + /** + * Applies the plugin to [project]. + */ override fun apply(project: Project) { val checkTaskName = availableTaskName(project, "checkEmbedding") val embedTaskName = availableTaskName(project, "embedCode") @@ -95,7 +99,9 @@ public class EmbedCodePlugin : Plugin { "https://github.com/SpineEventEngine/embed-code-go/releases" const val TASK_GROUP = "embed code" - /** Registers one mode-specific execution task backed by [installTask]. */ + /** + * Registers one mode-specific execution task backed by [installTask]. + */ fun registerExecutionTask( project: Project, extension: EmbedCodeExtension, @@ -122,7 +128,9 @@ public class EmbedCodePlugin : Plugin { } } - /** Returns [preferredName], prepending underscores until it is unused. */ + /** + * Returns [preferredName], prepending underscores until it is unused. + */ fun availableTaskName(project: Project, preferredName: String): String { var candidate = preferredName while (project.tasks.names.contains(candidate)) { diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt index 8f8306d..c5fd034 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -54,7 +54,9 @@ import java.util.Locale import java.util.TreeMap import javax.inject.Inject -/** Runs Embed Code in either check or embed mode. */ +/** + * Runs Embed Code in either check or embed mode. + */ @DisableCachingByDefault(because = "Embed Code checks or updates documentation files in place") public abstract class EmbedCodeTask : DefaultTask() { @@ -115,7 +117,9 @@ public abstract class EmbedCodeTask : DefaultTask() { @get:Internal public abstract val workingDirectory: DirectoryProperty - /** Executes Embed Code with arguments derived from the Gradle extension. */ + /** + * Executes Embed Code with arguments derived from the Gradle extension. + */ @TaskAction public fun runEmbedCode() { val configuredSources = TreeMap(namedSources.get()) @@ -152,7 +156,9 @@ public abstract class EmbedCodeTask : DefaultTask() { } } - /** Writes the generated configuration used when named source roots are configured. */ + /** + * Writes the generated configuration used when named source roots are configured. + */ private fun writeNamedSourceConfiguration(configuredSources: Map): Path { val normalizedSources = TreeMap() for (source in configuredSources.entries) { @@ -192,7 +198,9 @@ public abstract class EmbedCodeTask : DefaultTask() { private companion object { - /** Creates a JSON document accepted by Embed Code's YAML configuration parser. */ + /** + * Creates a JSON document accepted by Embed Code's YAML configuration parser. + */ fun createConfigurationJson( namedSources: Map, docsPath: String, @@ -230,7 +238,9 @@ public abstract class EmbedCodeTask : DefaultTask() { return json.toString() } - /** Appends a JSON array containing [values]. */ + /** + * Appends a JSON array containing [values]. + */ fun appendJsonArray(json: StringBuilder, values: List) { json.append('[') for (index in values.indices) { @@ -242,7 +252,9 @@ public abstract class EmbedCodeTask : DefaultTask() { json.append(']') } - /** Appends [value] as an escaped JSON string. */ + /** + * Appends [value] as an escaped JSON string. + */ fun appendJsonString(json: StringBuilder, value: String) { json.append('"') for (character in value) { diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index 0e5c589..4344908 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -78,7 +78,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:OutputFile public abstract val executableFile: RegularFileProperty - /** Downloads, extracts when necessary, and marks the executable runnable. */ + /** + * Downloads, extracts when necessary, and marks the executable runnable. + */ @TaskAction public fun install() { val requestedVersion = version.orNull?.trim() @@ -123,7 +125,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { const val READ_TIMEOUT_MILLIS = 120_000 const val BUFFER_SIZE = 8_192 - /** Returns the release asset URI for the latest or explicitly requested version. */ + /** + * Returns the release asset URI for the latest or explicitly requested version. + */ fun releaseAsset(baseUrl: String, requestedVersion: String?, asset: String): URI { if (requestedVersion == null) { return URI.create("$baseUrl/latest/download/$asset") @@ -136,7 +140,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { return URI.create("$baseUrl/download/$releaseTag/$asset") } - /** Downloads [source] into [destination], reporting HTTP failures clearly. */ + /** + * Downloads [source] into [destination], reporting HTTP failures clearly. + */ fun download(source: URI, destination: Path) { var connection: URLConnection? = null try { @@ -168,7 +174,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } - /** Extracts [entryName] from [archive] into [destination]. */ + /** + * Extracts [entryName] from [archive] into [destination]. + */ @Throws(IOException::class) fun extractExecutable(archive: Path, entryName: String, destination: Path) { ZipInputStream(Files.newInputStream(archive)).use { zip -> @@ -194,7 +202,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { throw GradleException("Archive `$archive` does not contain `$entryName`.") } - /** Copies all bytes from [input] into [output]. */ + /** + * Copies all bytes from [input] into [output]. + */ @Throws(IOException::class) fun copy(input: InputStream, output: OutputStream) { val buffer = ByteArray(BUFFER_SIZE) @@ -205,7 +215,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } - /** Moves [source] to [destination], atomically when supported. */ + /** + * Moves [source] to [destination], atomically when supported. + */ @Throws(IOException::class) fun moveAtomically(source: Path, destination: Path) { try { @@ -220,7 +232,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } - /** Removes trailing slashes without changing a URL scheme. */ + /** + * Removes trailing slashes without changing a URL scheme. + */ fun trimTrailingSlashes(value: String): String { var end = value.length while (end > 0 && value[end - 1] == '/') { 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 50f7ea3..317379b 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 @@ -332,7 +332,9 @@ internal class EmbedCodePluginIgTest { Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" } - /** Creates a runner using the plugin-under-test classpath. */ + /** + * Creates a runner using the plugin-under-test classpath. + */ private fun runner( vararg arguments: String, useConfigurationCache: Boolean = true, @@ -348,7 +350,9 @@ internal class EmbedCodePluginIgTest { .withPluginClasspath() } - /** Runs check mode with [gradleVersion]. */ + /** + * Runs check mode with [gradleVersion]. + */ private fun runCheckModeWithGradle(gradleVersion: String) { val result = runner(":checkEmbedding") .withGradleVersion(gradleVersion) @@ -359,7 +363,9 @@ internal class EmbedCodePluginIgTest { Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" } - /** Writes a consuming build configured entirely through the plugin extension. */ + /** + * Writes a consuming build configured entirely through the plugin extension. + */ private fun writeBuildFile( version: String? = null, downloadBaseUrl: String = releaseDirectory.toUri().toString().trimEnd('/'), @@ -387,7 +393,9 @@ internal class EmbedCodePluginIgTest { ) } - /** Writes a consuming build with two named source roots and no YAML file. */ + /** + * Writes a consuming build with two named source roots and no YAML file. + */ private fun writeNamedSourcesBuildFile( includeDirectSource: Boolean = false, firstSourceName: String = "company-site", @@ -423,7 +431,9 @@ internal class EmbedCodePluginIgTest { ) } - /** Creates a host-specific fake release asset that records received arguments. */ + /** + * Creates a host-specific fake release asset that records received arguments. + */ private fun createFakeRelease(root: Path, version: String = TEST_RELEASE_VERSION) { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), From 68eaa700dace30da78248f8f1b68a2bdf9d09e44 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 17:39:10 +0200 Subject: [PATCH 09/14] Improve params provision behavior. --- README.md | 11 +- gradle-plugin/build.gradle.kts | 43 ++++++++ .../embedcode/gradle/EmbedCodePluginSpec.kt} | 11 +- .../embedcode/gradle/EmbedCodeExtension.kt | 4 +- .../spine/embedcode/gradle/EmbedCodeJson.kt | 103 ++++++++++++++++++ .../spine/embedcode/gradle/EmbedCodePlugin.kt | 5 +- .../spine/embedcode/gradle/EmbedCodeTask.kt | 90 +-------------- .../embedcode/gradle/InstallEmbedCodeTask.kt | 2 +- .../embedcode/gradle/EmbedCodeJsonSpec.kt | 65 +++++++++++ 9 files changed, 234 insertions(+), 100 deletions(-) rename gradle-plugin/src/{test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt => functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt} (98%) create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeJson.kt create mode 100644 gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeJsonSpec.kt diff --git a/README.md b/README.md index dbb7b11..2d13b20 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ The plugin is written in Kotlin, but uses the Kotlin runtime supplied by Gradle. ## How to use -This section describes, how to use plugin, if you are interested in how to use -Embed Code application at all, see it's [documentation][embed-code]. +This section describes how to use the plugin. For information about the Embed +Code application itself, see its [documentation][embed-code]. Add the following configuration to the project's `build.gradle.kts`: @@ -107,7 +107,7 @@ Update documentation: The plugin prefers the `checkEmbedding` and `embedCode` task names. If a name is already occupied when the plugin is applied, underscores are prepended until -an available name is found, for example `_embedCode` or `__embedCode`. +an available name is found, for example `_embedCode` or `__embedCode`. The fallback cannot account for a conflicting task registered later. ## Development @@ -118,7 +118,10 @@ Run compilation, plugin validation, and the complete test suite: ./gradlew check ``` -To test the plugin publish it to the local Maven repository: +Fast unit tests run under `test`. TestKit coverage runs separately under +`functionalTest`; the `check` task includes both. + +To test the plugin in another project, publish it to the local Maven repository: ```bash ./gradlew :gradle-plugin:publishToMavenLocal diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 635d653..45aa95f 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -24,6 +24,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.BuildSettings import io.spine.embedcode.gradle.dependency.Kotlin import io.spine.embedcode.gradle.dependency.PluginPublish import org.gradle.api.publish.maven.MavenPublication @@ -41,6 +42,47 @@ dependencies { // Gradle supplies Kotlin at runtime, so the plugin does not publish the standard library. compileOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") testCompileOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") + // Unit tests no longer inherit TestKit's Gradle and Kotlin runtime; supply both explicitly. + testRuntimeOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") + testRuntimeOnly(gradleApi()) +} + +val functionalTestSourceSet = sourceSets.create("functionalTest") +functionalTestSourceSet.compileClasspath += sourceSets.main.get().output +functionalTestSourceSet.runtimeClasspath += sourceSets.main.get().output + +kotlin { + target.compilations.getByName("functionalTest") { + associateWith(target.compilations.getByName("main")) + } +} + +configurations[functionalTestSourceSet.implementationConfigurationName].extendsFrom( + configurations.testImplementation.get(), +) +configurations[functionalTestSourceSet.compileOnlyConfigurationName].extendsFrom( + configurations.testCompileOnly.get(), +) +configurations[functionalTestSourceSet.runtimeOnlyConfigurationName].extendsFrom( + configurations.testRuntimeOnly.get(), +) + +val functionalTest = tasks.register("functionalTest") { + description = "Runs TestKit functional tests." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = functionalTestSourceSet.output.classesDirs + classpath = functionalTestSourceSet.runtimeClasspath + useJUnitPlatform() + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(BuildSettings.bytecodeVersion)) + }, + ) + shouldRunAfter(tasks.test) +} + +tasks.check { + dependsOn(functionalTest) } base { @@ -59,6 +101,7 @@ tasks.withType().configureEach { } gradlePlugin { + testSourceSets(functionalTestSourceSet) website.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") vcsUrl.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") plugins { diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt similarity index 98% rename from gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt rename to gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 317379b..160dd13 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -46,7 +46,7 @@ import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @DisplayName("`EmbedCodePlugin` should") -internal class EmbedCodePluginIgTest { +internal class EmbedCodePluginSpec { @TempDir private lateinit var projectDirectory: Path @@ -115,10 +115,10 @@ internal class EmbedCodePluginIgTest { @Test @EnabledOnOs(OS.LINUX, OS.MAC) - fun `allow overriding the latest Embed Code version`() { + fun `trim an overridden Embed Code version`() { val overrideVersion = "0.0.0-test" createFakeRelease(releaseDirectory, overrideVersion) - writeBuildFile(overrideVersion) + writeBuildFile(" $overrideVersion ") val result = runner(":checkEmbedding").build() @@ -337,12 +337,9 @@ internal class EmbedCodePluginIgTest { */ private fun runner( vararg arguments: String, - useConfigurationCache: Boolean = true, ): GradleRunner { val gradleArguments = arguments.toMutableList() - if (useConfigurationCache) { - gradleArguments.add("--configuration-cache") - } + gradleArguments.add("--configuration-cache") gradleArguments.add("--stacktrace") return GradleRunner.create() .withProjectDir(projectDirectory.toFile()) diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt index be3d61e..ff19a61 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt @@ -52,10 +52,10 @@ public abstract class EmbedCodeExtension { public abstract val codePath: DirectoryProperty /** Named source roots keyed by the name used in embedding instructions. */ - public abstract val namedSources: MapProperty + internal abstract val namedSources: MapProperty /** Named source directories with their task dependencies. */ - public abstract val namedSourceDirectories: ConfigurableFileCollection + internal abstract val namedSourceDirectories: ConfigurableFileCollection /** * Adds a named source root. diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeJson.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeJson.kt new file mode 100644 index 0000000..f20e5cd --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeJson.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import java.util.Locale + +/** + * Creates a JSON document accepted by Embed Code's YAML configuration parser. + */ +internal fun createConfigurationJson( + namedSources: Map, + docsPath: String, + docIncludes: List, + docExcludes: List, + separator: String, + info: Boolean, + stacktrace: Boolean, +): String { + val json = StringBuilder() + json.append("{\n \"code-path\": [\n") + var index = 0 + for (source in namedSources.entries) { + if (index > 0) { + json.append(",\n") + } + json.append(" {\"name\": ") + appendJsonString(json, source.key) + json.append(", \"path\": ") + appendJsonString(json, source.value) + 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() +} + +private fun appendJsonArray(json: StringBuilder, values: List) { + json.append('[') + for (index in values.indices) { + if (index > 0) { + json.append(", ") + } + appendJsonString(json, values[index]) + } + json.append(']') +} + +private fun appendJsonString(json: StringBuilder, value: String) { + json.append('"') + for (character in value) { + when (character) { + '"' -> json.append("\\\"") + '\\' -> json.append("\\\\") + '\b' -> json.append("\\b") + '\u000C' -> json.append("\\f") + '\n' -> json.append("\\n") + '\r' -> json.append("\\r") + '\t' -> json.append("\\t") + else -> { + if (character < '\u0020') { + json.append(String.format(Locale.ROOT, "\\u%04x", character.code)) + } else { + json.append(character) + } + } + } + } + json.append('"') +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index fa21f82..e323b15 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -56,18 +56,19 @@ public class EmbedCodePlugin : Plugin { val operatingSystem = System.getProperty("os.name").orEmpty() val architecture = System.getProperty("os.arch").orEmpty() val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) + val requestedVersion = extension.version.map { version -> version.trim() } val installTask = project.tasks.register( "installEmbedCode", InstallEmbedCodeTask::class.java, ) { task -> task.description = "Installs the requested Embed Code executable" - task.version.set(extension.version) + task.version.set(requestedVersion) task.downloadBaseUrl.set(extension.downloadBaseUrl) task.operatingSystem.set(operatingSystem) task.architecture.set(architecture) task.executableFile.set( project.layout.buildDirectory.file( - extension.version.map { version -> + requestedVersion.map { version -> "embed-code/$version/$installedExecutableName" }.orElse("embed-code/latest/$installedExecutableName"), ), diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt index c5fd034..877c8e7 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -50,7 +50,6 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths -import java.util.Locale import java.util.TreeMap import javax.inject.Inject @@ -74,7 +73,12 @@ public abstract class EmbedCodeTask : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) public abstract val codePath: DirectoryProperty - /** Named source roots included in an internally generated configuration. */ + /** + * Named source roots included in an internally generated configuration. + * + * The absolute path values make this input machine-specific. The task intentionally + * declares no outputs and disables caching because it checks or modifies documentation. + */ @get:Input public abstract val namedSources: MapProperty @@ -196,86 +200,4 @@ public abstract class EmbedCodeTask : DefaultTask() { return configuration } - private companion object { - - /** - * Creates a JSON document accepted by Embed Code's YAML configuration parser. - */ - fun createConfigurationJson( - namedSources: Map, - docsPath: String, - docIncludes: List, - docExcludes: List, - separator: String, - info: Boolean, - stacktrace: Boolean, - ): String { - val json = StringBuilder() - json.append("{\n \"code-path\": [\n") - var index = 0 - for (source in namedSources.entries) { - if (index > 0) { - json.append(",\n") - } - json.append(" {\"name\": ") - appendJsonString(json, source.key) - json.append(", \"path\": ") - appendJsonString(json, source.value) - 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 [values]. - */ - fun appendJsonArray(json: StringBuilder, values: List) { - json.append('[') - for (index in values.indices) { - if (index > 0) { - json.append(", ") - } - appendJsonString(json, values[index]) - } - json.append(']') - } - - /** - * Appends [value] as an escaped JSON string. - */ - fun appendJsonString(json: StringBuilder, value: String) { - json.append('"') - for (character in value) { - when (character) { - '"' -> json.append("\\\"") - '\\' -> json.append("\\\\") - '\b' -> json.append("\\b") - '\u000C' -> json.append("\\f") - '\n' -> json.append("\\n") - '\r' -> json.append("\\r") - '\t' -> json.append("\\t") - else -> { - if (character < '\u0020') { - json.append(String.format(Locale.ROOT, "\\u%04x", character.code)) - } else { - json.append(character) - } - } - } - } - json.append('"') - } - } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index 4344908..8582f1f 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -83,7 +83,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { */ @TaskAction public fun install() { - val requestedVersion = version.orNull?.trim() + val requestedVersion = version.orNull if (requestedVersion != null && requestedVersion.isEmpty()) { throw GradleException("Embed Code version must not be empty.") } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeJsonSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeJsonSpec.kt new file mode 100644 index 0000000..5ec1fe4 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeJsonSpec.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`createConfigurationJson` should") +internal class EmbedCodeJsonSpec { + + @Test + fun `escape names paths and options`() { + val json = createConfigurationJson( + linkedMapOf("quoted\"\nsource" to "C:\\work\\\"quoted\nfile"), + "C:\\docs\nline", + listOf("**/\"quoted\".md", "line\nbreak"), + listOf("drafts\\**"), + "---\n---", + info = true, + stacktrace = false, + ) + + assertEquals( + """ + { + "code-path": [ + {"name": "quoted\"\nsource", "path": "C:\\work\\\"quoted\nfile"} + ], + "docs-path": "C:\\docs\nline", + "doc-includes": ["**/\"quoted\".md", "line\nbreak"], + "doc-excludes": ["drafts\\**"], + "separator": "---\n---", + "info": true, + "stacktrace": false + } + """.trimIndent() + "\n", + json, + ) + } +} From 4693d1318770d47035a89ac8ce8607ceb03f1348 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 17:41:15 +0200 Subject: [PATCH 10/14] Update workflow permissions. --- .github/workflows/check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e49ba76..87eb376 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -3,6 +3,9 @@ name: Check on: pull_request: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From ff05e13dcb4dffba7526fb9248acdb52e674686e Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 18:00:23 +0200 Subject: [PATCH 11/14] Improve binaries caching behavior. --- README.md | 5 + .../embedcode/gradle/EmbedCodePluginSpec.kt | 120 ++++++++++++++++++ .../spine/embedcode/gradle/EmbedCodePlugin.kt | 4 + .../embedcode/gradle/InstallEmbedCodeTask.kt | 119 ++++++++++++++++- 4 files changed, 245 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2d13b20..fc2eaae 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,11 @@ embedCode { Embedding instructions refer to these roots with `$model/` and `$database/`. `codePath` and `namedSource(...)` are mutually exclusive. +By default, the plugin checks the latest Embed Code release before running a task. +It reuses the executable in `build/embed-code/latest` while the release +version remains unchanged and downloads a new executable only after a new +release is published. + To use a specific Embed Code application release, add its version to the extension: ```kotlin diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 160dd13..d46a9a7 100644 --- a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -42,6 +42,8 @@ import java.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -113,6 +115,82 @@ internal class EmbedCodePluginSpec { Files.exists(installedExecutable) shouldBe true } + @Test + fun `reuse latest executable when the release version is unchanged`() { + val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val versionChecks = AtomicInteger() + val downloads = AtomicInteger() + val server = startReleaseServer(latestVersion, versionChecks, downloads) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + + runner(":installEmbedCode").build() + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + versionChecks.get() shouldBe 2 + downloads.get() shouldBe 1 + result.output shouldContain "Reusing Embed Code v$TEST_RELEASE_VERSION" + } finally { + server.stop(0) + } + } + + @Test + fun `download latest executable when the release version changes`() { + val nextVersion = "1.2.5-test" + createFakeRelease(releaseDirectory, nextVersion) + val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val versionChecks = AtomicInteger() + val downloads = AtomicInteger() + val server = startReleaseServer(latestVersion, versionChecks, downloads) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + runner(":installEmbedCode").build() + + latestVersion.set(nextVersion) + runner(":installEmbedCode").build() + + versionChecks.get() shouldBe 2 + downloads.get() shouldBe 2 + Files.readString( + projectDirectory.resolve("build/embed-code/latest/version.txt"), + ).trim() shouldBe "v$nextVersion" + } finally { + server.stop(0) + } + } + + @Test + fun `reuse latest executable in offline mode`() { + val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val server = startReleaseServer( + latestVersion, + AtomicInteger(), + AtomicInteger(), + ) + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + try { + runner(":installEmbedCode").build() + } finally { + server.stop(0) + } + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "Reusing cached Embed Code executable" + } + + @Test + fun `report a missing latest executable in offline mode`() { + val result = runner(":installEmbedCode", "--offline").buildAndFail() + + result.output shouldContain + "Cannot install the latest Embed Code release in offline mode because " + + "no cached executable exists" + } + @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `trim an overridden Embed Code version`() { @@ -474,6 +552,48 @@ internal class EmbedCodePluginSpec { ) } + /** + * Starts a release server whose latest endpoint redirects to a mutable version. + */ + private fun startReleaseServer( + latestVersion: AtomicReference, + versionChecks: AtomicInteger, + downloads: AtomicInteger, + ): HttpServer { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/releases/latest") { exchange -> + versionChecks.incrementAndGet() + if (exchange.requestMethod != "HEAD") { + exchange.sendResponseHeaders(405, -1) + } else { + exchange.responseHeaders.add( + "Location", + "/releases/tag/v${latestVersion.get()}", + ) + exchange.sendResponseHeaders(302, -1) + } + exchange.close() + } + server.createContext("/releases/download/") { exchange -> + downloads.incrementAndGet() + val relativePath = exchange.requestURI.path.removePrefix("/releases/download/") + val asset = releaseDirectory.resolve("download").resolve(relativePath).normalize() + if (!asset.startsWith(releaseDirectory.resolve("download")) || !Files.isRegularFile(asset)) { + exchange.sendResponseHeaders(404, -1) + } else { + val content = Files.readAllBytes(asset) + exchange.sendResponseHeaders(200, content.size.toLong()) + exchange.responseBody.use { output -> output.write(content) } + } + exchange.close() + } + server.start() + return server + } + + private val HttpServer.releaseBaseUrl: String + get() = "http://127.0.0.1:${address.port}/releases" + private companion object { const val TEST_RELEASE_VERSION = "1.2.4-test" } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index e323b15..8afcb52 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -66,6 +66,7 @@ public class EmbedCodePlugin : Plugin { task.downloadBaseUrl.set(extension.downloadBaseUrl) task.operatingSystem.set(operatingSystem) task.architecture.set(architecture) + task.offline.set(project.gradle.startParameter.isOffline) task.executableFile.set( project.layout.buildDirectory.file( requestedVersion.map { version -> @@ -73,6 +74,9 @@ public class EmbedCodePlugin : Plugin { }.orElse("embed-code/latest/$installedExecutableName"), ), ) + task.resolvedVersionFile.set( + project.layout.buildDirectory.file("embed-code/latest/version.txt"), + ) task.outputs.upToDateWhen { task.version.isPresent } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index 8582f1f..8e7d887 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -31,6 +31,7 @@ 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.LocalState import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction @@ -51,8 +52,8 @@ import java.util.zip.ZipInputStream * Downloads and prepares the Embed Code executable selected for the host. * * An explicitly selected version is reused using Gradle's normal up-to-date - * behavior. The latest release is downloaded on every invocation so that it - * cannot remain stale behind an existing output. + * behavior. For the latest release, the remote version is checked before an + * existing executable is replaced. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { @@ -74,10 +75,18 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:Input public abstract val architecture: Property + /** Whether Gradle is running without network access. */ + @get:Input + public abstract val offline: Property + /** The installed executable used by Embed Code execution tasks. */ @get:OutputFile public abstract val executableFile: RegularFileProperty + /** Stores the release version represented by the latest executable. */ + @get:LocalState + public abstract val resolvedVersionFile: RegularFileProperty + /** * Downloads, extracts when necessary, and marks the executable runnable. */ @@ -93,8 +102,27 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) val asset = platform.assetName val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) - val source = releaseAsset(baseUrl, requestedVersion, asset) val destination = executableFile.get().asFile.toPath() + val versionFile = resolvedVersionFile.get().asFile.toPath() + if (requestedVersion == null && offline.get()) { + reuseOfflineInstallation(destination) + return + } + val resolvedVersion = if (requestedVersion == null) { + resolveLatestVersion(baseUrl) + } else { + null + } + if ( + resolvedVersion != null && + Files.isRegularFile(destination) && + readResolvedVersion(versionFile) == resolvedVersion + ) { + logger.lifecycle("Reusing Embed Code {} from {}", resolvedVersion, destination) + return + } + val releaseVersion = requestedVersion ?: resolvedVersion + val source = releaseAsset(baseUrl, releaseVersion, asset) val download = temporaryDir.toPath().resolve(asset) val preparedExecutable = temporaryDir.toPath().resolve(platform.executableName) @@ -114,17 +142,80 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { throw GradleException("Could not make `$preparedExecutable` executable.") } moveAtomically(preparedExecutable, destination) + if (resolvedVersion != null) { + writeResolvedVersion(versionFile, resolvedVersion) + } } catch (exception: IOException) { throw GradleException("Could not install Embed Code from $source.", exception) } } + /** + * Reuses an installed executable while Gradle is offline. + */ + private fun reuseOfflineInstallation(destination: Path) { + if (!Files.isRegularFile(destination)) { + throw GradleException( + "Cannot install the latest Embed Code release in offline mode because " + + "no cached executable exists at `$destination`.", + ) + } + logger.lifecycle("Reusing cached Embed Code executable from {} in offline mode", destination) + } + private companion object { const val CONNECT_TIMEOUT_MILLIS = 30_000 const val READ_TIMEOUT_MILLIS = 120_000 const val BUFFER_SIZE = 8_192 + /** + * Returns the tag of the release targeted by the latest-release redirect. + * + * Non-HTTP release mirrors cannot expose an HTTP redirect, so they keep + * using the latest asset URL directly. + */ + fun resolveLatestVersion(baseUrl: String): String? { + val source = URI.create("$baseUrl/latest") + val connection = source.toURL().openConnection() + if (connection !is HttpURLConnection) { + return null + } + try { + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + connection.instanceFollowRedirects = false + connection.requestMethod = "HEAD" + val status = connection.responseCode + if (status < 300 || status > 399) { + throw GradleException( + "Could not resolve the latest Embed Code release: " + + "HTTP $status from $source.", + ) + } + val location = connection.getHeaderField("Location") + ?: throw GradleException( + "Could not resolve the latest Embed Code release: " + + "the redirect from $source has no Location header.", + ) + val releaseUri = source.resolve(location) + val tag = releaseUri.path.substringAfterLast('/') + if (tag.isEmpty()) { + throw GradleException( + "Could not resolve the latest Embed Code release from `$releaseUri`.", + ) + } + return tag + } catch (exception: IOException) { + throw GradleException( + "Could not resolve the latest Embed Code release from $source.", + exception, + ) + } finally { + connection.disconnect() + } + } + /** * Returns the release asset URI for the latest or explicitly requested version. */ @@ -174,6 +265,28 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } + /** + * Returns the recorded latest release version, if available. + */ + fun readResolvedVersion(versionFile: Path): String? { + return try { + Files.readString(versionFile).trim().ifEmpty { null } + } catch (_: IOException) { + null + } + } + + /** + * Records [version] after its executable has been installed. + */ + @Throws(IOException::class) + fun writeResolvedVersion(versionFile: Path, version: String) { + Files.createDirectories(versionFile.parent) + val temporaryFile = versionFile.resolveSibling("${versionFile.fileName}.tmp") + Files.writeString(temporaryFile, "$version\n") + moveAtomically(temporaryFile, versionFile) + } + /** * Extracts [entryName] from [archive] into [destination]. */ From 5772c43ec1bdf63497b1c36274a0d5969892259b Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 18:25:57 +0200 Subject: [PATCH 12/14] Improve network issues resolution. --- README.md | 3 +- .../embedcode/gradle/EmbedCodePluginSpec.kt | 34 +++++++++++++++++++ .../spine/embedcode/gradle/EmbedCodePlugin.kt | 2 ++ .../embedcode/gradle/InstallEmbedCodeTask.kt | 18 ++++++++-- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fc2eaae..dd48ea3 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ Embedding instructions refer to these roots with `$model/` and By default, the plugin checks the latest Embed Code release before running a task. It reuses the executable in `build/embed-code/latest` while the release version remains unchanged and downloads a new executable only after a new -release is published. +release is published. When the release check fails, for example without +network access, the plugin reuses the previously installed executable. To use a specific Embed Code application release, add its version to the extension: diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index d46a9a7..7e76c0f 100644 --- a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -182,6 +182,40 @@ internal class EmbedCodePluginSpec { result.output shouldContain "Reusing cached Embed Code executable" } + @Test + fun `reuse cached executable when the latest release check fails`() { + val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val server = startReleaseServer( + latestVersion, + AtomicInteger(), + AtomicInteger(), + ) + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + try { + runner(":installEmbedCode").build() + } finally { + server.stop(0) + } + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "Could not check the latest Embed Code release" + result.output shouldContain "Reusing the cached executable" + } + + @Test + fun `report a failed latest release check without a cached executable`() { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val baseUrl = "http://127.0.0.1:${server.address.port}/releases" + server.stop(0) + writeBuildFile(downloadBaseUrl = baseUrl) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "Could not resolve the latest Embed Code release" + } + @Test fun `report a missing latest executable in offline mode`() { val result = runner(":installEmbedCode", "--offline").buildAndFail() diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index 8afcb52..7d3bcc5 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -55,6 +55,8 @@ public class EmbedCodePlugin : Plugin { val operatingSystem = System.getProperty("os.name").orEmpty() val architecture = System.getProperty("os.arch").orEmpty() + // The installed file name always tracks the host operating system. + // Overriding the task's `operatingSystem` input changes asset selection only. val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) val requestedVersion = extension.version.map { version -> version.trim() } val installTask = project.tasks.register( diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index 8e7d887..45c9682 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -53,7 +53,8 @@ import java.util.zip.ZipInputStream * * An explicitly selected version is reused using Gradle's normal up-to-date * behavior. For the latest release, the remote version is checked before an - * existing executable is replaced. + * existing executable is replaced. When the check fails, for example without + * network access, a previously installed executable is reused. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { @@ -109,7 +110,20 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { return } val resolvedVersion = if (requestedVersion == null) { - resolveLatestVersion(baseUrl) + try { + resolveLatestVersion(baseUrl) + } catch (exception: GradleException) { + if (!Files.isRegularFile(destination)) { + throw exception + } + logger.warn( + "Could not check the latest Embed Code release ({}). " + + "Reusing the cached executable from `{}`.", + exception.message, + destination, + ) + return + } } else { null } From d19f8e4a1d347a464cc4ed2435c1d395bcff41bf Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 16 Jul 2026 18:39:13 +0200 Subject: [PATCH 13/14] Improve logging. --- .../embedcode/gradle/EmbedCodePluginSpec.kt | 15 ++++++++ .../spine/embedcode/gradle/EmbedCodePlugin.kt | 7 ++++ .../spine/embedcode/gradle/EmbedCodeTask.kt | 36 ++++++++++++++++--- .../embedcode/gradle/InstallEmbedCodeTask.kt | 19 ++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 7e76c0f..32ba139 100644 --- a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -89,6 +89,21 @@ internal class EmbedCodePluginSpec { arguments shouldContain "-stacktrace=true" } + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `log main execution points at info level`() { + val result = runner(":checkEmbedding", "--info").build() + + result.output shouldContain "Applying the Embed Code plugin to project `:`." + result.output shouldContain + "Registered Embed Code tasks `checkEmbedding` and `embedCode` in project `:`." + result.output shouldContain "Preparing the Embed Code executable for operating system" + result.output shouldContain "Preparing Embed Code `check` mode" + result.output shouldContain "Using source root" + result.output shouldContain "Starting Embed Code `check` mode with executable" + result.output shouldContain "Embed Code `check` mode completed successfully." + } + @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `reuse the configuration cache`() { diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index 7d3bcc5..f19bddf 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -39,6 +39,7 @@ public class EmbedCodePlugin : Plugin { * Applies the plugin to [project]. */ override fun apply(project: Project) { + project.logger.info("Applying the Embed Code plugin to project `{}`.", project.path) val checkTaskName = availableTaskName(project, "checkEmbedding") val embedTaskName = availableTaskName(project, "embedCode") val extension = project.extensions.create( @@ -98,6 +99,12 @@ public class EmbedCodePlugin : Plugin { "Updates embedded code snippets from source files", "embed", ) + project.logger.info( + "Registered Embed Code tasks `{}` and `{}` in project `{}`.", + checkTaskName, + embedTaskName, + project.path, + ) } private companion object { diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt index 877c8e7..9f0c3c0 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -126,6 +126,13 @@ public abstract class EmbedCodeTask : DefaultTask() { */ @TaskAction public fun runEmbedCode() { + val executionMode = mode.get() + val processDirectory = workingDirectory.get().asFile + logger.info( + "Preparing Embed Code `{}` mode in `{}`.", + executionMode, + processDirectory, + ) val configuredSources = TreeMap(namedSources.get()) val hasDirectSource = codePath.isPresent val hasNamedSources = configuredSources.isNotEmpty() @@ -136,12 +143,19 @@ public abstract class EmbedCodeTask : DefaultTask() { } val arguments = mutableListOf() - arguments.add("-mode=${mode.get()}") + arguments.add("-mode=$executionMode") if (hasNamedSources) { arguments.add("-config-path=${writeNamedSourceConfiguration(configuredSources)}") } else { - arguments.add("-code-path=${codePath.get().asFile.absolutePath}") - arguments.add("-docs-path=${docsPath.get().asFile.absolutePath}") + val sourceDirectory = codePath.get().asFile + val documentationDirectory = docsPath.get().asFile + logger.info( + "Using source root `{}` and documentation root `{}`.", + sourceDirectory, + documentationDirectory, + ) + arguments.add("-code-path=${sourceDirectory.absolutePath}") + arguments.add("-docs-path=${documentationDirectory.absolutePath}") if (docIncludes.get().isNotEmpty()) { arguments.add("-doc-includes=${docIncludes.get().joinToString(",")}") } @@ -153,11 +167,18 @@ public abstract class EmbedCodeTask : DefaultTask() { arguments.add("-stacktrace=${stacktrace.get()}") } + val executable = executableFile.get().asFile + logger.info( + "Starting Embed Code `{}` mode with executable `{}`.", + executionMode, + executable, + ) execOperations.exec { spec -> - spec.executable(executableFile.get().asFile) + spec.executable(executable) spec.args(arguments) - spec.setWorkingDir(workingDirectory.get().asFile) + spec.setWorkingDir(processDirectory) } + logger.info("Embed Code `{}` mode completed successfully.", executionMode) } /** @@ -197,6 +218,11 @@ public abstract class EmbedCodeTask : DefaultTask() { exception, ) } + logger.info( + "Generated Embed Code configuration at `{}` for {} named source roots.", + configuration, + configuredSources.size, + ) return configuration } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index 45c9682..c3ec925 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -97,10 +97,14 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (requestedVersion != null && requestedVersion.isEmpty()) { throw GradleException("Embed Code version must not be empty.") } - val platform = EmbedCodePlatform.detect( - operatingSystem.get(), - architecture.get(), + val hostOperatingSystem = operatingSystem.get() + val hostArchitecture = architecture.get() + logger.info( + "Preparing the Embed Code executable for operating system `{}` and architecture `{}`.", + hostOperatingSystem, + hostArchitecture, ) + val platform = EmbedCodePlatform.detect(hostOperatingSystem, hostArchitecture) val asset = platform.assetName val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) val destination = executableFile.get().asFile.toPath() @@ -110,6 +114,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { return } val resolvedVersion = if (requestedVersion == null) { + logger.info("Resolving the latest Embed Code release from {}.", baseUrl) try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { @@ -127,6 +132,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } else { null } + if (resolvedVersion != null) { + logger.info("Resolved the latest Embed Code release as {}.", resolvedVersion) + } if ( resolvedVersion != null && Files.isRegularFile(destination) && @@ -159,6 +167,11 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (resolvedVersion != null) { writeResolvedVersion(versionFile, resolvedVersion) } + logger.info( + "Installed Embed Code {} at {}.", + releaseVersion ?: "latest release", + destination, + ) } catch (exception: IOException) { throw GradleException("Could not install Embed Code from $source.", exception) } From e1830688aefd6fbf163507dbce5a82b4ea395b15 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Fri, 17 Jul 2026 11:11:29 +0200 Subject: [PATCH 14/14] Improve readability. --- README.md | 2 +- .../embedcode/gradle/EmbedCodePluginSpec.kt | 78 +++++++++++++++---- .../spine/embedcode/gradle/EmbedCodePlugin.kt | 3 +- .../embedcode/gradle/InstallEmbedCodeTask.kt | 28 ++++--- 4 files changed, 84 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index dd48ea3..c672a2e 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ embedCode { Embedding instructions refer to these roots with `$model/` and `$database/`. `codePath` and `namedSource(...)` are mutually exclusive. -By default, the plugin checks the latest Embed Code release before running a task. +By default, the plugin checks the latest Embed Code release before running a task. It reuses the executable in `build/embed-code/latest` while the release version remains unchanged and downloads a new executable only after a new release is published. When the release check fails, for example without diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 32ba139..14eb780 100644 --- a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -132,10 +132,10 @@ internal class EmbedCodePluginSpec { @Test fun `reuse latest executable when the release version is unchanged`() { - val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val latestTag = AtomicReference(TEST_RELEASE_TAG) val versionChecks = AtomicInteger() val downloads = AtomicInteger() - val server = startReleaseServer(latestVersion, versionChecks, downloads) + val server = startReleaseServer(latestTag, versionChecks, downloads) try { writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) @@ -151,19 +151,43 @@ internal class EmbedCodePluginSpec { } } + @Test + fun `use a resolved latest release tag without modification`() { + val releaseTag = "release-$TEST_RELEASE_VERSION" + createFakeRelease(releaseDirectory, tag = releaseTag) + val downloads = AtomicInteger() + val server = startReleaseServer( + AtomicReference(releaseTag), + AtomicInteger(), + downloads, + ) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + + runner(":installEmbedCode").build() + + downloads.get() shouldBe 1 + Files.readString( + projectDirectory.resolve("build/embed-code/latest/version.txt"), + ).trim() shouldBe releaseTag + } finally { + server.stop(0) + } + } + @Test fun `download latest executable when the release version changes`() { val nextVersion = "1.2.5-test" createFakeRelease(releaseDirectory, nextVersion) - val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val latestTag = AtomicReference(TEST_RELEASE_TAG) val versionChecks = AtomicInteger() val downloads = AtomicInteger() - val server = startReleaseServer(latestVersion, versionChecks, downloads) + val server = startReleaseServer(latestTag, versionChecks, downloads) try { writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) runner(":installEmbedCode").build() - latestVersion.set(nextVersion) + latestTag.set("v$nextVersion") runner(":installEmbedCode").build() versionChecks.get() shouldBe 2 @@ -178,9 +202,9 @@ internal class EmbedCodePluginSpec { @Test fun `reuse latest executable in offline mode`() { - val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val latestTag = AtomicReference(TEST_RELEASE_TAG) val server = startReleaseServer( - latestVersion, + latestTag, AtomicInteger(), AtomicInteger(), ) @@ -199,9 +223,9 @@ internal class EmbedCodePluginSpec { @Test fun `reuse cached executable when the latest release check fails`() { - val latestVersion = AtomicReference(TEST_RELEASE_VERSION) + val latestTag = AtomicReference(TEST_RELEASE_TAG) val server = startReleaseServer( - latestVersion, + latestTag, AtomicInteger(), AtomicInteger(), ) @@ -459,6 +483,27 @@ internal class EmbedCodePluginSpec { Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" } + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied installEmbedCode task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("installEmbedCode") + tasks.register("_installEmbedCode") + } + """.trimIndent(), + ) + + val result = runner(":checkEmbedding").build() + + result.task(":__installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + } + /** * Creates a runner using the plugin-under-test classpath. */ @@ -558,12 +603,16 @@ internal class EmbedCodePluginSpec { /** * Creates a host-specific fake release asset that records received arguments. */ - private fun createFakeRelease(root: Path, version: String = TEST_RELEASE_VERSION) { + private fun createFakeRelease( + root: Path, + version: String = TEST_RELEASE_VERSION, + tag: String = "v$version", + ) { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), System.getProperty("os.arch"), ) - val versionDirectory = root.resolve("download/v$version") + val versionDirectory = root.resolve("download/$tag") val latestDirectory = root.resolve("latest/download") Files.createDirectories(versionDirectory) Files.createDirectories(latestDirectory) @@ -602,10 +651,10 @@ internal class EmbedCodePluginSpec { } /** - * Starts a release server whose latest endpoint redirects to a mutable version. + * Starts a release server whose latest endpoint redirects to a mutable tag. */ private fun startReleaseServer( - latestVersion: AtomicReference, + latestTag: AtomicReference, versionChecks: AtomicInteger, downloads: AtomicInteger, ): HttpServer { @@ -617,7 +666,7 @@ internal class EmbedCodePluginSpec { } else { exchange.responseHeaders.add( "Location", - "/releases/tag/v${latestVersion.get()}", + "/releases/tag/${latestTag.get()}", ) exchange.sendResponseHeaders(302, -1) } @@ -645,6 +694,7 @@ internal class EmbedCodePluginSpec { private companion object { const val TEST_RELEASE_VERSION = "1.2.4-test" + const val TEST_RELEASE_TAG = "v1.2.4-test" } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index f19bddf..cb8801d 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -42,6 +42,7 @@ public class EmbedCodePlugin : Plugin { project.logger.info("Applying the Embed Code plugin to project `{}`.", project.path) val checkTaskName = availableTaskName(project, "checkEmbedding") val embedTaskName = availableTaskName(project, "embedCode") + val installTaskName = availableTaskName(project, "installEmbedCode") val extension = project.extensions.create( "embedCode", EmbedCodeExtension::class.java, @@ -61,7 +62,7 @@ public class EmbedCodePlugin : Plugin { val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) val requestedVersion = extension.version.map { version -> version.trim() } val installTask = project.tasks.register( - "installEmbedCode", + installTaskName, InstallEmbedCodeTask::class.java, ) { task -> task.description = "Installs the requested Embed Code executable" diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index c3ec925..fcd14d8 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -143,8 +143,12 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { logger.lifecycle("Reusing Embed Code {} from {}", resolvedVersion, destination) return } - val releaseVersion = requestedVersion ?: resolvedVersion - val source = releaseAsset(baseUrl, releaseVersion, asset) + val selectedReleaseTag = if (requestedVersion != null) { + releaseTagForVersion(requestedVersion) + } else { + resolvedVersion + } + val source = releaseAsset(baseUrl, selectedReleaseTag, asset) val download = temporaryDir.toPath().resolve(asset) val preparedExecutable = temporaryDir.toPath().resolve(platform.executableName) @@ -169,7 +173,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } logger.info( "Installed Embed Code {} at {}.", - releaseVersion ?: "latest release", + selectedReleaseTag ?: "latest release", destination, ) } catch (exception: IOException) { @@ -244,20 +248,22 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } /** - * Returns the release asset URI for the latest or explicitly requested version. + * Returns the release asset URI for the latest release or [releaseTag]. */ - fun releaseAsset(baseUrl: String, requestedVersion: String?, asset: String): URI { - if (requestedVersion == null) { + fun releaseAsset(baseUrl: String, releaseTag: String?, asset: String): URI { + if (releaseTag == null) { return URI.create("$baseUrl/latest/download/$asset") } - val releaseTag = if (requestedVersion.startsWith("v")) { - requestedVersion - } else { - "v$requestedVersion" - } return URI.create("$baseUrl/download/$releaseTag/$asset") } + /** + * Returns the release tag corresponding to a user-configured [version]. + */ + fun releaseTagForVersion(version: String): String { + return if (version.startsWith("v")) version else "v$version" + } + /** * Downloads [source] into [destination], reporting HTTP failures clearly. */