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/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/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
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/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 8d6ea9b..9bd5223 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.generateSpec ) {
+ 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 generateSpec task
+ if( config.generateSpec ) {
+ 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,32 @@ 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.generateSpec ) {
+ project.tasks.register('buildSpec', GenerateSpecTask)
+ 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.afterEvaluate {
+ if( config.generateSpec )
+ 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..bc897fd 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
+ * generateSpec = 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 generateSpec = true
+
/**
* Configure registry publishing settings (optional)
*/
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()
diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy
index 6b1a22c..d01e809 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..9197de6 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
/**
@@ -24,6 +25,14 @@ 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
+ @Optional
+ final RegularFileProperty specFile
+
RegistryReleaseIfNotExistsTask() {
group = 'Nextflow Plugin'
description = 'Release the assembled plugin to the registry, skipping if already exists'
@@ -33,6 +42,12 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask {
zipFile.convention(project.provider {
buildDir.file("distributions/${project.name}-${project.version}.zip")
})
+
+ specFile = project.objects.fileProperty()
+ specFile.convention(project.provider {
+ def file = buildDir.file("resources/main/META-INF/spec.json").asFile
+ file.exists() ? project.layout.projectDirectory.file(file.absolutePath) : null
+ })
}
/**
@@ -61,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(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 4b10b73..7f6d7cc 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
/**
@@ -23,6 +24,14 @@ 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
+ @Optional
+ final RegularFileProperty specFile
+
RegistryReleaseTask() {
group = 'Nextflow Plugin'
description = 'Release the assembled plugin to the registry'
@@ -32,6 +41,12 @@ class RegistryReleaseTask extends DefaultTask {
zipFile.convention(project.provider {
buildDir.file("distributions/${project.name}-${project.version}.zip")
})
+
+ specFile = project.objects.fileProperty()
+ specFile.convention(project.provider {
+ def file = buildDir.file("resources/main/META-INF/spec.json").asFile
+ file.exists() ? project.layout.projectDirectory.file(file.absolutePath) : null
+ })
}
/**
@@ -58,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(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}]!")
diff --git a/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy
new file mode 100644
index 0000000..dd06d90
--- /dev/null
+++ b/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy
@@ -0,0 +1,31 @@
+package io.nextflow.gradle
+
+import spock.lang.Specification
+
+/**
+ *
+ * @author Ben Sherman
+ */
+class GenerateSpecTaskTest 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..08e6f0f 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"() {
@@ -166,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