diff --git a/README.md b/README.md index cd71e43d..dd88ef6d 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ You can configure the `connectTimeout` and `clientTimeout` properties on the `ne The plugin does the following: - configure a Maven artifact repository for each repository defined in the `nexusPublishing { repositories { ... } }` block in each subproject that applies the `maven-publish` plugin +- creates a `retrieve{repository.name.capitalize()}StagingProfile` task that retrieves the staging profile id from the remote Nexus repository. This is a diagnostic task to enable setting the configuration property `stagingProfileId` in `nexusPublishing { repositories { myRepository { ... } } }`. Specifying the configuration property rather than relying on the API call is considered a performance optimization. - create a `initialize${repository.name.capitalize()}StagingRepository` task that starts a new staging repository in case the project's version does not end with `-SNAPSHOT` (customizable via the `useStaging` property) and sets the URL of the corresponding Maven artifact repository accordingly. In case of a multi-project build, all subprojects with the same `nexusUrl` will use the same staging repository. - make all publishing tasks for each configured repository depend on the `initialize${repository.name.capitalize()}StagingRepository` task - create a `publishTo${repository.name.capitalize()}` lifecycle task that depends on all publishing tasks for the corresponding Maven artifact repository diff --git a/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/MethodScopeWiremockResolver.kt b/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/MethodScopeWiremockResolver.kt new file mode 100644 index 00000000..8f2c027b --- /dev/null +++ b/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/MethodScopeWiremockResolver.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.gradlenexus.publishplugin + +import com.github.tomakehurst.wiremock.WireMockServer +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.ParameterContext +import org.junit.jupiter.api.extension.ParameterResolver +import ru.lanwen.wiremock.ext.WiremockResolver +import java.lang.IllegalArgumentException + +/** + * Composes a [WiremockResolver] and uses that by default. But, if a parameter is annotated + * with [MethodScopeWiremockResolver] it creates a new instance of the [WiremockResolver] extension and + * manages its lifecycle in the scope of that [ExtensionContext] + */ +class MethodScopeWiremockResolver( + private val inner: WiremockResolver = WiremockResolver() +) : ParameterResolver by inner, AfterEachCallback { + + /** + * Checks to see if a method the parameter is annotated with [MethodScopedWiremockServer]. If it is, we first verify + */ + override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any { + return if (parameterContext.isAnnotated(MethodScopedWiremockServer::class.java)) { + if (!parameterContext.parameter.type.isAssignableFrom(WireMockServer::class.java)) { + throw IllegalArgumentException("Annotated type must be a WireMockServer") + } + return getStore(extensionContext) + .getOrComputeIfAbsent(Keys.LOCAL_RESOLVER, { WiremockResolver() }, WiremockResolver::class.java) + .resolveParameter(parameterContext, extensionContext) + } else { + inner.resolveParameter(parameterContext, extensionContext) + } + } + + override fun afterEach(context: ExtensionContext) { + getStore(context).get(Keys.LOCAL_RESOLVER, WiremockResolver::class.java)?.afterEach(context) + inner.afterEach(context) + } + + /** + * helper method for get getting a [ExtensionContext.Store] specific to a test method + */ + private fun getStore(context: ExtensionContext): ExtensionContext.Store { + return context.getStore(ExtensionContext.Namespace.create(javaClass)) + } + + /** + * Keys for storing and accessing [MethodScopeWiremockResolver]s + */ + enum class Keys { + LOCAL_RESOLVER + } + + /** + * Decorates a parameter also annotated with [WiremockResolver.Wiremock] + */ + @Target(allowedTargets = [AnnotationTarget.VALUE_PARAMETER]) + @Retention(AnnotationRetention.RUNTIME) + annotation class MethodScopedWiremockServer +} diff --git a/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPluginTests.kt b/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPluginTests.kt index 533bee4b..36189667 100644 --- a/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPluginTests.kt +++ b/src/compatTest/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPluginTests.kt @@ -48,13 +48,12 @@ import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.io.TempDir -import ru.lanwen.wiremock.ext.WiremockResolver import ru.lanwen.wiremock.ext.WiremockResolver.Wiremock import java.nio.file.Files import java.nio.file.Path @Suppress("FunctionName") // TODO: How to suppress "kotlin:S100" from SonarLint? -@ExtendWith(WiremockResolver::class) +@ExtendWith(MethodScopeWiremockResolver::class) class NexusPublishPluginTests { companion object { @@ -110,6 +109,32 @@ class NexusPublishPluginTests { assertThat(result.output).contains("Plugin must be applied to the root project but was applied to :sub") } + @Test + fun `can get StagingProfileId from Nexus`() { + writeDefaultSingleProjectConfiguration() + //and + buildGradle.append(""" + nexusPublishing { + repositories { + sonatype { + nexusUrl = uri('${server.baseUrl()}') + allowInsecureProtocol = true + //No staging profile defined + } + } + } + """) + //and + stubGetStagingProfilesForOneProfileIdGivenId(STAGING_PROFILE_ID) + + val result = run("retrieveSonatypeStagingProfile") + + assertSuccess(result, ":retrieveSonatypeStagingProfile") + assertThat(result.output).containsOnlyOnce("Received staging profile id: '$STAGING_PROFILE_ID' for package org.example") + // and + assertGetStagingProfile(1) + } + @Test fun `publish task depends on correct tasks`() { projectDir.resolve("settings.gradle").write(""" @@ -206,6 +231,71 @@ class NexusPublishPluginTests { assertUploadedToStagingRepo("/org/example/sample/0.0.1/sample-0.0.1.jar") } + @Test + fun `publishes to two Nexus repositories`(@MethodScopeWiremockResolver.MethodScopedWiremockServer @Wiremock otherServer: WireMockServer) { + projectDir.resolve("settings.gradle").write(""" + rootProject.name = 'sample' + """) + projectDir.resolve("build.gradle").write(""" + plugins { + id('java-library') + id('maven-publish') + id('io.github.gradle-nexus.publish-plugin') + } + group = 'org.example' + version = '0.0.1' + publishing { + publications { + mavenJava(MavenPublication) { + from(components.java) + } + } + } + nexusPublishing { + repositories { + myNexus { + nexusUrl = uri('${server.baseUrl()}') + snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots/') + allowInsecureProtocol = true + username = 'username' + password = 'password' + } + someOtherNexus { + nexusUrl = uri('${otherServer.baseUrl()}') + snapshotRepositoryUrl = uri('${otherServer.baseUrl()}/snapshots/') + allowInsecureProtocol = true + username = 'someUsername' + password = 'somePassword' + } + } + } + """) + + val otherStagingProfileId = "otherStagingProfileId" + val otherStagingRepositoryId = "orgexample-43" + stubStagingProfileRequest("/staging/profiles", mapOf("id" to STAGING_PROFILE_ID, "name" to "org.example")) + stubStagingProfileRequest("/staging/profiles", mapOf("id" to otherStagingProfileId, "name" to "org.example"), wireMockServer = otherServer) + stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) + stubCreateStagingRepoRequest("/staging/profiles/$otherStagingProfileId/start", otherStagingRepositoryId, wireMockServer = otherServer) + expectArtifactUploads("/staging/deployByRepositoryId/$STAGED_REPOSITORY_ID") + expectArtifactUploads("/staging/deployByRepositoryId/$otherStagingRepositoryId", wireMockServer = otherServer) + + val result = run("publishToMyNexus", "publishToSomeOtherNexus") + + assertSuccess(result, ":initializeMyNexusStagingRepository") + assertSuccess(result, ":initializeSomeOtherNexusStagingRepository") + assertThat(result.output).containsOnlyOnce("Created staging repository '$STAGED_REPOSITORY_ID' at ${server.baseUrl()}/repositories/$STAGED_REPOSITORY_ID/content/") + assertThat(result.output).containsOnlyOnce("Created staging repository '$otherStagingRepositoryId' at ${otherServer.baseUrl()}/repositories/$otherStagingRepositoryId/content/") + server.verify(postRequestedFor(urlEqualTo("/staging/profiles/$STAGING_PROFILE_ID/start")) + .withRequestBody(matchingJsonPath("\$.data[?(@.description == 'org.example:sample:0.0.1')]"))) + otherServer.verify(postRequestedFor(urlEqualTo("/staging/profiles/$otherStagingProfileId/start")) + .withRequestBody(matchingJsonPath("\$.data[?(@.description == 'org.example:sample:0.0.1')]"))) + assertUploadedToStagingRepo("/org/example/sample/0.0.1/sample-0.0.1.pom") + assertUploadedToStagingRepo("/org/example/sample/0.0.1/sample-0.0.1.jar") + assertUploadedToStagingRepo("/org/example/sample/0.0.1/sample-0.0.1.pom", stagingRepositoryId = otherStagingRepositoryId, wireMockServer = otherServer) + assertUploadedToStagingRepo("/org/example/sample/0.0.1/sample-0.0.1.jar", stagingRepositoryId = otherStagingRepositoryId, wireMockServer = otherServer) + } + @Test fun `can be used with lazily applied Gradle Plugin Development Plugin`() { projectDir.resolve("settings.gradle").write(""" @@ -352,6 +442,7 @@ class NexusPublishPluginTests { val result = gradleRunner("initializeMyNexusStagingRepository").buildAndFail() + // we assert that the first task that sends an HTTP request to server fails as expected assertOutcome(result, ":initializeMyNexusStagingRepository", FAILED) assertThat(result.output).contains("SocketTimeoutException") } @@ -732,14 +823,18 @@ class NexusPublishPluginTests { } @SafeVarargs - private fun stubStagingProfileRequest(url: String, vararg stagingProfiles: Map) { - server.stubFor(get(urlEqualTo(url)) + private fun stubStagingProfileRequest( + url: String, + vararg stagingProfiles: Map, + wireMockServer: WireMockServer = server + ) { + wireMockServer.stubFor(get(urlEqualTo(url)) .withHeader("User-Agent", matching("gradle-nexus-publish-plugin/.*")) .willReturn(aResponse().withBody(gson.toJson(mapOf("data" to listOf(*stagingProfiles)))))) } - private fun stubCreateStagingRepoRequest(url: String, stagedRepositoryId: String) { - server.stubFor(post(urlEqualTo(url)) + private fun stubCreateStagingRepoRequest(url: String, stagedRepositoryId: String, wireMockServer: WireMockServer = server) { + wireMockServer.stubFor(post(urlEqualTo(url)) .willReturn(aResponse().withBody(gson.toJson(mapOf("data" to mapOf("stagedRepositoryId" to stagedRepositoryId)))))) } @@ -797,10 +892,10 @@ class NexusPublishPluginTests { .withBody(getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository2)))) } - private fun expectArtifactUploads(prefix: String) { - server.stubFor(put(urlMatching("$prefix/.+")) + private fun expectArtifactUploads(prefix: String, wireMockServer: WireMockServer = server) { + wireMockServer.stubFor(put(urlMatching("$prefix/.+")) .willReturn(aResponse().withStatus(201))) - server.stubFor(get(urlMatching("$prefix/.+/maven-metadata.xml")) + wireMockServer.stubFor(get(urlMatching("$prefix/.+/maven-metadata.xml")) .willReturn(aResponse().withStatus(404))) } @@ -827,12 +922,12 @@ class NexusPublishPluginTests { server.verify(count, getRequestedFor(urlMatching("/staging/profiles"))) } - private fun assertUploadedToStagingRepo(path: String) { - assertUploaded("/staging/deployByRepositoryId/$STAGED_REPOSITORY_ID$path") + private fun assertUploadedToStagingRepo(path: String, stagingRepositoryId: String = STAGED_REPOSITORY_ID, wireMockServer: WireMockServer = server) { + assertUploaded("/staging/deployByRepositoryId/$stagingRepositoryId$path", wireMockServer = wireMockServer) } - private fun assertUploaded(testUrl: String) { - server.verify(putRequestedFor(urlMatching(testUrl))) + private fun assertUploaded(testUrl: String, wireMockServer: WireMockServer = server) { + wireMockServer.verify(putRequestedFor(urlMatching(testUrl))) } private fun assertCloseOfStagingRepo(stagingRepositoryId: String = STAGED_REPOSITORY_ID) { diff --git a/src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPlugin.kt b/src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPlugin.kt index 9a9b97a6..1fcb4b4d 100644 --- a/src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPlugin.kt +++ b/src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPlugin.kt @@ -68,6 +68,7 @@ class NexusPublishPlugin : Plugin { private fun configureNexusTasks(rootProject: Project, extension: NexusPublishExtension, registry: Provider) { extension.repositories.all { val repository = this + val retrieveStagingProfileTask = rootProject.tasks.register("retrieve${capitalizedName}StagingProfile", rootProject.objects, extension, repository) val initializeTask = rootProject.tasks.register( "initialize${capitalizedName}StagingRepository", rootProject.objects, extension, repository, registry) val closeTask = rootProject.tasks.register( @@ -76,6 +77,9 @@ class NexusPublishPlugin : Plugin { "release${capitalizedName}StagingRepository", rootProject.objects, extension, repository, registry) val closeAndReleaseTask = rootProject.tasks.register( "closeAndRelease${capitalizedName}StagingRepository") + retrieveStagingProfileTask { + description = "Gets and displays a staging profile id for a given repository and package group. This is a diagnostic task to get the value and put it into the NexusRepository configuration closure as stagingProfileId." + } closeTask { description = "Closes open staging repository in '${repository.name}' Nexus instance." group = PublishingPlugin.PUBLISH_TASK_GROUP diff --git a/src/main/kotlin/io/github/gradlenexus/publishplugin/RetrieveStagingProfile.kt b/src/main/kotlin/io/github/gradlenexus/publishplugin/RetrieveStagingProfile.kt new file mode 100644 index 00000000..2b9a5b10 --- /dev/null +++ b/src/main/kotlin/io/github/gradlenexus/publishplugin/RetrieveStagingProfile.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.gradlenexus.publishplugin + +import io.github.gradlenexus.publishplugin.internal.NexusClient +import org.gradle.api.GradleException +import org.gradle.api.Incubating +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.property +import javax.inject.Inject + +/** + * Diagnostic task for retrieving the [NexusRepository.stagingProfileId] for the [packageGroup] from the provided [NexusRepository] and logging it + */ +@Incubating +open class RetrieveStagingProfile @Inject constructor( + objects: ObjectFactory, + extension: NexusPublishExtension, + repository: NexusRepository +) : AbstractNexusStagingRepositoryTask(objects, extension, repository) { + + @Input + val packageGroup = objects.property().apply { + set(extension.packageGroup) + } + + @TaskAction + fun retrieveStagingProfile() { + val repository = repository.get() + + val client = NexusClient( + repository.nexusUrl.get(), + repository.username.orNull, + repository.password.orNull, + clientTimeout.orNull, + connectTimeout.orNull + ) + + val packageGroup = packageGroup.get() + val stagingProfileId = client.findStagingProfileId(packageGroup) + ?: throw GradleException("Failed to find staging profile for package group: $packageGroup") + logger.lifecycle("Received staging profile id: '{}' for package {}", stagingProfileId, packageGroup) + } +}