diff --git a/build.gradle.kts b/build.gradle.kts index 001be36..8eea0be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,7 @@ contacts { dependencies { implementation("io.github.gradle-nexus:publish-plugin:1.0.0") + implementation("org.apache.maven:maven-model:3.6.2") constraints { val kotlinVersion by extra("1.4.30") implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") diff --git a/dependencies.lock b/dependencies.lock index 515e2b6..b7c9d7b 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -3,6 +3,9 @@ "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" + }, "org.jetbrains.kotlin:kotlin-reflect": { "locked": "1.4.30" }, @@ -29,6 +32,9 @@ "implementationDependenciesMetadata": { "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" + }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" } }, "integTestApiDependenciesMetadata": { @@ -43,6 +49,9 @@ "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" + }, "org.jetbrains.kotlin:kotlin-reflect": { "locked": "1.4.30" }, @@ -62,6 +71,9 @@ "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" + }, "org.jetbrains.kotlin:kotlin-reflect": { "locked": "1.4.30" }, @@ -100,6 +112,9 @@ "runtimeClasspath": { "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" + }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" } }, "testCompileClasspath": { @@ -109,6 +124,9 @@ "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" + }, "org.jetbrains.kotlin:kotlin-reflect": { "locked": "1.4.30" }, @@ -123,6 +141,9 @@ "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" + }, "org.jetbrains.kotlin:kotlin-reflect": { "locked": "1.4.30" }, @@ -137,6 +158,9 @@ "io.github.gradle-nexus:publish-plugin": { "locked": "1.0.0" }, + "org.apache.maven:maven-model": { + "locked": "3.6.2" + }, "org.jetbrains.kotlin:kotlin-reflect": { "locked": "1.4.30" }, diff --git a/src/main/kotlin/nebula/plugin/publishing/MavenCentralPublishingPlugin.kt b/src/main/kotlin/nebula/plugin/publishing/MavenCentralPublishingPlugin.kt index 0ee17ac..c85338d 100644 --- a/src/main/kotlin/nebula/plugin/publishing/MavenCentralPublishingPlugin.kt +++ b/src/main/kotlin/nebula/plugin/publishing/MavenCentralPublishingPlugin.kt @@ -17,9 +17,12 @@ package nebula.plugin.publishing import io.github.gradlenexus.publishplugin.NexusPublishExtension import io.github.gradlenexus.publishplugin.NexusPublishPlugin +import nebula.plugin.publishing.pom.VerifyPomForMavenCentralTask import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.tasks.GenerateMavenPom import org.gradle.api.publish.maven.tasks.PublishToMavenRepository import org.gradle.kotlin.dsl.* import java.net.URI @@ -32,14 +35,23 @@ class MavenCentralPublishingPlugin : Plugin { const val closeAndPromoteRepositoryTaskName = "closeAndReleaseSonatypeStagingRepository" const val sonatypeOssRepositoryUrl = "https://oss.sonatype.org/service/local/" const val nebulaMavenPublishPluginId = "nebula.maven-publish" + const val nebulaPublicationName = "nebula" } override fun apply(project: Project) { - if (project.rootProject != project) { + if (!isFinalRelease(project.rootProject) && !isCandidateReleaseAndPublishingToMavenCentralIsEnabled(project.rootProject)) { return } - if (!isFinalRelease(project) && !isCandidateReleaseAndPublishingToMavenCentralIsEnabled(project)) { + if (project.rootProject != project) { + /** + * When the project isn't the root one, just configure the POM verification + */ + project.afterEvaluate { + project.plugins.withId(nebulaMavenPublishPluginId) { + configureMavenCentralPomVerification(project) + } + } return } @@ -61,6 +73,10 @@ class MavenCentralPublishingPlugin : Plugin { project.afterEvaluate { project.plugins.withId(nebulaMavenPublishPluginId) { + /** + * Configuration POM verification for Maven Central + */ + configureMavenCentralPomVerification(project) project.rootProject.tasks.named("postRelease").configure { project.subprojects.forEach { subproject -> this.dependsOn(subproject.tasks.withType(PublishToMavenRepository::class.java)) @@ -81,6 +97,32 @@ class MavenCentralPublishingPlugin : Plugin { } } + private fun configureMavenCentralPomVerification(project: Project) { + val publishingExtension = project.extensions.findByType(PublishingExtension::class) ?: return + val publications = publishingExtension.publications.asMap + if(!publications.containsKey(nebulaPublicationName)) { + return + } + val nebulaPublication = publications[nebulaPublicationName] + val generateMavenPomTask = project.tasks.findByName("generatePomFileFor${nebulaPublicationName.capitalize()}Publication") + ?: return + + val verifyPomForMavenCentralTask = project.tasks.register("verify${nebulaPublicationName.capitalize()}PublicationPomForMavenCentral", VerifyPomForMavenCentralTask::class) { + group = "Publishing" + description = "Verifies $nebulaPublication POM can be published to Maven Central" + pomFile.set((generateMavenPomTask as GenerateMavenPom).destination) + dependsOn(generateMavenPomTask) + } + project.rootProject.tasks.named("release").configure { + dependsOn(verifyPomForMavenCentralTask) + } + project.plugins.withId("com.gradle.plugin-publish") { + project.tasks.named("publishPlugins").configure { + dependsOn(verifyPomForMavenCentralTask) + } + } + } + private fun isFinalRelease(project: Project): Boolean { return project.gradle.startParameter.taskNames.contains("final") || project.gradle.startParameter.taskNames.contains(":final") } diff --git a/src/main/kotlin/nebula/plugin/publishing/pom/MavenCentralPomVerificationException.kt b/src/main/kotlin/nebula/plugin/publishing/pom/MavenCentralPomVerificationException.kt new file mode 100644 index 0000000..1b82c4c --- /dev/null +++ b/src/main/kotlin/nebula/plugin/publishing/pom/MavenCentralPomVerificationException.kt @@ -0,0 +1,8 @@ +package nebula.plugin.publishing.pom + +class MavenCentralPomVerificationException : Exception { + constructor() : super() + constructor(message: String) : super(message) + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(cause: Throwable) : super(cause) +} diff --git a/src/main/kotlin/nebula/plugin/publishing/pom/MavenCentralPomVerifier.kt b/src/main/kotlin/nebula/plugin/publishing/pom/MavenCentralPomVerifier.kt new file mode 100644 index 0000000..ba0e531 --- /dev/null +++ b/src/main/kotlin/nebula/plugin/publishing/pom/MavenCentralPomVerifier.kt @@ -0,0 +1,63 @@ +package nebula.plugin.publishing.pom + +import org.apache.maven.model.Model +import org.gradle.api.GradleException + +/** + * Verifies if POM follows rules for Maven Central + * @see http://maven.apache.org/repository/guide-central-repository-upload.html + */ +class MavenCentralPomVerifier { + companion object { + + @JvmStatic + fun verify(model: Model) { + val errors = mutableListOf() + if(model.groupId.isNullOrEmpty()) { + errors.add(" must not be null or blank") + } + if(model.artifactId.isNullOrEmpty()) { + errors.add(" must not be null or blank") + } + if(model.version.isNullOrEmpty()) { + errors.add(" must not be null or blank. Please configure a valid version in your project") + } + if(model.description.isNullOrEmpty()) { + errors.add(" must not be null or blank. This information is added for you via nebula.netflixoss plugin") + } + if(model.url.isNullOrEmpty()) { + errors.add(" must not be null or blank. This information is added for you via nebula.netflixoss plugin") + } + if(model.scm?.url.isNullOrEmpty()) { + errors.add(" url is required. This information is added for you via nebula.netflixoss plugin") + } + if(model.licenses.isEmpty()) { + errors.add(" are required. This information is added for you via nebula.netflixoss plugin") + } + model.licenses.forEachIndexed { index, license -> + if(license.name.isNullOrBlank() || license.url.isNullOrBlank()) { + errors.add("License $index must have and . This information is added for you via nebula.netflixoss plugin") + } + } + if(model.developers.isEmpty()) { + errors.add(" are required. Please add this information using gradle-contacts-plugin (https://github.com/nebula-plugins/gradle-contacts-plugin)") + } + model.developers.forEachIndexed { index, developer -> + if(developer.name.isNullOrBlank() && developer.email.isNullOrBlank() && developer.id.isNullOrBlank()) { + errors.add("Developer $index must have one of: , or . Please add this information using gradle-contacts-plugin (https://github.com/nebula-plugins/gradle-contacts-plugin)") + } + } + model.dependencies.forEach { dependency -> + if(dependency.version.contains(".+")) { + errors.add("Dependency ${dependency.groupId}:${dependency.artifactId}:${dependency.version} contains '.+'. This is an invalid dynamic version syntax. Replace with a fixed version or standard mathematical notation e.g., [1.5,) for version 1.5 and higher. More info in https://docs.oracle.com/middleware/1212/core/MAVEN/maven_version.htm#MAVEN402") + } + } + + if (errors.isNotEmpty()) { + throw MavenCentralPomVerificationException("POM verification for Maven Central failed.\n " + + "POM contains the following errors: \n" + + errors.joinToString(separator = "\n")) + } + } + } +} diff --git a/src/main/kotlin/nebula/plugin/publishing/pom/PomParser.kt b/src/main/kotlin/nebula/plugin/publishing/pom/PomParser.kt new file mode 100644 index 0000000..a7dd9ea --- /dev/null +++ b/src/main/kotlin/nebula/plugin/publishing/pom/PomParser.kt @@ -0,0 +1,21 @@ +package nebula.plugin.publishing.pom + +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader +import java.io.File +import java.io.FileReader + +class PomParser { + companion object { + private val reader = MavenXpp3Reader() + + @JvmStatic + fun parse(pomFile: File): Model { + return try { + reader.read(FileReader(pomFile)) + } catch (e: Exception) { + throw MavenCentralPomVerificationException("Error while trying to read nebula publication Pom file", e) + } + } + } +} diff --git a/src/main/kotlin/nebula/plugin/publishing/pom/VerifyPomForMavenCentralTask.kt b/src/main/kotlin/nebula/plugin/publishing/pom/VerifyPomForMavenCentralTask.kt new file mode 100644 index 0000000..22180df --- /dev/null +++ b/src/main/kotlin/nebula/plugin/publishing/pom/VerifyPomForMavenCentralTask.kt @@ -0,0 +1,19 @@ +package nebula.plugin.publishing.pom + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject + +open class VerifyPomForMavenCentralTask @Inject constructor(objects: ObjectFactory): DefaultTask() { + @InputFile + val pomFile: RegularFileProperty = objects.fileProperty() + + @TaskAction + fun verifyPom() { + val model = PomParser.parse(pomFile.asFile.get()) + MavenCentralPomVerifier.verify(model) + } +} diff --git a/src/test/groovy/nebula/plugin/publishing/pom/BasePomSpec.groovy b/src/test/groovy/nebula/plugin/publishing/pom/BasePomSpec.groovy new file mode 100644 index 0000000..b4e592a --- /dev/null +++ b/src/test/groovy/nebula/plugin/publishing/pom/BasePomSpec.groovy @@ -0,0 +1,9 @@ +package nebula.plugin.publishing.pom + +import spock.lang.Specification + +class BasePomSpec extends Specification { + File findPomFile(String pomPath) { + return new File(this.class.getResource(pomPath).toURI()) + } +} diff --git a/src/test/groovy/nebula/plugin/publishing/pom/MavenCentralPomVerifierSpec.groovy b/src/test/groovy/nebula/plugin/publishing/pom/MavenCentralPomVerifierSpec.groovy new file mode 100644 index 0000000..d1f5366 --- /dev/null +++ b/src/test/groovy/nebula/plugin/publishing/pom/MavenCentralPomVerifierSpec.groovy @@ -0,0 +1,78 @@ +package nebula.plugin.publishing.pom + +class MavenCentralPomVerifierSpec extends BasePomSpec { + def 'POM file passes validation'() { + setup: + File pomFile = findPomFile("/my-module.pom") + def pom = PomParser.parse(pomFile) + + when: + MavenCentralPomVerifier.verify(pom) + + then: + notThrown(MavenCentralPomVerificationException) + } + + def 'collect errors from pom validation'() { + setup: + File pomFile = findPomFile("/my-module-with-invalid-metadata.pom") + def pom = PomParser.parse(pomFile) + + when: + MavenCentralPomVerifier.verify(pom) + + then: + def ex = thrown(MavenCentralPomVerificationException) + ex.message.contains('POM verification for Maven Central failed') + ex.message.contains(' must not be null or blank') + ex.message.contains(' must not be null or blank') + ex.message.contains(' must not be null or blank') + ex.message.contains(' must not be null or blank') + ex.message.contains(' must not be null or blank') + ex.message.contains(' url is required') + ex.message.contains(' are required') + ex.message.contains(' are required') + } + + def 'collect errors from pom validation - invalid license'() { + setup: + File pomFile = findPomFile("/my-module-with-invalid-license.pom") + def pom = PomParser.parse(pomFile) + + when: + MavenCentralPomVerifier.verify(pom) + + then: + def ex = thrown(MavenCentralPomVerificationException) + ex.message.contains('POM verification for Maven Central failed') + ex.message.contains('License 0 must have and ') + } + + def 'collect errors from pom validation - invalid developer'() { + setup: + File pomFile = findPomFile("/my-module-with-invalid-developers.pom") + def pom = PomParser.parse(pomFile) + + when: + MavenCentralPomVerifier.verify(pom) + + then: + def ex = thrown(MavenCentralPomVerificationException) + ex.message.contains('POM verification for Maven Central failed') + ex.message.contains('Developer 0 must have one of: , or ') + } + + def 'collect errors from pom validation - invalid version (dynamic range)'() { + setup: + File pomFile = findPomFile("/my-module-with-invalid-dynamic-range.pom") + def pom = PomParser.parse(pomFile) + + when: + MavenCentralPomVerifier.verify(pom) + + then: + def ex = thrown(MavenCentralPomVerificationException) + ex.message.contains('POM verification for Maven Central failed') + ex.message.contains('Dependency org.codehaus.groovy:groovy-all:2.5.+ contains \'.+\'. This is an invalid dynamic version syntax. Replace with a fixed version or standard mathematical notation e.g., [1.5,) for version 1.5 and higher. More info in https://docs.oracle.com/middleware/1212/core/MAVEN/maven_version.htm#MAVEN402') + } +} diff --git a/src/test/groovy/nebula/plugin/publishing/pom/PomParserSpec.groovy b/src/test/groovy/nebula/plugin/publishing/pom/PomParserSpec.groovy new file mode 100644 index 0000000..e858c75 --- /dev/null +++ b/src/test/groovy/nebula/plugin/publishing/pom/PomParserSpec.groovy @@ -0,0 +1,48 @@ +package nebula.plugin.publishing.pom + +import spock.lang.Subject + +@Subject(PomParser) +class PomParserSpec extends BasePomSpec { + + def 'should parse valid POM file'() { + setup: + File pomFile = findPomFile("/my-module.pom") + + when: + def model = PomParser.parse(pomFile) + + then: + model.groupId == 'com.netflix.nebula' + model.artifactId == 'my-module' + model.version == '1.0.0' + model.description == 'my-module description' + model.url == 'https://github.com/nebula-plugins/my-module' + model.developers[0].name == 'Netflix Open Source Development' + model.developers[0].email == 'netflixoss@netflix.com' + model.scm.url == 'https://github.com/nebula-plugins/my-module.git' + model.licenses[0].name == 'The Apache Software License, Version 2.0' + model.licenses[0].url == 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + + def 'should fail if POM file is invalid'() { + setup: + File pomFile = findPomFile("/invalid.pom") + + when: + PomParser.parse(pomFile) + + then: + def ex = thrown(MavenCentralPomVerificationException) + ex.message.contains 'Error while trying to read nebula publication Pom file' + } + + def 'should fail if POM file does not exist'() { + when: + PomParser.parse(new File("/this-does-not-exist.pom")) + + then: + def ex = thrown(MavenCentralPomVerificationException) + ex.message.contains 'Error while trying to read nebula publication Pom file' + } +} diff --git a/src/test/resources/invalid.pom b/src/test/resources/invalid.pom new file mode 100644 index 0000000..4a44857 --- /dev/null +++ b/src/test/resources/invalid.pom @@ -0,0 +1,7 @@ + + + 4.0.0 + + my-module + + diff --git a/src/test/resources/my-module-with-invalid-developers.pom b/src/test/resources/my-module-with-invalid-developers.pom new file mode 100644 index 0000000..dd5e220 --- /dev/null +++ b/src/test/resources/my-module-with-invalid-developers.pom @@ -0,0 +1,55 @@ + + + 4.0.0 + com.netflix.nebula + my-module + 1.0.0 + my-module + my-module description + https://github.com/nebula-plugins/my-module + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + + + https://github.com/nebula-plugins/my-module.git + + + + org.codehaus.groovy + groovy-all + 2.5.7 + runtime + + + + 1.0 + com.netflix.nebula#my-module;1.0.0 + 1.8.0 + integration + travis + Linux + 2019-11-21_21:30:10 + 5.6.2 + netflixoss@netflix.com + netflixoss@netflix.com + https://github.com/nebula-plugins/my-module.git + b5e55af + b5e55af8921fa1abe3345ca09c7acaefd8b50968 + travis-job-3fcfc919-7e06-4960-98cb-a538a09f08c3 + nebula-plugins/my-module + 23 + 615268376 + 1.8.0_222-8u222-b10-1ubuntu1~16.04.1-b10 (Private Build) + 1.8.0_222 + 1.8 + 1.8 + + diff --git a/src/test/resources/my-module-with-invalid-dynamic-range.pom b/src/test/resources/my-module-with-invalid-dynamic-range.pom new file mode 100644 index 0000000..f84759f --- /dev/null +++ b/src/test/resources/my-module-with-invalid-dynamic-range.pom @@ -0,0 +1,58 @@ + + + 4.0.0 + com.netflix.nebula + my-module + 1.0.0 + my-module + my-module description + https://github.com/nebula-plugins/my-module + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + netflixgithub + Netflix Open Source Development + netflixoss@netflix.com + + + + https://github.com/nebula-plugins/my-module.git + + + + org.codehaus.groovy + groovy-all + 2.5.+ + runtime + + + + 1.0 + com.netflix.nebula#my-module;1.0.0 + 1.8.0 + integration + travis + Linux + 2019-11-21_21:30:10 + 5.6.2 + netflixoss@netflix.com + netflixoss@netflix.com + https://github.com/nebula-plugins/my-module.git + b5e55af + b5e55af8921fa1abe3345ca09c7acaefd8b50968 + travis-job-3fcfc919-7e06-4960-98cb-a538a09f08c3 + nebula-plugins/my-module + 23 + 615268376 + 1.8.0_222-8u222-b10-1ubuntu1~16.04.1-b10 (Private Build) + 1.8.0_222 + 1.8 + 1.8 + + diff --git a/src/test/resources/my-module-with-invalid-license.pom b/src/test/resources/my-module-with-invalid-license.pom new file mode 100644 index 0000000..3ab07a3 --- /dev/null +++ b/src/test/resources/my-module-with-invalid-license.pom @@ -0,0 +1,57 @@ + + + 4.0.0 + com.netflix.nebula + my-module + 1.0.0 + my-module + my-module description + https://github.com/nebula-plugins/my-module + + + The Apache Software License, Version 2.0 + repo + + + + + netflixgithub + Netflix Open Source Development + netflixoss@netflix.com + + + + https://github.com/nebula-plugins/my-module.git + + + + org.codehaus.groovy + groovy-all + 2.5.7 + runtime + + + + 1.0 + com.netflix.nebula#my-module;1.0.0 + 1.8.0 + integration + travis + Linux + 2019-11-21_21:30:10 + 5.6.2 + netflixoss@netflix.com + netflixoss@netflix.com + https://github.com/nebula-plugins/my-module.git + b5e55af + b5e55af8921fa1abe3345ca09c7acaefd8b50968 + travis-job-3fcfc919-7e06-4960-98cb-a538a09f08c3 + nebula-plugins/my-module + 23 + 615268376 + 1.8.0_222-8u222-b10-1ubuntu1~16.04.1-b10 (Private Build) + 1.8.0_222 + 1.8 + 1.8 + + diff --git a/src/test/resources/my-module-with-invalid-metadata.pom b/src/test/resources/my-module-with-invalid-metadata.pom new file mode 100644 index 0000000..a5add95 --- /dev/null +++ b/src/test/resources/my-module-with-invalid-metadata.pom @@ -0,0 +1,41 @@ + + + 4.0.0 + + + + + + + + + org.codehaus.groovy + groovy-all + 2.5.7 + runtime + + + + 1.0 + com.netflix.nebula#my-module;1.0.0 + 1.8.0 + integration + travis + Linux + 2019-11-21_21:30:10 + 5.6.2 + netflixoss@netflix.com + netflixoss@netflix.com + https://github.com/nebula-plugins/my-module.git + b5e55af + b5e55af8921fa1abe3345ca09c7acaefd8b50968 + travis-job-3fcfc919-7e06-4960-98cb-a538a09f08c3 + nebula-plugins/my-module + 23 + 615268376 + 1.8.0_222-8u222-b10-1ubuntu1~16.04.1-b10 (Private Build) + 1.8.0_222 + 1.8 + 1.8 + + diff --git a/src/test/resources/my-module.pom b/src/test/resources/my-module.pom new file mode 100644 index 0000000..85ae80b --- /dev/null +++ b/src/test/resources/my-module.pom @@ -0,0 +1,58 @@ + + + 4.0.0 + com.netflix.nebula + my-module + 1.0.0 + my-module + my-module description + https://github.com/nebula-plugins/my-module + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + netflixgithub + Netflix Open Source Development + netflixoss@netflix.com + + + + https://github.com/nebula-plugins/my-module.git + + + + org.codehaus.groovy + groovy-all + 2.5.7 + runtime + + + + 1.0 + com.netflix.nebula#my-module;1.0.0 + 1.8.0 + integration + travis + Linux + 2019-11-21_21:30:10 + 5.6.2 + netflixoss@netflix.com + netflixoss@netflix.com + https://github.com/nebula-plugins/my-module.git + b5e55af + b5e55af8921fa1abe3345ca09c7acaefd8b50968 + travis-job-3fcfc919-7e06-4960-98cb-a538a09f08c3 + nebula-plugins/my-module + 23 + 615268376 + 1.8.0_222-8u222-b10-1ubuntu1~16.04.1-b10 (Private Build) + 1.8.0_222 + 1.8 + 1.8 + +