From 8b413b384c3cf6eb21c244170387c7282ed8b505 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 25 Sep 2025 12:30:39 -0500 Subject: [PATCH 1/9] Generate plugin spec --- Makefile | 11 ++- .../io/nextflow/gradle/BuildSpecTask.groovy | 70 +++++++++++++++++++ .../io/nextflow/gradle/NextflowPlugin.groovy | 35 ++++++++-- .../gradle/registry/RegistryClient.groovy | 50 ++++++------- .../RegistryReleaseIfNotExistsTask.groovy | 14 +++- .../registry/RegistryReleaseTask.groovy | 14 +++- .../nextflow/gradle/BuildSpecTaskTest.groovy | 31 ++++++++ .../gradle/registry/RegistryClientTest.groovy | 49 ++++++++----- .../registry/RegistryReleaseTaskTest.groovy | 5 ++ 9 files changed, 229 insertions(+), 50 deletions(-) create mode 100644 src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy create mode 100644 src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy 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/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy b/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy new file mode 100644 index 0000000..9c03303 --- /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 + */ +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..ac350df 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,11 @@ class NextflowPlugin implements Plugin { reps.maven { url = "https://s3-eu-west-1.amazonaws.com/maven.seqera.io/releases" } } + project.configurations { + specFile + specFileImplementation.extendsFrom(specFile) + } + project.afterEvaluate { config.validate() final nextflowVersion = config.nextflowVersion @@ -66,10 +73,20 @@ class NextflowPlugin implements Plugin { if (config.useDefaultDependencies) { addDefaultDependencies(project, nextflowVersion) } + + // dependencies for buildSpec task + 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,11 +105,23 @@ 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 + project.sourceSets.create('specFile') { sourceSet -> + sourceSet.compileClasspath += project.configurations.getByName('specFile') + sourceSet.runtimeClasspath += project.configurations.getByName('specFile') + } + 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 + project.tasks.classes, + project.tasks.buildSpec ] project.tasks.assemble.dependsOn << project.tasks.packagePlugin @@ -100,9 +129,7 @@ class NextflowPlugin implements Plugin { 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/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"() { From e384e10031bd7d41669bfc6dc9a19dd0e764c826 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 9 Oct 2025 10:38:59 -0500 Subject: [PATCH 2/9] Fix issues with Gradle 9 and add flag to disable plugin spec --- .../io/nextflow/gradle/BuildSpecTask.groovy | 2 +- .../io/nextflow/gradle/NextflowPlugin.groovy | 39 +++++++++++-------- .../gradle/NextflowPluginConfig.groovy | 6 +++ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy b/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy index 9c03303..b02098d 100644 --- a/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy @@ -11,7 +11,7 @@ import org.gradle.api.tasks.OutputFile * * @author Ben Sherman */ -class BuildSpecTask extends JavaExec { +abstract class BuildSpecTask extends JavaExec { @Input final ListProperty extensionPoints diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy index ac350df..d32f199 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy @@ -61,9 +61,15 @@ class NextflowPlugin implements Plugin { reps.maven { url = "https://s3-eu-west-1.amazonaws.com/maven.seqera.io/releases" } } - project.configurations { - specFile - specFileImplementation.extendsFrom(specFile) + // 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 { @@ -75,9 +81,11 @@ class NextflowPlugin implements Plugin { } // dependencies for buildSpec task - project.dependencies { deps -> - deps.specFile "io.nextflow:nextflow:${nextflowVersion}" - deps.specFile project.files(project.tasks.jar.archiveFile) + if( config.buildSpec ) { + project.dependencies { deps -> + deps.specFile "io.nextflow:nextflow:${nextflowVersion}" + deps.specFile project.files(project.tasks.jar.archiveFile) + } } } @@ -106,23 +114,22 @@ class NextflowPlugin implements Plugin { project.tasks.compileTestGroovy.dependsOn << extensionPointsTask // buildSpec - generates the plugin spec file - project.sourceSets.create('specFile') { sourceSet -> - sourceSet.compileClasspath += project.configurations.getByName('specFile') - sourceSet.runtimeClasspath += project.configurations.getByName('specFile') + if( config.buildSpec ) { + project.tasks.register('buildSpec', BuildSpecTask) + project.tasks.buildSpec.dependsOn << [ + project.tasks.jar, + project.tasks.compileSpecFileGroovy + ] } - 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, - project.tasks.buildSpec + 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 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) */ From 5a039648b592bdd0e247d034606162222cab6ff0 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 9 Oct 2025 10:46:12 -0500 Subject: [PATCH 3/9] Upgrade gradle wrapper to Gradle 9 --- build.gradle | 3 ++- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 From d950e571197c8fc7f0970946e1f489e510699194 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Tue, 21 Oct 2025 15:32:51 -0500 Subject: [PATCH 4/9] Fix buildSpec config option --- src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy index d32f199..c30deec 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy @@ -128,8 +128,10 @@ class NextflowPlugin implements Plugin { project.tasks.extensionPoints, project.tasks.classes ] - if( config.buildSpec ) - project.tasks.packagePlugin.dependsOn << project.tasks.buildSpec + project.afterEvaluate { + 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 From e4f3a64ca33e40aa9ba940b9c2f291c36f2a5be5 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Wed, 22 Oct 2025 11:52:39 -0500 Subject: [PATCH 5/9] Make specFile optional in release task --- .../gradle/registry/RegistryClient.groovy | 2 +- .../RegistryReleaseIfNotExistsTask.groovy | 2 ++ .../registry/RegistryReleaseTask.groovy | 2 ++ .../registry/RegistryReleaseTaskTest.groovy | 25 +++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy index c4e626b..2819c02 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy @@ -139,7 +139,7 @@ class RegistryClient { id: id, version: version, checksum: "sha512:${checksum}", - spec: spec.text, + spec: spec.exists() ? spec.text : null, provider: provider ] def jsonBody = JsonOutput.toJson(requestBody) diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy index 714adfb..104aad8 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy @@ -5,6 +5,7 @@ import io.nextflow.gradle.NextflowPluginConfig import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction /** @@ -29,6 +30,7 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { * By default, this points to the spec file created by the packagePlugin task. */ @InputFile + @Optional final RegularFileProperty specFile RegistryReleaseIfNotExistsTask() { diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy index f6b1950..5213ab4 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy @@ -5,6 +5,7 @@ import io.nextflow.gradle.NextflowPluginConfig import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction /** @@ -28,6 +29,7 @@ class RegistryReleaseTask extends DefaultTask { * By default, this points to the spec file created by the packagePlugin task. */ @InputFile + @Optional final RegularFileProperty specFile RegistryReleaseTask() { diff --git a/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy index 0a4e1f4..08e6f0f 100644 --- a/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy +++ b/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy @@ -171,4 +171,29 @@ class RegistryReleaseTaskTest extends Specification { // This proves the fallback configuration is working thrown(RegistryReleaseException) } + + def "should work when spec file is missing"() { + given: + project.ext['npr.apiKey'] = 'project-token' + project.ext['npr.apiUrl'] = 'https://project-registry.com/api' + project.nextflowPlugin { + description = 'A test plugin' + provider = 'Test Author' + className = 'com.example.TestPlugin' + nextflowVersion = '24.04.0' + extensionPoints = ['com.example.TestExtension'] + registry { + url = 'https://example.com/registry' + } + } + + // Don't set specFile - it should be optional + + when: + task.run() + + then: + // Should fail with connection error, not with missing file error + thrown(RegistryReleaseException) + } } \ No newline at end of file From c257b47481ebdc8f27fc3148131f89459d7471d4 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 07:14:24 -0500 Subject: [PATCH 6/9] Make spec file optional in release task (for real this time) --- .../io/nextflow/gradle/registry/RegistryClient.groovy | 2 +- .../gradle/registry/RegistryReleaseIfNotExistsTask.groovy | 6 ++++-- .../io/nextflow/gradle/registry/RegistryReleaseTask.groovy | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy index 2819c02..d01e809 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy @@ -139,7 +139,7 @@ class RegistryClient { id: id, version: version, checksum: "sha512:${checksum}", - spec: spec.exists() ? spec.text : null, + spec: spec?.text, provider: provider ] def jsonBody = JsonOutput.toJson(requestBody) diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy index 104aad8..9197de6 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy @@ -45,7 +45,8 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { specFile = project.objects.fileProperty() specFile.convention(project.provider { - buildDir.file("resources/main/META-INF/spec.json") + def file = buildDir.file("resources/main/META-INF/spec.json").asFile + file.exists() ? project.layout.projectDirectory.file(file.absolutePath) : null }) } @@ -75,7 +76,8 @@ 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(specFile), project.file(zipFile), plugin.provider) as Map + def specFileValue = specFile.isPresent() ? project.file(specFile) : null + def result = client.releaseIfNotExists(project.name, version, specFileValue, 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 5213ab4..7f6d7cc 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy @@ -44,7 +44,8 @@ class RegistryReleaseTask extends DefaultTask { specFile = project.objects.fileProperty() specFile.convention(project.provider { - buildDir.file("resources/main/META-INF/spec.json") + def file = buildDir.file("resources/main/META-INF/spec.json").asFile + file.exists() ? project.layout.projectDirectory.file(file.absolutePath) : null }) } @@ -72,7 +73,8 @@ class RegistryReleaseTask extends DefaultTask { def registryUri = new URI(registryConfig.resolvedUrl) def client = new RegistryClient(registryUri, registryConfig.resolvedAuthToken) - client.release(project.name, version, project.file(specFile), project.file(zipFile), plugin.provider) + def specFileValue = specFile.isPresent() ? project.file(specFile) : null + client.release(project.name, version, specFileValue, 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}]!") From fa585b95f8106c9ec64e5e4ca39a4c02d1d13648 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Oct 2025 10:24:00 +0200 Subject: [PATCH 7/9] Fix "Could not find method checkPluginClassIncluded" error Signed-off-by: Paolo Di Tommaso --- src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy b/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy index fdcf8b2..fef5bd8 100644 --- a/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy @@ -31,7 +31,7 @@ abstract class PluginPackageTask extends Zip { } // Scan the sources to check that the declared main plugin classes is included - private void checkPluginClassIncluded(String className) { + protected void checkPluginClassIncluded(String className) { def sourceSets = project.extensions.getByType(SourceSetContainer) .named(SourceSet.MAIN_SOURCE_SET_NAME).get() From 1c3091e4f2bfd1391abcda6fffecef556d935497 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Oct 2025 10:49:41 +0200 Subject: [PATCH 8/9] Refactor BuildSpecTask to GenerateSpecTask Signed-off-by: Paolo Di Tommaso --- README.md | 1 + .../io/nextflow/gradle/BuildSpecTask.groovy | 70 ----------- .../nextflow/gradle/GenerateSpecTask.groovy | 118 ++++++++++++++++++ .../io/nextflow/gradle/NextflowPlugin.groovy | 12 +- .../gradle/NextflowPluginConfig.groovy | 4 +- ...est.groovy => GenerateSpecTaskTest.groovy} | 2 +- 6 files changed, 128 insertions(+), 79 deletions(-) delete mode 100644 src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy create mode 100644 src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy rename src/test/groovy/io/nextflow/gradle/{BuildSpecTaskTest.groovy => GenerateSpecTaskTest.groovy} (93%) diff --git a/README.md b/README.md index eb97596..fe7bc1d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The `nextflowPlugin` block supports the following configuration options: - **`requirePlugins`** (optional) - List of plugin dependencies that must be present - **`extensionPoints`** (optional) - List of extension point class names provided by the plugin - **`useDefaultDependencies`** (optional, default: `true`) - Whether to automatically add default dependencies required for Nextflow plugin development +- **`generateSpec`** (optional, default: `true`) - Whether to generate a plugin spec file during the build. Set to `false` to skip spec file generation ### Registry Configuration diff --git a/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy b/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy deleted file mode 100644 index b02098d..0000000 --- a/src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy +++ /dev/null @@ -1,70 +0,0 @@ -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/GenerateSpecTask.groovy b/src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy new file mode 100644 index 0000000..8f1320b --- /dev/null +++ b/src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy @@ -0,0 +1,118 @@ +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 specification file for a Nextflow plugin. + * + *

This task creates a JSON specification file (spec.json) that describes the plugin's + * structure and capabilities. The spec file is used by Nextflow's plugin system to understand + * what extension points and functionality the plugin provides. + * + *

This task extends {@link JavaExec} because it needs to execute Java code from the + * Nextflow core library (specifically {@code nextflow.plugin.spec.PluginSpecWriter}) to + * generate the spec file. The JavaExec task type provides the necessary infrastructure to: + *

    + *
  • Set up a Java process with the correct classpath
  • + *
  • Execute a main class with arguments
  • + *
  • Handle the execution lifecycle and error reporting
  • + *
+ * + *

The task automatically checks if the configured Nextflow version supports plugin specs + * (version 25.09.0 or later). For earlier versions, it creates an empty spec file to maintain + * compatibility. + * + *

The generated spec file is placed at {@code build/resources/main/META-INF/spec.json} + * and is included in the plugin's JAR file. + * + * @author Ben Sherman + */ +abstract class GenerateSpecTask extends JavaExec { + + /** + * List of fully qualified class names that represent extension points provided by this plugin. + * These classes extend or implement Nextflow extension point interfaces. + */ + @Input + final ListProperty extensionPoints + + /** + * The output file where the plugin specification JSON will be written. + * Defaults to {@code build/resources/main/META-INF/spec.json}. + */ + @OutputFile + final RegularFileProperty specFile + + /** + * Constructor that configures the task to execute the PluginSpecWriter from Nextflow core. + * Sets up the classpath, main class, and arguments needed to generate the spec file. + */ + GenerateSpecTask() { + 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() + } + } + + /** + * Executes the task to generate the plugin spec file. + * Checks if the Nextflow version supports plugin specs (>= 25.09.0). + * For unsupported versions, creates an empty spec file instead. + */ + @Override + void exec() { + def config = project.extensions.getByType(NextflowPluginConfig) + if (!isVersionSupported(config.nextflowVersion)) { + createEmptySpecFile() + return + } + super.exec() + } + + /** + * Determines whether the given Nextflow version supports plugin specifications. + * Plugin specs are supported in Nextflow version 25.09.0 and later. + * + * @param nextflowVersion the Nextflow version string (e.g., "25.09.0-edge") + * @return true if the version supports plugin specs, false otherwise + */ + 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 + } + } + + /** + * Creates an empty spec file for backward compatibility with Nextflow versions + * that don't support plugin specifications. + */ + 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 c30deec..9bd5223 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy @@ -62,7 +62,7 @@ class NextflowPlugin implements Plugin { } // Create specFile source set early so configurations are available - if( config.buildSpec ) { + if( config.generateSpec ) { project.configurations.create('specFile') if (!project.sourceSets.findByName('specFile')) { project.sourceSets.create('specFile') { sourceSet -> @@ -80,8 +80,8 @@ class NextflowPlugin implements Plugin { addDefaultDependencies(project, nextflowVersion) } - // dependencies for buildSpec task - if( config.buildSpec ) { + // dependencies for generateSpec task + if( config.generateSpec ) { project.dependencies { deps -> deps.specFile "io.nextflow:nextflow:${nextflowVersion}" deps.specFile project.files(project.tasks.jar.archiveFile) @@ -114,8 +114,8 @@ class NextflowPlugin implements Plugin { project.tasks.compileTestGroovy.dependsOn << extensionPointsTask // buildSpec - generates the plugin spec file - if( config.buildSpec ) { - project.tasks.register('buildSpec', BuildSpecTask) + if( config.generateSpec ) { + project.tasks.register('buildSpec', GenerateSpecTask) project.tasks.buildSpec.dependsOn << [ project.tasks.jar, project.tasks.compileSpecFileGroovy @@ -129,7 +129,7 @@ class NextflowPlugin implements Plugin { project.tasks.classes ] project.afterEvaluate { - if( config.buildSpec ) + if( config.generateSpec ) project.tasks.packagePlugin.dependsOn << project.tasks.buildSpec } project.tasks.assemble.dependsOn << project.tasks.packagePlugin diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy index d8edf10..bc897fd 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy @@ -16,7 +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 + * generateSpec = false // optional, defaults to true * extensionPoints = [ * 'com.example.ExampleFunctions' * ] @@ -71,7 +71,7 @@ class NextflowPluginConfig { /** * Whether to generate a plugin spec (default: true) */ - boolean buildSpec = true + boolean generateSpec = true /** * Configure registry publishing settings (optional) diff --git a/src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy similarity index 93% rename from src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy rename to src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy index de4da44..dd06d90 100644 --- a/src/test/groovy/io/nextflow/gradle/BuildSpecTaskTest.groovy +++ b/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy @@ -6,7 +6,7 @@ import spock.lang.Specification * * @author Ben Sherman */ -class BuildSpecTaskTest extends Specification { +class GenerateSpecTaskTest extends Specification { def 'should determine whether Nextflow version is >=25.09.0-edge' () { given: From 0d27d66daf8e6279714e419b3db16e4689a51fe8 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Oct 2025 10:55:03 +0200 Subject: [PATCH 9/9] Add adr doc Signed-off-by: Paolo Di Tommaso --- ...24-plugin-specification-generation-task.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 adr/20251024-plugin-specification-generation-task.md diff --git a/adr/20251024-plugin-specification-generation-task.md b/adr/20251024-plugin-specification-generation-task.md new file mode 100644 index 0000000..db9bb8e --- /dev/null +++ b/adr/20251024-plugin-specification-generation-task.md @@ -0,0 +1,91 @@ +# ADR: Plugin Specification Generation Task + +## Context + +Nextflow plugins require a machine-readable specification file that describes their capabilities and extension points. The `GenerateSpecTask` automates the generation of this specification file (`spec.json`) using Nextflow's built-in tooling. + +## Implementation + +### Task Type +- **Extends**: `JavaExec` (not a standard task) +- **Rationale**: Must execute Java code from Nextflow core library to generate the specification +- **Location**: `src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy:34` + +### What Runs + +The task executes the Java class `nextflow.plugin.spec.PluginSpecWriter` from Nextflow core: + +```groovy +getMainClass().set('nextflow.plugin.spec.PluginSpecWriter') +``` + +**Arguments**: `[specFile path] + [list of extension point class names]` + +Example: `/path/to/spec.json com.example.MyExecutor com.example.MyTraceObserver` + +### Nextflow Dependency + +**Minimum Version**: Nextflow **25.09.0** +- Versions >= 25.09.0: Executes `PluginSpecWriter` to generate full specification +- Versions < 25.09.0: Creates empty spec file for backward compatibility + +**Dependency Resolution**: Uses dedicated `specFile` source set and configuration + +```groovy +configurations.create('specFile') +sourceSets.create('specFile') { + compileClasspath += configurations.specFile + runtimeClasspath += configurations.specFile +} +``` + +**Classpath includes**: +- `io.nextflow:nextflow:${nextflowVersion}` - provides PluginSpecWriter class +- Plugin's own JAR file - provides extension point classes for introspection + +### Output Format & Location + +**Format**: JSON file +**Path**: `build/resources/main/META-INF/spec.json` +**Packaging**: Included in plugin JAR at `META-INF/spec.json` + +The specification describes plugin structure and capabilities for Nextflow's plugin system to discover. + +### Task Configuration + +**Inputs**: +- `extensionPoints`: List of fully qualified class names implementing Nextflow extension points + +**Outputs**: +- `specFile`: The generated JSON specification + +**Execution order**: +1. Compile plugin classes (`jar`) +2. Compile specFile source set (`compileSpecFileGroovy`) +3. Execute `buildSpec` task + +### Version Detection Logic + +Simple integer parsing of `major.minor.patch` format: +- Splits on first two dots +- Compares: `major >= 25 && minor >= 9` +- Handles edge suffixes: `25.09.0-edge` → supported + +## Decision + +Generate plugin specification using Nextflow's own tooling rather than custom implementation to ensure: +- Compatibility with Nextflow's plugin system evolution +- Correct introspection of extension point capabilities +- Consistency across plugin ecosystem + +## Consequences + +**Positive**: +- Delegates specification format to Nextflow core +- Automatic compatibility with Nextflow's plugin discovery +- Empty file fallback maintains compatibility with older Nextflow versions + +**Negative**: +- Requires JavaExec complexity instead of simple file generation +- Circular dependency: needs compiled plugin JAR before generating spec +- Hard version cutoff at 25.09.0 (no graceful degradation between this version) \ No newline at end of file