Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
70 changes: 70 additions & 0 deletions src/main/groovy/io/nextflow/gradle/BuildSpecTask.groovy
Original file line number Diff line number Diff line change
@@ -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 <bentshermann@gmail.com>
*/
class BuildSpecTask extends JavaExec {

@Input
final ListProperty<String> 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 = ''
}
}
35 changes: 31 additions & 4 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,32 @@ class NextflowPlugin implements Plugin<Project> {
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

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
// -----------------------------------
Expand All @@ -88,21 +105,31 @@ 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
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

// 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
50 changes: 26 additions & 24 deletions src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
}

Expand All @@ -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]
Expand All @@ -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")
}
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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")
})
}

/**
Expand Down Expand Up @@ -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<String, Object>
def result = client.releaseIfNotExists(project.name, version, project.file(specFile), project.file(zipFile), plugin.provider) as Map<String, Object>

if (result.skipped as Boolean) {
// Plugin already exists - log info message and continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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")
})
}

/**
Expand All @@ -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}]!")
Expand Down
Loading