├── .gitattributes ├── gradle.properties ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── libs.versions.toml └── license-header.txt ├── .gitignore ├── stutter.lockfile ├── renovate.json ├── settings.gradle.kts ├── src ├── test │ ├── resources │ │ └── junit-platform.properties │ └── kotlin │ │ └── io │ │ └── github │ │ └── gradlenexus │ │ └── publishplugin │ │ ├── KotlinParameterizeTest.kt │ │ ├── internal │ │ └── StagingRepositoryTransitionerTest.kt │ │ └── TaskOrchestrationTest.kt ├── main │ └── kotlin │ │ └── io │ │ └── github │ │ └── gradlenexus │ │ └── publishplugin │ │ ├── internal │ │ ├── ActionRetrier.kt │ │ ├── StagingRepositoryDescriptor.kt │ │ ├── StagingRepositoryDescriptorRegistryBuildService.kt │ │ ├── StagingRepositoryDescriptorRegistry.kt │ │ ├── DetermineStagingProfileId.kt │ │ ├── StagingRepository.kt │ │ ├── BasicActionRetrier.kt │ │ ├── InvalidatingStagingRepositoryDescriptorRegistry.kt │ │ ├── StagingRepositoryTransitioner.kt │ │ └── NexusClient.kt │ │ ├── NexusPublishExceptions.kt │ │ ├── TransitionCheckOptions.kt │ │ ├── NexusRepositoryContainer.kt │ │ ├── CloseNexusStagingRepository.kt │ │ ├── ReleaseNexusStagingRepository.kt │ │ ├── RetrieveStagingProfile.kt │ │ ├── NexusPublishExtension.kt │ │ ├── AbstractNexusStagingRepositoryTask.kt │ │ ├── AbstractTransitionNexusStagingRepositoryTask.kt │ │ ├── InitializeNexusStagingRepository.kt │ │ ├── NexusRepository.kt │ │ ├── FindStagingRepository.kt │ │ ├── DefaultNexusRepositoryContainer.kt │ │ └── NexusPublishPlugin.kt ├── compatTest │ └── kotlin │ │ └── io │ │ └── github │ │ └── gradlenexus │ │ └── publishplugin │ │ ├── TestExtensions.kt │ │ ├── BaseGradleTest.kt │ │ ├── MavenNexusPublishPluginTests.kt │ │ ├── MethodScopeWiremockResolver.kt │ │ ├── IvyNexusPublishPluginTests.kt │ │ └── BaseNexusPublishPluginTests.kt └── e2eTest │ └── kotlin │ └── io │ └── github │ └── gradlenexus │ └── publishplugin │ └── e2e │ └── NexusPublishE2ETests.kt ├── .gitmodules ├── .github └── workflows │ ├── main.yml │ ├── e2e.yml │ ├── java-versions.yml │ └── gradle-latest-versions.yml ├── .editorconfig ├── gradlew.bat ├── gradlew └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.bat eol=crlf 3 | *.png binary 4 | *.jar binary 5 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.caching=true 2 | org.gradle.parallel=true 3 | org.gradle.configuration-cache=true 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradle-nexus/publish-plugin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle 3 | /build/ 4 | /buildSrc/build/ 5 | 6 | # IDEA 7 | /.idea/ 8 | /buildSrc/out/ 9 | /out/ 10 | 11 | #asdf 12 | .tool-versions 13 | -------------------------------------------------------------------------------- /stutter.lockfile: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY: Generated by Stutter plugin. 2 | java11=6.2.2,6.9.4,7.0.2,7.6.3,8.0.2,8.5 3 | java17=7.3.3,7.6.3,8.0.2,8.5 4 | java8=6.2.2,6.9.4,7.0.2,7.6.3,8.0.2,8.5 5 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | ktlint = "1.8.0" 3 | 4 | [libraries] 5 | ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } # Help Renovate recognize the version. 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "helpers:pinGitHubActionDigests" 6 | ], 7 | "configMigration": true, 8 | "packageRules": [ 9 | { 10 | "groupName": "Retrofit monorepo", 11 | "description": "Group together all dependencies of Retrofit.", 12 | "matchPackageNames": [ 13 | "com.squareup.retrofit2:*" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.gradle.develocity") version "4.0.2" 3 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" 4 | } 5 | 6 | develocity { 7 | buildScan { 8 | termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" 9 | termsOfUseAgree = "yes" 10 | uploadInBackground = false 11 | publishing.onlyIf { System.getenv("CI") != null } 12 | } 13 | } 14 | 15 | rootProject.name = "gradle-nexus-publish-plugin" 16 | -------------------------------------------------------------------------------- /gradle/license-header.txt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | -------------------------------------------------------------------------------- /src/test/resources/junit-platform.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 the original author or authors. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | junit.jupiter.displayname.generator.default = org.junit.jupiter.api.DisplayNameGenerator$Simple 18 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/e2eTest/resources/nexus-publish-e2e-minimal"] 2 | path = src/e2eTest/resources/nexus-publish-e2e-minimal 3 | url = git@github.com:gradle-nexus/nexus-publish-e2e-minimal.git 4 | [submodule "src/e2eTest/resources/nexus-publish-e2e-multi-project"] 5 | path = src/e2eTest/resources/nexus-publish-e2e-multi-project 6 | url = git@github.com:gradle-nexus/nexus-publish-e2e-multi-project.git 7 | [submodule "src/e2eTest/resources/nexus-publish-e2e-minimal-ivy"] 8 | path = src/e2eTest/resources/nexus-publish-e2e-minimal-ivy 9 | url = git@github.com:gradle-nexus/nexus-publish-e2e-minimal-ivy.git 10 | [submodule "src/e2eTest/resources/nexus-publish-e2e-minimal-ivy-sbt"] 11 | path = src/e2eTest/resources/nexus-publish-e2e-minimal-ivy-sbt 12 | url = git@github.com:gradle-nexus/nexus-publish-e2e-minimal-ivy-sbt.git 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - "szpak/**" 8 | - "marc/**" 9 | pull_request: 10 | branches: 11 | - '*' 12 | merge_group: 13 | 14 | jobs: 15 | gradle: 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 23 | 24 | - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 25 | with: 26 | java-version: 11 27 | distribution: 'zulu' 28 | 29 | - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4 30 | 31 | - name: "Build and Test" 32 | run: ./gradlew --stacktrace build compatTestJava11 33 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | insert_final_newline = true 6 | charset = utf-8 7 | end_of_line = lf 8 | 9 | [*.{kt,kts}] 10 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 11 | ij_java_use_single_class_imports = true 12 | trim_trailing_whitespace = true 13 | # Trailing comma language feature requires Kotlin plugin 1.4+, at the moment the compilation is done with Kotlin 1.3. 14 | # This helps IDEA to format the code properly, see also spotless { } in build.gradle.kts. 15 | ij_kotlin_allow_trailing_comma = false # Only used for declaration site 16 | ij_kotlin_allow_trailing_comma_on_call_site = false 17 | 18 | # Ensure the method signature and implementing code are on separate lines. 19 | ktlint_function_signature_body_expression_wrapping = always 20 | 21 | [*.bat] 22 | end_of_line = crlf 23 | 24 | [*.yml] 25 | indent_size = 2 26 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/ActionRetrier.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | interface ActionRetrier { 20 | fun execute(operationToExecuteWithRetrying: () -> R): R 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishExceptions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | open class NexusPublishException(message: String) : RuntimeException(message) 20 | 21 | open class RepositoryTransitionException(message: String) : NexusPublishException(message) 22 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/TransitionCheckOptions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.gradle.api.provider.Property 20 | import java.time.Duration 21 | 22 | interface TransitionCheckOptions { 23 | 24 | val maxRetries: Property 25 | 26 | val delayBetween: Property 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/NexusRepositoryContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.gradle.api.Action 20 | import org.gradle.api.NamedDomainObjectContainer 21 | 22 | interface NexusRepositoryContainer : NamedDomainObjectContainer { 23 | fun sonatype(): NexusRepository 24 | fun sonatype(action: Action): NexusRepository 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/StagingRepositoryDescriptor.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import java.net.URI 20 | 21 | data class StagingRepositoryDescriptor(private val baseUrl: URI, val stagingRepositoryId: String) { 22 | val stagingRepositoryUrl: URI by lazy { 23 | URI.create("${baseUrl.toString().removeSuffix("/")}/staging/deployByRepositoryId/$stagingRepositoryId") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/StagingRepositoryDescriptorRegistryBuildService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import org.gradle.api.services.BuildService 20 | import org.gradle.api.services.BuildServiceParameters 21 | 22 | abstract class StagingRepositoryDescriptorRegistryBuildService : BuildService { 23 | val registry = InvalidatingStagingRepositoryDescriptorRegistry() 24 | } 25 | -------------------------------------------------------------------------------- /src/compatTest/kotlin/io/github/gradlenexus/publishplugin/TestExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import java.nio.file.Files 20 | import java.nio.file.Path 21 | 22 | fun Path.write(text: String): Path { 23 | Files.createDirectories(this.parent) 24 | this.toFile().writeText(text) 25 | return this 26 | } 27 | 28 | fun Path.append(text: String): Path { 29 | Files.createDirectories(this.parent) 30 | this.toFile().appendText(text) 31 | return this 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E tests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 6 * * MON" 6 | push: 7 | branches: 8 | - master 9 | - "e2e/**" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - name: Checkout project with submodules 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 19 | with: 20 | submodules: true 21 | 22 | - name: Setup JDK 23 | uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 24 | with: 25 | java-version: 11 26 | distribution: 'zulu' 27 | 28 | - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4 29 | 30 | - name: Run sanity check 31 | run: ./gradlew --stacktrace test 32 | 33 | - name: Run E2E tests 34 | env: 35 | ORG_GRADLE_PROJECT_sonatypeUsernameE2E: ${{ secrets.SONATYPE_USERNAME_E2E }} 36 | ORG_GRADLE_PROJECT_sonatypePasswordE2E: ${{ secrets.SONATYPE_PASSWORD_E2E }} 37 | ORG_GRADLE_PROJECT_signingKeyE2E: ${{ secrets.GPG_SIGNING_KEY_E2E }} 38 | ORG_GRADLE_PROJECT_signingPasswordE2E: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE_E2E }} 39 | run: | 40 | ./gradlew --stacktrace -Pe2eVerboseOutput e2eTest 41 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/gradlenexus/publishplugin/KotlinParameterizeTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.junit.jupiter.api.TestInstance 20 | 21 | /** 22 | * Change the lifecycle of a test class to [PER_CLASS][TestInstance.Lifecycle.PER_CLASS] 23 | * so that parameterized tests can have non-static [MethodSource][org.junit.jupiter.params.provider.MethodSource] 24 | * test argument factory methods and can be used in Kotlin tests 25 | * 26 | * Based on: https://blog.oio.de/2018/11/13/how-to-use-junit-5-methodsource-parameterized-tests-with-kotlin/ 27 | */ 28 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 29 | @Target(AnnotationTarget.CLASS) 30 | @Retention(AnnotationRetention.RUNTIME) 31 | annotation class KotlinParameterizeTest 32 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/StagingRepositoryDescriptorRegistry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import java.util.concurrent.ConcurrentHashMap 20 | 21 | open class StagingRepositoryDescriptorRegistry { 22 | 23 | private val mapping = ConcurrentHashMap() 24 | 25 | open operator fun set(name: String, descriptor: StagingRepositoryDescriptor) { 26 | mapping[name] = descriptor 27 | } 28 | 29 | operator fun get(name: String): StagingRepositoryDescriptor = 30 | mapping[name] ?: error("No staging repository with name $name created") 31 | 32 | fun tryGet(name: String): StagingRepositoryDescriptor? = 33 | mapping[name] 34 | 35 | override fun toString(): String = 36 | mapping.toString() 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/java-versions.yml: -------------------------------------------------------------------------------- 1 | name: Java cross-version tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - "szpak/**" 8 | - "marc/**" 9 | pull_request: 10 | branches: 11 | - '*' 12 | merge_group: 13 | 14 | jobs: 15 | openjdk: 16 | strategy: 17 | matrix: 18 | jdk: [11, 17] 19 | name: "OpenJDK ${{ matrix.jdk }}" 20 | runs-on: ubuntu-latest 21 | steps: 22 | 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 24 | 25 | - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 26 | with: 27 | java-version: ${{ matrix.jdk }} 28 | distribution: 'zulu' 29 | 30 | - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4 31 | 32 | # Workaround https://github.com/ajoberstar/gradle-stutter/issues/22 33 | - name: Reduce number of Gradle regressions builds 34 | run: | 35 | # Keep only the last Gradle version per Stutter matrix; for each line this transformation is done: 36 | # java11=6.2.2,6.9.4,7.0.2,7.6.1,8.0.2,8.1.1,8.2-rc-1 37 | # -> 38 | # java11=8.2-rc-1 39 | # The trick is that \2 will greedily eat everything before the last comma. 40 | sed -r 's/^(.*?)=(.*),(.*)$/\1=\3/g' -i stutter.lockfile 41 | cat stutter.lockfile 42 | 43 | - name: Test cross Java versions compatibility 44 | run: | 45 | ./gradlew --version 46 | ./gradlew --stacktrace build compatTestJava${{ matrix.jdk }} 47 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/DetermineStagingProfileId.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import io.github.gradlenexus.publishplugin.NexusRepository 20 | import org.gradle.api.GradleException 21 | import org.gradle.api.logging.Logger 22 | 23 | internal fun determineStagingProfileId( 24 | client: NexusClient, 25 | logger: Logger, 26 | repository: NexusRepository, 27 | packageGroup: String 28 | ): String { 29 | var stagingProfileId = repository.stagingProfileId.orNull 30 | if (stagingProfileId == null) { 31 | logger.info("No stagingProfileId set, querying for packageGroup '{}'", packageGroup) 32 | stagingProfileId = client.findStagingProfileId(packageGroup) 33 | ?: throw GradleException("Failed to find staging profile for package group: $packageGroup") 34 | } 35 | return stagingProfileId 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/CloseNexusStagingRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.internal.StagingRepositoryTransitioner 20 | import org.gradle.api.tasks.options.Option 21 | 22 | abstract class CloseNexusStagingRepository : AbstractTransitionNexusStagingRepositoryTask() { 23 | 24 | @Option(option = "staging-repository-id", description = "staging repository id to close") 25 | fun setStagingRepositoryIdToClose(stagingRepositoryId: String) { 26 | this.stagingRepositoryId.set(stagingRepositoryId) 27 | } 28 | 29 | override fun transitionStagingRepo(repositoryTransitioner: StagingRepositoryTransitioner) { 30 | logger.info("Closing staging repository with id '{}'", stagingRepositoryId.get()) 31 | repositoryTransitioner.effectivelyClose(stagingRepositoryId.get(), repositoryDescription.get()) 32 | logger.info("Repository with id '{}' effectively closed", stagingRepositoryId.get()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/ReleaseNexusStagingRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.internal.StagingRepositoryTransitioner 20 | import org.gradle.api.tasks.options.Option 21 | 22 | abstract class ReleaseNexusStagingRepository : AbstractTransitionNexusStagingRepositoryTask() { 23 | 24 | @Option(option = "staging-repository-id", description = "staging repository id to release") 25 | fun setStagingRepositoryIdToRelease(stagingRepositoryId: String) { 26 | this.stagingRepositoryId.set(stagingRepositoryId) 27 | } 28 | 29 | override fun transitionStagingRepo(repositoryTransitioner: StagingRepositoryTransitioner) { 30 | logger.info("Releasing staging repository with id '{}'", stagingRepositoryId.get()) 31 | repositoryTransitioner.effectivelyRelease(stagingRepositoryId.get(), repositoryDescription.get()) 32 | logger.info("Repository with id '{}' effectively released", stagingRepositoryId.get()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/StagingRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | data class StagingRepository constructor(val id: String, val state: State, val transitioning: Boolean) { 20 | 21 | enum class State { 22 | OPEN, 23 | CLOSED, 24 | RELEASED, 25 | NOT_FOUND; 26 | 27 | override fun toString(): String = 28 | name.toLowerCase() 29 | 30 | companion object { 31 | fun parseString(stateAsString: String): State { 32 | try { 33 | return valueOf(stateAsString.toUpperCase()) 34 | } catch (e: IllegalArgumentException) { 35 | error("Unsupported repository state '$stateAsString'. Supported values: ${values()}") 36 | } 37 | } 38 | } 39 | } 40 | 41 | companion object { 42 | fun notFound(id: String): StagingRepository = 43 | StagingRepository(id, State.NOT_FOUND, false) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/RetrieveStagingProfile.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.gradle.api.GradleException 20 | import org.gradle.api.Incubating 21 | import org.gradle.api.provider.Property 22 | import org.gradle.api.tasks.Input 23 | import org.gradle.api.tasks.TaskAction 24 | 25 | /** 26 | * Diagnostic task for retrieving the [NexusRepository.stagingProfileId] for the [packageGroup] from the provided [NexusRepository] and logging it 27 | */ 28 | @Incubating 29 | abstract class RetrieveStagingProfile : AbstractNexusStagingRepositoryTask() { 30 | 31 | @get:Input 32 | abstract val packageGroup: Property 33 | 34 | @TaskAction 35 | fun retrieveStagingProfile() { 36 | val client = createNexusClient() 37 | val packageGroup = packageGroup.get() 38 | val stagingProfileId = client.findStagingProfileId(packageGroup) 39 | ?: throw GradleException("Failed to find staging profile for package group: $packageGroup") 40 | logger.lifecycle("Received staging profile id: '{}' for package {}", stagingProfileId, packageGroup) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/gradle-latest-versions.yml: -------------------------------------------------------------------------------- 1 | name: Gradle latest versions tests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 7 * * MON" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | gradle-compatTest: 10 | name: "Compatibility tests with latest Gradle versions" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 14 | 15 | - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 16 | with: 17 | java-version: 11 18 | distribution: 'zulu' 19 | 20 | - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4 21 | 22 | - name: Fetch latest available Gradle versions 23 | run: | 24 | GRADLE_VERSIONS_TYPES_TO_FETCH="current release-candidate nightly release-nightly" 25 | GRADLE_VERSIONS_OUTPUT_FILE=latest-gradle-versions.txt 26 | 27 | for gv in $GRADLE_VERSIONS_TYPES_TO_FETCH; do 28 | echo "Fetching latest Gradle version for '$gv'" 29 | curl --silent --show-error "https://services.gradle.org/versions/$gv" | jq -r '.version | select( . != null )' >> latest-gradle-versions.txt 30 | done 31 | 32 | echo -e "\nGradle versions configured for compatibility tests (in $GRADLE_VERSIONS_OUTPUT_FILE):" 33 | cat $GRADLE_VERSIONS_OUTPUT_FILE 34 | 35 | echo "GRADLE_VERSIONS_OUTPUT_FILE=$GRADLE_VERSIONS_OUTPUT_FILE" >> $GITHUB_ENV 36 | 37 | - name: Set latest Gradle versions to test with 38 | run: | 39 | echo "java11=$(cat ${{ env.GRADLE_VERSIONS_OUTPUT_FILE }} | tr '\n' ',')" > stutter.lockfile 40 | echo "Gradle versions configured for compatibility tests:" 41 | cat stutter.lockfile 42 | 43 | - name: Run compatTest 44 | run: ./gradlew --continue test compatTest 45 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/BasicActionRetrier.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import net.jodah.failsafe.Failsafe 20 | import net.jodah.failsafe.RetryPolicy 21 | import org.slf4j.Logger 22 | import org.slf4j.LoggerFactory 23 | import java.time.Duration 24 | 25 | open class BasicActionRetrier(maxRetries: Int, delayBetween: Duration, stopFunction: (R) -> Boolean) : ActionRetrier { 26 | 27 | private val maxAttempts: Int = maxRetries + 1 28 | 29 | private val retrier: RetryPolicy = RetryPolicy() 30 | // TODO: Some exceptions could be handled separately 31 | .handleResultIf(stopFunction) 32 | .onFailedAttempt { event -> 33 | log.info("Attempt ${event.attemptCount}/$maxAttempts failed with result: ${event.lastResult}") 34 | } 35 | .withMaxRetries(maxRetries) 36 | .withDelay(delayBetween) 37 | 38 | override fun execute(operationToExecuteWithRetrying: () -> R): R = 39 | Failsafe.with(retrier).get(operationToExecuteWithRetrying) 40 | 41 | companion object { 42 | private val log: Logger = LoggerFactory.getLogger(BasicActionRetrier::class.java) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.gradle.api.Action 20 | import org.gradle.api.model.ObjectFactory 21 | import org.gradle.api.provider.Property 22 | import org.gradle.api.tasks.Nested 23 | import java.time.Duration 24 | 25 | abstract class NexusPublishExtension(objects: ObjectFactory) { 26 | 27 | companion object { 28 | internal const val NAME = "nexusPublishing" 29 | } 30 | 31 | abstract val useStaging: Property 32 | 33 | abstract val packageGroup: Property 34 | 35 | abstract val repositoryDescription: Property 36 | 37 | abstract val clientTimeout: Property 38 | 39 | abstract val connectTimeout: Property 40 | 41 | @get:Nested 42 | abstract val transitionCheckOptions: TransitionCheckOptions 43 | 44 | fun transitionCheckOptions(action: Action) { 45 | action.execute(transitionCheckOptions) 46 | } 47 | 48 | val repositories: NexusRepositoryContainer = objects.newInstance( 49 | DefaultNexusRepositoryContainer::class.java, 50 | objects.domainObjectContainer(NexusRepository::class.java) 51 | ) 52 | 53 | fun repositories(action: Action) { 54 | action.execute(repositories) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/AbstractNexusStagingRepositoryTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.internal.NexusClient 20 | import org.gradle.api.DefaultTask 21 | import org.gradle.api.provider.Property 22 | import org.gradle.api.tasks.Input 23 | import org.gradle.api.tasks.Internal 24 | import org.gradle.api.tasks.Nested 25 | import java.time.Duration 26 | 27 | abstract class AbstractNexusStagingRepositoryTask : DefaultTask() { 28 | 29 | @get:Internal 30 | abstract val clientTimeout: Property 31 | 32 | @get:Internal 33 | abstract val connectTimeout: Property 34 | 35 | // TODO: Expose externally as interface with getters only 36 | @get:Nested 37 | abstract val repository: Property 38 | 39 | @get:Input 40 | abstract val repositoryDescription: Property 41 | 42 | @get:Internal 43 | abstract val useStaging: Property 44 | 45 | init { 46 | this.onlyIf { useStaging.getOrElse(false) } 47 | } 48 | 49 | protected fun createNexusClient(): NexusClient { 50 | val repository = repository.get() 51 | return NexusClient( 52 | repository.nexusUrl.get(), 53 | repository.username.orNull, 54 | repository.password.orNull, 55 | clientTimeout.orNull, 56 | connectTimeout.orNull 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/AbstractTransitionNexusStagingRepositoryTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.internal.BasicActionRetrier 20 | import io.github.gradlenexus.publishplugin.internal.StagingRepository 21 | import io.github.gradlenexus.publishplugin.internal.StagingRepositoryTransitioner 22 | import org.gradle.api.Action 23 | import org.gradle.api.provider.Property 24 | import org.gradle.api.tasks.Input 25 | import org.gradle.api.tasks.Internal 26 | import org.gradle.api.tasks.TaskAction 27 | 28 | abstract class AbstractTransitionNexusStagingRepositoryTask : AbstractNexusStagingRepositoryTask() { 29 | 30 | @get:Input 31 | abstract val stagingRepositoryId: Property 32 | 33 | @get:Internal 34 | abstract val transitionCheckOptions: Property 35 | 36 | fun transitionCheckOptions(action: Action) { 37 | action.execute(transitionCheckOptions.get()) 38 | } 39 | 40 | @TaskAction 41 | fun transitionStagingRepo() { 42 | val retrier = transitionCheckOptions.get().run { 43 | BasicActionRetrier(maxRetries.get(), delayBetween.get(), StagingRepository::transitioning) 44 | } 45 | transitionStagingRepo(StagingRepositoryTransitioner(createNexusClient(), retrier)) 46 | } 47 | 48 | protected abstract fun transitionStagingRepo(repositoryTransitioner: StagingRepositoryTransitioner) 49 | } 50 | -------------------------------------------------------------------------------- /src/compatTest/kotlin/io/github/gradlenexus/publishplugin/BaseGradleTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.assertj.core.api.Assertions.assertThat 20 | import org.gradle.testkit.runner.BuildResult 21 | import org.gradle.testkit.runner.BuildTask 22 | import org.gradle.testkit.runner.GradleRunner 23 | import org.gradle.testkit.runner.TaskOutcome 24 | import org.junit.jupiter.api.io.TempDir 25 | import java.io.File 26 | 27 | abstract class BaseGradleTest { 28 | 29 | private val gradleRunner = GradleRunner.create() 30 | .withPluginClasspath() 31 | 32 | @TempDir 33 | protected lateinit var projectDir: File 34 | 35 | protected fun run(vararg arguments: String): BuildResult = 36 | gradleRunner(*arguments).build() 37 | 38 | protected fun gradleRunner(vararg arguments: String): GradleRunner = 39 | gradleRunner 40 | // .withDebug(true) 41 | .withProjectDir(projectDir) 42 | .withArguments(*arguments, "--stacktrace") 43 | .forwardOutput() 44 | 45 | protected fun BuildResult.assertSuccess(taskPath: String) { 46 | assertSuccess { it.path == taskPath } 47 | } 48 | 49 | protected fun BuildResult.assertSuccess(taskPredicate: (BuildTask) -> Boolean) { 50 | assertOutcome(taskPredicate, TaskOutcome.SUCCESS) 51 | } 52 | 53 | protected fun BuildResult.assertOutcome(taskPredicate: (BuildTask) -> Boolean, outcome: TaskOutcome) { 54 | val tasks = tasks.filter(taskPredicate) 55 | assertThat(tasks).hasSizeGreaterThanOrEqualTo(1) 56 | assertThat(tasks.map { it.outcome }.distinct()).describedAs(tasks.toString()).containsExactly(outcome) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/InitializeNexusStagingRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.internal.StagingRepositoryDescriptorRegistryBuildService 20 | import io.github.gradlenexus.publishplugin.internal.determineStagingProfileId 21 | import okhttp3.HttpUrl 22 | import org.gradle.api.provider.Property 23 | import org.gradle.api.tasks.Input 24 | import org.gradle.api.tasks.Internal 25 | import org.gradle.api.tasks.Optional 26 | import org.gradle.api.tasks.TaskAction 27 | 28 | abstract class InitializeNexusStagingRepository : AbstractNexusStagingRepositoryTask() { 29 | 30 | @get:Internal 31 | // TODO use @ServiceReference instead of @Internal when minimum is Gradle 8.0. 32 | abstract val registry: Property 33 | 34 | @get:Optional 35 | @get:Input 36 | abstract val packageGroup: Property 37 | 38 | @TaskAction 39 | fun createStagingRepo() { 40 | val repository = repository.get() 41 | val serverUrl = repository.nexusUrl.get() 42 | val client = createNexusClient() 43 | val stagingProfileId = determineStagingProfileId(client, logger, repository, packageGroup.get()) 44 | logger.info("Creating staging repository for {} at {}, stagingProfileId '{}'", repository.name, serverUrl, stagingProfileId) 45 | val descriptor = client.createStagingRepository(stagingProfileId, repositoryDescription.get()) 46 | val consumerUrl = HttpUrl.get(serverUrl)!!.newBuilder().addEncodedPathSegments("repositories/${descriptor.stagingRepositoryId}/content/").build() 47 | logger.lifecycle("Created staging repository '{}' at {}", descriptor.stagingRepositoryId, consumerUrl) 48 | registry.get().registry[repository.name] = descriptor 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/InvalidatingStagingRepositoryDescriptorRegistry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import org.gradle.api.artifacts.repositories.ArtifactRepository 20 | import org.gradle.api.internal.artifacts.repositories.AbstractResolutionAwareArtifactRepository 21 | import org.slf4j.Logger 22 | import org.slf4j.LoggerFactory 23 | import java.util.concurrent.ConcurrentHashMap 24 | 25 | /* 26 | * This class is a temporary workaround to invalidate the repository description on a IvyArtifactRepository after the creation of the staging sonatype repo 27 | */ 28 | class InvalidatingStagingRepositoryDescriptorRegistry : StagingRepositoryDescriptorRegistry() { 29 | 30 | private val invalidateMapping = ConcurrentHashMap() 31 | 32 | override operator fun set(name: String, descriptor: StagingRepositoryDescriptor) { 33 | super.set(name, descriptor) 34 | invalidateMapping.remove(name)?.invalidate() 35 | } 36 | 37 | fun invalidateLater(name: String, artifactRepository: ArtifactRepository) { 38 | invalidateMapping[name] = artifactRepository 39 | } 40 | 41 | companion object { 42 | private val log: Logger = LoggerFactory.getLogger(InvalidatingStagingRepositoryDescriptorRegistry::class.java) 43 | 44 | private fun ArtifactRepository.invalidate() { 45 | if (this is AbstractResolutionAwareArtifactRepository<*>) { 46 | try { 47 | AbstractResolutionAwareArtifactRepository::class.java 48 | .getDeclaredMethod("invalidateDescriptor") 49 | .apply { isAccessible = true } 50 | .invoke(this) 51 | } catch (e: Exception) { 52 | log.warn("Failed to invalidate artifact repository URL, publishing will not work correctly.") 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/NexusRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.gradle.api.Action 20 | import org.gradle.api.DefaultTask 21 | import org.gradle.api.artifacts.repositories.IvyPatternRepositoryLayout 22 | import org.gradle.api.provider.Property 23 | import org.gradle.api.publish.Publication 24 | import org.gradle.api.publish.ivy.IvyPublication 25 | import org.gradle.api.publish.ivy.tasks.PublishToIvyRepository 26 | import org.gradle.api.publish.maven.MavenPublication 27 | import org.gradle.api.publish.maven.tasks.PublishToMavenRepository 28 | import org.gradle.api.tasks.Input 29 | import org.gradle.api.tasks.Internal 30 | import org.gradle.api.tasks.Optional 31 | import java.net.URI 32 | 33 | abstract class NexusRepository(@Input val name: String) { 34 | 35 | @get:Input 36 | abstract val nexusUrl: Property 37 | 38 | @get:Input 39 | abstract val snapshotRepositoryUrl: Property 40 | 41 | @get:Input 42 | abstract val publicationType: Property 43 | 44 | @get:Internal 45 | abstract val username: Property 46 | 47 | @get:Internal 48 | abstract val password: Property 49 | 50 | @get:Internal 51 | abstract val allowInsecureProtocol: Property 52 | 53 | @get:Optional 54 | @get:Input 55 | abstract val stagingProfileId: Property 56 | 57 | @get:Internal 58 | internal val capitalizedName: String by lazy { name.capitalize() } 59 | 60 | @get:Optional 61 | @get:Input 62 | abstract val ivyPatternLayout: Property> 63 | fun ivyPatternLayout(action: Action) { 64 | ivyPatternLayout.set(action) 65 | } 66 | 67 | enum class PublicationType(internal val gradleType: Class, internal val publishTaskType: Class) { 68 | MAVEN(MavenPublication::class.java, PublishToMavenRepository::class.java), 69 | IVY(IvyPublication::class.java, PublishToIvyRepository::class.java) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/FindStagingRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.internal.StagingRepositoryDescriptorRegistryBuildService 20 | import io.github.gradlenexus.publishplugin.internal.determineStagingProfileId 21 | import org.gradle.api.Incubating 22 | import org.gradle.api.provider.Property 23 | import org.gradle.api.tasks.Input 24 | import org.gradle.api.tasks.Internal 25 | import org.gradle.api.tasks.Optional 26 | import org.gradle.api.tasks.TaskAction 27 | 28 | @Incubating 29 | abstract class FindStagingRepository : AbstractNexusStagingRepositoryTask() { 30 | 31 | @get:Internal 32 | // TODO use @ServiceReference instead of @Internal when minimum is Gradle 8.0. 33 | abstract val registry: Property 34 | 35 | @get:Optional 36 | @get:Input 37 | abstract val packageGroup: Property 38 | 39 | @get:Input 40 | abstract val descriptionRegex: Property 41 | 42 | @get:Internal 43 | abstract val stagingRepositoryId: Property 44 | 45 | init { 46 | outputs.cacheIf("the task requests data from the external repository, so we don't want to cache it") { 47 | false 48 | } 49 | } 50 | 51 | @TaskAction 52 | fun findStagingRepository() { 53 | val repository = repository.get() 54 | val serverUrl = repository.nexusUrl.get() 55 | val client = createNexusClient() 56 | val stagingProfileId = determineStagingProfileId(client, logger, repository, packageGroup.get()) 57 | logger.info("Fetching staging repositories for {} at {}, stagingProfileId '{}'", repository.name, serverUrl, stagingProfileId) 58 | val descriptionRegex = descriptionRegex.get() 59 | val descriptor = client.findStagingRepository(stagingProfileId, Regex(descriptionRegex)) 60 | logger.lifecycle("Staging repository for {} at {}, stagingProfileId '{}', descriptionRegex '{}' is '{}'", repository.name, serverUrl, stagingProfileId, descriptionRegex, descriptor.stagingRepositoryId) 61 | stagingRepositoryId.set(descriptor.stagingRepositoryId) 62 | registry.get().registry[repository.name] = descriptor 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/compatTest/kotlin/io/github/gradlenexus/publishplugin/MavenNexusPublishPluginTests.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.junit.jupiter.api.Test 20 | 21 | class MavenNexusPublishPluginTests : BaseNexusPublishPluginTests() { 22 | init { 23 | publishPluginId = "maven-publish" 24 | publishPluginContent = """ mavenJava(MavenPublication) { 25 | from(components.java) 26 | }""" 27 | publishGoalPrefix = "publishMavenJava" 28 | publicationTypeName = "MAVEN" 29 | snapshotArtifactList = listOf( 30 | "sample-0.0.1-.*.pom", 31 | "sample-0.0.1-.*.jar" 32 | ) 33 | artifactList = listOf( 34 | "sample-0.0.1.pom", 35 | "sample-0.0.1.jar" 36 | ) 37 | pluginArtifactList = listOf( 38 | "/org/example/gradle-plugin/0.0.1/gradle-plugin-0.0.1.pom", 39 | "/org/example/foo/org.example.foo.gradle.plugin/0.0.1/org.example.foo.gradle.plugin-0.0.1.pom" 40 | ) 41 | } 42 | 43 | @Test 44 | fun `setting publication type to null will use maven`() { 45 | projectDir.resolve("settings.gradle").write( 46 | """ 47 | rootProject.name = 'sample' 48 | """ 49 | ) 50 | 51 | projectDir.resolve("build.gradle").write( 52 | """ 53 | plugins { 54 | id('java-library') 55 | id('$publishPluginId') 56 | id('io.github.gradle-nexus.publish-plugin') 57 | } 58 | group = 'org.example' 59 | version = '0.0.1-SNAPSHOT' 60 | publishing { 61 | publications { 62 | $publishPluginContent 63 | } 64 | } 65 | nexusPublishing { 66 | repositories { 67 | myNexus { 68 | publicationType = null 69 | nexusUrl = uri('${server.baseUrl()}/shouldNotBeUsed') 70 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots') 71 | allowInsecureProtocol = true 72 | username = 'username' 73 | password = 'password' 74 | } 75 | } 76 | } 77 | """ 78 | ) 79 | 80 | expectArtifactUploads("/snapshots") 81 | 82 | val result = run("publishToMyNexus") 83 | 84 | assertSkipped(result, ":initializeMyNexusStagingRepository") 85 | snapshotArtifactList.forEach { assertUploaded("/snapshots/org/example/sample/0.0.1-SNAPSHOT/$it") } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/compatTest/kotlin/io/github/gradlenexus/publishplugin/MethodScopeWiremockResolver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import com.github.tomakehurst.wiremock.WireMockServer 20 | import org.junit.jupiter.api.extension.AfterEachCallback 21 | import org.junit.jupiter.api.extension.ExtensionContext 22 | import org.junit.jupiter.api.extension.ParameterContext 23 | import org.junit.jupiter.api.extension.ParameterResolver 24 | import ru.lanwen.wiremock.ext.WiremockResolver 25 | import java.lang.IllegalArgumentException 26 | 27 | /** 28 | * Composes a [WiremockResolver] and uses that by default. But, if a parameter is annotated 29 | * with [MethodScopeWiremockResolver] it creates a new instance of the [WiremockResolver] extension and 30 | * manages its lifecycle in the scope of that [ExtensionContext] 31 | */ 32 | class MethodScopeWiremockResolver( 33 | private val inner: WiremockResolver = WiremockResolver() 34 | ) : ParameterResolver by inner, 35 | AfterEachCallback { 36 | 37 | /** 38 | * Checks to see if a method the parameter is annotated with [MethodScopedWiremockServer]. If it is, we first verify 39 | */ 40 | override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any { 41 | return if (parameterContext.isAnnotated(MethodScopedWiremockServer::class.java)) { 42 | if (!parameterContext.parameter.type.isAssignableFrom(WireMockServer::class.java)) { 43 | throw IllegalArgumentException("Annotated type must be a WireMockServer") 44 | } 45 | return getStore(extensionContext) 46 | .getOrComputeIfAbsent(Keys.LOCAL_RESOLVER, { WiremockResolver() }, WiremockResolver::class.java) 47 | .resolveParameter(parameterContext, extensionContext) 48 | } else { 49 | inner.resolveParameter(parameterContext, extensionContext) 50 | } 51 | } 52 | 53 | override fun afterEach(context: ExtensionContext) { 54 | getStore(context).get(Keys.LOCAL_RESOLVER, WiremockResolver::class.java)?.afterEach(context) 55 | inner.afterEach(context) 56 | } 57 | 58 | /** 59 | * helper method for get getting a [ExtensionContext.Store] specific to a test method 60 | */ 61 | private fun getStore(context: ExtensionContext): ExtensionContext.Store = 62 | context.getStore(ExtensionContext.Namespace.create(javaClass)) 63 | 64 | /** 65 | * Keys for storing and accessing [MethodScopeWiremockResolver]s 66 | */ 67 | enum class Keys { 68 | LOCAL_RESOLVER 69 | } 70 | 71 | /** 72 | * Decorates a parameter also annotated with [WiremockResolver.Wiremock] 73 | */ 74 | @Target(allowedTargets = [AnnotationTarget.VALUE_PARAMETER]) 75 | @Retention(AnnotationRetention.RUNTIME) 76 | annotation class MethodScopedWiremockServer 77 | } 78 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/DefaultNexusRepositoryContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import groovy.lang.Closure 20 | import org.gradle.api.Action 21 | import org.gradle.api.NamedDomainObjectContainer 22 | import org.gradle.api.internal.NamedDomainObjectContainerConfigureDelegate 23 | import org.gradle.util.GradleVersion 24 | import java.net.URI 25 | import javax.inject.Inject 26 | 27 | internal open class DefaultNexusRepositoryContainer @Inject constructor( 28 | delegate: NamedDomainObjectContainer 29 | ) : NexusRepositoryContainer, 30 | NamedDomainObjectContainer by delegate { 31 | 32 | override fun sonatype(): NexusRepository = 33 | // `sonatype { }`, but in Kotlin 1.3 "New Inference" is not implemented yet, so we have to be explicit. 34 | // https://kotlinlang.org/docs/whatsnew14.html#new-more-powerful-type-inference-algorithm 35 | sonatype(Action {}) 36 | 37 | override fun sonatype(action: Action): NexusRepository = 38 | create("sonatype") { 39 | it.nexusUrl.set(URI.create("https://oss.sonatype.org/service/local/")) 40 | it.snapshotRepositoryUrl.set(URI.create("https://oss.sonatype.org/content/repositories/snapshots/")) 41 | action.execute(it) 42 | } 43 | 44 | override fun configure(configureClosure: Closure<*>): NamedDomainObjectContainer = 45 | if (GradleVersion.current().baseVersion < GradleVersion.version("7.6")) { 46 | // Keep using the old API on old Gradle versions. 47 | // It was deprecated in Gradle 7.1, but only from Gradle 7.6 it emits a deprecation warning. 48 | // https://docs.gradle.org/current/userguide/upgrading_version_7.html#org_gradle_util_reports_deprecations 49 | // Note: this will fail to compile when this project starts building on Gradle 9.0, 50 | // at which point, this will need to be fully resolved, 51 | // OR this call for older support will need to be removed OR reflective. 52 | @Suppress("DEPRECATION") 53 | org.gradle.util.ConfigureUtil.configureSelf( 54 | configureClosure, 55 | this, 56 | NamedDomainObjectContainerConfigureDelegate(configureClosure, this) 57 | ) 58 | } else { 59 | // Keep using the new *internal* API on new Gradle versions. 60 | // At least until https://github.com/gradle/gradle/issues/23874 is resolved. 61 | // Introduced in Gradle 7.1, it's internal but stable up until the latest Gradle 8.0 at the time of writing. 62 | // The Gradle 7.1 version of this class is a verbatim copy of Gradle 8.0's version, but without the nagging. 63 | org.gradle.util.internal.ConfigureUtil.configureSelf( 64 | configureClosure, 65 | this, 66 | NamedDomainObjectContainerConfigureDelegate(configureClosure, this) 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/StagingRepositoryTransitioner.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import io.github.gradlenexus.publishplugin.RepositoryTransitionException 20 | import org.slf4j.Logger 21 | import org.slf4j.LoggerFactory 22 | 23 | class StagingRepositoryTransitioner(val nexusClient: NexusClient, val retrier: ActionRetrier) { 24 | 25 | companion object { 26 | private val log: Logger = LoggerFactory.getLogger(StagingRepositoryTransitioner::class.java.simpleName) 27 | } 28 | 29 | fun effectivelyClose(repoId: String, description: String) { 30 | effectivelyChangeState(repoId, StagingRepository.State.CLOSED) { nexusClient.closeStagingRepository(it, description) } 31 | } 32 | 33 | fun effectivelyRelease(repoId: String, description: String) { 34 | effectivelyChangeState(repoId, StagingRepository.State.RELEASED, StagingRepository.State.NOT_FOUND) { nexusClient.releaseStagingRepository(it, description) } 35 | } 36 | 37 | private fun effectivelyChangeState(repoId: String, vararg desiredStates: StagingRepository.State, transitionClientRequest: (String) -> Unit) { 38 | transitionClientRequest.invoke(repoId) 39 | val readStagingRepository = waitUntilTransitionIsDoneOrTimeoutAndReturnLastRepositoryState(repoId) 40 | assertRepositoryNotTransitioning(readStagingRepository) 41 | assertRepositoryInDesiredState(readStagingRepository, *desiredStates) 42 | } 43 | 44 | private fun waitUntilTransitionIsDoneOrTimeoutAndReturnLastRepositoryState(repoId: String) = 45 | retrier.execute { getStagingRepositoryStateById(repoId) } 46 | 47 | private fun getStagingRepositoryStateById(repoId: String): StagingRepository { 48 | val readStagingRepository: StagingRepository = nexusClient.getStagingRepositoryStateById(repoId) 49 | log.info("Current staging repository status: state: ${readStagingRepository.state}, transitioning: ${readStagingRepository.transitioning}") 50 | return readStagingRepository 51 | } 52 | 53 | private fun assertRepositoryNotTransitioning(repository: StagingRepository) { 54 | if (repository.transitioning) { 55 | throw RepositoryTransitionException("Staging repository is still transitioning after defined time. Consider its increment. $repository") 56 | } 57 | } 58 | 59 | private fun assertRepositoryInDesiredState(repository: StagingRepository, vararg desiredStates: StagingRepository.State) { 60 | if (repository.state !in desiredStates) { 61 | throw RepositoryTransitionException( 62 | "Staging repository is not in desired state ${desiredStates.contentDeepToString()}: $repository. It is unexpected. Please check " + 63 | "the Nexus logs using its web interface - it can be caused by validation rules violation (e.g. publishing artifacts with the " + 64 | "same version again). If not, please report it to https://github.com/gradle-nexus/publish-plugin/issues/ with the '--info' logs." 65 | ) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/compatTest/kotlin/io/github/gradlenexus/publishplugin/IvyNexusPublishPluginTests.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.junit.jupiter.api.Test 20 | 21 | class IvyNexusPublishPluginTests : BaseNexusPublishPluginTests() { 22 | init { 23 | publishPluginId = "ivy-publish" 24 | publishPluginContent = """ ivyCustom(IvyPublication) { 25 | from(components.java) 26 | }""" 27 | publishGoalPrefix = "publishIvyCustom" 28 | publicationTypeName = "IVY" 29 | snapshotArtifactList = listOf( 30 | "sample-0.0.1-.*.jar", 31 | "ivy-0.0.1-.*.xml" 32 | ) 33 | artifactList = listOf( 34 | "sample-0.0.1.jar", 35 | "ivy-0.0.1.xml" 36 | ) 37 | pluginArtifactList = listOf( 38 | "/org/example/gradle-plugin/0.0.1/gradle-plugin-0.0.1.jar", 39 | "/org/example/gradle-plugin/0.0.1/ivy-0.0.1.xml", 40 | "/org.example.foo/org.example.foo.gradle.plugin/0.0.1/ivy-0.0.1.xml" 41 | ) 42 | } 43 | 44 | @Test 45 | fun `publishes snapshots with ivy pattern`() { 46 | projectDir.resolve("settings.gradle").write( 47 | """ 48 | rootProject.name = 'sample' 49 | """ 50 | ) 51 | 52 | projectDir.resolve("build.gradle").write( 53 | """ 54 | plugins { 55 | id('java-library') 56 | id('$publishPluginId') 57 | id('io.github.gradle-nexus.publish-plugin') 58 | } 59 | group = 'org.example' 60 | version = '0.0.1-SNAPSHOT' 61 | publishing { 62 | publications { 63 | $publishPluginContent 64 | } 65 | } 66 | nexusPublishing { 67 | repositories { 68 | myNexus { 69 | nexusUrl = uri('${server.baseUrl()}/shouldNotBeUsed') 70 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots') 71 | allowInsecureProtocol = true 72 | username = 'username' 73 | password = 'password' 74 | 75 | ivyPatternLayout { 76 | artifact "[organisation]/[module]_foo/[revision]/[artifact]-[revision](-[classifier])(.[ext])" 77 | m2compatible = true 78 | } 79 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 80 | } 81 | } 82 | } 83 | """ 84 | ) 85 | 86 | expectArtifactUploads("/snapshots") 87 | 88 | val result = run("publishToMyNexus") 89 | 90 | assertSkipped(result, ":initializeMyNexusStagingRepository") 91 | assertUploaded("/snapshots/org/example/sample_foo/0.0.1-SNAPSHOT/sample-0.0.1-.*.jar") 92 | assertUploaded("/snapshots/org/example/sample_foo/0.0.1-SNAPSHOT/ivy-0.0.1-.*.xml") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/e2eTest/kotlin/io/github/gradlenexus/publishplugin/e2e/NexusPublishE2ETests.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.e2e 18 | 19 | import io.github.gradlenexus.publishplugin.BaseGradleTest 20 | import org.junit.jupiter.params.ParameterizedTest 21 | import org.junit.jupiter.params.provider.ValueSource 22 | import java.io.File 23 | import java.text.SimpleDateFormat 24 | import java.util.* 25 | 26 | @Suppress("FunctionName") 27 | class NexusPublishE2ETests : BaseGradleTest() { 28 | 29 | @ParameterizedTest(name = "{0}") 30 | @ValueSource(strings = ["nexus-publish-e2e-minimal", "nexus-publish-e2e-minimal-ivy", "nexus-publish-e2e-minimal-ivy-sbt", "nexus-publish-e2e-multi-project"]) 31 | fun `release project to real Sonatype Nexus`(projectName: String) { 32 | File("src/e2eTest/resources/$projectName").copyRecursively(projectDir) 33 | 34 | // when 35 | val buildResult = run("build") 36 | // then 37 | buildResult.assertSuccess { it.path.substringAfterLast(':').matches("build".toRegex()) } 38 | 39 | // when 40 | val result = run( 41 | "publishToSonatype", 42 | "closeAndReleaseSonatypeStagingRepository", 43 | "--info" 44 | ) 45 | // then 46 | result.apply { 47 | assertSuccess { it.path.substringAfterLast(':').matches("publish.+PublicationToSonatypeRepository".toRegex()) } 48 | assertSuccess(":closeSonatypeStagingRepository") 49 | assertSuccess(":releaseSonatypeStagingRepository") 50 | } 51 | } 52 | 53 | @ParameterizedTest(name = "{0}") 54 | @ValueSource( 55 | strings = [ 56 | "nexus-publish-e2e-minimal", 57 | "nexus-publish-e2e-minimal-ivy" 58 | // Disabled due to: https://github.com/gradle-nexus/publish-plugin/issues/200. 59 | // "nexus-publish-e2e-multi-project" 60 | ] 61 | ) 62 | fun `release project to real Sonatype Nexus in two executions`(projectName: String) { 63 | File("src/e2eTest/resources/$projectName").copyRecursively(projectDir) 64 | // Even though published e2e package is effectively dropped, Sonatype Nexus rules requires unique versions - https://issues.sonatype.org/browse/OSSRH-86532 65 | // On the other hand, findSonatypeStagingRepository mechanism assumes constant project version across calls to find proper repository 66 | val uniqueVersion = "0.0.2-unique-${SimpleDateFormat("yyyyMMdd-HHmmss").format(Date())}" 67 | 68 | // when 69 | val buildResult = run( 70 | "build", 71 | "-PoverriddenVersion=$uniqueVersion" 72 | ) 73 | // then 74 | buildResult.assertSuccess { it.path.substringAfterLast(':').matches("build".toRegex()) } 75 | 76 | // when 77 | // Publish artifacts to staging repository, close it == prepare artifacts for the review 78 | val result = run( 79 | "publishToSonatype", 80 | "closeSonatypeStagingRepository", 81 | "--info", 82 | "-PoverriddenVersion=$uniqueVersion" 83 | ) 84 | 85 | result.apply { 86 | assertSuccess { it.path.substringAfterLast(':').matches("publish.+PublicationToSonatypeRepository".toRegex()) } 87 | assertSuccess(":closeSonatypeStagingRepository") 88 | } 89 | 90 | // Release artifacts after the review 91 | val closeResult = run( 92 | "findSonatypeStagingRepository", 93 | "releaseSonatypeStagingRepository", 94 | "--info", 95 | "-PoverriddenVersion=$uniqueVersion" 96 | ) 97 | 98 | // then 99 | closeResult.apply { 100 | assertSuccess(":findSonatypeStagingRepository") 101 | assertSuccess(":releaseSonatypeStagingRepository") 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/gradlenexus/publishplugin/internal/StagingRepositoryTransitionerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import com.nhaarman.mockitokotlin2.anyOrNull 20 | import io.github.gradlenexus.publishplugin.KotlinParameterizeTest 21 | import io.github.gradlenexus.publishplugin.RepositoryTransitionException 22 | import org.assertj.core.api.Assertions.assertThatExceptionOfType 23 | import org.junit.jupiter.api.BeforeEach 24 | import org.junit.jupiter.api.Test 25 | import org.junit.jupiter.api.extension.ExtendWith 26 | import org.junit.jupiter.params.ParameterizedTest 27 | import org.junit.jupiter.params.provider.MethodSource 28 | import org.mockito.BDDMockito.given 29 | import org.mockito.Mock 30 | import org.mockito.Mockito.inOrder 31 | import org.mockito.invocation.InvocationOnMock 32 | import org.mockito.junit.jupiter.MockitoExtension 33 | 34 | @KotlinParameterizeTest 35 | @ExtendWith(MockitoExtension::class) 36 | internal class StagingRepositoryTransitionerTest { 37 | 38 | companion object { 39 | private const val TEST_STAGING_REPO_ID = "orgexample-42" 40 | private const val DESCRIPTION = "some description" 41 | } 42 | 43 | @Mock 44 | private lateinit var nexusClient: NexusClient 45 | 46 | @Mock 47 | private lateinit var retrier: ActionRetrier 48 | 49 | private lateinit var transitioner: StagingRepositoryTransitioner 50 | 51 | @BeforeEach 52 | internal fun setUp() { 53 | transitioner = StagingRepositoryTransitioner(nexusClient, retrier) 54 | } 55 | 56 | @Test 57 | internal fun `request repository close and get its state after execution by retrier`() { 58 | given(nexusClient.getStagingRepositoryStateById(TEST_STAGING_REPO_ID)) 59 | .willReturn(StagingRepository(TEST_STAGING_REPO_ID, StagingRepository.State.CLOSED, false)) 60 | given(retrier.execute(anyOrNull())).willAnswer(executeFunctionPassedAsFirstArgument()) 61 | 62 | transitioner.effectivelyClose(TEST_STAGING_REPO_ID, DESCRIPTION) 63 | 64 | val inOrder = inOrder(nexusClient, retrier) 65 | inOrder.verify(nexusClient).closeStagingRepository(TEST_STAGING_REPO_ID, DESCRIPTION) 66 | inOrder.verify(nexusClient).getStagingRepositoryStateById(TEST_STAGING_REPO_ID) 67 | } 68 | 69 | @ParameterizedTest 70 | @MethodSource("repositoryStatesForRelease") 71 | internal fun `request release repository and get its state after execution by retrier`(state: StagingRepository.State) { 72 | given(nexusClient.getStagingRepositoryStateById(TEST_STAGING_REPO_ID)) 73 | .willReturn(StagingRepository(TEST_STAGING_REPO_ID, state, false)) 74 | given(retrier.execute(anyOrNull())).willAnswer(executeFunctionPassedAsFirstArgument()) 75 | 76 | transitioner.effectivelyRelease(TEST_STAGING_REPO_ID, DESCRIPTION) 77 | 78 | val inOrder = inOrder(nexusClient, retrier) 79 | inOrder.verify(nexusClient).releaseStagingRepository(TEST_STAGING_REPO_ID, DESCRIPTION) 80 | inOrder.verify(nexusClient).getStagingRepositoryStateById(TEST_STAGING_REPO_ID) 81 | } 82 | 83 | private fun repositoryStatesForRelease(): List = 84 | listOf(StagingRepository.State.RELEASED, StagingRepository.State.NOT_FOUND) 85 | 86 | @Test 87 | internal fun `throw meaningful exception on repository still in transition on released`() { 88 | given(nexusClient.getStagingRepositoryStateById(TEST_STAGING_REPO_ID)) 89 | .willReturn(StagingRepository(TEST_STAGING_REPO_ID, StagingRepository.State.RELEASED, true)) 90 | given(retrier.execute(anyOrNull())).willAnswer(executeFunctionPassedAsFirstArgument()) 91 | 92 | assertThatExceptionOfType(RepositoryTransitionException::class.java) 93 | .isThrownBy { transitioner.effectivelyClose(TEST_STAGING_REPO_ID, DESCRIPTION) } 94 | .withMessageContainingAll(TEST_STAGING_REPO_ID, "transitioning=true") 95 | } 96 | 97 | @Test 98 | internal fun `throw meaningful exception on repository still in wrong state on release`() { 99 | given(nexusClient.getStagingRepositoryStateById(TEST_STAGING_REPO_ID)) 100 | .willReturn(StagingRepository(TEST_STAGING_REPO_ID, StagingRepository.State.OPEN, false)) 101 | given(retrier.execute(anyOrNull())).willAnswer(executeFunctionPassedAsFirstArgument()) 102 | 103 | assertThatExceptionOfType(RepositoryTransitionException::class.java) 104 | .isThrownBy { transitioner.effectivelyRelease(TEST_STAGING_REPO_ID, DESCRIPTION) } 105 | .withMessageContainingAll(TEST_STAGING_REPO_ID, StagingRepository.State.OPEN.toString(), StagingRepository.State.RELEASED.toString()) 106 | } 107 | 108 | private fun executeFunctionPassedAsFirstArgument(): (InvocationOnMock) -> StagingRepository = 109 | { invocation: InvocationOnMock -> 110 | val passedFunction: () -> StagingRepository = invocation.getArgument(0) 111 | passedFunction.invoke() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/gradlenexus/publishplugin/TaskOrchestrationTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import org.assertj.core.api.Assertions.assertThat 20 | import org.gradle.api.Project 21 | import org.gradle.api.Task 22 | import org.gradle.api.publish.PublishingExtension 23 | import org.gradle.api.publish.maven.MavenPublication 24 | import org.gradle.testfixtures.ProjectBuilder 25 | import org.junit.jupiter.api.BeforeEach 26 | import org.junit.jupiter.api.Disabled 27 | import org.junit.jupiter.api.Test 28 | import org.junit.jupiter.api.io.TempDir 29 | import org.junit.jupiter.params.ParameterizedTest 30 | import org.junit.jupiter.params.provider.CsvSource 31 | import org.junit.jupiter.params.provider.MethodSource 32 | import org.junit.jupiter.params.provider.ValueSource 33 | import java.nio.file.Path 34 | 35 | @KotlinParameterizeTest 36 | class TaskOrchestrationTest { 37 | 38 | @TempDir 39 | lateinit var projectDir: Path 40 | 41 | private lateinit var project: Project 42 | 43 | @BeforeEach 44 | internal fun setUp() { 45 | project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() 46 | } 47 | 48 | @ParameterizedTest(name = "task: {0}") 49 | @MethodSource("transitioningTaskNamesForSonatype") 50 | internal fun `transitioning task should run after init`(transitioningTaskName: String) { 51 | initSingleProjectWithDefaultConfiguration() 52 | assertGivenTaskMustRunAfterAnother(transitioningTaskName, "initializeSonatypeStagingRepository") 53 | } 54 | 55 | @ParameterizedTest(name = "task: {0}") 56 | @MethodSource("transitioningTaskNamesForSonatype") 57 | internal fun `transitioning task should run after related publish`(transitioningTaskName: String) { 58 | initSingleProjectWithDefaultConfiguration() 59 | assertGivenTaskMustRunAfterAnother(transitioningTaskName, "publishToSonatype") 60 | } 61 | 62 | @ParameterizedTest(name = "task: {0}") 63 | @MethodSource("transitioningTaskNamesForSonatype") 64 | internal fun `transitioning task should not run after non-related publish`(transitioningTaskName: String) { 65 | // given 66 | initSingleProjectWithDefaultConfiguration() 67 | project.extensions.configure(NexusPublishExtension::class.java) { 68 | it.repositories.create("myNexus") 69 | } 70 | // expect 71 | assertGivenTaskMustNotRunAfterAnother(transitioningTaskName, "publishToMyNexus") 72 | } 73 | 74 | @Test 75 | @Disabled("TODO") 76 | internal fun `close task should run after all related publish tasks in multi-project build`() {} 77 | 78 | @Test 79 | internal fun `release task should run after close`() { 80 | initSingleProjectWithDefaultConfiguration() 81 | assertGivenTaskMustRunAfterAnother("releaseSonatypeStagingRepository", "closeSonatypeStagingRepository") 82 | } 83 | 84 | @Test 85 | internal fun `closeAndRelease for given repository should depend on close and release tasks`() { 86 | initSingleProjectWithDefaultConfiguration() 87 | 88 | val closeAndReleaseTask = getJustOneTaskByNameOrFail("closeAndReleaseSonatypeStagingRepository") 89 | 90 | assertThat(closeAndReleaseTask.taskDependencies.getDependencies(null).map { it.name }) 91 | .contains("closeSonatypeStagingRepository", "releaseSonatypeStagingRepository") 92 | } 93 | 94 | @ParameterizedTest(name = "{0}") 95 | @ValueSource( 96 | strings = [ 97 | "closeAndReleaseStagingRepositories", 98 | "closeStagingRepositories", 99 | "releaseStagingRepositories" 100 | ] 101 | ) 102 | internal fun `simplified task without repository name should be available but trigger nothing if no repositories are configured`(simplifiedTaskName: String) { 103 | initSingleProjectWithDefaultConfiguration() 104 | project.extensions.configure(NexusPublishExtension::class.java) { 105 | it.repositories.clear() 106 | } 107 | 108 | val simplifiedTasks = project.getTasksByName(simplifiedTaskName, true) 109 | 110 | assertThat(simplifiedTasks).hasSize(1) 111 | assertThat(simplifiedTasks.first().dependsOn).isEmpty() 112 | } 113 | 114 | @ParameterizedTest(name = "{0}") 115 | @CsvSource( 116 | textBlock = """ 117 | closeAndReleaseStagingRepositories, closeAndReleaseSonatypeStagingRepository, closeAndReleaseOtherNexusStagingRepository 118 | closeStagingRepositories , closeSonatypeStagingRepository , closeOtherNexusStagingRepository 119 | releaseStagingRepositories , releaseSonatypeStagingRepository , releaseOtherNexusStagingRepository""" 120 | ) 121 | internal fun `simplified task without repository name should depend on all normal tasks (created one per defined repository)`(simplifiedTaskName: String, sonatypeTaskName: String, otherNexusTaskName: String) { 122 | initSingleProjectWithDefaultConfiguration() 123 | project.extensions.configure(NexusPublishExtension::class.java) { 124 | it.repositories.create("otherNexus") 125 | } 126 | 127 | val simplifiedTasks = getJustOneTaskByNameOrFail(simplifiedTaskName) 128 | 129 | assertThat(simplifiedTasks.taskDependencies.getDependencies(null).map { it.name }) 130 | .hasSize(2) 131 | .contains(sonatypeTaskName) 132 | .contains(otherNexusTaskName) 133 | } 134 | 135 | @ParameterizedTest(name = "{0}") 136 | @ValueSource( 137 | strings = [ 138 | "closeAndReleaseStagingRepositories", 139 | "closeStagingRepositories", 140 | "releaseStagingRepositories" 141 | ] 142 | ) 143 | internal fun `description of simplified task contains names of all defined Nexus instances`(simplifiedTaskName: String) { 144 | initSingleProjectWithDefaultConfiguration() 145 | project.extensions.configure(NexusPublishExtension::class.java) { 146 | it.repositories.create("otherNexus") 147 | } 148 | 149 | val simplifiedTasks = getJustOneTaskByNameOrFail(simplifiedTaskName) 150 | 151 | assertThat(simplifiedTasks.description) 152 | .contains("sonatype") 153 | .contains("otherNexus") 154 | } 155 | 156 | private fun initSingleProjectWithDefaultConfiguration() { 157 | project.pluginManager.apply("java") 158 | project.pluginManager.apply("maven-publish") 159 | project.pluginManager.apply(NexusPublishPlugin::class.java) 160 | project.extensions.configure(NexusPublishExtension::class.java) { 161 | it.repositories.sonatype() 162 | } 163 | project.extensions.configure(PublishingExtension::class.java) { 164 | it.publications.create("mavenJava", MavenPublication::class.java) { publication -> 165 | publication.from(project.components.getByName("java")) 166 | } 167 | } 168 | } 169 | 170 | private fun assertGivenTaskMustRunAfterAnother(taskName: String, expectedPredecessorName: String) { 171 | val task = getJustOneTaskByNameOrFail(taskName) 172 | val expectedPredecessor = getJustOneTaskByNameOrFail(expectedPredecessorName) 173 | assertThat(task.mustRunAfter.getDependencies(task)).contains(expectedPredecessor) 174 | } 175 | 176 | private fun assertGivenTaskMustNotRunAfterAnother(taskName: String, notExpectedPredecessorName: String) { 177 | val task = getJustOneTaskByNameOrFail(taskName) 178 | val notExpectedPredecessor = getJustOneTaskByNameOrFail(notExpectedPredecessorName) 179 | assertThat(task.mustRunAfter.getDependencies(task)).doesNotContain(notExpectedPredecessor) 180 | } 181 | 182 | private fun getJustOneTaskByNameOrFail(taskName: String): Task { 183 | val tasks = project.getTasksByName(taskName, true) // forces project evaluation 184 | assertThat(tasks.size).describedAs("Expected just one task: $taskName. Found: ${project.tasks.names}").isOne() 185 | return tasks.first() 186 | } 187 | 188 | private fun transitioningTaskNamesForSonatype(): List = 189 | listOf("closeSonatypeStagingRepository", "releaseSonatypeStagingRepository") 190 | } 191 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/internal/NexusClient.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin.internal 18 | 19 | import okhttp3.Credentials 20 | import okhttp3.OkHttpClient 21 | import retrofit2.Call 22 | import retrofit2.Response 23 | import retrofit2.Retrofit 24 | import retrofit2.converter.gson.GsonConverterFactory 25 | import retrofit2.http.Body 26 | import retrofit2.http.GET 27 | import retrofit2.http.Headers 28 | import retrofit2.http.POST 29 | import retrofit2.http.Path 30 | import java.io.IOException 31 | import java.net.URI 32 | import java.time.Duration 33 | 34 | open class NexusClient(private val baseUrl: URI, username: String?, password: String?, timeout: Duration?, connectTimeout: Duration?) { 35 | private val api: NexusApi 36 | 37 | init { 38 | val httpClientBuilder = OkHttpClient.Builder() 39 | if (timeout != null) { 40 | httpClientBuilder 41 | .readTimeout(timeout) 42 | .writeTimeout(timeout) 43 | } 44 | if (connectTimeout != null) { 45 | httpClientBuilder.connectTimeout(connectTimeout) 46 | } 47 | if (username != null || password != null) { 48 | val credentials = Credentials.basic(username ?: "", password ?: "") 49 | httpClientBuilder 50 | .addInterceptor { chain -> 51 | chain.proceed( 52 | chain.request().newBuilder() 53 | .header("Authorization", credentials) 54 | .build() 55 | ) 56 | } 57 | } 58 | httpClientBuilder.addInterceptor { chain -> 59 | val version = javaClass.`package`.implementationVersion ?: "dev" 60 | chain.proceed( 61 | chain.request().newBuilder() 62 | .header("User-Agent", "gradle-nexus-publish-plugin/$version") 63 | .build() 64 | ) 65 | } 66 | val retrofit = Retrofit.Builder() 67 | .baseUrl(baseUrl.toString()) 68 | .client(httpClientBuilder.build()) 69 | .addConverterFactory(GsonConverterFactory.create()) 70 | .build() 71 | api = retrofit.create(NexusApi::class.java) 72 | } 73 | 74 | fun findStagingProfileId(packageGroup: String): String? { 75 | val response = api.stagingProfiles.execute() 76 | if (!response.isSuccessful) { 77 | throw failure("load staging profiles", response) 78 | } 79 | return response.body() 80 | ?.data 81 | ?.filter { profile -> 82 | // profile.name either matches exactly 83 | // or it is a prefix of a packageGroup 84 | packageGroup.startsWith(profile.name) && 85 | ( 86 | packageGroup.length == profile.name.length || 87 | packageGroup[profile.name.length] == '.' 88 | ) 89 | } 90 | ?.maxBy { it.name.length } 91 | ?.id 92 | } 93 | 94 | fun findStagingRepository(stagingProfileId: String, descriptionRegex: Regex): StagingRepositoryDescriptor { 95 | val response = api.getStagingRepositories(stagingProfileId).execute() 96 | if (!response.isSuccessful) { 97 | throw failure("find staging repository, stagingProfileId: $stagingProfileId", response) 98 | } 99 | val data = response.body()?.data 100 | if (data.isNullOrEmpty()) { 101 | throw NoSuchElementException("No staging repositories found for stagingProfileId: $stagingProfileId") 102 | } 103 | val matchingRepositories = data.filter { it.description?.contains(descriptionRegex) == true } 104 | if (matchingRepositories.isEmpty()) { 105 | throw NoSuchElementException( 106 | "No staging repositories found for stagingProfileId: $stagingProfileId, descriptionRegex: $descriptionRegex. " + 107 | "Here are all the repositories: $data" 108 | ) 109 | } 110 | if (matchingRepositories.size > 1) { 111 | throw IllegalStateException( 112 | "Too many repositories found for stagingProfileId: $stagingProfileId, descriptionRegex: $descriptionRegex. " + 113 | "If some of the repositories are not needed, consider deleting them manually. " + 114 | "Here are the repositories matching the regular expression: $matchingRepositories" 115 | ) 116 | } 117 | return StagingRepositoryDescriptor(baseUrl, matchingRepositories.first().repositoryId) 118 | } 119 | 120 | fun createStagingRepository(stagingProfileId: String, description: String): StagingRepositoryDescriptor { 121 | val response = api.startStagingRepo(stagingProfileId, Dto(Description(description))).execute() 122 | if (!response.isSuccessful) { 123 | throw failure("create staging repository", response) 124 | } 125 | val stagingRepositoryId = response.body()?.data?.stagedRepositoryId ?: throw RuntimeException("No response body") 126 | return StagingRepositoryDescriptor(baseUrl, stagingRepositoryId) 127 | } 128 | 129 | open fun closeStagingRepository(stagingRepositoryId: String, description: String) { 130 | val response = api.closeStagingRepo(Dto(StagingRepositoryToTransit(listOf(stagingRepositoryId), description))).execute() 131 | if (!response.isSuccessful) { 132 | throw failure("close staging repository", response) 133 | } 134 | } 135 | 136 | open fun releaseStagingRepository(stagingRepositoryId: String, description: String) { 137 | val response = api.releaseStagingRepo(Dto(StagingRepositoryToTransit(listOf(stagingRepositoryId), description))).execute() 138 | if (!response.isSuccessful) { 139 | throw failure("release staging repository", response) 140 | } 141 | } 142 | 143 | open fun getStagingRepositoryStateById(stagingRepositoryId: String): StagingRepository { 144 | val response = api.getStagingRepoById(stagingRepositoryId).execute() 145 | if (response.code() == 404 && response.errorBody()?.string()?.contains(stagingRepositoryId) == true) { 146 | return StagingRepository.notFound(stagingRepositoryId) 147 | } 148 | if (!response.isSuccessful) { 149 | throw failure("get staging repository by id", response) 150 | } 151 | val readStagingRepo: ReadStagingRepository? = response.body() 152 | if (readStagingRepo != null) { 153 | require(stagingRepositoryId == readStagingRepo.repositoryId) { 154 | "Unexpected read repository id ($stagingRepositoryId != ${readStagingRepo.repositoryId})" 155 | } 156 | return StagingRepository( 157 | readStagingRepo.repositoryId, 158 | StagingRepository.State.parseString(readStagingRepo.type), 159 | readStagingRepo.transitioning 160 | ) 161 | } else { 162 | return StagingRepository.notFound(stagingRepositoryId) // Should not happen 163 | } 164 | } 165 | 166 | // TODO: Cover all API calls with unified error handling (including unexpected IOExceptions) 167 | private fun failure(action: String, response: Response<*>): RuntimeException { 168 | var message = "Failed to $action, server at $baseUrl responded with status code ${response.code()}" 169 | val errorBody = response.errorBody() 170 | if (errorBody != null) { 171 | message += try { 172 | ", body: ${errorBody.string()}" 173 | } catch (exception: IOException) { 174 | ", body: " 175 | } 176 | } 177 | return RuntimeException(message) 178 | } 179 | 180 | private interface NexusApi { 181 | 182 | companion object { 183 | private const val RELEASE_OPERATION_NAME_IN_NEXUS = "promote" // promote and release use the same operation, provided body parameters matter 184 | } 185 | 186 | @get:Headers("Accept: application/json") 187 | @get:GET("staging/profiles") 188 | val stagingProfiles: Call>> 189 | 190 | @Headers("Content-Type: application/json") 191 | @POST("staging/profiles/{stagingProfileId}/start") 192 | fun startStagingRepo(@Path("stagingProfileId") stagingProfileId: String, @Body description: Dto): Call> 193 | 194 | @Headers("Content-Type: application/json") 195 | @POST("staging/bulk/close") 196 | fun closeStagingRepo(@Body stagingRepoToClose: Dto): Call 197 | 198 | @Headers("Content-Type: application/json") 199 | @POST("staging/bulk/$RELEASE_OPERATION_NAME_IN_NEXUS") 200 | fun releaseStagingRepo(@Body stagingRepoToClose: Dto): Call 201 | 202 | @Headers("Accept: application/json") 203 | @GET("staging/repository/{stagingRepoId}") 204 | fun getStagingRepoById(@Path("stagingRepoId") stagingRepoId: String): Call 205 | 206 | @Headers("Accept: application/json") 207 | @GET("staging/profile_repositories/{stagingProfileId}") 208 | fun getStagingRepositories(@Path("stagingProfileId") stagingProfileId: String): Call>> 209 | } 210 | 211 | data class Dto(var data: T) 212 | 213 | data class StagingProfile(var id: String, var name: String) 214 | 215 | data class Description(val description: String) 216 | 217 | data class CreatedStagingRepository(var stagedRepositoryId: String) 218 | 219 | data class ReadStagingRepository(var repositoryId: String, var type: String, var transitioning: Boolean, var description: String?) 220 | 221 | data class StagingRepositoryToTransit(val stagedRepositoryIds: List, val description: String, val autoDropAfterRelease: Boolean = true) 222 | } 223 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/gradlenexus/publishplugin/NexusPublishPlugin.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import io.github.gradlenexus.publishplugin.NexusRepository.PublicationType 20 | import io.github.gradlenexus.publishplugin.internal.StagingRepositoryDescriptorRegistryBuildService 21 | import org.gradle.api.Plugin 22 | import org.gradle.api.Project 23 | import org.gradle.api.Task 24 | import org.gradle.api.artifacts.repositories.ArtifactRepository 25 | import org.gradle.api.artifacts.repositories.AuthenticationSupported 26 | import org.gradle.api.artifacts.repositories.UrlArtifactRepository 27 | import org.gradle.api.provider.Provider 28 | import org.gradle.api.publish.PublishingExtension 29 | import org.gradle.api.publish.plugins.PublishingPlugin 30 | import org.gradle.api.reflect.TypeOf.typeOf 31 | import org.gradle.api.tasks.TaskContainer 32 | import org.gradle.api.tasks.TaskProvider 33 | import org.gradle.util.GradleVersion 34 | import java.net.URI 35 | import java.time.Duration 36 | 37 | class NexusPublishPlugin : Plugin { 38 | 39 | companion object { 40 | private const val SIMPLIFIED_CLOSE_AND_RELEASE_TASK_NAME = "closeAndReleaseStagingRepositories" 41 | private const val SIMPLIFIED_CLOSE_TASK_NAME = "closeStagingRepositories" 42 | private const val SIMPLIFIED_RELEASE_TASK_NAME = "releaseStagingRepositories" 43 | } 44 | 45 | override fun apply(project: Project) { 46 | require(project == project.rootProject) { 47 | "Plugin must be applied to the root project but was applied to ${project.path}" 48 | } 49 | 50 | require(GradleVersion.current() >= GradleVersion.version("6.2")) { 51 | "io.github.gradle-nexus.publish-plugin requires Gradle version 6.2+" 52 | } 53 | 54 | val registry = createRegistry(project) 55 | val extension = project.extensions.create(NexusPublishExtension.NAME, NexusPublishExtension::class.java) 56 | configureExtension(project, extension) 57 | configureNexusTasks(project, extension, registry) 58 | configurePublishingForAllProjects(project, extension, registry) 59 | } 60 | 61 | private fun configureExtension(project: Project, extension: NexusPublishExtension) { 62 | with(extension) { 63 | useStaging.convention(project.provider { !project.version.toString().endsWith("-SNAPSHOT") }) 64 | packageGroup.convention(project.provider { project.group.toString() }) 65 | repositoryDescription.convention(project.provider { project.run { "$group:$name:$version" } }) 66 | // Staging repository initialization can take a few minutes on Sonatype Nexus. 67 | clientTimeout.convention(Duration.ofMinutes(5)) 68 | connectTimeout.convention(Duration.ofMinutes(5)) 69 | transitionCheckOptions.maxRetries.convention(60) 70 | transitionCheckOptions.delayBetween.convention(Duration.ofSeconds(10)) 71 | } 72 | } 73 | 74 | private fun createRegistry(rootProject: Project): Provider = 75 | rootProject.gradle.sharedServices.registerIfAbsent( 76 | "stagingRepositoryUrlRegistry", 77 | StagingRepositoryDescriptorRegistryBuildService::class.java 78 | ) { } 79 | 80 | private fun configureNexusTasks( 81 | rootProject: Project, 82 | extension: NexusPublishExtension, 83 | registryService: Provider 84 | ) { 85 | rootProject.tasks.withType(AbstractNexusStagingRepositoryTask::class.java).configureEach { 86 | it.clientTimeout.convention(extension.clientTimeout) 87 | it.connectTimeout.convention(extension.connectTimeout) 88 | it.repositoryDescription.convention(extension.repositoryDescription) 89 | it.useStaging.convention(extension.useStaging) 90 | // repository.convention() is set in configureRepositoryTasks(). 91 | } 92 | rootProject.tasks.withType(AbstractTransitionNexusStagingRepositoryTask::class.java).configureEach { 93 | it.transitionCheckOptions.convention(extension.transitionCheckOptions) 94 | it.usesService(registryService) 95 | it.stagingRepositoryId.convention(registryService.map { service -> service.registry[it.repository.get().name].stagingRepositoryId }) 96 | } 97 | extension.repositories.all { 98 | it.username.convention(rootProject.provider { rootProject.findProperty("${it.name}Username") as? String }) 99 | it.password.convention(rootProject.provider { rootProject.findProperty("${it.name}Password") as? String }) 100 | it.publicationType.convention(PublicationType.MAVEN) 101 | configureRepositoryTasks(rootProject.tasks, extension, it, registryService) 102 | } 103 | extension.repositories.whenObjectRemoved { repository -> 104 | rootProject.tasks.named("initialize${repository.capitalizedName}StagingRepository").configure { 105 | it.enabled = false 106 | } 107 | rootProject.tasks.named("find${repository.capitalizedName}StagingRepository").configure { 108 | it.enabled = false 109 | } 110 | rootProject.tasks.named("close${repository.capitalizedName}StagingRepository").configure { 111 | it.enabled = false 112 | } 113 | rootProject.tasks.named("release${repository.capitalizedName}StagingRepository").configure { 114 | it.enabled = false 115 | } 116 | rootProject.tasks.named("closeAndRelease${repository.capitalizedName}StagingRepository").configure { 117 | it.enabled = false 118 | } 119 | } 120 | rootProject.tasks.register(SIMPLIFIED_CLOSE_AND_RELEASE_TASK_NAME) { 121 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 122 | it.description = "Closes and releases open staging repositories in all configured Nexus instances." 123 | } 124 | rootProject.tasks.register(SIMPLIFIED_CLOSE_TASK_NAME) { 125 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 126 | it.description = "Closes open staging repositories in all configured Nexus instances." 127 | } 128 | rootProject.tasks.register(SIMPLIFIED_RELEASE_TASK_NAME) { 129 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 130 | it.description = "Releases open staging repositories in all configured Nexus instances." 131 | } 132 | } 133 | 134 | private fun configureRepositoryTasks( 135 | tasks: TaskContainer, 136 | extension: NexusPublishExtension, 137 | repo: NexusRepository, 138 | registryService: Provider 139 | ) { 140 | @Suppress("UNUSED_VARIABLE") // Keep it consistent. 141 | val retrieveStagingProfileTask = tasks.register( 142 | "retrieve${repo.capitalizedName}StagingProfile", 143 | RetrieveStagingProfile::class.java 144 | ) { 145 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 146 | it.description = "Gets and displays a staging profile id for a given repository and package group. " + 147 | "This is a diagnostic task to get the value and " + 148 | "put it into the NexusRepository configuration closure as stagingProfileId." 149 | it.repository.convention(repo) 150 | it.packageGroup.convention(extension.packageGroup) 151 | } 152 | val initializeTask = tasks.register( 153 | "initialize${repo.capitalizedName}StagingRepository", 154 | InitializeNexusStagingRepository::class.java 155 | ) { 156 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 157 | it.description = "Initializes the staging repository in '${repo.name}' Nexus instance." 158 | it.registry.set(registryService) 159 | it.usesService(registryService) 160 | it.repository.convention(repo) 161 | it.packageGroup.convention(extension.packageGroup) 162 | } 163 | val findStagingRepository = tasks.register( 164 | "find${repo.capitalizedName}StagingRepository", 165 | FindStagingRepository::class.java 166 | ) { 167 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 168 | it.description = "Finds the staging repository for ${repo.name}" 169 | it.registry.set(registryService) 170 | it.usesService(registryService) 171 | it.repository.convention(repo) 172 | it.packageGroup.convention(extension.packageGroup) 173 | it.descriptionRegex.convention(extension.repositoryDescription.map { repoDescription -> "\\b" + Regex.escape(repoDescription) + "(\\s|$)" }) 174 | } 175 | val closeTask = tasks.register( 176 | "close${repo.capitalizedName}StagingRepository", 177 | CloseNexusStagingRepository::class.java 178 | ) { 179 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 180 | it.description = "Closes open staging repository in '${repo.name}' Nexus instance." 181 | it.repository.convention(repo) 182 | } 183 | val releaseTask = tasks.register( 184 | "release${repo.capitalizedName}StagingRepository", 185 | ReleaseNexusStagingRepository::class.java 186 | ) { 187 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 188 | it.description = "Releases closed staging repository in '${repo.name}' Nexus instance." 189 | it.repository.convention(repo) 190 | } 191 | val closeAndReleaseTask = tasks.register( 192 | "closeAndRelease${repo.capitalizedName}StagingRepository" 193 | ) { 194 | it.group = PublishingPlugin.PUBLISH_TASK_GROUP 195 | it.description = "Closes and releases open staging repository in '${repo.name}' Nexus instance." 196 | } 197 | 198 | closeTask.configure { 199 | it.mustRunAfter(initializeTask) 200 | it.mustRunAfter(findStagingRepository) 201 | } 202 | releaseTask.configure { 203 | it.mustRunAfter(initializeTask) 204 | it.mustRunAfter(findStagingRepository) 205 | it.mustRunAfter(closeTask) 206 | } 207 | closeAndReleaseTask.configure { 208 | it.dependsOn(closeTask, releaseTask) 209 | } 210 | } 211 | 212 | private fun configurePublishingForAllProjects( 213 | rootProject: Project, 214 | extension: NexusPublishExtension, 215 | registry: Provider 216 | ) { 217 | rootProject.afterEvaluate { 218 | it.allprojects { publishingProject -> 219 | publishingProject.plugins.withType(PublishingPlugin::class.java) { 220 | val nexusRepositories = addPublicationRepositories(publishingProject, extension, registry) 221 | nexusRepositories.forEach { (nexusRepo, publicationRepo) -> 222 | val publicationType = nexusRepo.publicationType.get() 223 | val id = when (publicationType) { 224 | PublicationType.IVY -> "ivy-publish" 225 | PublicationType.MAVEN -> "maven-publish" 226 | null -> error("Repo publication type must be \"ivy-publish\" or \"maven-publish\"") 227 | } 228 | publishingProject.plugins.withId(id) { 229 | val initializeTask = rootProject.tasks.named("initialize${nexusRepo.capitalizedName}StagingRepository", InitializeNexusStagingRepository::class.java) 230 | val findStagingRepositoryTask = rootProject.tasks.named("find${nexusRepo.capitalizedName}StagingRepository", FindStagingRepository::class.java) 231 | val closeTask = rootProject.tasks.named("close${nexusRepo.capitalizedName}StagingRepository", CloseNexusStagingRepository::class.java) 232 | val releaseTask = rootProject.tasks.named("release${nexusRepo.capitalizedName}StagingRepository", ReleaseNexusStagingRepository::class.java) 233 | val publishAllTask = publishingProject.tasks.register("publishTo${nexusRepo.capitalizedName}") { task -> 234 | task.group = PublishingPlugin.PUBLISH_TASK_GROUP 235 | task.description = "Publishes all Maven/Ivy publications produced by this project to the '${nexusRepo.name}' Nexus repository." 236 | } 237 | closeTask.configure { task -> 238 | task.mustRunAfter(publishAllTask) 239 | } 240 | releaseTask.configure { task -> 241 | task.mustRunAfter(publishAllTask) 242 | } 243 | configureTaskDependencies(publishingProject, initializeTask, findStagingRepositoryTask, publishAllTask, closeTask, releaseTask, publicationRepo, publicationType) 244 | } 245 | } 246 | } 247 | } 248 | configureSimplifiedCloseAndReleaseTasks(rootProject, extension) 249 | } 250 | } 251 | 252 | private fun addPublicationRepositories( 253 | project: Project, 254 | extension: NexusPublishExtension, 255 | registry: Provider 256 | ): Map = 257 | extension.repositories.associateWith { nexusRepo -> 258 | createArtifactRepository(nexusRepo.publicationType.get(), project, nexusRepo, extension, registry) 259 | } 260 | 261 | private fun createArtifactRepository( 262 | publicationType: PublicationType, 263 | project: Project, 264 | nexusRepo: NexusRepository, 265 | extension: NexusPublishExtension, 266 | registry: Provider 267 | ): ArtifactRepository = 268 | when (publicationType) { 269 | PublicationType.MAVEN -> project.theExtension().repositories.maven { 270 | it.configureArtifactRepo(nexusRepo, extension, registry, false) 271 | } 272 | 273 | PublicationType.IVY -> project.theExtension().repositories.ivy { repository -> 274 | repository.configureArtifactRepo(nexusRepo, extension, registry, true) 275 | if (nexusRepo.ivyPatternLayout.isPresent) { 276 | nexusRepo.ivyPatternLayout.get().let { repository.patternLayout(it) } 277 | } else { 278 | repository.layout("maven") 279 | } 280 | } 281 | } 282 | 283 | private fun T.configureArtifactRepo( 284 | nexusRepo: NexusRepository, 285 | extension: NexusPublishExtension, 286 | registry: Provider, 287 | provideFallback: Boolean 288 | ) where T : UrlArtifactRepository, T : ArtifactRepository, T : AuthenticationSupported { 289 | name = nexusRepo.name 290 | setUrl(getRepoUrl(nexusRepo, extension, registry, provideFallback, this)) 291 | val allowInsecureProtocol = nexusRepo.allowInsecureProtocol.orNull 292 | if (allowInsecureProtocol != null) { 293 | isAllowInsecureProtocol = allowInsecureProtocol 294 | } 295 | credentials { 296 | it.username = nexusRepo.username.orNull 297 | it.password = nexusRepo.password.orNull 298 | } 299 | } 300 | 301 | private fun configureTaskDependencies( 302 | project: Project, 303 | initializeTask: TaskProvider, 304 | findStagingRepositoryTask: TaskProvider, 305 | publishAllTask: TaskProvider, 306 | closeTask: TaskProvider, 307 | releaseTask: TaskProvider, 308 | artifactRepo: ArtifactRepository, 309 | publicationType: PublicationType 310 | ) { 311 | val publications = project.theExtension().publications.withType(publicationType.gradleType) 312 | publications.configureEach { publication -> 313 | val publishTask = project.tasks.named( 314 | "publish${publication.name.capitalize()}PublicationTo${artifactRepo.name.capitalize()}Repository", 315 | publicationType.publishTaskType 316 | ) 317 | publishTask.configure { 318 | it.dependsOn(initializeTask) 319 | it.mustRunAfter(findStagingRepositoryTask) 320 | it.doFirst { task -> 321 | if (artifactRepo is UrlArtifactRepository) { 322 | task.logger.info("Uploading to {}", artifactRepo.url) 323 | } 324 | } 325 | } 326 | publishAllTask.configure { 327 | it.dependsOn(publishTask) 328 | } 329 | closeTask.configure { 330 | it.mustRunAfter(publishTask) 331 | } 332 | releaseTask.configure { 333 | it.mustRunAfter(publishTask) 334 | } 335 | } 336 | } 337 | 338 | private fun getRepoUrl( 339 | nexusRepo: NexusRepository, 340 | extension: NexusPublishExtension, 341 | registry: Provider, 342 | provideFallback: Boolean, 343 | artifactRepo: ArtifactRepository 344 | ): Provider = 345 | extension.useStaging.flatMap { useStaging -> 346 | if (useStaging) { 347 | registry.map { it.registry }.map { descriptorRegistry -> 348 | if (provideFallback) { 349 | descriptorRegistry.invalidateLater(nexusRepo.name, artifactRepo) 350 | descriptorRegistry.tryGet(nexusRepo.name)?.stagingRepositoryUrl ?: nexusRepo.nexusUrl.get() 351 | } else { 352 | descriptorRegistry[nexusRepo.name].stagingRepositoryUrl 353 | } 354 | } 355 | } else { 356 | nexusRepo.snapshotRepositoryUrl 357 | } 358 | } 359 | 360 | private fun configureSimplifiedCloseAndReleaseTasks(rootProject: Project, extension: NexusPublishExtension) { 361 | if (extension.repositories.isNotEmpty()) { 362 | val repositoryNamesAsString = extension.repositories.joinToString(", ") { "'${it.name}'" } 363 | val instanceCardinalityAwareString = if (extension.repositories.size > 1) { 364 | "instances" 365 | } else { 366 | "instance" 367 | } 368 | val closeAndReleaseSimplifiedTask = rootProject.tasks.named(SIMPLIFIED_CLOSE_AND_RELEASE_TASK_NAME) { 369 | it.description = "Closes and releases open staging repositories in the following Nexus $instanceCardinalityAwareString: $repositoryNamesAsString" 370 | } 371 | val closeSimplifiedTask = rootProject.tasks.named(SIMPLIFIED_CLOSE_TASK_NAME) { 372 | it.description = "Closes open staging repositories in the following Nexus $instanceCardinalityAwareString: $repositoryNamesAsString" 373 | } 374 | val releaseSimplifiedTask = rootProject.tasks.named(SIMPLIFIED_RELEASE_TASK_NAME) { 375 | it.description = "Releases open staging repositories in the following Nexus $instanceCardinalityAwareString: $repositoryNamesAsString" 376 | } 377 | extension.repositories.all { 378 | val repositoryCapitalizedName = it.capitalizedName 379 | val closeAndReleaseTask = rootProject.tasks.named("closeAndRelease${repositoryCapitalizedName}StagingRepository") 380 | closeAndReleaseSimplifiedTask.configure { task -> 381 | task.dependsOn(closeAndReleaseTask) 382 | } 383 | val closeTask = rootProject.tasks.named("close${repositoryCapitalizedName}StagingRepository") 384 | closeSimplifiedTask.configure { task -> 385 | task.dependsOn(closeTask) 386 | } 387 | val releaseTask = rootProject.tasks.named("release${repositoryCapitalizedName}StagingRepository") 388 | releaseSimplifiedTask.configure { task -> 389 | task.dependsOn(releaseTask) 390 | } 391 | } 392 | } 393 | } 394 | } 395 | 396 | private inline fun Project.theExtension(): T = 397 | typeOf(T::class.java).let { 398 | this.extensions.findByType(it) 399 | ?: error("The plugin cannot be applied without the publishing plugin") 400 | } 401 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gradle Nexus Publish Plugin 2 | 3 | [![CI Status](https://github.com/gradle-nexus/publish-plugin/workflows/CI/badge.svg)](https://github.com/gradle-nexus/publish-plugin/actions?workflow=CI) [![Gradle Plugin Portal](https://img.shields.io/maven-metadata/v/https/plugins.gradle.org/m2/io.github.gradle-nexus/publish-plugin/maven-metadata.xml.svg?label=Gradle%20Plugin%20Portal)](https://plugins.gradle.org/plugin/io.github.gradle-nexus.publish-plugin) 4 | 5 | This Gradle plugin is a turn-key solution for publishing to Nexus. You can use it to publish your artifacts to any Nexus Repository Manager 2.x instance (internal or public). It is great for publishing your open source to Sonatype, and then to [Maven Central][maven-central], in a fully automated fashion. 6 | 7 | Vanilla Gradle is great, but it cannot fully automate publications to Nexus. This plugin enables isolation of staging repositories so that you can reliably publish from CI, and each publication uses a brand new, explicitly created staging repo ([more](https://github.com/gradle-nexus/publish-plugin/issues/63)). Moreover, the plugin provides tasks to close and release staging repositories, covering the whole releasing process to Maven Central. 8 | 9 | This plugin is intended as a replacement of the [Gradle Nexus Staging Plugin](https://github.com/Codearte/gradle-nexus-staging-plugin/) and [Nexus Publish Plugin](https://github.com/marcphilipp/nexus-publish-plugin) duo. See a dedicated [migration guide](https://github.com/gradle-nexus/publish-plugin/wiki/Migration-from-gradle_nexus_staging-plugin---nexus_publish-plugin-duo). 10 | 11 | ## Usage 12 | 13 | ### Applying the plugin 14 | 15 | The plugin must be applied to the root project and requires Gradle 6.2 or later. It is important to 16 | set the group and the version to the root project, so the plugin can detect if it is a snapshot 17 | version or not in order to select the correct repository where artifacts will be published. 18 | 19 | ```groovy 20 | plugins { 21 | id("io.github.gradle-nexus.publish-plugin") version "«version»" 22 | } 23 | 24 | group = "com.example.library" 25 | version = "1.0.0" 26 | ``` 27 | 28 | #### Java compatibility 29 | 30 | As of version 2.x, support for JDK <11 is [deprecated](https://github.com/gradle-nexus/publish-plugin/issues/171). The JDK taget compatibility is still set to 8, however, it is encouraged to use the latest possible Java version (e.g. 21+). As being deprecated, support for JDK <11 might be dropped in a future minor plugin version (i.e. 2.x). 31 | 32 | ### Publishing to Maven Central via Sonatype Central 33 | 34 | In order to publish to Maven Central (aka the Central Repository or just Central) via [Sonatype Central], you need to add the `sonatype()` repository like in the example below. Its `nexusUrl` and `snapshotRepositoryUrl` values must be configured as below, see [Sonatype's site](https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/#configuration). 35 | 36 | ```groovy 37 | nexusPublishing { 38 | repositories { 39 | sonatype() 40 | } 41 | repositories { 42 | // see https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/#configuration 43 | sonatype { 44 | nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) 45 | snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) 46 | } 47 | } 48 | } 49 | ``` 50 | 51 | You need to set your **Central credentials** (those are [different from the legacy OSSRH ones](https://central.sonatype.org/faq/what-is-different-between-central-portal-and-legacy-ossrh/#authentication)). To increase security, it is advised to use the [user token's username and password pair](https://central.sonatype.org/publish/generate-portal-token/) (instead of regular username and password). Those values should be set as the `sonatypeUsername` and `sonatypePassword` project properties, e.g. in `~/.gradle/gradle.properties` or via the `ORG_GRADLE_PROJECT_sonatypeUsername` and `ORG_GRADLE_PROJECT_sonatypePassword` environment variables. 52 | 53 | Alternatively (e.g. for local testing), you can configure credentials in the `sonatype` block directly: 54 | 55 | ```groovy 56 | nexusPublishing { 57 | repositories { 58 | sonatype { 59 | username = "your-user-token-username" 60 | password = "your-user-token-password" 61 | } 62 | } 63 | } 64 | ``` 65 | ### ~~Publishing to Maven Central via Sonatype OSSRH~~ 66 | 67 | **DEPRECATED. [OSSRH reaches end-of-life on June 30, 2025!](https://central.sonatype.org/news/20250326_ossrh_sunset/)** You should migrate to Sonatype Central. The migration is straightforward, only [configure the correct URLs](#publishing-to-maven-central-via-sonatype-central) ↑↑↑. 68 | 69 |
70 | 71 | Deprecated configuration for historical purpose only. 72 | 73 |
74 | In order to publish to Maven Central (aka the Central Repository or just Central) via Sonatype's OSSRH Nexus, you simply need to add the `sonatype()` repository like in the example below. Its `nexusUrl` and `snapshotRepositoryUrl` values are pre-configured. 75 | 76 | ```groovy 77 | nexusPublishing { 78 | repositories { 79 | sonatype() 80 | } 81 | } 82 | ``` 83 | 84 | **Important**. Users registered in Sonatype after [24 February 2021](https://central.sonatype.org/news/20210223_new-users-on-s01/) need to customize the following URLs: 85 | 86 | ```groovy 87 | nexusPublishing { 88 | repositories { 89 | sonatype { //only for users registered in Sonatype after 24 Feb 2021 90 | nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) 91 | snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) 92 | } 93 | } 94 | } 95 | ``` 96 | (if unsure check the server address in a corresponding ticket for your project in Sonatype's Jira) 97 | 98 | In addition, for both groups of users, you need to set your Nexus credentials. To increase security, it is advised to use the [user token's username and password pair](https://blog.solidsoft.pl/2015/09/08/deploy-to-maven-central-using-api-key-aka-auth-token/) (instead of regular username and password). Those values should be set as the `sonatypeUsername` and `sonatypePassword` project properties, e.g. in `~/.gradle/gradle.properties` or via the `ORG_GRADLE_PROJECT_sonatypeUsername` and `ORG_GRADLE_PROJECT_sonatypePassword` environment variables. 99 | 100 | Alternatively (e.g. for local testing), you can configure credentials in the `sonatype` block directly: 101 | 102 | ```groovy 103 | nexusPublishing { 104 | repositories { 105 | sonatype { 106 | username = "your-user-token-username" 107 | password = "your-user-token-password" 108 | } 109 | } 110 | } 111 | ``` 112 | 113 |
114 | 115 | #### Configure [Signing](https://docs.gradle.org/current/userguide/signing_plugin.html#sec:signing_publications) #### 116 | 117 | Add the signing plugin: 118 | ```kotlin 119 | plugins { 120 | // ... 121 | signing 122 | } 123 | ``` 124 | then configure: 125 | ```kotlin 126 | signing { 127 | sign(publishing.publications["mavenJava"]) 128 | } 129 | ``` 130 | 131 | #### Publishing with Ivy #### 132 | 133 | There are cases where it may be necessary to use the `ivy-publish` plugin instead of `maven-publish`. 134 | For example, when publishing Sbt plugins the directory structure needs to be customized which is only possible with Gradle's `IvyArtifactRepository`. 135 | 136 | In such cases, you need to apply the `ivy-publish` plugin and configure the `publicationType` fore each `NexusRepository`, that should be ivy compatible, to `IVY` (default is `MAVEN`). 137 | 138 | In case of Ivy publishing, because of compatibility with Sonatype the nexus repository layout will be used by default 139 | 140 | ```groovy 141 | nexusPublishing { 142 | respositories { 143 | ivyRepository { 144 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.IVY 145 | } 146 | } 147 | } 148 | ``` 149 | 150 | Or use the kotlin DSL: 151 | 152 | ```kotlin 153 | nexusPublishing { 154 | respositories { 155 | register("ivyRepository") { 156 | publicationType.set(io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.IVY) 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | ##### Using Ivy repositories with different artifact patterns #### 163 | 164 | In case of ivy it's possible to override the default artifact pattern that is used, which is the Maven pattern due to compatibility reasons with sonatype 165 | 166 | To change the pattern of artifacts and ivy files configure the `ivyPatternLayout` on each repository that should be used with this layout with: 167 | 168 | ```groovy 169 | nexusPublishing { 170 | respositories { 171 | ivyRepository { 172 | ivyPatternLayout { 173 | artifact "[organisation]/[module]_foo/[revision]/[artifact]-[revision](-[classifier])(.[ext])" 174 | m2compatible = true 175 | } 176 | } 177 | } 178 | } 179 | ``` 180 | 181 | Or use the kotlin DSL: 182 | 183 | ```kotlin 184 | nexusPublishing { 185 | respositories { 186 | register("ivyRepository") { 187 | ivyPatternLayout { 188 | ivyPatternLayout { 189 | artifact("[organisation]/[module]_foo/[revision]/[artifact]-[revision](-[classifier])(.[ext])") 190 | m2compatible = true 191 | } 192 | } 193 | } 194 | } 195 | } 196 | ``` 197 | 198 | #### Add Metadata #### 199 | 200 | See the [Maven publishing page in Gradle documentation](https://docs.gradle.org/current/userguide/publishing_maven.html#publishing_maven:publications) for what these mean, default values, and how to override coordinates (`groupId:artifactId:version`). 201 | 202 | ```kotlin 203 | publishing { 204 | publications { 205 | create("mavenJava") { 206 | from(components["java"]) 207 | 208 | pom { 209 | name.set("<>") 210 | description.set("<>") 211 | url.set("<>") 212 | licenses { 213 | license { 214 | name.set("<>") 215 | url.set("<>") 216 | } 217 | } 218 | developers { 219 | developer { 220 | id.set("<>") 221 | name.set("<>") 222 | email.set("<>") 223 | } 224 | } 225 | scm { 226 | connection.set("<>") 227 | developerConnection.set("<>") 228 | url.set("<>") 229 | } 230 | } 231 | } 232 | } 233 | } 234 | ``` 235 | 236 | Finally, call `publishToSonatype closeAndReleaseSonatypeStagingRepository` to publish all publications to Sonatype's OSSRH Nexus and subsequently close and release the corresponding staging repository, effectively making the artifacts available in Maven Central (usually after a few minutes). 237 | 238 | Please bear in mind that - especially on the initial project publishing to Maven Central - it might be wise to call just `publishToSonatype closeSonatypeStagingRepository` and manually verify that the artifacts placed in the closed staging repository in Nexus looks ok. After that, the staging repository might be dropped (if needed) or manually released from the Nexus UI. 239 | 240 | #### Publishing and closing in different Gradle invocations 241 | 242 | You might want to publish and close in different Gradle invocations. For example, you might want to publish from CI 243 | and close and release from your local machine. 244 | An alternative use case is to publish and close the repository and let others review and preview the publication before 245 | the release. 246 | 247 | The use case is possible by using `find${repository.name.capitalize()}StagingRepository` (e.g. `findSonatypeStagingRepository`) task. 248 | By default, `initialize${repository.name.capitalize()}StagingRepository` task adds a description to the repository which defaults to 249 | `$group:$module:$version` of the root project, so the repository can be found later using the same description. 250 | 251 | The description can be customized via: 252 | * `io.github.gradlenexus.publishplugin.NexusPublishExtension.getRepositoryDescription` property (default: `$group:$module:$version` of the root project) 253 | * `io.github.gradlenexus.publishplugin.InitializeNexusStagingRepository.repositoryDescription` property 254 | * `io.github.gradlenexus.publishplugin.FindStagingRepository.descriptionRegex` property (regex, default: `"\\b" + Regex.escape(repositoryDescription) + "(\\s|$)"`) 255 | 256 | So the steps to publish and release in different Gradle invocations are: 257 | 1. Publish the artifacts to the staging repository: `./gradlew publishToSonatype` 258 | 2. Close the staging repository: `./gradlew findSonatypeStagingRepository closeSonatypeStagingRepository` 259 | 3. Release the staging repository: `./gradlew findSonatypeStagingRepository releaseSonatypeStagingRepository` 260 | 261 | (in the above example, steps 1 and 2 could be also combined into `./gradlew publishToSonatype closeSonatypeStagingRepository`, to make only the releasing done in a separate step) 262 | 263 | ### Summary Tasks 264 | 265 | If you declare multiple repositories, you get a separate set of tasks for each of the repositories. 266 | If you for example declared the repositories `sonatype` and `otherNexus`, you get these tasks: 267 | - `closeSonatypeStagingRepository` 268 | - `closeOtherNexusStagingRepository` 269 | - `releaseSonatypeStagingRepository` 270 | - `releaseOtherNexusStagingRepository` 271 | - `closeAndReleaseSonatypeStagingRepository` 272 | - `closeAndReleaseOtherNexusStagingRepository` 273 | 274 | For convenience there are also summary tasks generated, that group the tasks for the different repositories, which are 275 | - `closeStagingRepositories` 276 | - `releaseStagingRepositories` 277 | - `closeAndReleaseStagingRepositories` 278 | 279 | In the typical use-case, which is only one repository for publishing to Maven Central, these tasks still are useful, 280 | especially if you are using Kotlin DSL build scripts, because those summary tasks are always added, independent of 281 | declared repositories. Due to that there are type-safe accessors generated that can be used conveniently for task dependencies. 282 | 283 | ### Full example 284 | 285 | #### Groovy DSL 286 | 287 | ```groovy 288 | plugins { 289 | id "java-library" 290 | id "maven-publish" 291 | id "signing" 292 | id "io.github.gradle-nexus.publish-plugin" version "«version»" 293 | } 294 | 295 | publishing { 296 | publications { 297 | mavenJava(MavenPublication) { 298 | from(components.java) 299 | 300 | pom { 301 | name = "<>" 302 | description = "<>" 303 | url = "<>" 304 | licenses { 305 | license { 306 | name = "<>" 307 | url = "<>" 308 | } 309 | } 310 | developers { 311 | developer { 312 | id = "<>" 313 | name = "<>" 314 | email = "<>" 315 | } 316 | } 317 | scm { 318 | connection = "<>" 319 | developerConnection = "<>" 320 | url = "<>" 321 | } 322 | } 323 | } 324 | } 325 | } 326 | 327 | nexusPublishing { 328 | repositories { 329 | myNexus { 330 | nexusUrl = uri("https://your-server.com/staging") 331 | snapshotRepositoryUrl = uri("https://your-server.com/snapshots") 332 | username = "your-username" // defaults to project.properties["myNexusUsername"] 333 | password = "your-password" // defaults to project.properties["myNexusPassword"] 334 | } 335 | } 336 | } 337 | 338 | signing { 339 | sign publishing.publications.mavenJava 340 | } 341 | ``` 342 | 343 | #### Kotlin DSL 344 | 345 | ```kotlin 346 | plugins { 347 | `java-library` 348 | `maven-publish` 349 | signing 350 | id("io.github.gradle-nexus.publish-plugin") version "«version»" 351 | } 352 | 353 | publishing { 354 | publications { 355 | create("mavenJava") { 356 | from(components["java"]) 357 | 358 | pom { 359 | name.set("<>") 360 | description.set("<>") 361 | url.set("<>") 362 | licenses { 363 | license { 364 | name.set("<>") 365 | url.set("<>") 366 | } 367 | } 368 | developers { 369 | developer { 370 | id.set("<>") 371 | name.set("<>") 372 | email.set("<>") 373 | } 374 | } 375 | scm { 376 | connection.set("<>") 377 | developerConnection.set("<>") 378 | url.set("<>") 379 | } 380 | } 381 | } 382 | } 383 | } 384 | 385 | nexusPublishing { 386 | repositories { 387 | create("myNexus") { 388 | nexusUrl.set(uri("https://your-server.com/staging")) 389 | snapshotRepositoryUrl.set(uri("https://your-server.com/snapshots")) 390 | username.set("your-username") // defaults to project.properties["myNexusUsername"] 391 | password.set("your-password") // defaults to project.properties["myNexusPassword"] 392 | } 393 | } 394 | } 395 | 396 | signing { 397 | sign(publishing.publications["mavenJava"]) 398 | } 399 | ``` 400 | 401 | ### HTTP Timeouts 402 | 403 | You can configure the `connectTimeout` and `clientTimeout` properties on the `nexusPublishing` extension to set the connect and read/write timeouts (both default to 5 minutes). Good luck! 404 | 405 | ### Retries for state transitions 406 | 407 | When closing or releasing a staging repository the plugin first initiates the transition and then retries a configurable number of times with a configurable delay after each attempt. 408 | Both can be configured like this: 409 | 410 | #### Groovy DSL 411 | 412 | ```gradle 413 | import java.time.Duration 414 | 415 | nexusPublishing { 416 | transitionCheckOptions { 417 | maxRetries = 100 418 | delayBetween = Duration.ofSeconds(5) 419 | } 420 | } 421 | ``` 422 | 423 | #### Kotlin DSL 424 | 425 | ```gradle 426 | import java.time.Duration 427 | 428 | nexusPublishing { 429 | transitionCheckOptions { 430 | maxRetries.set(100) 431 | delayBetween.set(Duration.ofSeconds(5)) 432 | } 433 | } 434 | ``` 435 | 436 | - `maxRetries` default value is 60. 437 | - `delayBetween` default value is 10 seconds. 438 | 439 | ### Compatibility 440 | 441 | | Nexus Version | Compatible? | 442 | |----------------------------------------------------|--------------------| 443 | | Sonatype [Maven Central Repository][maven-central] | Yes | 444 | | Sonatype Nexus Repository Manager 2.x | Yes | 445 | | Sonatype Nexus Repository Manager 3.x | [No][nexus-compat] / https://github.com/gradle-nexus/publish-plugin/issues/320 | 446 | 447 | ### Troubleshooting 448 | 449 | Log into your staging repository account. On the left side, expand "Build Promotion", then click "Staging Repositories". 450 | Here, you should see your newly created repositories. You can click on one of them, then select the "Activity" tab to 451 | see any errors that have occurred. 452 | 453 | --- 454 | 455 | ## Behind the scenes 456 | 457 | The plugin does the following: 458 | 459 | - configure a Maven artifact repository for each repository defined in the `nexusPublishing { repositories { ... } }` block in each subproject that applies the `maven-publish` or the `ivy-publish` plugin 460 | - 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. 461 | - 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. 462 | - make all publishing tasks for each configured repository depend on the `initialize${repository.name.capitalize()}StagingRepository` task 463 | - create a `publishTo${repository.name.capitalize()}` lifecycle task that depends on all publishing tasks for the corresponding Maven artifact repository 464 | - create `close${repository.name.capitalize()}StagingRepository` and `release${repository.name.capitalize()}StagingRepository` tasks that must run after the all publishing tasks 465 | - to simplify the common use case also a `closeAndRelease${repository.name.capitalize()}StagingRepository` task is created which depends on all the `close*` and `release*` tasks for a given repository 466 | 467 | --- 468 | 469 | ## Historical background 470 | 471 | In 2015, [Marcin Zajączkowski](https://blog.solidsoft.pl/) created [gradle-nexus-staging-plugin](https://github.com/Codearte/gradle-nexus-staging-plugin/) which was providing an ability to close and release staging repositories in Nexus repository manager. It opened an opportunity to manage releasing Gradle projects to Maven Central completely from code. Over the years, it has been adopted by various projects across the globe, however there was a small problem. Due to technical limitations in the publishing process in Gradle, it was required to use heuristics to track implicitly created staging repositories, what often failed for multiple repositories in a given state. The situation became even worse when Travis changed its network architecture in late 2019 and the majority of releases started to fail. 472 | Here, [Marc Philipp](https://github.com/marcphilipp/) entered the stage who created [Nexus Publish Plugin](https://github.com/marcphilipp/nexus-publish-plugin) which was enriching the publishing mechanism in Gradle to explicitly create staging repositories and publish (upload) artifacts directly to it. 473 | 474 | Those two plugins nicely worked together, providing a reliable way to handle publishing artifacts to Maven Central (and to other Nexus instances in general). However, the need of using two plugins was very often confusing for users. As a result, an idea to create one plugin mixing the aforementioned capabilities emerged. It materialized in 2020/2021 as Gradle Nexus Publish Plugin, an effect of combined work of Marc and Marcin, supported by a pack of [contributors](https://github.com/gradle-nexus/publish-plugin/graphs/contributors). 475 | 476 | [nexus-compat]: https://help.sonatype.com/en/nexus-repository-2-vs--nexus-repository-3-feature-equivalency-matrix.html#:~:text=API%20documentation.-,Note,-Note%20that%20NexusRepository 477 | [maven-central]: https://central.sonatype.com/ 478 | -------------------------------------------------------------------------------- /src/compatTest/kotlin/io/github/gradlenexus/publishplugin/BaseNexusPublishPluginTests.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.gradlenexus.publishplugin 18 | 19 | import com.github.tomakehurst.wiremock.WireMockServer 20 | import com.github.tomakehurst.wiremock.client.WireMock 21 | import com.github.tomakehurst.wiremock.stubbing.Scenario 22 | import com.google.gson.Gson 23 | import io.github.gradlenexus.publishplugin.internal.StagingRepository 24 | import org.assertj.core.api.Assertions.assertThat 25 | import org.gradle.testkit.runner.BuildResult 26 | import org.gradle.testkit.runner.GradleRunner 27 | import org.gradle.testkit.runner.TaskOutcome 28 | import org.gradle.util.GradleVersion 29 | import org.junit.jupiter.api.BeforeEach 30 | import org.junit.jupiter.api.Disabled 31 | import org.junit.jupiter.api.Test 32 | import org.junit.jupiter.api.extension.ExtendWith 33 | import org.junit.jupiter.api.io.TempDir 34 | import ru.lanwen.wiremock.ext.WiremockResolver 35 | import java.nio.file.Files 36 | import java.nio.file.Path 37 | 38 | @Suppress("FunctionName") // TODO: How to suppress "kotlin:S100" from SonarLint? 39 | @ExtendWith(MethodScopeWiremockResolver::class) 40 | abstract class BaseNexusPublishPluginTests { 41 | 42 | lateinit var publishPluginId: String 43 | lateinit var publishPluginContent: String 44 | lateinit var publishGoalPrefix: String 45 | lateinit var publicationTypeName: String 46 | lateinit var artifactList: List 47 | lateinit var snapshotArtifactList: List 48 | lateinit var pluginArtifactList: List 49 | 50 | companion object { 51 | const val STAGING_PROFILE_ID = "someProfileId" 52 | const val STAGED_REPOSITORY_ID = "orgexample-42" 53 | private const val OVERRIDDEN_STAGED_REPOSITORY_ID = "orgexample-42o" 54 | } 55 | 56 | private enum class StagingRepoTransitionOperation( 57 | val urlSufix: String, 58 | val desiredState: StagingRepository.State 59 | ) { 60 | CLOSE("close", StagingRepository.State.CLOSED), 61 | RELEASE("promote", StagingRepository.State.NOT_FOUND) 62 | } 63 | 64 | private val gson = Gson() 65 | 66 | protected val gradleVersion: GradleVersion = 67 | System.getProperty("compat.gradle.version")?.let { GradleVersion.version(it) } ?: GradleVersion.current() 68 | 69 | private val gradleRunner = GradleRunner.create() 70 | .withPluginClasspath() 71 | .withGradleVersion(gradleVersion.version) 72 | 73 | private val pluginClasspathAsString: String 74 | get() = gradleRunner.pluginClasspath.joinToString(", ") { "'${it.absolutePath.replace('\\', '/')}'" } 75 | 76 | lateinit var server: WireMockServer 77 | 78 | @TempDir 79 | lateinit var projectDir: Path 80 | 81 | lateinit var buildGradle: Path 82 | 83 | @BeforeEach 84 | internal fun setup(@WiremockResolver.Wiremock server: WireMockServer) { 85 | this.server = server 86 | buildGradle = projectDir.resolve("build.gradle") 87 | } 88 | 89 | @Test 90 | fun `publishes snapshots`() { 91 | projectDir.resolve("settings.gradle").write( 92 | """ 93 | rootProject.name = 'sample' 94 | """ 95 | ) 96 | 97 | projectDir.resolve("build.gradle").write( 98 | """ 99 | plugins { 100 | id('java-library') 101 | id('$publishPluginId') 102 | id('io.github.gradle-nexus.publish-plugin') 103 | } 104 | group = 'org.example' 105 | version = '0.0.1-SNAPSHOT' 106 | publishing { 107 | publications { 108 | $publishPluginContent 109 | } 110 | } 111 | nexusPublishing { 112 | repositories { 113 | myNexus { 114 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 115 | nexusUrl = uri('${server.baseUrl()}/shouldNotBeUsed') 116 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots') 117 | allowInsecureProtocol = true 118 | username = 'username' 119 | password = 'password' 120 | } 121 | } 122 | } 123 | """ 124 | ) 125 | 126 | expectArtifactUploads("/snapshots") 127 | 128 | val result = run("publishToMyNexus") 129 | 130 | assertSkipped(result, ":initializeMyNexusStagingRepository") 131 | snapshotArtifactList.forEach { assertUploaded("/snapshots/org/example/sample/0.0.1-SNAPSHOT/$it") } 132 | } 133 | 134 | @Test 135 | fun `publishes to two Nexus repositories`( 136 | @MethodScopeWiremockResolver.MethodScopedWiremockServer @WiremockResolver.Wiremock 137 | otherServer: WireMockServer 138 | ) { 139 | projectDir.resolve("settings.gradle").write( 140 | """ 141 | rootProject.name = 'sample' 142 | """ 143 | ) 144 | projectDir.resolve("build.gradle").write( 145 | """ 146 | plugins { 147 | id('java-library') 148 | id('$publishPluginId') 149 | id('io.github.gradle-nexus.publish-plugin') 150 | } 151 | group = 'org.example' 152 | version = '0.0.1' 153 | publishing { 154 | publications { 155 | $publishPluginContent 156 | } 157 | } 158 | nexusPublishing { 159 | repositories { 160 | myNexus { 161 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 162 | nexusUrl = uri('${server.baseUrl()}') 163 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots/') 164 | allowInsecureProtocol = true 165 | username = 'username' 166 | password = 'password' 167 | } 168 | someOtherNexus { 169 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 170 | nexusUrl = uri('${otherServer.baseUrl()}') 171 | snapshotRepositoryUrl = uri('${otherServer.baseUrl()}/snapshots/') 172 | allowInsecureProtocol = true 173 | username = 'someUsername' 174 | password = 'somePassword' 175 | } 176 | } 177 | } 178 | """ 179 | ) 180 | 181 | val otherStagingProfileId = "otherStagingProfileId" 182 | val otherStagingRepositoryId = "orgexample-43" 183 | stubStagingProfileRequest("/staging/profiles", mapOf("id" to STAGING_PROFILE_ID, "name" to "org.example")) 184 | stubStagingProfileRequest( 185 | "/staging/profiles", 186 | mapOf("id" to otherStagingProfileId, "name" to "org.example"), 187 | wireMockServer = otherServer 188 | ) 189 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 190 | stubCreateStagingRepoRequest( 191 | "/staging/profiles/$otherStagingProfileId/start", 192 | otherStagingRepositoryId, 193 | wireMockServer = otherServer 194 | ) 195 | expectArtifactUploads("/staging/deployByRepositoryId/$STAGED_REPOSITORY_ID") 196 | expectArtifactUploads("/staging/deployByRepositoryId/$otherStagingRepositoryId", wireMockServer = otherServer) 197 | 198 | val result = run("publishToMyNexus", "publishToSomeOtherNexus") 199 | 200 | assertSuccess(result, ":initializeMyNexusStagingRepository") 201 | assertSuccess(result, ":initializeSomeOtherNexusStagingRepository") 202 | assertThat(result.output) 203 | .containsOnlyOnce("Created staging repository '$STAGED_REPOSITORY_ID' at ${server.baseUrl()}/repositories/$STAGED_REPOSITORY_ID/content/") 204 | assertThat(result.output) 205 | .containsOnlyOnce("Created staging repository '$otherStagingRepositoryId' at ${otherServer.baseUrl()}/repositories/$otherStagingRepositoryId/content/") 206 | server.verify( 207 | WireMock.postRequestedFor(WireMock.urlEqualTo("/staging/profiles/$STAGING_PROFILE_ID/start")) 208 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.description == 'org.example:sample:0.0.1')]")) 209 | ) 210 | otherServer.verify( 211 | WireMock.postRequestedFor(WireMock.urlEqualTo("/staging/profiles/$otherStagingProfileId/start")) 212 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.description == 'org.example:sample:0.0.1')]")) 213 | ) 214 | 215 | artifactList.forEach { assertUploadedToStagingRepo("/org/example/sample/0.0.1/$it") } 216 | artifactList.forEach { 217 | assertUploadedToStagingRepo( 218 | "/org/example/sample/0.0.1/$it", 219 | stagingRepositoryId = otherStagingRepositoryId, 220 | wireMockServer = otherServer 221 | ) 222 | } 223 | } 224 | 225 | @Test 226 | fun `publishes to Nexus`() { 227 | projectDir.resolve("settings.gradle").write( 228 | """ 229 | rootProject.name = 'sample' 230 | """ 231 | ) 232 | projectDir.resolve("build.gradle").write( 233 | """ 234 | plugins { 235 | id('java-library') 236 | id('$publishPluginId') 237 | id('io.github.gradle-nexus.publish-plugin') 238 | } 239 | group = 'org.example' 240 | version = '0.0.1' 241 | publishing { 242 | publications { 243 | $publishPluginContent 244 | } 245 | } 246 | nexusPublishing { 247 | repositories { 248 | myNexus { 249 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 250 | nexusUrl = uri('${server.baseUrl()}') 251 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots/') 252 | allowInsecureProtocol = true 253 | username = 'username' 254 | password = 'password' 255 | } 256 | someOtherNexus { 257 | nexusUrl = uri('http://example.org') 258 | snapshotRepositoryUrl = uri('http://example.org/snapshots/') 259 | } 260 | } 261 | } 262 | """ 263 | ) 264 | 265 | stubStagingProfileRequest("/staging/profiles", mapOf("id" to STAGING_PROFILE_ID, "name" to "org.example")) 266 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 267 | expectArtifactUploads("/staging/deployByRepositoryId/$STAGED_REPOSITORY_ID") 268 | 269 | val result = run("publishToMyNexus") 270 | 271 | assertSuccess(result, ":initializeMyNexusStagingRepository") 272 | assertThat(result.output) 273 | .containsOnlyOnce("Created staging repository '$STAGED_REPOSITORY_ID' at ${server.baseUrl()}/repositories/$STAGED_REPOSITORY_ID/content/") 274 | assertNotConsidered(result, ":initializeSomeOtherNexusStagingRepository") 275 | server.verify( 276 | WireMock.postRequestedFor(WireMock.urlEqualTo("/staging/profiles/$STAGING_PROFILE_ID/start")) 277 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.description == 'org.example:sample:0.0.1')]")) 278 | ) 279 | artifactList.forEach { assertUploadedToStagingRepo("/org/example/sample/0.0.1/$it") } 280 | } 281 | 282 | @Test 283 | fun `can be used with lazily applied Gradle Plugin Development Plugin`() { 284 | projectDir.resolve("settings.gradle").write( 285 | """ 286 | rootProject.name = 'sample' 287 | include 'gradle-plugin' 288 | """ 289 | ) 290 | 291 | projectDir.resolve("build.gradle").write( 292 | """ 293 | plugins { 294 | id('io.github.gradle-nexus.publish-plugin') 295 | } 296 | nexusPublishing { 297 | repositories { 298 | sonatype { 299 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 300 | nexusUrl = uri('${server.baseUrl()}') 301 | stagingProfileId = '$STAGING_PROFILE_ID' 302 | allowInsecureProtocol = true 303 | username = 'username' 304 | password = 'password' 305 | } 306 | } 307 | } 308 | """ 309 | ) 310 | 311 | val pluginDir = Files.createDirectories(projectDir.resolve("gradle-plugin")) 312 | pluginDir.resolve("build.gradle").write( 313 | """ 314 | plugins { 315 | id('$publishPluginId') 316 | id('java-gradle-plugin') 317 | } 318 | gradlePlugin { 319 | plugins { 320 | foo { 321 | id = 'org.example.foo' 322 | implementationClass = 'org.example.FooPlugin' 323 | } 324 | } 325 | } 326 | group = 'org.example' 327 | version = '0.0.1' 328 | """ 329 | ) 330 | val srcDir = Files.createDirectories(pluginDir.resolve("src/main/java/org/example/")) 331 | srcDir.resolve("FooPlugin.java").write( 332 | """ 333 | import org.gradle.api.*; 334 | public class FooPlugin implements Plugin { 335 | public void apply(Project p) {} 336 | } 337 | """ 338 | ) 339 | 340 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 341 | expectArtifactUploads("/staging/deployByRepositoryId/$STAGED_REPOSITORY_ID") 342 | 343 | val result = run("publishToSonatype", "-s") 344 | 345 | assertSuccess(result, ":initializeSonatypeStagingRepository") 346 | pluginArtifactList.forEach { assertUploadedToStagingRepo(it) } 347 | } 348 | 349 | @Test 350 | fun `must be applied to root project`() { 351 | projectDir.resolve("settings.gradle").append( 352 | """ 353 | include('sub') 354 | """ 355 | ) 356 | buildGradle.append( 357 | """ 358 | plugins { 359 | id('io.github.gradle-nexus.publish-plugin') apply false 360 | } 361 | subprojects { 362 | apply plugin: 'io.github.gradle-nexus.publish-plugin' 363 | } 364 | """ 365 | ) 366 | 367 | val result = gradleRunner("tasks").buildAndFail() 368 | 369 | assertThat(result.output) 370 | .contains("Plugin must be applied to the root project but was applied to :sub") 371 | } 372 | 373 | @Test 374 | fun `can get StagingProfileId from Nexus`() { 375 | writeDefaultSingleProjectConfiguration() 376 | // and 377 | buildGradle.append( 378 | """ 379 | nexusPublishing { 380 | repositories { 381 | sonatype { 382 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 383 | nexusUrl = uri('${server.baseUrl()}') 384 | allowInsecureProtocol = true 385 | //No staging profile defined 386 | } 387 | } 388 | } 389 | """ 390 | ) 391 | // and 392 | stubGetStagingProfilesForOneProfileIdGivenId(STAGING_PROFILE_ID) 393 | 394 | val result = run("retrieveSonatypeStagingProfile") 395 | 396 | assertSuccess(result, ":retrieveSonatypeStagingProfile") 397 | assertThat(result.output) 398 | .containsOnlyOnce("Received staging profile id: '$STAGING_PROFILE_ID' for package org.example") 399 | // and 400 | assertGetStagingProfile(1) 401 | } 402 | 403 | @Test 404 | fun `publish task depends on correct tasks`() { 405 | projectDir.resolve("settings.gradle").write( 406 | """ 407 | rootProject.name = 'sample' 408 | """ 409 | ) 410 | projectDir.resolve("build.gradle").write( 411 | """ 412 | plugins { 413 | id('java-library') 414 | id('$publishPluginId') 415 | id('io.github.gradle-nexus.publish-plugin') 416 | } 417 | group = 'org.example' 418 | version = '0.0.1' 419 | publishing { 420 | publications { 421 | $publishPluginContent 422 | } 423 | repositories { 424 | maven { 425 | name 'someOtherRepo' 426 | url 'someOtherRepo' 427 | } 428 | } 429 | } 430 | nexusPublishing { 431 | repositories { 432 | myNexus { 433 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 434 | nexusUrl = uri('https://example.com') 435 | } 436 | } 437 | } 438 | // use this instead of --dry-run to get the tasks in the result for verification 439 | tasks.all { enabled = false } 440 | """ 441 | ) 442 | 443 | val result = run("publishToMyNexus") 444 | 445 | assertSkipped(result, ":publishToMyNexus") 446 | assertSkipped(result, ":${publishGoalPrefix}PublicationToMyNexusRepository") 447 | assertNotConsidered(result, ":${publishGoalPrefix}PublicationToSomeOtherRepoRepository") 448 | } 449 | 450 | @Test 451 | fun `displays the error response to the user when a request fails`() { 452 | projectDir.resolve("settings.gradle").write( 453 | """ 454 | rootProject.name = 'sample' 455 | """ 456 | ) 457 | projectDir.resolve("build.gradle").write( 458 | """ 459 | plugins { 460 | id('java-library') 461 | id('$publishPluginId') 462 | id('io.github.gradle-nexus.publish-plugin') 463 | } 464 | group = 'org.example' 465 | version = '0.0.1' 466 | publishing { 467 | publications { 468 | $publishPluginContent 469 | } 470 | } 471 | 472 | nexusPublishing { 473 | repositories { 474 | myNexus { 475 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 476 | nexusUrl = uri('${server.baseUrl()}') 477 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots/') 478 | allowInsecureProtocol = true 479 | username = 'username' 480 | password = 'password' 481 | } 482 | } 483 | } 484 | """ 485 | ) 486 | 487 | stubMissingStagingProfileRequest("/staging/profiles") 488 | 489 | val result = runAndFail("publishToMyNexus") 490 | 491 | assertFailure(result, ":initializeMyNexusStagingRepository") 492 | assertThat(result.output).contains("status code 404") 493 | assertThat(result.output).contains("""{"failure":"message"}""") 494 | } 495 | 496 | @Test 497 | fun `uses configured timeout`() { 498 | projectDir.resolve("settings.gradle").write( 499 | """ 500 | rootProject.name = 'sample' 501 | """ 502 | ) 503 | projectDir.resolve("build.gradle").write( 504 | """ 505 | import java.time.Duration 506 | 507 | plugins { 508 | id('java-library') 509 | id('$publishPluginId') 510 | id('io.github.gradle-nexus.publish-plugin') 511 | } 512 | group = 'org.example' 513 | version = '0.0.1' 514 | publishing { 515 | publications { 516 | $publishPluginContent 517 | } 518 | } 519 | nexusPublishing { 520 | repositories { 521 | myNexus { 522 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 523 | nexusUrl = uri('${server.baseUrl()}') 524 | snapshotRepositoryUrl = uri('${server.baseUrl()}/snapshots/') 525 | username = 'username' 526 | password = 'password' 527 | } 528 | } 529 | clientTimeout = Duration.ofSeconds(1) 530 | } 531 | """ 532 | ) 533 | 534 | server.stubFor(WireMock.get(WireMock.anyUrl()).willReturn(WireMock.aResponse().withFixedDelay(5_000))) 535 | 536 | val result = gradleRunner("initializeMyNexusStagingRepository").buildAndFail() 537 | 538 | // we assert that the first task that sends an HTTP request to server fails as expected 539 | assertOutcome(result, ":initializeMyNexusStagingRepository", TaskOutcome.FAILED) 540 | assertThat(result.output).contains("SocketTimeoutException") 541 | } 542 | 543 | @Test 544 | @Disabled("Fails on my Fedora...") 545 | fun `uses configured connect timeout`() { 546 | // Taken from https://stackoverflow.com/a/904609/5866817 547 | val nonRoutableAddress = "10.255.255.1" 548 | 549 | projectDir.resolve("settings.gradle").write( 550 | """ 551 | rootProject.name = 'sample' 552 | """ 553 | ) 554 | projectDir.resolve("build.gradle").write( 555 | """ 556 | import java.time.Duration 557 | 558 | plugins { 559 | id('java-library') 560 | id('$publishPluginId') 561 | id('io.github.gradle-nexus.publish-plugin') 562 | } 563 | group = 'org.example' 564 | version = '0.0.1' 565 | publishing { 566 | publications { 567 | $publishPluginContent 568 | } 569 | } 570 | nexusPublishing { 571 | repositories { 572 | myNexus { 573 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 574 | nexusUrl = uri('http://$nonRoutableAddress/') 575 | snapshotRepositoryUrl = uri('$nonRoutableAddress/snapshots/') 576 | username = 'username' 577 | password = 'password' 578 | } 579 | } 580 | connectTimeout = Duration.ofSeconds(1) 581 | } 582 | initializeMyNexusStagingRepository { 583 | timeout = Duration.ofSeconds(10) 584 | } 585 | """ 586 | ) 587 | 588 | val result = gradleRunner("initializeMyNexusStagingRepository").buildAndFail() 589 | 590 | assertOutcome(result, ":initializeMyNexusStagingRepository", TaskOutcome.FAILED) 591 | assertThat(result.output).contains("SocketTimeoutException") 592 | } 593 | 594 | @Test 595 | fun `uses default URLs for sonatype repos in Groovy DSL`() { 596 | projectDir.resolve("settings.gradle").write( 597 | """ 598 | rootProject.name = 'sample' 599 | """ 600 | ) 601 | projectDir.resolve("build.gradle").write( 602 | """ 603 | plugins { 604 | id('io.github.gradle-nexus.publish-plugin') 605 | } 606 | task printSonatypeConfig { 607 | doFirst { 608 | println "nexusUrl = ${"$"}{nexusPublishing.repositories['sonatype'].nexusUrl.orNull}" 609 | println "snapshotRepositoryUrl = ${"$"}{nexusPublishing.repositories['sonatype'].snapshotRepositoryUrl.orNull}" 610 | } 611 | } 612 | nexusPublishing { 613 | repositories { 614 | sonatype { 615 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 616 | } 617 | } 618 | } 619 | """ 620 | ) 621 | 622 | val result = run("printSonatypeConfig") 623 | 624 | assertThat(result.output) 625 | .contains("nexusUrl = https://oss.sonatype.org/service/local/") 626 | .contains("snapshotRepositoryUrl = https://oss.sonatype.org/content/repositories/snapshots/") 627 | } 628 | 629 | @Test 630 | fun `uses default URLs for sonatype repos in Kotlin DSL`() { 631 | projectDir.resolve("settings.gradle").write( 632 | """ 633 | rootProject.name = 'sample' 634 | """ 635 | ) 636 | projectDir.resolve("build.gradle.kts").write( 637 | """ 638 | plugins { 639 | id("io.github.gradle-nexus.publish-plugin") 640 | } 641 | tasks.create("printSonatypeConfig") { 642 | doFirst { 643 | println("nexusUrl = ${"$"}{nexusPublishing.repositories["sonatype"].nexusUrl.orNull}") 644 | println("snapshotRepositoryUrl = ${"$"}{nexusPublishing.repositories["sonatype"].snapshotRepositoryUrl.orNull}") 645 | } 646 | } 647 | nexusPublishing { 648 | repositories { 649 | sonatype { 650 | publicationType.set(io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName) 651 | } 652 | } 653 | } 654 | """ 655 | ) 656 | 657 | val result = run("printSonatypeConfig") 658 | 659 | assertThat(result.output) 660 | .contains("nexusUrl = https://oss.sonatype.org/service/local/") 661 | .contains("snapshotRepositoryUrl = https://oss.sonatype.org/content/repositories/snapshots/") 662 | } 663 | 664 | @Test 665 | fun `should close staging repository`() { 666 | writeDefaultSingleProjectConfiguration() 667 | writeMockedSonatypeNexusPublishingConfiguration() 668 | 669 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 670 | stubCloseStagingRepoRequestWithSubsequentQueryAboutItsState(STAGED_REPOSITORY_ID) 671 | 672 | val result = run("initializeSonatypeStagingRepository", "closeSonatypeStagingRepository") 673 | 674 | assertSuccess(result, ":initializeSonatypeStagingRepository") 675 | assertSuccess(result, ":closeSonatypeStagingRepository") 676 | assertCloseOfStagingRepo() 677 | } 678 | 679 | private fun stubCloseStagingRepoRequestWithSubsequentQueryAboutItsState(stagingRepositoryId: String = STAGED_REPOSITORY_ID) { 680 | stubTransitToDesiredStateStagingRepoRequestWithSubsequentQueryAboutItsState( 681 | StagingRepoTransitionOperation.CLOSE, 682 | stagingRepositoryId 683 | ) 684 | } 685 | 686 | private fun stubReleaseStagingRepoRequestWithSubsequentQueryAboutItsState(stagingRepositoryId: String = STAGED_REPOSITORY_ID) { 687 | stubTransitToDesiredStateStagingRepoRequestWithSubsequentQueryAboutItsState( 688 | StagingRepoTransitionOperation.RELEASE, 689 | stagingRepositoryId 690 | ) 691 | } 692 | 693 | private fun stubTransitToDesiredStateStagingRepoRequestWithSubsequentQueryAboutItsState( 694 | operation: StagingRepoTransitionOperation, 695 | stagingRepositoryId: String 696 | ) { 697 | stubTransitToDesiredStateStagingRepoRequest(operation, stagingRepositoryId) 698 | stubGetStagingRepoWithIdAndStateRequest( 699 | StagingRepository( 700 | stagingRepositoryId, 701 | operation.desiredState, 702 | false 703 | ) 704 | ) 705 | } 706 | 707 | private fun stubTransitToDesiredStateStagingRepoRequest( 708 | operation: StagingRepoTransitionOperation, 709 | stagingRepositoryId: String = STAGED_REPOSITORY_ID 710 | ) { 711 | server.stubFor( 712 | WireMock.post(WireMock.urlEqualTo("/staging/bulk/${operation.urlSufix}")) 713 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.stagedRepositoryIds[0] == '$stagingRepositoryId')]")) 714 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.autoDropAfterRelease == true)]")) 715 | .willReturn(WireMock.aResponse().withHeader("Content-Type", "application/json").withBody("{}")) 716 | ) 717 | } 718 | 719 | @Test 720 | fun `should close and release staging repository`() { 721 | writeDefaultSingleProjectConfiguration() 722 | writeMockedSonatypeNexusPublishingConfiguration() 723 | 724 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 725 | stubReleaseStagingRepoRequestWithSubsequentQueryAboutItsState(STAGED_REPOSITORY_ID) 726 | 727 | val result = run("tasks", "initializeSonatypeStagingRepository", "releaseSonatypeStagingRepository") 728 | 729 | assertSuccess(result, ":initializeSonatypeStagingRepository") 730 | assertSuccess(result, ":releaseSonatypeStagingRepository") 731 | assertReleaseOfStagingRepo() 732 | } 733 | 734 | // TODO: Move to separate subclass with command line tests for @Option 735 | // TODO: Consider switching to parameterized tests for close and release 736 | @Test 737 | fun `should allow to take staging repo id to close from command line without its initialization`() { 738 | writeDefaultSingleProjectConfiguration() 739 | writeMockedSonatypeNexusPublishingConfiguration() 740 | // and 741 | stubCloseStagingRepoRequestWithSubsequentQueryAboutItsState(OVERRIDDEN_STAGED_REPOSITORY_ID) 742 | 743 | val result = run("closeSonatypeStagingRepository", "--staging-repository-id=$OVERRIDDEN_STAGED_REPOSITORY_ID") 744 | 745 | assertSuccess(result, ":closeSonatypeStagingRepository") 746 | assertCloseOfStagingRepo(OVERRIDDEN_STAGED_REPOSITORY_ID) 747 | } 748 | 749 | @Test 750 | fun `should allow to take staging repo id to release from command line without its initialization`() { 751 | writeDefaultSingleProjectConfiguration() 752 | writeMockedSonatypeNexusPublishingConfiguration() 753 | // and 754 | stubReleaseStagingRepoRequestWithSubsequentQueryAboutItsState(OVERRIDDEN_STAGED_REPOSITORY_ID) 755 | 756 | val result = run("releaseSonatypeStagingRepository", "--staging-repository-id=$OVERRIDDEN_STAGED_REPOSITORY_ID") 757 | 758 | assertSuccess(result, ":releaseSonatypeStagingRepository") 759 | assertReleaseOfStagingRepo(OVERRIDDEN_STAGED_REPOSITORY_ID) 760 | } 761 | 762 | @Test 763 | @Disabled("Should override or fail with meaningful error?") 764 | fun `command line option should override initialized staging repository to close`() { 765 | } 766 | 767 | @Test 768 | @Disabled("Should override or fail with meaningful error?") 769 | fun `command line option should override initialized staging repository to release`() { 770 | } 771 | 772 | @Test 773 | internal fun `initialize task should resolve stagingProfileId if not provided and keep it for close task`() { 774 | writeDefaultSingleProjectConfiguration() 775 | // and 776 | buildGradle.append( 777 | """ 778 | nexusPublishing { 779 | repositories { 780 | sonatype { 781 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 782 | nexusUrl = uri('${server.baseUrl()}') 783 | allowInsecureProtocol = true 784 | //No staging profile defined 785 | } 786 | } 787 | } 788 | """ 789 | ) 790 | // and 791 | stubGetStagingProfilesForOneProfileIdGivenId(STAGING_PROFILE_ID) 792 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 793 | stubCloseStagingRepoRequestWithSubsequentQueryAboutItsState() 794 | 795 | val result = run("initializeSonatypeStagingRepository", "closeSonatypeStagingRepository") 796 | 797 | assertSuccess(result, ":initializeSonatypeStagingRepository") 798 | assertSuccess(result, ":closeSonatypeStagingRepository") 799 | // and 800 | assertGetStagingProfile(1) 801 | } 802 | 803 | // TODO: Parameterize them 804 | @Test 805 | internal fun `close task should retry getting repository state on transitioning`() { 806 | writeDefaultSingleProjectConfiguration() 807 | writeMockedSonatypeNexusPublishingConfiguration() 808 | // and 809 | stubTransitToDesiredStateStagingRepoRequest(StagingRepoTransitionOperation.CLOSE) 810 | stubGetGivenStagingRepositoryInFirstAndSecondCall( 811 | StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.OPEN, true), 812 | StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.CLOSED, false) 813 | ) 814 | 815 | val result = run("closeSonatypeStagingRepository", "--staging-repository-id=$STAGED_REPOSITORY_ID") 816 | 817 | assertSuccess(result, ":closeSonatypeStagingRepository") 818 | // and 819 | assertGetStagingRepository(STAGED_REPOSITORY_ID, 2) 820 | } 821 | 822 | @Test 823 | internal fun `release task should retry getting repository state on transitioning`() { 824 | writeDefaultSingleProjectConfiguration() 825 | writeMockedSonatypeNexusPublishingConfiguration() 826 | // and 827 | stubTransitToDesiredStateStagingRepoRequest(StagingRepoTransitionOperation.RELEASE) 828 | stubGetGivenStagingRepositoryInFirstAndSecondCall( 829 | StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.CLOSED, true), 830 | StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.NOT_FOUND, false) 831 | ) 832 | 833 | val result = run("releaseSonatypeStagingRepository", "--staging-repository-id=$STAGED_REPOSITORY_ID") 834 | 835 | assertSuccess(result, ":releaseSonatypeStagingRepository") 836 | // and 837 | assertGetStagingRepository(STAGED_REPOSITORY_ID, 2) 838 | } 839 | 840 | @Test 841 | fun `disables tasks for removed repos`() { 842 | writeDefaultSingleProjectConfiguration() 843 | projectDir.resolve("build.gradle").append( 844 | """ 845 | nexusPublishing { 846 | repositories { 847 | remove(create("myNexus") { 848 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 849 | nexusUrl = uri('${server.baseUrl()}/b/') 850 | snapshotRepositoryUrl = uri('${server.baseUrl()}/b/snapshots/') 851 | }) 852 | } 853 | } 854 | """ 855 | ) 856 | 857 | val result = run("initializeMyNexusStagingRepository") 858 | 859 | assertSkipped(result, ":initializeMyNexusStagingRepository") 860 | } 861 | 862 | @Test 863 | fun `repository description can be customized`() { 864 | writeDefaultSingleProjectConfiguration() 865 | writeMockedSonatypeNexusPublishingConfiguration() 866 | buildGradle.append( 867 | """ 868 | nexusPublishing { 869 | repositoryDescription = "Some custom description" 870 | } 871 | """ 872 | ) 873 | 874 | stubStagingProfileRequest("/staging/profiles", mapOf("id" to STAGING_PROFILE_ID, "name" to "org.example")) 875 | stubCreateStagingRepoRequest("/staging/profiles/$STAGING_PROFILE_ID/start", STAGED_REPOSITORY_ID) 876 | expectArtifactUploads("/staging/deployByRepositoryId/$STAGED_REPOSITORY_ID") 877 | stubCloseStagingRepoRequestWithSubsequentQueryAboutItsState(STAGED_REPOSITORY_ID) 878 | 879 | run("publishToSonatype", "closeSonatypeStagingRepository") 880 | 881 | server.verify( 882 | WireMock.postRequestedFor(WireMock.urlEqualTo("/staging/profiles/$STAGING_PROFILE_ID/start")) 883 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.description == 'Some custom description')]")) 884 | ) 885 | server.verify( 886 | WireMock.postRequestedFor(WireMock.urlEqualTo("/staging/bulk/close")) 887 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.description == 'Some custom description')]")) 888 | ) 889 | 890 | stubReleaseStagingRepoRequestWithSubsequentQueryAboutItsState(STAGED_REPOSITORY_ID) 891 | 892 | run("releaseSonatypeStagingRepository", "--staging-repository-id=$STAGED_REPOSITORY_ID") 893 | 894 | server.verify( 895 | WireMock.postRequestedFor(WireMock.urlEqualTo("/staging/bulk/promote")) 896 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.description == 'Some custom description')]")) 897 | ) 898 | } 899 | 900 | @Test 901 | fun `should find staging repository by description`() { 902 | // given 903 | writeDefaultSingleProjectConfiguration() 904 | writeMockedSonatypeNexusPublishingConfiguration() 905 | // and 906 | val stagingRepository = StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.OPEN, false) 907 | val responseBody = getStagingReposWithOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository) 908 | stubGetStagingReposForStagingProfileIdWithResponseStatusCodeAndResponseBody( 909 | STAGING_PROFILE_ID, 910 | 200, 911 | responseBody 912 | ) 913 | 914 | val result = run("findSonatypeStagingRepository") 915 | 916 | assertSuccess(result, ":findSonatypeStagingRepository") 917 | assertThat(result.output) 918 | .containsPattern(Regex("Staging repository for .* '$STAGED_REPOSITORY_ID'").toPattern()) 919 | // and 920 | assertGetStagingRepositoriesForStatingProfile(STAGING_PROFILE_ID) 921 | } 922 | 923 | @Test 924 | fun `should not find staging repository by wrong description`() { 925 | // given 926 | writeDefaultSingleProjectConfiguration() 927 | buildGradle.append("version='2.3.4-so staging repository is not found'") 928 | writeMockedSonatypeNexusPublishingConfiguration() 929 | // and 930 | val stagingRepository = StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.OPEN, false) 931 | val responseBody = getStagingReposWithOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository) 932 | stubGetStagingReposForStagingProfileIdWithResponseStatusCodeAndResponseBody( 933 | STAGING_PROFILE_ID, 934 | 200, 935 | responseBody 936 | ) 937 | 938 | val result = runAndFail("findSonatypeStagingRepository") 939 | 940 | assertFailure(result, ":findSonatypeStagingRepository") 941 | assertThat(result.output) 942 | .contains("No staging repositories found for stagingProfileId: someProfileId, descriptionRegex: \\b\\Qorg.example:sample:2.3.4-so staging repository is not found\\E(\\s|\$). Here are all the repositories: [ReadStagingRepository(repositoryId=orgexample-42, type=open, transitioning=false, description=org.example:sample:0.0.1)]") 943 | } 944 | 945 | @Test 946 | fun `should fail when multiple repositories exist`() { 947 | // given 948 | writeDefaultSingleProjectConfiguration() 949 | writeMockedSonatypeNexusPublishingConfiguration() 950 | // and 951 | val stagingRepository = StagingRepository(STAGED_REPOSITORY_ID, StagingRepository.State.OPEN, false) 952 | val stagingRepository2 = StagingRepository(OVERRIDDEN_STAGED_REPOSITORY_ID, StagingRepository.State.OPEN, false) 953 | // Return two repositories with the same description, so the find call would get both, and it should fail 954 | val responseBody = """ 955 | { 956 | "data": [ 957 | ${getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository)}, 958 | ${getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository2)} 959 | ] 960 | } 961 | """.trimIndent() 962 | stubGetStagingReposForStagingProfileIdWithResponseStatusCodeAndResponseBody( 963 | STAGING_PROFILE_ID, 964 | 200, 965 | responseBody 966 | ) 967 | 968 | val result = runAndFail("findSonatypeStagingRepository") 969 | 970 | assertFailure(result, ":findSonatypeStagingRepository") 971 | assertThat(result.output) 972 | .contains("Too many repositories found for stagingProfileId: someProfileId, descriptionRegex: \\b\\Qorg.example:sample:0.0.1\\E(\\s|\$). If some of the repositories are not needed, consider deleting them manually. Here are the repositories matching the regular expression: [ReadStagingRepository(repositoryId=orgexample-42, type=open, transitioning=false, description=org.example:sample:0.0.1), ReadStagingRepository(repositoryId=orgexample-42o, type=open, transitioning=false, description=org.example:sample:0.0.1)]") 973 | } 974 | 975 | // TODO: To be used also in other tests 976 | private fun writeDefaultSingleProjectConfiguration() { 977 | projectDir.resolve("settings.gradle").write( 978 | """ 979 | rootProject.name = 'sample' 980 | """ 981 | ) 982 | buildGradle.write( 983 | """ 984 | buildscript { 985 | repositories { 986 | gradlePluginPortal() 987 | } 988 | dependencies { 989 | classpath files($pluginClasspathAsString) 990 | } 991 | } 992 | plugins { 993 | id('java-library') 994 | id('$publishPluginId') 995 | } 996 | apply plugin: 'io.github.gradle-nexus.publish-plugin' 997 | group = 'org.example' 998 | version = '0.0.1' 999 | publishing { 1000 | publications { 1001 | $publishPluginContent 1002 | } 1003 | } 1004 | """ 1005 | ) 1006 | } 1007 | 1008 | private fun writeMockedSonatypeNexusPublishingConfiguration() { 1009 | buildGradle.append( 1010 | """ 1011 | nexusPublishing { 1012 | repositories { 1013 | sonatype { 1014 | publicationType = io.github.gradlenexus.publishplugin.NexusRepository.PublicationType.$publicationTypeName 1015 | nexusUrl = uri('${server.baseUrl()}') 1016 | allowInsecureProtocol = true 1017 | username = 'username' 1018 | password = 'password' 1019 | stagingProfileId = '$STAGING_PROFILE_ID' 1020 | } 1021 | } 1022 | transitionCheckOptions { 1023 | maxRetries = 3 1024 | delayBetween = java.time.Duration.ofMillis(1) 1025 | } 1026 | } 1027 | """ 1028 | ) 1029 | } 1030 | 1031 | protected fun run(vararg arguments: String): BuildResult = 1032 | gradleRunner(*arguments).build() 1033 | 1034 | private fun runAndFail(vararg arguments: String): BuildResult = 1035 | gradleRunner(*arguments).buildAndFail() 1036 | 1037 | private fun gradleRunner(vararg arguments: String): GradleRunner = 1038 | gradleRunner 1039 | // .withDebug(true) 1040 | .withProjectDir(projectDir.toFile()) 1041 | .withArguments(*arguments, "--stacktrace", "--warning-mode=fail") 1042 | .forwardOutput() 1043 | 1044 | @SafeVarargs 1045 | protected fun stubStagingProfileRequest( 1046 | url: String, 1047 | vararg stagingProfiles: Map, 1048 | wireMockServer: WireMockServer = server 1049 | ) { 1050 | wireMockServer.stubFor( 1051 | WireMock.get(WireMock.urlEqualTo(url)) 1052 | .withHeader("User-Agent", WireMock.matching("gradle-nexus-publish-plugin/.*")) 1053 | .willReturn(WireMock.aResponse().withBody(gson.toJson(mapOf("data" to listOf(*stagingProfiles))))) 1054 | ) 1055 | } 1056 | 1057 | private fun stubMissingStagingProfileRequest(url: String, wireMockServer: WireMockServer = server) { 1058 | wireMockServer.stubFor( 1059 | WireMock.get(WireMock.urlEqualTo(url)) 1060 | .withHeader("User-Agent", WireMock.matching("gradle-nexus-publish-plugin/.*")) 1061 | .willReturn(WireMock.notFound().withBody(gson.toJson(mapOf("failure" to "message")))) 1062 | ) 1063 | } 1064 | 1065 | protected fun stubCreateStagingRepoRequest( 1066 | url: String, 1067 | stagedRepositoryId: String, 1068 | wireMockServer: WireMockServer = server 1069 | ) { 1070 | wireMockServer.stubFor( 1071 | WireMock.post(WireMock.urlEqualTo(url)) 1072 | .willReturn( 1073 | WireMock.aResponse() 1074 | .withBody(gson.toJson(mapOf("data" to mapOf("stagedRepositoryId" to stagedRepositoryId)))) 1075 | ) 1076 | ) 1077 | } 1078 | 1079 | private fun stubGetStagingProfilesForOneProfileIdGivenId(stagingProfileId: String = STAGING_PROFILE_ID) { 1080 | server.stubFor( 1081 | WireMock.get(WireMock.urlEqualTo("/staging/profiles")) 1082 | .withHeader("Accept", WireMock.containing("application/json")) 1083 | .willReturn( 1084 | WireMock.aResponse() 1085 | .withStatus(200) 1086 | .withHeader("Content-Type", "application/json") 1087 | .withBody(getOneStagingProfileWithGivenIdShrunkJsonResponseAsString(stagingProfileId)) 1088 | ) 1089 | ) 1090 | } 1091 | 1092 | private fun stubGetStagingRepoWithIdAndStateRequest(stagingRepository: StagingRepository) { 1093 | if (stagingRepository.state == StagingRepository.State.NOT_FOUND) { 1094 | val responseBody = """{"errors":[{"id":"*","msg":"No such repository: ${stagingRepository.id}"}]}""" 1095 | stubGetStagingRepoWithIdAndResponseStatusCodeAndResponseBody(stagingRepository.id, 404, responseBody) 1096 | } else { 1097 | val responseBody = getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository) 1098 | stubGetStagingRepoWithIdAndResponseStatusCodeAndResponseBody(stagingRepository.id, 200, responseBody) 1099 | } 1100 | } 1101 | 1102 | private fun stubGetStagingRepoWithIdAndResponseStatusCodeAndResponseBody( 1103 | stagingRepositoryId: String, 1104 | statusCode: Int, 1105 | responseBody: String 1106 | ) { 1107 | server.stubFor( 1108 | WireMock.get(WireMock.urlEqualTo("/staging/repository/$stagingRepositoryId")) 1109 | .withHeader("Accept", WireMock.containing("application/json")) 1110 | .willReturn( 1111 | WireMock.aResponse() 1112 | .withStatus(statusCode) 1113 | .withHeader("Content-Type", "application/json") 1114 | .withBody(responseBody) 1115 | ) 1116 | ) 1117 | } 1118 | 1119 | private fun stubGetGivenStagingRepositoryInFirstAndSecondCall( 1120 | stagingRepository1: StagingRepository, 1121 | stagingRepository2: StagingRepository 1122 | ) { 1123 | server.stubFor( 1124 | WireMock.get(WireMock.urlEqualTo("/staging/repository/${stagingRepository1.id}")) 1125 | .inScenario("State") 1126 | .whenScenarioStateIs(Scenario.STARTED) 1127 | .withHeader("Accept", WireMock.containing("application/json")) 1128 | .willReturn( 1129 | WireMock.aResponse() 1130 | .withStatus(200) 1131 | .withHeader("Content-Type", "application/json") 1132 | .withBody(getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository1)) 1133 | ) 1134 | .willSetStateTo("CLOSED") 1135 | ) 1136 | 1137 | server.stubFor( 1138 | WireMock.get(WireMock.urlEqualTo("/staging/repository/${stagingRepository2.id}")) 1139 | .inScenario("State") 1140 | .whenScenarioStateIs("CLOSED") 1141 | .withHeader("Accept", WireMock.containing("application/json")) 1142 | .willReturn( 1143 | WireMock.aResponse() 1144 | .withStatus(200) 1145 | .withHeader("Content-Type", "application/json") 1146 | .withBody(getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository2)) 1147 | ) 1148 | ) 1149 | } 1150 | 1151 | private fun stubGetStagingReposForStagingProfileIdWithResponseStatusCodeAndResponseBody( 1152 | stagingProfileId: String, 1153 | statusCode: Int, 1154 | responseBody: String 1155 | ) { 1156 | server.stubFor( 1157 | WireMock.get(WireMock.urlEqualTo("/staging/profile_repositories/$stagingProfileId")) 1158 | .withHeader("Accept", WireMock.containing("application/json")) 1159 | .willReturn( 1160 | WireMock.aResponse() 1161 | .withStatus(statusCode) 1162 | .withHeader("Content-Type", "application/json") 1163 | .withBody(responseBody) 1164 | ) 1165 | ) 1166 | } 1167 | 1168 | protected fun expectArtifactUploads(prefix: String, wireMockServer: WireMockServer = server) { 1169 | wireMockServer.stubFor( 1170 | WireMock.put(WireMock.urlMatching("$prefix/.+")) 1171 | .willReturn(WireMock.aResponse().withStatus(201)) 1172 | ) 1173 | wireMockServer.stubFor( 1174 | WireMock.get(WireMock.urlMatching("$prefix/.+/maven-metadata.xml")) 1175 | .willReturn(WireMock.aResponse().withStatus(404)) 1176 | ) 1177 | } 1178 | 1179 | protected fun assertSuccess(result: BuildResult, taskPath: String) { 1180 | assertOutcome(result, taskPath, TaskOutcome.SUCCESS) 1181 | } 1182 | 1183 | private fun assertFailure(result: BuildResult, taskPath: String) { 1184 | assertOutcome(result, taskPath, TaskOutcome.FAILED) 1185 | } 1186 | 1187 | protected fun assertSkipped(result: BuildResult, taskPath: String) { 1188 | assertOutcome(result, taskPath, TaskOutcome.SKIPPED) 1189 | } 1190 | 1191 | private fun assertOutcome(result: BuildResult, taskPath: String, outcome: TaskOutcome) { 1192 | assertThat(result.task(taskPath)).describedAs("Task $taskPath") 1193 | .isNotNull 1194 | .extracting { it!!.outcome } 1195 | .isEqualTo(outcome) 1196 | } 1197 | 1198 | protected fun assertNotConsidered(result: BuildResult, taskPath: String) { 1199 | assertThat(result.task(taskPath)).describedAs("Task $taskPath").isNull() 1200 | } 1201 | 1202 | private fun assertGetStagingProfile(count: Int = 1) { 1203 | server.verify(count, WireMock.getRequestedFor(WireMock.urlMatching("/staging/profiles"))) 1204 | } 1205 | 1206 | protected fun assertUploadedToStagingRepo( 1207 | path: String, 1208 | stagingRepositoryId: String = STAGED_REPOSITORY_ID, 1209 | wireMockServer: WireMockServer = server 1210 | ) { 1211 | assertUploaded("/staging/deployByRepositoryId/$stagingRepositoryId$path", wireMockServer = wireMockServer) 1212 | } 1213 | 1214 | protected fun assertUploaded(testUrl: String, wireMockServer: WireMockServer = server) { 1215 | wireMockServer.verify(WireMock.putRequestedFor(WireMock.urlMatching(testUrl))) 1216 | } 1217 | 1218 | private fun assertCloseOfStagingRepo(stagingRepositoryId: String = STAGED_REPOSITORY_ID) { 1219 | assertGivenTransitionOperationOfStagingRepo("close", stagingRepositoryId) 1220 | } 1221 | 1222 | private fun assertReleaseOfStagingRepo(stagingRepositoryId: String = STAGED_REPOSITORY_ID) { 1223 | assertGivenTransitionOperationOfStagingRepo("promote", stagingRepositoryId) 1224 | } 1225 | 1226 | private fun assertGivenTransitionOperationOfStagingRepo(transitionOperation: String, stagingRepositoryId: String) { 1227 | server.verify( 1228 | WireMock.postRequestedFor(WireMock.urlMatching("/staging/bulk/$transitionOperation")) 1229 | .withRequestBody(WireMock.matchingJsonPath("\$.data[?(@.stagedRepositoryIds[0] == '$stagingRepositoryId')]")) 1230 | ) 1231 | } 1232 | 1233 | private fun assertGetStagingRepository(stagingRepositoryId: String = STAGED_REPOSITORY_ID, count: Int = 1) { 1234 | server.verify(count, WireMock.getRequestedFor(WireMock.urlMatching("/staging/repository/$stagingRepositoryId"))) 1235 | } 1236 | 1237 | private fun assertGetStagingRepositoriesForStatingProfile( 1238 | stagingProfileId: String = STAGING_PROFILE_ID, 1239 | count: Int = 1 1240 | ) { 1241 | server.verify( 1242 | count, 1243 | WireMock.getRequestedFor(WireMock.urlMatching("/staging/profile_repositories/$stagingProfileId")) 1244 | ) 1245 | } 1246 | 1247 | private fun getOneStagingProfileWithGivenIdShrunkJsonResponseAsString(stagingProfileId: String): String = 1248 | """ 1249 | { 1250 | "data": [ 1251 | { 1252 | "deployURI": "https://oss.sonatype.org/service/local/staging/deploy/maven2", 1253 | "id": "$stagingProfileId", 1254 | "inProgress": false, 1255 | "mode": "BOTH", 1256 | "name": "org.example", 1257 | "order": 6445, 1258 | "promotionTargetRepository": "releases", 1259 | "repositoryType": "maven2", 1260 | "resourceURI": "https://oss.sonatype.org/service/local/staging/profiles/$stagingProfileId", 1261 | "targetGroups": ["staging"] 1262 | } 1263 | ] 1264 | } 1265 | """.trimIndent() 1266 | 1267 | private fun getStagingReposWithOneStagingRepoWithGivenIdJsonResponseAsString( 1268 | stagingRepository: StagingRepository, 1269 | stagingProfileId: String = STAGING_PROFILE_ID 1270 | ): String = 1271 | """ 1272 | { 1273 | "data": [ 1274 | ${getOneStagingRepoWithGivenIdJsonResponseAsString(stagingRepository, stagingProfileId)} 1275 | ] 1276 | } 1277 | """.trimIndent() 1278 | 1279 | private fun getOneStagingRepoWithGivenIdJsonResponseAsString( 1280 | stagingRepository: StagingRepository, 1281 | stagingProfileId: String = STAGING_PROFILE_ID 1282 | ): String = 1283 | """ 1284 | { 1285 | "profileId": "$stagingProfileId", 1286 | "profileName": "some.profile.id", 1287 | "profileType": "repository", 1288 | "repositoryId": "${stagingRepository.id}", 1289 | "type": "${stagingRepository.state}", 1290 | "policy": "release", 1291 | "userId": "gradle-nexus-e2e", 1292 | "userAgent": "okhttp/3.14.4", 1293 | "ipAddress": "1.1.1.1", 1294 | "repositoryURI": "https://oss.sonatype.org/content/repositories/${stagingRepository.id}", 1295 | "created": "2020-01-28T09:51:42.804Z", 1296 | "createdDate": "Tue Jan 28 09:51:42 UTC 2020", 1297 | "createdTimestamp": 1580205102804, 1298 | "updated": "2020-01-28T10:23:49.616Z", 1299 | "updatedDate": "Tue Jan 28 10:23:49 UTC 2020", 1300 | "updatedTimestamp": 1580207029616, 1301 | "description": "org.example:sample:0.0.1", 1302 | "provider": "maven2", 1303 | "releaseRepositoryId": "no-sync-releases", 1304 | "releaseRepositoryName": "No Sync Releases", 1305 | "notifications": 0, 1306 | "transitioning": ${stagingRepository.transitioning} 1307 | } 1308 | """.trimIndent() 1309 | } 1310 | --------------------------------------------------------------------------------