Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
clean:
./gradlew clean

assemble:
./gradlew assemble

test:
./gradlew test

install:
./gradlew publishToMavenLocal

clean:
./gradlew clean
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions adr/20251024-plugin-specification-generation-task.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
118 changes: 118 additions & 0 deletions src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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:
* <ul>
* <li>Set up a Java process with the correct classpath</li>
* <li>Execute a main class with arguments</li>
* <li>Handle the execution lifecycle and error reporting</li>
* </ul>
*
* <p>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.
*
* <p>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 <[email protected]>
*/
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<String> 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 = ''
}
}
42 changes: 39 additions & 3 deletions src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
* A gradle plugin for nextflow plugin projects.
*/
class NextflowPlugin implements Plugin<Project> {

private static final int JAVA_TOOLCHAIN_VERSION = 21

private static final int JAVA_VERSION = 17

@Override
Expand Down Expand Up @@ -59,17 +61,40 @@ class NextflowPlugin implements Plugin<Project> {
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

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
// -----------------------------------
Expand All @@ -88,21 +113,32 @@ class NextflowPlugin implements Plugin<Project> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
* ]
Expand Down Expand Up @@ -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)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading