diff --git a/README.md b/README.md index a988b0c..47ee963 100644 --- a/README.md +++ b/README.md @@ -106,18 +106,15 @@ The fallback cannot account for a conflicting task registered later. ### Version -By default, the plugin resolves and verifies the latest Embed Code release on -the first installation. Before reusing the executable in -`build/embed-code/latest`, it verifies the executable against the digest stored -during installation. This local check does not require another release or -checksum-metadata request. If the executable was modified, the plugin restores -it from the authenticated cached release asset, or fails safely when that asset -cannot be authenticated offline. Run `clean` or remove that directory to check -for a newer release. Changing the configured `version` selects a separate cache -entry. If that entry does not already contain a verified installation, the -plugin downloads and verifies the selected release while online. - -To use a specific Embed Code application release, add its exact release tag to the extension: +Each plugin release has a default Embed Code version that was tested with it. +The executable is downloaded and verified on its first use. Before reusing it, +the plugin compares its digest with the one recorded during installation. This +local check does not require a network request. If the executable was modified, +the plugin restores it from the authenticated cached release asset, or fails +safely when that asset cannot be authenticated offline. Changing `version` +selects a separate cache entry. + +To set the application version explicitly, use its exact release tag: ```kotlin embedCode { @@ -136,9 +133,7 @@ embedCode { ``` For CI, configure `githubToken` to avoid GitHub's unauthenticated API rate limit -during the initial resolution. Without `sha256`, the first online installation -resolves the asset digest from GitHub release metadata. Pin both `version` and -`sha256` to keep that initial installation tied to an immutable release. +during the initial checksum lookup. ## Development diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 45aa95f..8da03be 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -27,7 +27,9 @@ import io.spine.embedcode.gradle.BuildSettings import io.spine.embedcode.gradle.dependency.Kotlin import io.spine.embedcode.gradle.dependency.PluginPublish +import org.apache.tools.ant.filters.ReplaceTokens import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.tasks.Sync import org.gradle.plugin.compatibility.compatibility plugins { @@ -47,6 +49,22 @@ dependencies { testRuntimeOnly(gradleApi()) } +val embedCodeAppVersion = rootProject.extra["embedCodeAppVersion"] as String +val generateEmbedCodeVersion = tasks.register("generateEmbedCodeVersion") { + description = "Generates the default Embed Code application version." + inputs.property("embedCodeAppVersion", embedCodeAppVersion) + from(layout.projectDirectory.dir("src/main/templates")) { + filter( + "tokens" to mapOf("embedCodeAppVersion" to embedCodeAppVersion), + ) + } + into(layout.buildDirectory.dir("generated/sources/embedCodeVersion/kotlin")) +} + +kotlin.sourceSets.named("main") { + kotlin.srcDir(generateEmbedCodeVersion) +} + val functionalTestSourceSet = sourceSets.create("functionalTest") functionalTestSourceSet.compileClasspath += sourceSets.main.get().output functionalTestSourceSet.runtimeClasspath += sourceSets.main.get().output 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 4527235..2788e4c 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 @@ -38,15 +38,12 @@ 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.HttpURLConnection.HTTP_MOVED_TEMP -import java.net.HttpURLConnection.HTTP_UNAVAILABLE import java.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -120,25 +117,18 @@ internal class EmbedCodePluginSpec { } @Test - fun `install platform release asset`() { + fun `install the plugin default application release`() { val result = runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.installedExecutableName( - System.getProperty("os.name"), - ) - val installedExecutable = projectDirectory.resolve( - "build/embed-code/latest/$executableName", - ) result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - Files.exists(installedExecutable) shouldBe true + Files.exists(installedExecutable()) shouldBe true + result.output shouldContain "Downloading Embed Code $TEST_RELEASE_TAG" } @Test - fun `reuse a verified latest executable without another release request`() { - val latestTag = AtomicReference(TEST_RELEASE_TAG) - val versionChecks = AtomicInteger() + fun `reuse the verified default executable without another download`() { val downloads = AtomicInteger() - val server = startReleaseServer(latestTag, versionChecks, downloads) + val server = startReleaseServer(downloads) try { writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) @@ -150,10 +140,9 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - versionChecks.get() shouldBe 1 downloads.get() shouldBe 1 result.output shouldContain - "Reusing previously verified Embed Code v$TEST_RELEASE_VERSION" + "Reusing previously verified Embed Code $TEST_RELEASE_TAG" } finally { server.stop(0) } @@ -188,63 +177,34 @@ 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 configured digest changes`() { + fun `install an overridden application release into a separate cache`() { val nextVersion = "1.2.5-test" - createFakeRelease(releaseDirectory, nextVersion) - val latestTag = AtomicReference(TEST_RELEASE_TAG) - val versionChecks = AtomicInteger() + val nextTag = "v$nextVersion" + createFakeRelease(releaseDirectory, nextVersion, nextTag) val downloads = AtomicInteger() - val server = startReleaseServer(latestTag, versionChecks, downloads) + val server = startReleaseServer(downloads) try { - writeBuildFile( - downloadBaseUrl = server.releaseBaseUrl, - sha256 = releaseAssetSha256(tag = TEST_RELEASE_TAG), - ) + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) runner(":installEmbedCode").build() - latestTag.set("v$nextVersion") writeBuildFile( + version = nextTag, downloadBaseUrl = server.releaseBaseUrl, - sha256 = releaseAssetSha256(tag = "v$nextVersion"), + sha256 = releaseAssetSha256(tag = nextTag), ) 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" + Files.exists(installedExecutable(TEST_RELEASE_TAG)) shouldBe true + Files.readString(installedExecutable(nextTag)) shouldContain + "# release-marker: $nextVersion" } finally { server.stop(0) } } @Test - fun `reuse latest executable in offline mode`() { + fun `reuse the default executable in offline mode`() { val server = startReleaseServer() writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) try { @@ -278,136 +238,12 @@ internal class EmbedCodePluginSpec { } @Test - fun `reuse cached executable when the latest release check fails`() { - val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) - val versionChecks = AtomicInteger() - val downloads = AtomicInteger() - val server = startReleaseServer( - versionChecks = versionChecks, - downloads = downloads, - latestStatus = latestStatus, - ) - writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) - try { - runner(":installEmbedCode").build() - writeBuildFile( - downloadBaseUrl = server.releaseBaseUrl, - configureSha256 = false, - ) - Files.delete(installationDirectory().resolve("source.sha256")) - latestStatus.set(HTTP_UNAVAILABLE) - - val result = runner(":installEmbedCode").build() - - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - versionChecks.get() shouldBe 2 - downloads.get() shouldBe 1 - result.output shouldContain "Could not check the latest Embed Code release" - result.output shouldContain "HTTP 503 from ${server.releaseBaseUrl}/latest" - result.output shouldContain "Reusing the locally verified installed executable" - } finally { - server.stop(0) - } - } - - @Test - fun `require a configured digest to restore a cached asset after latest check failure`() { - val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) - val downloads = AtomicInteger() - val server = startReleaseServer( - downloads = downloads, - latestStatus = latestStatus, - ) - try { - writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) - runner(":installEmbedCode").build() - Files.delete(installedExecutable()) - - writeBuildFile( - downloadBaseUrl = server.releaseBaseUrl, - configureSha256 = false, - ) - latestStatus.set(HTTP_UNAVAILABLE) - val result = runner(":installEmbedCode").buildAndFail() - - result.output shouldContain - "No locally verified installed executable is available for reuse, and " + - "the cached asset cannot be authenticated without a configured digest." - result.output shouldContain "Configure `embedCode.sha256`" - downloads.get() shouldBe 1 - } finally { - server.stop(0) - } - } - - @Test - fun `restore a verified cached asset after latest release check failure`() { - val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) - val downloads = AtomicInteger() - val server = startReleaseServer( - downloads = downloads, - latestStatus = latestStatus, - ) - try { - writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) - runner(":installEmbedCode").build() - Files.delete(installedExecutable()) - latestStatus.set(HTTP_UNAVAILABLE) - - val result = runner(":installEmbedCode").build() - - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - Files.exists(installedExecutable()) shouldBe true - downloads.get() shouldBe 1 - result.output shouldContain "Reusing the cached executable" - } finally { - server.stop(0) - } - } - - @Test - fun `report a failed latest release check without a cached executable`() { - val versionChecks = AtomicInteger() - val downloads = AtomicInteger() - val server = startReleaseServer( - versionChecks = versionChecks, - downloads = downloads, - latestStatus = AtomicInteger(HTTP_UNAVAILABLE), - ) - try { - writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) - - val result = runner(":installEmbedCode").buildAndFail() - - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.FAILED - versionChecks.get() shouldBe 1 - downloads.get() shouldBe 0 - result.output shouldContain - "Could not resolve the latest Embed Code release: " + - "HTTP 503 from ${server.releaseBaseUrl}/latest." - result.output shouldNotContain "Reusing the cached executable" - } finally { - server.stop(0) - } - } - - @Test - fun `report a missing latest executable in offline mode`() { + fun `report a missing default executable in offline mode`() { val result = runner(":installEmbedCode", "--offline").buildAndFail() result.output shouldContain "Cannot reuse cached Embed Code asset" } - @Test - fun `keep explicit versions offline when no verified executable is cached`() { - writeBuildFile(version = TEST_RELEASE_TAG) - - val result = runner(":installEmbedCode", "--offline").buildAndFail() - - result.output shouldContain "Cannot install Embed Code in offline mode" - result.output shouldNotContain "Downloading Embed Code" - } - @Test fun `reject a release asset whose SHA-256 digest does not match`() { writeBuildFile(sha256 = "0".repeat(64)) @@ -612,18 +448,12 @@ internal class EmbedCodePluginSpec { } @Test - fun `treat an empty release tag as the rolling latest release`() { - writeBuildFile(version = " ") + fun `reject an empty release tag`() { + writeBuildFile(version = " ", configureSha256 = false) - val result = runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.installedExecutableName( - System.getProperty("os.name"), - ) + val result = runner(":installEmbedCode").buildAndFail() - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - Files.exists( - projectDirectory.resolve("build/embed-code/latest/$executableName"), - ) shouldBe true + result.output shouldContain "The Embed Code release tag must not be empty." } @Test @@ -632,12 +462,10 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode").build() val cacheKey = releaseTagCacheKey(TEST_RELEASE_TAG) - val installation = installationDirectory(TEST_RELEASE_TAG) result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS cacheKey.length shouldBe 64 cacheKey.all { it in '0'..'9' || it in 'a'..'f' } shouldBe true - Files.readString(installation.resolve("version.txt")).trim() shouldBe TEST_RELEASE_TAG Files.exists(installedExecutable(TEST_RELEASE_TAG)) shouldBe true } @@ -656,37 +484,19 @@ internal class EmbedCodePluginSpec { runner(":installEmbedCode").build() (releaseTagCacheKey(lowerTag) == releaseTagCacheKey(upperTag)) shouldBe false - Files.readString( - installationDirectory(lowerTag).resolve("version.txt"), - ).trim() shouldBe lowerTag Files.readString(installedExecutable(lowerTag)) shouldContain "# release-marker: lower" - Files.readString( - installationDirectory(upperTag).resolve("version.txt"), - ).trim() shouldBe upperTag Files.readString(installedExecutable(upperTag)) shouldContain "# release-marker: upper" } @Test - fun `keep an explicit latest tag separate from the rolling latest cache`() { - runner(":installEmbedCode").build() - createFakeRelease(releaseDirectory, version = "explicit-latest", tag = "latest") - writeBuildFile(version = "latest") + fun `reject the rolling latest version`() { + writeBuildFile(version = "latest", configureSha256 = false) - val result = runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.installedExecutableName( - System.getProperty("os.name"), - ) - val rollingLatest = projectDirectory.resolve("build/embed-code/latest/$executableName") - val pinnedLatest = projectDirectory.resolve( - "build/embed-code/versions/${releaseTagCacheKey("latest")}/$executableName", - ) + val result = runner(":installEmbedCode").buildAndFail() - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "/download/latest/" - Files.readString(rollingLatest) shouldContain "# release-marker: $TEST_RELEASE_VERSION" - Files.readString(pinnedLatest) shouldContain "# release-marker: explicit-latest" + result.output shouldContain "The rolling Embed Code version `latest` is not supported." } @Test @@ -1163,7 +973,8 @@ internal class EmbedCodePluginSpec { ) { val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() val checksumConfiguration = if (configureSha256) { - val configuredSha256 = sha256 ?: releaseAssetSha256(tag = version) + val selectedTag = version?.trim() ?: TEST_RELEASE_TAG + val configuredSha256 = sha256 ?: releaseAssetSha256(tag = selectedTag) "sha256.set(\"$configuredSha256\")" } else { "" @@ -1192,21 +1003,17 @@ internal class EmbedCodePluginSpec { } /** - * Returns the cache directory for the rolling latest release or an exact [releaseTag]. + * Returns the cache directory for [releaseTag]. */ - private fun installationDirectory(releaseTag: String? = null): Path { - val relativePath = if (releaseTag == null) { - "build/embed-code/latest" - } else { - "build/embed-code/versions/${releaseTagCacheKey(releaseTag)}" - } - return projectDirectory.resolve(relativePath) - } + private fun installationDirectory(releaseTag: String = TEST_RELEASE_TAG): Path = + projectDirectory.resolve( + "build/embed-code/versions/${releaseTagCacheKey(releaseTag)}", + ) /** - * Returns the installed executable for the rolling latest release or [releaseTag]. + * Returns the installed executable for [releaseTag]. */ - private fun installedExecutable(releaseTag: String? = null): Path { + private fun installedExecutable(releaseTag: String = TEST_RELEASE_TAG): Path { val executableName = EmbedCodePlatform.installedExecutableName( System.getProperty("os.name"), ) @@ -1265,9 +1072,7 @@ internal class EmbedCodePluginSpec { System.getProperty("os.arch"), ) val versionDirectory = root.resolve("download/$tag") - val latestDirectory = root.resolve("latest/download") Files.createDirectories(versionDirectory) - Files.createDirectories(latestDirectory) val executable = projectDirectory.resolve(platform.executableName) Files.writeString( executable, @@ -1300,12 +1105,6 @@ internal class EmbedCodePluginSpec { StandardCopyOption.REPLACE_EXISTING, ) } - val latestAsset = latestDirectory.resolve(platform.assetName) - Files.copy( - asset, - latestAsset, - StandardCopyOption.REPLACE_EXISTING, - ) } /** @@ -1313,47 +1112,23 @@ internal class EmbedCodePluginSpec { */ private fun releaseAssetSha256( root: Path = releaseDirectory, - tag: String? = null, + tag: String = TEST_RELEASE_TAG, ): String { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), System.getProperty("os.arch"), ) - val normalizedTag = tag?.trim()?.ifEmpty { null } - val asset = if (normalizedTag == null) { - root.resolve("latest/download/${platform.assetName}") - } else { - root.resolve("download/$normalizedTag/${platform.assetName}") - } + val asset = root.resolve("download/${tag.trim()}/${platform.assetName}") return sha256(asset) } /** - * Starts a release server with mutable latest-release status and tag responses. + * Starts a release server that records exact-tag asset downloads. */ private fun startReleaseServer( - latestTag: AtomicReference = AtomicReference(TEST_RELEASE_TAG), - versionChecks: AtomicInteger = AtomicInteger(), downloads: AtomicInteger = AtomicInteger(), - latestStatus: AtomicInteger = AtomicInteger(HTTP_MOVED_TEMP), ): HttpServer { val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - server.createContext("/releases/latest") { exchange -> - versionChecks.incrementAndGet() - val responseStatus = latestStatus.get() - if (exchange.requestMethod != "HEAD") { - exchange.sendResponseHeaders(405, -1) - } else if (responseStatus != HTTP_MOVED_TEMP) { - exchange.sendResponseHeaders(responseStatus, -1) - } else { - exchange.responseHeaders.add( - "Location", - "/releases/tag/${latestTag.get()}", - ) - exchange.sendResponseHeaders(HTTP_MOVED_TEMP, -1) - } - exchange.close() - } server.createContext("/releases/download/") { exchange -> val relativePath = exchange.requestURI.path.removePrefix("/releases/download/") downloads.incrementAndGet() @@ -1375,8 +1150,8 @@ internal class EmbedCodePluginSpec { get() = "http://127.0.0.1:${address.port}/releases" private companion object { - const val TEST_RELEASE_VERSION = "1.2.4-test" - const val TEST_RELEASE_TAG = "v1.2.4-test" + val TEST_RELEASE_TAG = DEFAULT_EMBED_CODE_VERSION + val TEST_RELEASE_VERSION = TEST_RELEASE_TAG.removePrefix("v") } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt index a7e766f..41b53d9 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt @@ -76,9 +76,9 @@ internal fun sha256(value: String): String { */ internal fun releaseAssetIdentity( releaseBaseUrl: String, - releaseTag: String?, + releaseTag: String, assetName: String, -): String = sha256("$releaseBaseUrl\u0000${releaseTag.orEmpty()}\u0000$assetName") +): String = sha256("$releaseBaseUrl\u0000$releaseTag\u0000$assetName") /** * Validates and normalizes a SHA-256 [value]. @@ -148,14 +148,14 @@ internal fun parseGitHubAssetSha256(json: String, assetName: String): String { internal fun resolveExpectedAssetSha256( configuredSha256: String?, releaseBaseUrl: String, - releaseTag: String?, + releaseTag: String, assetName: String, readMetadata: (URI) -> String, ): String { if (configuredSha256 != null) { return configuredSha256 } - val githubApi = releaseTag?.let { githubReleaseApi(releaseBaseUrl, it) } + val githubApi = githubReleaseApi(releaseBaseUrl, releaseTag) ?: throw GradleException( "Automatic SHA-256 resolution is available only for github.com releases. " + "Configure `embedCode.sha256` for asset `$assetName`.", 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 20f3f99..c82f9c9 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 @@ -46,8 +46,9 @@ public abstract class EmbedCodeExtension { private val configuredSourceNames = mutableSetOf() /** - * An optional release tag used verbatim; absent or empty selects the latest release. + * The exact Embed Code release tag to install. * + * By default, the plugin selects the release tested with this plugin version. * Tags must start with an ASCII letter or digit and may then contain only letters, * digits, dots, hyphens, underscores, plus signs, or tildes. * Tags containing `/` are unsupported because the tag is used as a release URL segment. @@ -58,8 +59,8 @@ public abstract class EmbedCodeExtension { * An optional SHA-256 digest of the configured release asset. * * The plugin resolves this digest automatically for releases hosted on - * `github.com`. Configure this property together with [version] to pin a - * release asset explicitly. + * `github.com`. Configure this property to pin the release asset explicitly + * and avoid a GitHub metadata request during its first installation. */ public abstract val sha256: Property @@ -129,9 +130,8 @@ public abstract class EmbedCodeExtension { /** * The base URL of the Embed Code releases. * - * The plugin appends `/latest/download/` when [version] is - * absent or empty, or `/download//` for an - * explicit tag. This property primarily supports functional testing. + * The plugin appends `/download//`. + * This property primarily supports functional testing. */ public abstract val downloadBaseUrl: Property 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 19ce05f..189bdd7 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 @@ -53,6 +53,7 @@ public class EmbedCodePlugin : Plugin { extension.separator.convention("...") extension.info.convention(false) extension.stacktrace.convention(false) + extension.version.convention(DEFAULT_EMBED_CODE_VERSION) extension.downloadBaseUrl.convention(DEFAULT_DOWNLOAD_BASE_URL) val operatingSystem = System.getProperty("os.name").orEmpty() @@ -75,14 +76,9 @@ public class EmbedCodePlugin : Plugin { task.offline.set(project.gradle.startParameter.isOffline) task.installationDirectory.set(installationDirectory) task.installationDirectory.disallowChanges() - val requestedTag = task.version.map(::validateVersion) - val installationSubdirectory = requestedTag.map { tag -> - if (tag.isEmpty()) { - "embed-code/latest" - } else { - "embed-code/versions/${releaseTagCacheKey(tag)}" - } - }.orElse("embed-code/latest") + val installationSubdirectory = task.version.map(::validateVersion).map { tag -> + "embed-code/versions/${releaseTagCacheKey(tag)}" + } task.executableFile.set( project.layout.buildDirectory.file( installationSubdirectory.map { directory -> @@ -90,11 +86,6 @@ public class EmbedCodePlugin : Plugin { }, ), ) - task.resolvedVersionFile.set( - project.layout.buildDirectory.file( - installationSubdirectory.map { directory -> "$directory/version.txt" }, - ), - ) task.cachedAssetFile.set( project.layout.buildDirectory.file( installationSubdirectory.map { directory -> "$directory/release-asset" }, @@ -117,7 +108,7 @@ public class EmbedCodePlugin : Plugin { installationSubdirectory.map { directory -> "$directory/source.sha256" }, ), ) - // Intentionally rerun and revalidate the cached release asset before execution. + // Intentionally rerun to validate local installation state before execution. task.outputs.upToDateWhen { false } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt index 59dd4e6..298c05d 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt @@ -34,11 +34,21 @@ private val validReleaseTag = Regex("[A-Za-z0-9][A-Za-z0-9._+~-]*") * Trims and validates a user-configured Embed Code release tag. * * The tag becomes a release URL segment, so only URL-safe characters are accepted. - * An empty tag selects the latest release. */ internal fun validateVersion(value: String): String { val version = value.trim() - if (version.isNotEmpty() && !validReleaseTag.matches(version)) { + if (version.isEmpty()) { + throw InvalidUserDataException( + "The Embed Code release tag must not be empty. Configure an exact release tag.", + ) + } + if (version.equals("latest", ignoreCase = true)) { + throw InvalidUserDataException( + "The rolling Embed Code version `latest` is not supported. " + + "Configure an exact release tag.", + ) + } + if (!validReleaseTag.matches(version)) { throw InvalidUserDataException( "Embed Code release tag `$value` is invalid. " + "Use letters, digits, dots, hyphens, underscores, plus signs, or tildes, " + 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 aa803c1..b466eb0 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,9 +66,8 @@ import java.util.zip.ZipInputStream @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { - /** An optional Embed Code release tag used verbatim; absent or empty selects latest. */ + /** The exact Embed Code release tag used verbatim. */ @get:Input - @get:Optional public abstract val version: Property /** An optional user-provided SHA-256 digest of the release asset. */ @@ -104,10 +103,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:OutputFile public abstract val executableFile: RegularFileProperty - /** Stores the exact release tag represented by this installation. */ - @get:LocalState - public abstract val resolvedVersionFile: RegularFileProperty - /** Stores the downloaded release asset used to recreate the executable. */ @get:LocalState public abstract val cachedAssetFile: RegularFileProperty @@ -130,7 +125,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @TaskAction public fun install() { // Validate again because consumers can configure this public task input directly. - val requestedTag = version.orNull?.let(::validateVersion)?.ifEmpty { null } + val releaseTag = validateVersion(version.get()) val configuredSha256 = sha256.orNull?.let(::normalizeSha256) val hostOperatingSystem = operatingSystem.get() val hostArchitecture = architecture.get() @@ -153,10 +148,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { cachedAssetFile.get().asFile.toPath(), installationRoot, ) - val versionFile = requireInsideInstallationDirectory( - resolvedVersionFile.get().asFile.toPath(), - installationRoot, - ) val assetChecksum = requireInsideInstallationDirectory( assetChecksumFile.get().asFile.toPath(), installationRoot, @@ -177,11 +168,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { platform, destination, cachedAsset, - versionFile, assetChecksum, executableChecksum, sourceIdentity, - requestedTag, + releaseTag, baseUrl, asset, configuredSha256, @@ -189,14 +179,12 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) return } - val cachedTag = readStoredValue(versionFile, installationRoot) if (isPreviouslyVerifiedInstallation( destination, - versionFile, assetChecksum, executableChecksum, sourceIdentity, - requestedTag, + releaseTag, baseUrl, asset, configuredSha256, @@ -205,86 +193,25 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) { logger.lifecycle( "Reusing previously verified Embed Code {} from {}", - selectedVersionName(requestedTag, cachedTag), + releaseTag, destination, ) return } - val resolvedTag = if (requestedTag == null) { - logger.info("Resolving the latest Embed Code release from {}.", baseUrl) - try { - resolveLatestVersion(baseUrl) - } catch (exception: GradleException) { - if (isLocallyVerifiedExecutable( - destination, - executableChecksum, - installationRoot, - ) - ) { - logger.warn( - "Could not check the latest Embed Code release ({}). " + - "Reusing the locally verified installed executable from `{}`.", - exception.message, - destination, - ) - return - } - val expectedAssetSha256 = configuredSha256 ?: throw GradleException( - "${exception.message} No locally verified installed executable is " + - "available for reuse, and the cached asset cannot be authenticated " + - "without a configured digest. Configure `embedCode.sha256` to " + - "restore it safely.", - exception, - ) - val cachedTag = readStoredValue(versionFile, installationRoot) - val expectedSourceIdentity = releaseAssetIdentity(baseUrl, cachedTag, asset) - val reused = installFromVerifiedAsset( - platform, - destination, - cachedAsset, - versionFile, - assetChecksum, - executableChecksum, - sourceIdentity, - cachedTag, - expectedSourceIdentity, - expectedAssetSha256, - installationRoot, - ) - if (!reused) { - throw exception - } - logger.warn( - "Could not check the latest Embed Code release ({}). " + - "Reusing the cached executable from `{}`.", - exception.message, - destination, - ) - return - } - } else { - null - } - if (resolvedTag != null) { - logger.info("Resolved the latest Embed Code release as {}.", resolvedTag) - } - val selectedReleaseTag = requestedTag ?: resolvedTag val expectedAssetSha256 = resolveTrustedAssetSha256( configuredSha256, baseUrl, - selectedReleaseTag, + releaseTag, asset, ) - val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedReleaseTag, asset) + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, releaseTag, asset) if (installFromVerifiedAsset( platform, destination, cachedAsset, - versionFile, assetChecksum, executableChecksum, sourceIdentity, - selectedReleaseTag, expectedSourceIdentity, expectedAssetSha256, installationRoot, @@ -292,22 +219,21 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) { logger.lifecycle( "Reusing verified Embed Code {} from {}", - selectedVersionName(requestedTag, resolvedTag), + releaseTag, destination, ) return } - val source = releaseAsset(baseUrl, selectedReleaseTag, asset) + val source = releaseAsset(baseUrl, releaseTag, asset) val download = Files.createTempFile(temporaryDir.toPath(), "downloaded-asset-", ".tmp") try { createDirectoriesSafely(destination.parent, installationRoot) - val release = requestedTag ?: "latest release" - logger.lifecycle("Downloading Embed Code {} from {}", release, source) + logger.lifecycle("Downloading Embed Code {} from {}", releaseTag, source) try { download(source, download) } catch (exception: GradleException) { - throw addReleaseTagMigrationHint(exception, requestedTag) + throw addReleaseTagMigrationHint(exception, releaseTag) } val downloadedSha256 = sha256(download) if (downloadedSha256 != expectedAssetSha256) { @@ -318,20 +244,13 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } logger.info("Verified SHA-256 digest `{}` for {}.", downloadedSha256, asset) moveSafely(download, cachedAsset, installationRoot) - if (selectedReleaseTag == null) { - deleteSafely(versionFile, installationRoot) - } else { - writeStoredValue(versionFile, selectedReleaseTag, installationRoot) - } val installed = installFromVerifiedAsset( platform, destination, cachedAsset, - versionFile, assetChecksum, executableChecksum, sourceIdentity, - selectedReleaseTag, expectedSourceIdentity, expectedAssetSha256, installationRoot, @@ -343,7 +262,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } logger.info( "Installed Embed Code {} at {}.", - selectedReleaseTag ?: "latest release", + releaseTag, destination, ) } catch (exception: IOException) { @@ -360,11 +279,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { platform: EmbedCodePlatform, destination: Path, cachedAsset: Path, - versionFile: Path, assetChecksum: Path, executableChecksum: Path, sourceIdentity: Path, - requestedTag: String?, + releaseTag: String, baseUrl: String, asset: String, configuredSha256: String?, @@ -372,11 +290,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) { if (isPreviouslyVerifiedInstallation( destination, - versionFile, assetChecksum, executableChecksum, sourceIdentity, - requestedTag, + releaseTag, baseUrl, asset, configuredSha256, @@ -395,24 +312,14 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { "be authenticated without a trusted digest. Configure `embedCode.sha256` " + "to restore it safely.", ) - val cachedTag = readStoredValue(versionFile, installationRoot) - val selectedTag = requestedTag ?: cachedTag - if (requestedTag != null && cachedTag != requestedTag) { - throw GradleException( - "Cannot install Embed Code in offline mode because " + - "no cached asset exists for release tag `$requestedTag`.", - ) - } - val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedTag, asset) + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, releaseTag, asset) if (!installFromVerifiedAsset( platform, destination, cachedAsset, - versionFile, assetChecksum, executableChecksum, sourceIdentity, - selectedTag, expectedSourceIdentity, expectedAssetSha256, installationRoot, @@ -457,22 +364,16 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { */ private fun isPreviouslyVerifiedInstallation( destination: Path, - versionFile: Path, assetChecksum: Path, executableChecksum: Path, sourceIdentity: Path, - requestedTag: String?, + releaseTag: String, baseUrl: String, asset: String, configuredSha256: String?, installationRoot: Path, ): Boolean { - val cachedTag = readStoredValue(versionFile, installationRoot) - if (requestedTag != null && cachedTag != requestedTag) { - return false - } - val selectedTag = requestedTag ?: cachedTag - val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedTag, asset) + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, releaseTag, asset) if (readStoredValue(sourceIdentity, installationRoot) != expectedSourceIdentity) { return false } @@ -502,7 +403,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { private fun resolveTrustedAssetSha256( configuredSha256: String?, baseUrl: String, - releaseTag: String?, + releaseTag: String, asset: String, ): String = resolveExpectedAssetSha256( configuredSha256, @@ -525,11 +426,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { platform: EmbedCodePlatform, destination: Path, cachedAsset: Path, - versionFile: Path, assetChecksum: Path, executableChecksum: Path, sourceIdentity: Path, - releaseTag: String?, expectedSourceIdentity: String, expectedAssetSha256: String, installationRoot: Path, @@ -538,9 +437,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (!Files.isRegularFile(cachedAsset, LinkOption.NOFOLLOW_LINKS)) { return false } - if (readStoredValue(versionFile, installationRoot) != releaseTag) { - return false - } val stagedAsset = Files.createTempFile(temporaryDir.toPath(), "cached-asset-", ".tmp") var preparedExecutable: Path? = null try { @@ -567,11 +463,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { writeStoredValue(assetChecksum, expectedAssetSha256, installationRoot) writeStoredValue(executableChecksum, preparedExecutableSha256, installationRoot) writeStoredValue(sourceIdentity, expectedSourceIdentity, installationRoot) - if (releaseTag == null) { - deleteSafely(versionFile, installationRoot) - } else { - writeStoredValue(versionFile, releaseTag, installationRoot) - } return true } catch (_: IOException) { return false @@ -587,9 +478,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { const val READ_TIMEOUT_MILLIS = 120_000 const val BUFFER_SIZE = 8_192 - fun selectedVersionName(requestedTag: String?, resolvedTag: String?): String = - requestedTag ?: resolvedTag ?: "latest release" - /** * Normalizes [path] and verifies that it is below [installationDirectory]. */ @@ -732,9 +620,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { */ fun addReleaseTagMigrationHint( exception: GradleException, - requestedTag: String?, + requestedTag: String, ): GradleException { - if (requestedTag == null || requestedTag.startsWith('v')) { + if (requestedTag.startsWith('v')) { return exception } return GradleException( @@ -745,61 +633,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } /** - * Returns the tag of the release targeted by the latest-release redirect. - * - * Non-HTTP sources have no redirect response, so their tag is unknown. - */ - 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.setRequestProperty("User-Agent", "embed-code-gradle-plugin") - 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 release or [releaseTag]. + * Returns the release asset URI for [releaseTag]. */ - fun releaseAsset(baseUrl: String, releaseTag: String?, asset: String): URI { - if (releaseTag == null) { - return URI.create("$baseUrl/latest/download/$asset") - } - return URI.create("$baseUrl/download/$releaseTag/$asset") - } + fun releaseAsset(baseUrl: String, releaseTag: String, asset: String): URI = + URI.create("$baseUrl/download/$releaseTag/$asset") /** * Downloads [source] into [destination], reporting HTTP failures clearly. @@ -930,15 +767,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } - /** - * Removes [file] without following symbolic links. - */ - @Throws(IOException::class) - fun deleteSafely(file: Path, installationDirectory: Path) { - requireNoSymbolicLinks(file, installationDirectory) - Files.deleteIfExists(file) - } - /** * Copies [source] without following it when it is a symbolic link. */ diff --git a/gradle-plugin/src/main/templates/io/spine/embedcode/gradle/EmbedCodeVersionDefaults.kt b/gradle-plugin/src/main/templates/io/spine/embedcode/gradle/EmbedCodeVersionDefaults.kt new file mode 100644 index 0000000..72d0aca --- /dev/null +++ b/gradle-plugin/src/main/templates/io/spine/embedcode/gradle/EmbedCodeVersionDefaults.kt @@ -0,0 +1,30 @@ +/* + * 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 + +/** The Embed Code release bundled by default with this plugin version. */ +internal const val DEFAULT_EMBED_CODE_VERSION: String = "v@embedCodeAppVersion@" diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt index 878485c..790eacf 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt @@ -133,7 +133,7 @@ internal class ChecksumSpec { val resolved = resolveExpectedAssetSha256( digest, "file:///tmp/embed-code/releases", - null, + "v1.2.4", "embed-code-linux", ) { throw AssertionError("Metadata must not be read for a configured checksum.") @@ -148,7 +148,7 @@ internal class ChecksumSpec { resolveExpectedAssetSha256( null, "file:///tmp/embed-code/releases", - null, + "v1.2.4", "embed-code-linux", ) { throw AssertionError("Metadata must not be read outside GitHub.") diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt index e007dc5..aae0121 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt @@ -43,7 +43,6 @@ internal class EmbedCodeVersionSpec { "v1.2.3", "1.0.0-beta+build", "2.0_rc1", - "latest", "CON", "release.", ) @@ -65,8 +64,19 @@ internal class EmbedCodeVersionSpec { } @Test - fun `treat an empty release tag as latest`() { - assertEquals("", validateVersion(" ")) + fun `reject a missing exact release tag`() { + assertThrows(InvalidUserDataException::class.java) { + validateVersion(" ") + } + } + + @Test + fun `reject the rolling latest version`() { + listOf("latest", "LATEST", "Latest").forEach { tag -> + assertThrows(InvalidUserDataException::class.java) { + validateVersion(tag) + } + } } @Test diff --git a/version.gradle.kts b/version.gradle.kts index 2102fb6..d15787f 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -26,3 +26,6 @@ /** Version of the Embed Code Gradle plugin. */ extra.set("embedCodePluginVersion", "0.1.1") + +/** Version of the Embed Code application used by default. */ +extra.set("embedCodeAppVersion", "1.2.4")