diff --git a/Makefile b/Makefile index 6c307de..13d8af3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,12 @@ -clean: - ./gradlew clean assemble: ./gradlew assemble + +test: + ./gradlew test + +install: + ./gradlew publishToMavenLocal + +clean: + ./gradlew clean diff --git a/build.gradle b/build.gradle index 0f9851d..10003fe 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,8 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'org.apache.httpcomponents:httpmime:4.5.14' - testImplementation('org.spockframework:spock-core:2.3-groovy-3.0') + testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'org.wiremock:wiremock:3.5.4' } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72..2e11132 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy b/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy new file mode 100644 index 0000000..b02098d --- /dev/null +++ b/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy @@ -0,0 +1,70 @@ +package io.nextflow.gradle + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.OutputFile + +/** + * Gradle task to generate the plugin spec file. + * + * @author Ben Sherman + */ +abstract class BuildSpecTask extends JavaExec { + + @Input + final ListProperty extensionPoints + + @OutputFile + final RegularFileProperty specFile + + BuildSpecTask() { + extensionPoints = project.objects.listProperty(String) + extensionPoints.convention(project.provider { + project.extensions.getByType(NextflowPluginConfig).extensionPoints + }) + + specFile = project.objects.fileProperty() + specFile.convention(project.layout.buildDirectory.file("resources/main/META-INF/spec.json")) + + getMainClass().set('nextflow.plugin.spec.PluginSpecWriter') + + project.afterEvaluate { + setClasspath(project.sourceSets.getByName('specFile').runtimeClasspath) + setArgs([specFile.get().asFile.toString()] + extensionPoints.get()) + } + + doFirst { + specFile.get().asFile.parentFile.mkdirs() + } + } + + @Override + void exec() { + def config = project.extensions.getByType(NextflowPluginConfig) + if (!isVersionSupported(config.nextflowVersion)) { + createEmptySpecFile() + return + } + super.exec() + } + + private boolean isVersionSupported(String nextflowVersion) { + try { + def parts = nextflowVersion.split(/\./, 3) + if (parts.length < 3) + return false + def major = Integer.parseInt(parts[0]) + def minor = Integer.parseInt(parts[1]) + return major >= 25 && minor >= 9 + } catch (Exception e) { + project.logger.warn("Unable to parse Nextflow version '${nextflowVersion}', assuming plugin spec is not supported: ${e.message}") + return false + } + } + + private void createEmptySpecFile() { + specFile.get().asFile.text = '' + } +} diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy index 8d6ea9b..d32f199 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy @@ -15,7 +15,9 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion * A gradle plugin for nextflow plugin projects. */ class NextflowPlugin implements Plugin { + private static final int JAVA_TOOLCHAIN_VERSION = 21 + private static final int JAVA_VERSION = 17 @Override @@ -59,6 +61,17 @@ class NextflowPlugin implements Plugin { reps.maven { url = "https://s3-eu-west-1.amazonaws.com/maven.seqera.io/releases" } } + // Create specFile source set early so configurations are available + if( config.buildSpec ) { + project.configurations.create('specFile') + if (!project.sourceSets.findByName('specFile')) { + project.sourceSets.create('specFile') { sourceSet -> + sourceSet.compileClasspath += project.configurations.getByName('specFile') + sourceSet.runtimeClasspath += project.configurations.getByName('specFile') + } + } + } + project.afterEvaluate { config.validate() final nextflowVersion = config.nextflowVersion @@ -66,10 +79,22 @@ class NextflowPlugin implements Plugin { if (config.useDefaultDependencies) { addDefaultDependencies(project, nextflowVersion) } + + // dependencies for buildSpec task + if( config.buildSpec ) { + project.dependencies { deps -> + deps.specFile "io.nextflow:nextflow:${nextflowVersion}" + deps.specFile project.files(project.tasks.jar.archiveFile) + } + } } + // use JUnit 5 platform project.test.useJUnitPlatform() + // sometimes tests depend on the assembled plugin + project.tasks.test.dependsOn << project.tasks.assemble + // ----------------------------------- // Add plugin details to jar manifest // ----------------------------------- @@ -88,21 +113,30 @@ class NextflowPlugin implements Plugin { project.tasks.jar.from(project.layout.buildDirectory.dir('resources/main')) project.tasks.compileTestGroovy.dependsOn << extensionPointsTask + // buildSpec - generates the plugin spec file + if( config.buildSpec ) { + project.tasks.register('buildSpec', BuildSpecTask) + project.tasks.buildSpec.dependsOn << [ + project.tasks.jar, + project.tasks.compileSpecFileGroovy + ] + } + // packagePlugin - builds the zip file project.tasks.register('packagePlugin', PluginPackageTask) project.tasks.packagePlugin.dependsOn << [ project.tasks.extensionPoints, project.tasks.classes ] + if( config.buildSpec ) + project.tasks.packagePlugin.dependsOn << project.tasks.buildSpec project.tasks.assemble.dependsOn << project.tasks.packagePlugin // installPlugin - installs plugin to (local) nextflow plugins dir project.tasks.register('installPlugin', PluginInstallTask) project.tasks.installPlugin.dependsOn << project.tasks.assemble - // sometimes tests depend on the assembled plugin - project.tasks.test.dependsOn << project.tasks.assemble - + // releasePlugin - publish plugin release to registry project.afterEvaluate { // Always create registry release task - it will use fallback configuration if needed project.tasks.register('releasePluginToRegistry', RegistryReleaseTask) diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy index ab9bc2d..d8edf10 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy @@ -16,6 +16,7 @@ import org.gradle.api.Project * publisher = 'nextflow' * className = 'com.example.ExamplePlugin' * useDefaultDependencies = false // optional, defaults to true + * buildSpec = false // optional, defaults to true * extensionPoints = [ * 'com.example.ExampleFunctions' * ] @@ -67,6 +68,11 @@ class NextflowPluginConfig { */ boolean useDefaultDependencies = true + /** + * Whether to generate a plugin spec (default: true) + */ + boolean buildSpec = true + /** * Configure registry publishing settings (optional) */ diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy index 6b1a22c..c4e626b 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy @@ -52,19 +52,20 @@ class RegistryClient { * * @param id The plugin identifier/name * @param version The plugin version (must be valid semver) - * @param file The plugin zip file to upload + * @param spec The plugin spec JSON file + * @param archive The plugin zip file to upload * @param provider The plugin provider * @throws RegistryReleaseException if the upload fails or returns an error */ - def release(String id, String version, File file, String provider) { + def release(String id, String version, File spec, File archive, String provider) { log.info("Releasing plugin ${id}@${version} using two-step upload") // Step 1: Create draft release with metadata - def releaseId = createDraftRelease(id, version, file, provider) + def releaseId = createDraftRelease(id, version, spec, archive, provider) log.debug("Created draft release with ID: ${releaseId}") // Step 2: Upload artifact and complete the release - uploadArtifact(releaseId, file) + uploadArtifact(releaseId, archive) log.info("Successfully released plugin ${id}@${version}") } @@ -76,21 +77,22 @@ class RegistryClient { * * @param id The plugin identifier/name * @param version The plugin version (must be valid semver) - * @param file The plugin zip file to upload + * @param spec The plugin spec to upload + * @param archive The plugin zip archive to upload * @param provider The plugin provider * @return Map with keys: success (boolean), skipped (boolean), message (String) * @throws RegistryReleaseException if the upload fails for reasons other than duplicates */ - def releaseIfNotExists(String id, String version, File file, String provider) { + def releaseIfNotExists(String id, String version, File spec, File archive, String provider) { log.info("Releasing plugin ${id}@${version} using two-step upload (if not exists)") try { // Step 1: Create draft release with metadata - def releaseId = createDraftRelease(id, version, file, provider) + def releaseId = createDraftRelease(id, version, spec, archive, provider) log.debug("Created draft release with ID: ${releaseId}") // Step 2: Upload artifact and complete the release - uploadArtifact(releaseId, file) + uploadArtifact(releaseId, archive) log.info("Successfully released plugin ${id}@${version}") return [success: true, skipped: false, message: null] @@ -113,12 +115,13 @@ class RegistryClient { * * @param id The plugin identifier/name * @param version The plugin version - * @param file The plugin zip file (used to compute checksum) + * @param spec The plugin spec to upload + * @param archive The plugin zip archive to upload * @param provider The plugin provider * @return The draft release ID * @throws RegistryReleaseException if the request fails */ - private Long createDraftRelease(String id, String version, File file, String provider) { + private Long createDraftRelease(String id, String version, File spec, File archive, String provider) { if (!provider) { throw new IllegalArgumentException("Plugin provider is required for plugin upload") } @@ -128,14 +131,15 @@ class RegistryClient { .build() // Calculate SHA-512 checksum - def fileBytes = Files.readAllBytes(file.toPath()) - def checksum = computeSha512(fileBytes) + def archiveBytes = Files.readAllBytes(archive.toPath()) + def checksum = computeSha512(archiveBytes) // Build JSON request body def requestBody = [ id: id, version: version, checksum: "sha512:${checksum}", + spec: spec.text, provider: provider ] def jsonBody = JsonOutput.toJson(requestBody) @@ -176,16 +180,16 @@ class RegistryClient { * and publish it to the registry. * * @param releaseId The draft release ID from Step 1 - * @param file The plugin zip file to upload + * @param archive The plugin zip archive to upload * @throws RegistryReleaseException if the upload fails */ - private void uploadArtifact(Long releaseId, File file) { + private void uploadArtifact(Long releaseId, File archive) { def client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .build() def boundary = "----FormBoundary" + UUID.randomUUID().toString().replace("-", "") - def multipartBody = buildArtifactUploadBody(file, boundary) + def multipartBody = buildArtifactUploadBody(archive, boundary) def requestUri = URI.create(url.toString() + "v1/plugins/release/${releaseId}/upload") def request = HttpRequest.newBuilder() @@ -224,27 +228,25 @@ class RegistryClient { /** * Builds multipart body for Step 2 (artifact upload only). * - * @param file The plugin zip file to upload + * @param archive The plugin zip archive to upload * @param boundary The multipart boundary string * @return Multipart body as byte array */ - private byte[] buildArtifactUploadBody(File file, String boundary) { + private byte[] buildArtifactUploadBody(File archive, String boundary) { def output = new ByteArrayOutputStream() def writer = new PrintWriter(new OutputStreamWriter(output, "UTF-8"), true) def lineEnd = "\r\n" - // Read file bytes - def fileBytes = Files.readAllBytes(file.toPath()) - - // Add file field (changed from "artifact" to "payload" per API spec) + // Add archive field (changed from "artifact" to "payload" per API spec) writer.append("--${boundary}").append(lineEnd) - writer.append("Content-Disposition: form-data; name=\"payload\"; filename=\"${file.name}\"").append(lineEnd) + writer.append("Content-Disposition: form-data; name=\"payload\"; filename=\"${archive.name}\"").append(lineEnd) writer.append("Content-Type: application/zip").append(lineEnd) writer.append(lineEnd) writer.flush() - // Write file bytes - output.write(fileBytes) + // Write archive bytes + def archiveBytes = Files.readAllBytes(archive.toPath()) + output.write(archiveBytes) writer.append(lineEnd) writer.append("--${boundary}--").append(lineEnd) diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy index e9335e3..714adfb 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy @@ -24,6 +24,13 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { @InputFile final RegularFileProperty zipFile + /** + * The plugin spec file to be uploaded to the registry. + * By default, this points to the spec file created by the packagePlugin task. + */ + @InputFile + final RegularFileProperty specFile + RegistryReleaseIfNotExistsTask() { group = 'Nextflow Plugin' description = 'Release the assembled plugin to the registry, skipping if already exists' @@ -33,6 +40,11 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { zipFile.convention(project.provider { buildDir.file("distributions/${project.name}-${project.version}.zip") }) + + specFile = project.objects.fileProperty() + specFile.convention(project.provider { + buildDir.file("resources/main/META-INF/spec.json") + }) } /** @@ -61,7 +73,7 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { def registryUri = new URI(registryConfig.resolvedUrl) def client = new RegistryClient(registryUri, registryConfig.resolvedAuthToken) - def result = client.releaseIfNotExists(project.name, version, project.file(zipFile), plugin.provider) as Map + def result = client.releaseIfNotExists(project.name, version, project.file(specFile), project.file(zipFile), plugin.provider) as Map if (result.skipped as Boolean) { // Plugin already exists - log info message and continue diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy index 4b10b73..f6b1950 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy @@ -23,6 +23,13 @@ class RegistryReleaseTask extends DefaultTask { @InputFile final RegularFileProperty zipFile + /** + * The plugin spec file to be uploaded to the registry. + * By default, this points to the spec file created by the packagePlugin task. + */ + @InputFile + final RegularFileProperty specFile + RegistryReleaseTask() { group = 'Nextflow Plugin' description = 'Release the assembled plugin to the registry' @@ -32,6 +39,11 @@ class RegistryReleaseTask extends DefaultTask { zipFile.convention(project.provider { buildDir.file("distributions/${project.name}-${project.version}.zip") }) + + specFile = project.objects.fileProperty() + specFile.convention(project.provider { + buildDir.file("resources/main/META-INF/spec.json") + }) } /** @@ -58,7 +70,7 @@ class RegistryReleaseTask extends DefaultTask { def registryUri = new URI(registryConfig.resolvedUrl) def client = new RegistryClient(registryUri, registryConfig.resolvedAuthToken) - client.release(project.name, version, project.file(zipFile), plugin.provider) + client.release(project.name, version, project.file(specFile), project.file(zipFile), plugin.provider) // Celebrate successful plugin upload! 🎉 project.logger.lifecycle("🎉 SUCCESS! Plugin '${project.name}' version ${version} has been successfully released to Nextflow Registry [${registryUri}]!") diff --git a/src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy new file mode 100644 index 0000000..de4da44 --- /dev/null +++ b/src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy @@ -0,0 +1,31 @@ +package io.nextflow.gradle + +import spock.lang.Specification + +/** + * + * @author Ben Sherman + */ +class BuildSpecTaskTest extends Specification { + + def 'should determine whether Nextflow version is >=25.09.0-edge' () { + given: + def parts = VERSION.split(/\./, 3) + def major = Integer.parseInt(parts[0]) + def minor = Integer.parseInt(parts[1]) + def isSupported = major >= 25 && minor >= 9 + + expect: + isSupported == RESULT + + where: + VERSION | RESULT + '25.04.0' | false + '25.04.1' | false + '25.09.0-edge' | true + '25.09.1-edge' | true + '25.10.0' | true + '25.10.1' | true + } + +} diff --git a/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy b/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy index 6dfc88a..ed0169d 100644 --- a/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy +++ b/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy @@ -27,6 +27,18 @@ class RegistryClientTest extends Specification { wireMockServer?.stop() } + def createPluginArchive() { + def result = tempDir.resolve("test-plugin.zip").toFile() + result.text = "fake plugin zip content" + return result + } + + def createPluginSpec() { + def result = tempDir.resolve("spec.json").toFile() + result.text = "fake plugin spec content" + return result + } + def "should construct client with URL ending in slash"() { when: def client1 = new RegistryClient(new URI("http://example.com"), "token") @@ -47,8 +59,8 @@ class RegistryClientTest extends Specification { def "should successfully publish plugin using two-step process"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() // Step 1: Create draft release (JSON) wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) @@ -70,7 +82,7 @@ class RegistryClientTest extends Specification { .withBody('{"pluginRelease": {"status": "PUBLISHED"}}'))) when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: noExceptionThrown() @@ -85,15 +97,15 @@ class RegistryClientTest extends Specification { def "should throw RegistryReleaseException on HTTP error in draft creation without response body"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) .willReturn(aResponse() .withStatus(400))) when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -103,8 +115,8 @@ class RegistryClientTest extends Specification { def "should throw RegistryReleaseException on HTTP error in draft creation with response body"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) .willReturn(aResponse() @@ -112,7 +124,7 @@ class RegistryClientTest extends Specification { .withBody('{"error": "Plugin validation failed"}'))) when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -123,14 +135,14 @@ class RegistryClientTest extends Specification { def "should fail when connection error"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() // Stop the server to simulate connection error wireMockServer.stop() when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -141,11 +153,11 @@ class RegistryClientTest extends Specification { def "should fail when unknown host"(){ given: def clientNotfound = new RegistryClient(new URI("http://fake-host.fake-domain-blabla.com"), "token") - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() when: - clientNotfound.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + clientNotfound.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -155,8 +167,8 @@ class RegistryClientTest extends Specification { def "should send correct JSON in two-step process"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin zip content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() // Step 1: Create draft with metadata (JSON) wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) @@ -169,7 +181,7 @@ class RegistryClientTest extends Specification { .willReturn(aResponse().withStatus(200))) when: - client.release("my-plugin", "2.1.0", pluginFile, "seqera.io") + client.release("my-plugin", "2.1.0", pluginSpec, pluginArchive, "seqera.io") then: // Verify Step 1: draft creation with JSON metadata @@ -179,6 +191,7 @@ class RegistryClientTest extends Specification { .withRequestBody(containing("\"id\":\"my-plugin\"")) .withRequestBody(containing("\"version\":\"2.1.0\"")) .withRequestBody(containing("\"checksum\":\"sha512:35ab27d09f1bc0d4a73b38fbd020064996fb013e2f92d3dd36bda7364765c229e90e0213fcd90c56fc4c9904e259c482cfaacb22dab327050d7d52229eb1a73c\"")) + .withRequestBody(containing("\"spec\":\"fake plugin spec content\"")) .withRequestBody(containing("\"provider\":\"seqera.io\""))) // Verify Step 2: artifact upload with multipart form data diff --git a/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy index 9e9305a..0a4e1f4 100644 --- a/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy +++ b/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy @@ -34,6 +34,11 @@ class RegistryReleaseTaskTest extends Specification { def testZip = tempDir.resolve("test-plugin-1.0.0.zip").toFile() testZip.text = "fake plugin content" task.zipFile.set(testZip) + + // Set up a test spec file + def testSpec = tempDir.resolve("spec.json").toFile() + testSpec.text = "fake plugin spec" + task.specFile.set(testSpec) } def "should use default fallback configuration when registry is not configured"() {