diff --git a/plugins/stage-vote-release-plugin/README.md b/plugins/stage-vote-release-plugin/README.md index 3191031..0e8ba40 100644 --- a/plugins/stage-vote-release-plugin/README.md +++ b/plugins/stage-vote-release-plugin/README.md @@ -116,13 +116,13 @@ releaseParams { } } nexus { // Configures Nexus repository - mavenCentral() // By default the plugin publishes to repository.apache.org, and it can be overridden + mavenCentral() // Publish to Maven Central via the Central Portal OSSRH Staging API compatibility host (defaults to repository.apache.org) prodUrl.set(uri("https://nexus.url")) // An alternative option to override Nexus URL credentials { username.set(String) // -PasfNexusUsername or -PasfTestNexusUsername password.set(String) // -PasfNexusPassword or -PasfTestNexusPassword } - stagingProfileId.set(String) // Specify Nexus staging profile to save Nexus roundtrip on publishing + stagingProfileId.set(String) // Nexus staging profile id; setting it skips the profile lookup. mavenCentral() defaults it to ${project.group} } source { // This is a Git configuration for the source code Git repository (e.g. for pushing the release tag) credentials { @@ -166,6 +166,7 @@ releaseParams { // ReleaseExtension connectTimeout.set(Duration.ofMinutes(10)) operationTimeout.set(Duration.ofMinutes(10)) url.set(URI) // Defaults to https://repository.apache.org (prod) or http://127.0.0.1:8080 (test) + snapshotUrl.set(URI) // Defaults to the snapshot path derived from url; mavenCentral() points it at central.sonatype.com packageGroup.set(String) // Defaults to ${project.group} } source { diff --git a/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/ReleaseExtension.kt b/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/ReleaseExtension.kt index 7b88490..dc0c02e 100644 --- a/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/ReleaseExtension.kt +++ b/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/ReleaseExtension.kt @@ -272,8 +272,42 @@ open class NexusConfig @Inject constructor( val prodUrl = objects.property().convention(project.uri("https://repository.apache.org")) val testUrl = objects.property().convention(project.uri("http://127.0.0.1:8080")) + /** + * Snapshot repository URL. When unset, the plugin derives it from [url] using the Nexus 2 + * layout. The Central Portal serves snapshots from a different host, so [mavenCentral] sets + * it for production releases. + */ + val snapshotUrl = objects.property() + + /** + * Publishes to Maven Central through the Central Portal OSSRH Staging API compatibility host. + * + * The legacy OSSRH host (`oss.sonatype.org`) was sunset in 2025. The compatibility host keeps + * Nexus-based publishing working without pointing the build at it by hand, and it identifies + * staging profiles by namespace, so [stagingProfileId] defaults to the project group. + * + * Like [apacheRepository], this only affects production releases; the test environment keeps + * using the local Nexus stub. + */ fun mavenCentral() { - prodUrl.set(project.uri("https://oss.sonatype.org")) + prodUrl.set(project.uri("https://ossrh-staging-api.central.sonatype.com")) + snapshotUrl.convention( + ext.repositoryType.flatMap { + when (it) { + RepositoryType.PROD -> + project.provider { project.uri("https://central.sonatype.com/repository/maven-snapshots/") } + RepositoryType.TEST -> project.objects.property() + } + } + ) + stagingProfileId.convention( + ext.repositoryType.flatMap { + when (it) { + RepositoryType.PROD -> project.provider { project.group.toString() } + RepositoryType.TEST -> project.objects.property() + } + } + ) } fun apacheRepository() { @@ -286,6 +320,12 @@ open class NexusConfig @Inject constructor( project.provider { project.group.toString() }) + + /** + * Nexus staging profile id. Setting it lets the publish plugin skip the staging-profile lookup. + * [mavenCentral] defaults it to the project group, which the Central Portal treats as the + * namespace. + */ val stagingProfileId = objects.property() } diff --git a/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/StageVoteReleasePlugin.kt b/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/StageVoteReleasePlugin.kt index adea44c..966c7b5 100644 --- a/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/StageVoteReleasePlugin.kt +++ b/plugins/stage-vote-release-plugin/src/main/kotlin/com/github/vlsi/gradle/release/StageVoteReleasePlugin.kt @@ -579,15 +579,20 @@ class StageVoteReleasePlugin @Inject constructor( validateBeforeBuildingReleaseArtifacts: TaskProvider<*> ) { configure { + val nexus = releaseExt.nexus val repo = repositories.create(REPOSITORY_NAME) { - nexusUrl.set(releaseExt.nexus.url.map { it.replacePath("/service/local/") }) - snapshotRepositoryUrl.set(releaseExt.nexus.url.map { it.replacePath("/content/repositories/snapshots/") }) - username.set(project.provider { releaseExt.nexus.credentials.username(project) }) - password.set(project.provider { releaseExt.nexus.credentials.password(project) }) + nexusUrl.set(nexus.url.map { it.replacePath("/service/local/") }) + snapshotRepositoryUrl.set( + nexus.snapshotUrl.orElse( + nexus.url.map { it.replacePath("/content/repositories/snapshots/") } + ) + ) + username.set(project.provider { nexus.credentials.username(project) }) + password.set(project.provider { nexus.credentials.password(project) }) + stagingProfileId.set(nexus.stagingProfileId) } - connectTimeout.set(releaseExt.nexus.connectTimeout) - clientTimeout.set(releaseExt.nexus.operationTimeout) - val nexus = releaseExt.nexus + connectTimeout.set(nexus.connectTimeout) + clientTimeout.set(nexus.operationTimeout) packageGroup.set(nexus.packageGroup) transitionCheckOptions { delayBetween.set(Duration.ofSeconds(10)) diff --git a/plugins/stage-vote-release-plugin/src/test/kotlin/com/github/vlsi/gradle/release/NexusConfigTest.kt b/plugins/stage-vote-release-plugin/src/test/kotlin/com/github/vlsi/gradle/release/NexusConfigTest.kt new file mode 100644 index 0000000..01b6352 --- /dev/null +++ b/plugins/stage-vote-release-plugin/src/test/kotlin/com/github/vlsi/gradle/release/NexusConfigTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Vladimir Sitnikov + * + * 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 com.github.vlsi.gradle.release + +import com.github.vlsi.gradle.BaseGradleTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@Execution(ExecutionMode.SAME_THREAD) +class NexusConfigTest : BaseGradleTest() { + private fun buildScript(nexusConfig: String): String = + /* language=gradle */ + """ + plugins { + id 'com.github.vlsi.stage-vote-release' + } + + group = 'org.example.demo' + version = '1.0.0' + + releaseParams { + nexus { + $nexusConfig + } + } + + def repo = nexusPublishing.repositories.getByName('nexus') + tasks.register('printNexus') { + def nexusUrl = repo.nexusUrl + def snapshotUrl = repo.snapshotRepositoryUrl + def stagingProfileId = repo.stagingProfileId + doLast { + println("nexusUrl=" + nexusUrl.get()) + println("snapshotUrl=" + snapshotUrl.get()) + println("stagingProfileId=" + stagingProfileId.getOrElse('')) + } + } + """.trimIndent() + + private fun BuildResultOf(output: String): Map = + output.lineSequence() + .mapNotNull { line -> + KEYS.firstOrNull { line.startsWith("$it=") }?.let { it to line.substringAfter('=') } + } + .toMap() + + @ParameterizedTest + @MethodSource("disabledConfigurationCacheGradleVersionAndSettings") + fun `mavenCentral targets the Central Portal compatibility host in production`(testCase: TestCase) { + createSettings(testCase) + projectDir.resolve("build.gradle").write(buildScript("mavenCentral()")) + + val values = BuildResultOf(prepare(testCase, "printNexus", "-Pasf").build().output) + + assertEquals("https://ossrh-staging-api.central.sonatype.com/service/local/", values["nexusUrl"]) { + "mavenCentral() should point nexusUrl at the Central Portal OSSRH Staging API host" + } + assertEquals("https://central.sonatype.com/repository/maven-snapshots/", values["snapshotUrl"]) { + "mavenCentral() should publish snapshots to the Central Portal snapshot host" + } + assertEquals("org.example.demo", values["stagingProfileId"]) { + "stagingProfileId should default to the project group" + } + } + + @ParameterizedTest + @MethodSource("disabledConfigurationCacheGradleVersionAndSettings") + fun `mavenCentral keeps the local stub in the test environment`(testCase: TestCase) { + createSettings(testCase) + projectDir.resolve("build.gradle").write(buildScript("mavenCentral()")) + + val values = BuildResultOf(prepare(testCase, "printNexus").build().output) + + assertEquals("http://127.0.0.1:8080/service/local/", values["nexusUrl"]) { + "the test environment should keep using the local Nexus stub" + } + assertEquals("http://127.0.0.1:8080/content/repositories/snapshots/", values["snapshotUrl"]) { + "the test environment should derive the snapshot URL from the local stub" + } + assertEquals("", values["stagingProfileId"]) { + "the test environment should not default the staging profile id" + } + } + + @ParameterizedTest + @MethodSource("disabledConfigurationCacheGradleVersionAndSettings") + fun `explicit stagingProfileId reaches the nexus repository`(testCase: TestCase) { + createSettings(testCase) + projectDir.resolve("build.gradle").write( + buildScript( + /* language=gradle */ + """ + mavenCentral() + stagingProfileId.set('custom-profile') + """.trimIndent() + ) + ) + + val values = BuildResultOf(prepare(testCase, "printNexus", "-Pasf").build().output) + + assertEquals("custom-profile", values["stagingProfileId"]) { + "an explicit stagingProfileId should reach the publish plugin" + } + } + + companion object { + private val KEYS = listOf("nexusUrl", "snapshotUrl", "stagingProfileId") + } +}