Skip to content

Commit 8b257a8

Browse files
pditommasoclaude
andcommitted
Add comprehensive plugin configuration validation and celebratory upload message
- Enhanced NextflowPluginConfig.validate() with robust validation: * nextflowVersion: Added semver validation with Nextflow version normalization (24.04.0 → 24.4.0) * className: Added Java FQCN validation requiring package namespace * provider: Enhanced validation to check for empty/whitespace-only values * project.version: Existing semver validation maintained - Added comprehensive test suite (NextflowPluginConfigTest) with 51 test cases covering: * Valid configuration scenarios and edge cases * Invalid version formats, class names, and provider values * Boundary conditions and error message validation - Updated README.md with detailed configuration option documentation - Added celebratory success message to RegistryReleaseTask: * Displays plugin name, version, and registry hostname upon successful upload * Uses lifecycle logging level for visibility: "🎉 SUCCESS\! Plugin 'name' version X.Y.Z has been successfully uploaded to registry.host\!" All existing functionality preserved with backward compatibility. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent b6dfc69 commit 8b257a8

File tree

4 files changed

+339
-3
lines changed

4 files changed

+339
-3
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ nextflowPlugin {
4343
}
4444
```
4545

46+
### Configuration Options
47+
48+
The `nextflowPlugin` block supports the following configuration options:
49+
50+
- **`nextflowVersion`** (required) - Specifies the minimum Nextflow version required by the plugin
51+
- **`className`** (required) - The fully qualified name of the main plugin class
52+
- **`provider`** (required) - The plugin provider/author name
53+
- **`description`** (optional) - A short description of the plugin
54+
- **`requirePlugins`** (optional) - List of plugin dependencies that must be present
55+
```
56+
4657
### Registry Configuration
4758
4859
The `registry` block is optional and supports several configuration methods:
@@ -120,14 +131,12 @@ pluginManagement {
120131
3. Apply the configuration, as described above
121132

122133

123-
124134
## Development of Gradle plugin for Nextflow
125135

126136
To release this plugin include the [Gradle plugins registry](https://plugins.gradle.org) API keys in your `gradle.properties`.
127137

128138
Then use this command:
129139

130-
131140
```
132141
./gradlew publishPlugins
133142
```

src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ class NextflowPluginConfig {
7070
this.project = project
7171
}
7272

73+
private String normalizeVersion(String version) {
74+
try {
75+
// Normalize Nextflow version format (e.g., "24.04.0" -> "24.4.0") to comply with semver
76+
def parts = version.split(/\.|-/, 3)
77+
if (parts.length >= 3) {
78+
def major = Integer.parseInt(parts[0])
79+
def minor = Integer.parseInt(parts[1])
80+
def patch = parts[2]
81+
def patchParts = patch.split(/-/, 2)
82+
def patchNumber = Integer.parseInt(patchParts[0])
83+
def normalized = "${major}.${minor}.${patchNumber}"
84+
if (patchParts.length > 1) {
85+
normalized += "-${patchParts[1]}"
86+
}
87+
return normalized
88+
}
89+
} catch (NumberFormatException e) {
90+
// If we can't parse the version, return as-is to let semver validation handle it
91+
}
92+
return version
93+
}
94+
7395
def validate() {
7496
// check for missing config
7597
if (!nextflowVersion) {
@@ -81,6 +103,20 @@ class NextflowPluginConfig {
81103
if (!provider) {
82104
throw new RuntimeException('nextflowPlugin.provider not specified')
83105
}
106+
if (provider && !provider.trim()) {
107+
throw new RuntimeException('nextflowPlugin.provider cannot be empty')
108+
}
109+
110+
// validate nextflowVersion is valid semver (normalize to handle Nextflow's version format)
111+
def normalizedNextflowVersion = normalizeVersion(nextflowVersion)
112+
if (!Version.isValid(normalizedNextflowVersion, true)) {
113+
throw new RuntimeException("nextflowPlugin.nextflowVersion '${nextflowVersion}' is invalid. Must be a valid semantic version (semver) string")
114+
}
115+
116+
// validate className is valid Java fully qualified class name (must have package)
117+
if (!className.matches(/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)+$/)) {
118+
throw new RuntimeException("nextflowPlugin.className '${className}' is invalid. Must be a valid Java fully qualified class name with package")
119+
}
84120

85121
// validate name/id
86122
if (!project.name.toString().matches(/[a-zA-Z0-9-]{5,64}/)) {

src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ class RegistryReleaseTask extends DefaultTask {
5656
registryConfig = new RegistryReleaseConfig(project)
5757
}
5858

59-
def client = new RegistryClient(new URI(registryConfig.resolvedUrl), registryConfig.resolvedAuthToken)
59+
def registryUri = new URI(registryConfig.resolvedUrl)
60+
def client = new RegistryClient(registryUri, registryConfig.resolvedAuthToken)
6061
client.release(project.name, version, project.file(zipFile))
62+
63+
// Celebrate successful plugin upload! 🎉
64+
project.logger.lifecycle("🎉 SUCCESS! Plugin '${project.name}' version ${version} has been successfully uploaded to ${registryUri.host}!")
6165
}
6266
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package io.nextflow.gradle
2+
3+
import org.gradle.api.Project
4+
import org.gradle.testfixtures.ProjectBuilder
5+
import spock.lang.Specification
6+
7+
/**
8+
* Unit tests for NextflowPluginConfig validation
9+
*/
10+
class NextflowPluginConfigTest extends Specification {
11+
12+
Project project
13+
NextflowPluginConfig config
14+
15+
def setup() {
16+
project = ProjectBuilder.builder()
17+
.withName('test-plugin')
18+
.build()
19+
project.version = '1.0.0'
20+
config = new NextflowPluginConfig(project)
21+
}
22+
23+
def "should pass validation with valid configuration"() {
24+
given:
25+
config.nextflowVersion = '24.04.0'
26+
config.className = 'com.example.TestPlugin'
27+
config.provider = 'Test Author'
28+
29+
when:
30+
config.validate()
31+
32+
then:
33+
noExceptionThrown()
34+
}
35+
36+
def "should fail when nextflowVersion is null"() {
37+
given:
38+
config.nextflowVersion = null
39+
config.className = 'com.example.TestPlugin'
40+
config.provider = 'Test Author'
41+
42+
when:
43+
config.validate()
44+
45+
then:
46+
RuntimeException ex = thrown()
47+
ex.message == 'nextflowPlugin.nextflowVersion not specified'
48+
}
49+
50+
def "should fail when nextflowVersion is empty"() {
51+
given:
52+
config.nextflowVersion = ''
53+
config.className = 'com.example.TestPlugin'
54+
config.provider = 'Test Author'
55+
56+
when:
57+
config.validate()
58+
59+
then:
60+
RuntimeException ex = thrown()
61+
ex.message == 'nextflowPlugin.nextflowVersion not specified'
62+
}
63+
64+
def "should fail when nextflowVersion is not valid semver"() {
65+
given:
66+
config.nextflowVersion = invalidVersion
67+
config.className = 'com.example.TestPlugin'
68+
config.provider = 'Test Author'
69+
70+
when:
71+
config.validate()
72+
73+
then:
74+
RuntimeException ex = thrown()
75+
ex.message == "nextflowPlugin.nextflowVersion '${invalidVersion}' is invalid. Must be a valid semantic version (semver) string"
76+
77+
where:
78+
invalidVersion << ['not.a.version', '1', '1.0', 'v1.0.0', '1.0.0.0', 'latest']
79+
}
80+
81+
def "should pass when nextflowVersion is valid semver"() {
82+
given:
83+
config.nextflowVersion = validVersion
84+
config.className = 'com.example.TestPlugin'
85+
config.provider = 'Test Author'
86+
87+
when:
88+
config.validate()
89+
90+
then:
91+
noExceptionThrown()
92+
93+
where:
94+
validVersion << ['1.0.0', '24.04.0', '1.2.3-alpha', '1.0.0-beta.1', '2.0.0-rc.1', '25.04.0-edge']
95+
}
96+
97+
def "should fail when className is null"() {
98+
given:
99+
config.nextflowVersion = '24.04.0'
100+
config.className = null
101+
config.provider = 'Test Author'
102+
103+
when:
104+
config.validate()
105+
106+
then:
107+
RuntimeException ex = thrown()
108+
ex.message == 'nextflowPlugin.className not specified'
109+
}
110+
111+
def "should fail when className is empty"() {
112+
given:
113+
config.nextflowVersion = '24.04.0'
114+
config.className = ''
115+
config.provider = 'Test Author'
116+
117+
when:
118+
config.validate()
119+
120+
then:
121+
RuntimeException ex = thrown()
122+
ex.message == 'nextflowPlugin.className not specified'
123+
}
124+
125+
def "should fail when className is not valid Java FQCN"() {
126+
given:
127+
config.nextflowVersion = '24.04.0'
128+
config.className = invalidClassName
129+
config.provider = 'Test Author'
130+
131+
when:
132+
config.validate()
133+
134+
then:
135+
RuntimeException ex = thrown()
136+
ex.message == "nextflowPlugin.className '${invalidClassName}' is invalid. Must be a valid Java fully qualified class name with package"
137+
138+
where:
139+
invalidClassName << [
140+
'InvalidClassName', // no package - single class names are invalid
141+
'com.example.', // trailing dot
142+
'.com.example.TestPlugin', // leading dot
143+
'com..example.TestPlugin', // double dot
144+
'com.example..TestPlugin', // double dot
145+
'com.123example.TestPlugin', // package starts with number
146+
'com.example.123Plugin', // class starts with number
147+
'com.example.Test-Plugin', // invalid character
148+
'com.example.Test Plugin', // space
149+
'com.example.Test@Plugin' // special character
150+
]
151+
}
152+
153+
def "should pass when className is valid Java FQCN"() {
154+
given:
155+
config.nextflowVersion = '24.04.0'
156+
config.className = validClassName
157+
config.provider = 'Test Author'
158+
159+
when:
160+
config.validate()
161+
162+
then:
163+
noExceptionThrown()
164+
165+
where:
166+
validClassName << [
167+
'com.example.TestPlugin',
168+
'io.nextflow.plugins.MyPlugin',
169+
'org.springframework.boot.Application',
170+
'com.company.product.module.ClassName',
171+
'com.example.Test$InnerClass',
172+
'com.example._ValidClass',
173+
'com.example.$ValidClass',
174+
'com.example.TestPlugin123',
175+
'_root.TestPlugin',
176+
'$root.TestPlugin'
177+
]
178+
}
179+
180+
def "should fail when provider is null"() {
181+
given:
182+
config.nextflowVersion = '24.04.0'
183+
config.className = 'com.example.TestPlugin'
184+
config.provider = null
185+
186+
when:
187+
config.validate()
188+
189+
then:
190+
RuntimeException ex = thrown()
191+
ex.message == 'nextflowPlugin.provider not specified'
192+
}
193+
194+
def "should fail when provider is empty string"() {
195+
given:
196+
config.nextflowVersion = '24.04.0'
197+
config.className = 'com.example.TestPlugin'
198+
config.provider = ''
199+
200+
when:
201+
config.validate()
202+
203+
then:
204+
RuntimeException ex = thrown()
205+
ex.message == 'nextflowPlugin.provider not specified'
206+
}
207+
208+
def "should fail when provider is empty or whitespace"() {
209+
given:
210+
config.nextflowVersion = '24.04.0'
211+
config.className = 'com.example.TestPlugin'
212+
config.provider = emptyProvider
213+
214+
when:
215+
config.validate()
216+
217+
then:
218+
RuntimeException ex = thrown()
219+
ex.message == 'nextflowPlugin.provider cannot be empty'
220+
221+
where:
222+
emptyProvider << [' ', '\t', '\n', ' \t \n ']
223+
}
224+
225+
def "should pass when provider is valid"() {
226+
given:
227+
config.nextflowVersion = '24.04.0'
228+
config.className = 'com.example.TestPlugin'
229+
config.provider = validProvider
230+
231+
when:
232+
config.validate()
233+
234+
then:
235+
noExceptionThrown()
236+
237+
where:
238+
validProvider << ['Test Author', 'Nextflow', 'Company Inc.', 'John Doe', '[email protected]']
239+
}
240+
241+
def "should fail when project version is not valid semver"() {
242+
given:
243+
project.version = 'invalid-version'
244+
config.nextflowVersion = '24.04.0'
245+
config.className = 'com.example.TestPlugin'
246+
config.provider = 'Test Author'
247+
248+
when:
249+
config.validate()
250+
251+
then:
252+
RuntimeException ex = thrown()
253+
ex.message == "Plugin version 'invalid-version' is invalid. Plugin versions must be a valid semantic version (semver) string"
254+
}
255+
256+
def "should fail when project name is invalid plugin id"() {
257+
given:
258+
project = ProjectBuilder.builder()
259+
.withName('bad_name') // underscore not allowed
260+
.build()
261+
project.version = '1.0.0'
262+
config = new NextflowPluginConfig(project)
263+
config.nextflowVersion = '24.04.0'
264+
config.className = 'com.example.TestPlugin'
265+
config.provider = 'Test Author'
266+
267+
when:
268+
config.validate()
269+
270+
then:
271+
RuntimeException ex = thrown()
272+
ex.message == "Plugin id 'bad_name' is invalid. Plugin ids can contain numbers, letters, and the '-' symbol"
273+
}
274+
275+
def "should pass with all valid configuration including edge cases"() {
276+
given:
277+
config.nextflowVersion = '25.04.0-edge'
278+
config.className = 'io.nextflow.plugin.test.MyTestPlugin$InnerClass'
279+
config.provider = 'Nextflow Community'
280+
281+
when:
282+
config.validate()
283+
284+
then:
285+
noExceptionThrown()
286+
}
287+
}

0 commit comments

Comments
 (0)