From 2ccf77cead56cdb03c717106639af124d0884d0a Mon Sep 17 00:00:00 2001 From: Gabriel Ittner Date: Sun, 10 Mar 2024 19:53:09 +0100 Subject: [PATCH] support publishing through central portal (#733) * support publishing through central portal * update changelog * fix test setup * rename to isCentralPortal --- CHANGELOG.md | 2 + central-portal/build.gradle.kts | 1 + .../publish/portal/SonatypeCentralPortal.kt | 14 +- .../portal/SonatypeCentralPortalService.kt | 4 +- docs/central.md | 11 ++ gradle/libs.versions.toml | 1 + plugin/build.gradle.kts | 2 + .../publish/MavenPublishBaseExtension.kt | 6 +- .../vanniktech/maven/publish/SonatypeHost.kt | 19 ++- .../SonatypeRepositoryBuildService.kt | 143 ++++++++++++++++-- 10 files changed, 177 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8409610..5af3aa4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.28.0 **UNRELEASED** +- Added support for publishing through the new [Central Portal](https://central.sonatype.com). To use + this use the `CENTRAL_PORTAL` option when specifying the Sonatype host. - Updated minimum supported Gradle, Android Gradle Plugin and Kotlin versions. - Removed support for the deprecated Kotlin/JS plugin. - Removed the deprecated `closeAndReleaseRepository` task. Use `releaseRepository`, which diff --git a/central-portal/build.gradle.kts b/central-portal/build.gradle.kts index cede62a5..8aa3f0b8 100644 --- a/central-portal/build.gradle.kts +++ b/central-portal/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { implementation(libs.moshi) implementation(libs.retrofit) implementation(libs.retrofit.converter.moshi) + implementation(libs.retrofit.converter.scalars) } diff --git a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt index 7cd348f2..eacf3fe4 100644 --- a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt +++ b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt @@ -6,10 +6,10 @@ import java.util.concurrent.TimeUnit import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory class SonatypeCentralPortal( private val baseUrl: String, @@ -27,6 +27,7 @@ class SonatypeCentralPortal( .writeTimeout(okhttpTimeoutSeconds, TimeUnit.SECONDS) .build() val retrofit = Retrofit.Builder() + .addConverterFactory(ScalarsConverterFactory.create()) .addConverterFactory(MoshiConverterFactory.create()) .client(okHttpClient) .baseUrl(baseUrl) @@ -35,7 +36,7 @@ class SonatypeCentralPortal( retrofit.create(SonatypeCentralPortalService::class.java) } - private fun deleteDeployment(deploymentId: String) { + fun deleteDeployment(deploymentId: String) { val deleteDeploymentResponse = service.deleteDeployment(deploymentId).execute() if (!deleteDeploymentResponse.isSuccessful) { throw IOException( @@ -46,7 +47,7 @@ class SonatypeCentralPortal( } } - private fun publishDeployment(deploymentId: String) { + fun publishDeployment(deploymentId: String) { val publishDeploymentResponse = service.publishDeployment(deploymentId).execute() if (!publishDeploymentResponse.isSuccessful) { throw IOException( @@ -118,10 +119,9 @@ class SonatypeCentralPortal( } } - private fun upload(name: String?, publishingType: String?, file: File): String { - val uploadFile: RequestBody = file.asRequestBody("application/octet-stream".toMediaType()) - val multipart = - MultipartBody.Part.createFormData("bundle", file.getName(), uploadFile) + fun upload(name: String?, publishingType: String?, file: File): String { + val uploadFile = file.asRequestBody("application/octet-stream".toMediaType()) + val multipart = MultipartBody.Part.createFormData("bundle", file.name, uploadFile) val uploadResponse = service.uploadBundle(name, publishingType, multipart).execute() if (uploadResponse.isSuccessful) { return uploadResponse.body()!! diff --git a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt index 5b2c0b13..c2807770 100644 --- a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt +++ b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt @@ -3,11 +3,11 @@ package com.vanniktech.maven.publish.portal import okhttp3.MultipartBody import okhttp3.ResponseBody import retrofit2.Call -import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.Streaming @@ -50,7 +50,7 @@ internal interface SonatypeCentralPortalService { fun uploadBundle( @Query("name") name: String?, @Query("publishingType") publishingType: String?, - @Body input: MultipartBody.Part, + @Part input: MultipartBody.Part, ): Call @Streaming diff --git a/docs/central.md b/docs/central.md index 7d06b16d..b2b0ec79 100755 --- a/docs/central.md +++ b/docs/central.md @@ -77,6 +77,8 @@ This can be done through either the DSL or by setting Gradle properties. publishToMavenCentral(SonatypeHost.DEFAULT) // or when publishing to https://s01.oss.sonatype.org publishToMavenCentral(SonatypeHost.S01) + // or when publishing to https://central.sonatype.com/ + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) signAllPublications() } @@ -91,6 +93,8 @@ This can be done through either the DSL or by setting Gradle properties. publishToMavenCentral(SonatypeHost.DEFAULT) // or when publishing to https://s01.oss.sonatype.org publishToMavenCentral(SonatypeHost.S01) + // or when publishing to https://central.sonatype.com/ + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) signAllPublications() } @@ -102,6 +106,8 @@ This can be done through either the DSL or by setting Gradle properties. SONATYPE_HOST=DEFAULT # or when publishing to https://s01.oss.sonatype.org SONATYPE_HOST=S01 + // or when publishing to https://central.sonatype.com/ + SONATYPE_HOST=CENTRAL_PORTAL RELEASE_SIGNING_ENABLED=true ``` @@ -253,6 +259,11 @@ lQdGBF4jUfwBEACblZV4uBViHcYLOb2280tEpr64iB9b6YRkWil3EODiiLd9JS3V...9pip+B1QLwEdL ## Publishing snapshots +!!! warning "Central Portal" + + Publishing snapshots is not supported when using the Central Portal (central.sonatype.com). + + Snapshots can be published by setting the version to something ending with `-SNAPSHOT` and then running the following Gradle task: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21f7f0ae..ead8b78e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.re retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } +retrofit-converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } testParameterInjector = "com.google.testparameterinjector:test-parameter-injector-junit5:1.15" diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 641b23e0..38e6d65f 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { compileOnly(libs.kotlin.plugin) compileOnly(libs.android.plugin) + implementation(projects.centralPortal) implementation(projects.nexus) testImplementation(gradleTestKit()) @@ -60,6 +61,7 @@ val integrationTest by tasks.registering(Test::class) { dependsOn( tasks.publishToMavenLocal, projects.nexus.dependencyProject.tasks.publishToMavenLocal, + projects.centralPortal.dependencyProject.tasks.publishToMavenLocal, ) mustRunAfter(tasks.test) diff --git a/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt b/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt index a1dfd336..d4857316 100644 --- a/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt +++ b/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt @@ -63,13 +63,17 @@ abstract class MavenPublishBaseExtension( repositoryUsername = project.providers.gradleProperty("mavenCentralUsername"), repositoryPassword = project.providers.gradleProperty("mavenCentralPassword"), automaticRelease = automaticRelease, + // TODO: stop accessing rootProject https://github.com/gradle/gradle/pull/26635 + rootBuildDirectory = project.rootProject.layout.buildDirectory, ) val configCacheEnabled = project.configurationCache() project.gradlePublishing.repositories.maven { repo -> repo.name = "mavenCentral" repo.setUrl(buildService.map { it.publishingUrl(configCacheEnabled) }) - repo.credentials(PasswordCredentials::class.java) + if (!host.isCentralPortal) { + repo.credentials(PasswordCredentials::class.java) + } } val createRepository = project.tasks.registerCreateRepository(buildService) diff --git a/plugin/src/main/kotlin/com/vanniktech/maven/publish/SonatypeHost.kt b/plugin/src/main/kotlin/com/vanniktech/maven/publish/SonatypeHost.kt index b0eacb68..6330e71c 100644 --- a/plugin/src/main/kotlin/com/vanniktech/maven/publish/SonatypeHost.kt +++ b/plugin/src/main/kotlin/com/vanniktech/maven/publish/SonatypeHost.kt @@ -8,11 +8,18 @@ import java.io.Serializable * * https://central.sonatype.org/articles/2021/Feb/23/new-users-on-s01osssonatypeorg/ */ -data class SonatypeHost( +data class SonatypeHost internal constructor( internal val rootUrl: String, + internal val isCentralPortal: Boolean, ) : Serializable { + constructor(rootUrl: String) : this(rootUrl, isCentralPortal = false) + internal fun apiBaseUrl(): String { - return "$rootUrl/service/local/" + return if (isCentralPortal) { + "$rootUrl/api/v1/" + } else { + "$rootUrl/service/local/" + } } companion object { @@ -20,13 +27,17 @@ data class SonatypeHost( fun valueOf(sonatypeHost: String): SonatypeHost = when (sonatypeHost) { "DEFAULT" -> DEFAULT "S01" -> S01 + "CENTRAL_PORTAL" -> CENTRAL_PORTAL else -> throw IllegalArgumentException("No SonatypeHost constant $sonatypeHost") } @JvmField - val DEFAULT = SonatypeHost("https://oss.sonatype.org") + val DEFAULT = SonatypeHost("https://oss.sonatype.org", isCentralPortal = false) + + @JvmField + val S01 = SonatypeHost("https://s01.oss.sonatype.org", isCentralPortal = false) @JvmField - val S01 = SonatypeHost("https://s01.oss.sonatype.org") + val CENTRAL_PORTAL = SonatypeHost("https://central.sonatype.com", isCentralPortal = true) } } diff --git a/plugin/src/main/kotlin/com/vanniktech/maven/publish/sonatype/SonatypeRepositoryBuildService.kt b/plugin/src/main/kotlin/com/vanniktech/maven/publish/sonatype/SonatypeRepositoryBuildService.kt index 5c03fcc4..5db048d7 100644 --- a/plugin/src/main/kotlin/com/vanniktech/maven/publish/sonatype/SonatypeRepositoryBuildService.kt +++ b/plugin/src/main/kotlin/com/vanniktech/maven/publish/sonatype/SonatypeRepositoryBuildService.kt @@ -3,8 +3,17 @@ package com.vanniktech.maven.publish.sonatype import com.vanniktech.maven.publish.BuildConfig import com.vanniktech.maven.publish.SonatypeHost import com.vanniktech.maven.publish.nexus.Nexus +import com.vanniktech.maven.publish.portal.SonatypeCentralPortal +import java.io.File +import java.io.FileOutputStream import java.io.IOException +import java.util.Base64 +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging import org.gradle.api.provider.Property @@ -30,6 +39,7 @@ internal abstract class SonatypeRepositoryBuildService : val automaticRelease: Property val okhttpTimeoutSeconds: Property val closeTimeoutSeconds: Property + val rootBuildDirectory: DirectoryProperty } private sealed interface EndOfBuildAction { @@ -51,6 +61,19 @@ internal abstract class SonatypeRepositoryBuildService : ) : EndOfBuildAction } + private val centralPortal by lazy { + SonatypeCentralPortal( + baseUrl = parameters.sonatypeHost.get().apiBaseUrl(), + usertoken = Base64.getEncoder().encode( + "${parameters.repositoryUsername.get()}:${parameters.repositoryPassword.get()}".toByteArray(), + ).toString(Charsets.UTF_8), + userAgentName = BuildConfig.NAME, + userAgentVersion = BuildConfig.VERSION, + okhttpTimeoutSeconds = parameters.okhttpTimeoutSeconds.get(), + closeTimeoutSeconds = parameters.closeTimeoutSeconds.get(), + ) + } + private val nexus by lazy { Nexus( baseUrl = parameters.sonatypeHost.get().apiBaseUrl(), @@ -63,10 +86,19 @@ internal abstract class SonatypeRepositoryBuildService : ) } - private var stagingRepositoryId: String? = null + // used for the publishing tasks + private var uploadId: String? = null set(value) { check(field == null || field == value) { - "stagingRepositoryId was already set to '$field', new value '$value'" + "uploadId was already set to '$field', new value '$value'" + } + field = value + } + + private var publishId: String? = null + set(value) { + check(field == null || field == value) { + "publishId was already set to '$field', new value '$value'" } field = value } @@ -83,11 +115,16 @@ internal abstract class SonatypeRepositoryBuildService : return } - if (stagingRepositoryId != null) { + if (uploadId != null) { return } - stagingRepositoryId = nexus.createRepositoryForGroup(parameters.groupId.get()) + uploadId = if (parameters.sonatypeHost.get().isCentralPortal) { + UUID.randomUUID().toString() + } else { + nexus.createRepositoryForGroup(parameters.groupId.get()) + } + endOfBuildActions += EndOfBuildAction.Close(searchForRepositoryIfNoIdPresent = false) if (parameters.automaticRelease.get()) { endOfBuildActions += EndOfBuildAction.ReleaseAfterClose @@ -104,7 +141,11 @@ internal abstract class SonatypeRepositoryBuildService : */ fun shouldCloseAndReleaseRepository(manualStagingRepositoryId: String?) { if (manualStagingRepositoryId != null) { - stagingRepositoryId = manualStagingRepositoryId + publishId = manualStagingRepositoryId + } else { + if (uploadId == null && parameters.sonatypeHost.get().isCentralPortal) { + error("A deployment id needs to be provided with `--repository` when publishing through Central Portal") + } } endOfBuildActions += EndOfBuildAction.Close(searchForRepositoryIfNoIdPresent = true) @@ -117,7 +158,11 @@ internal abstract class SonatypeRepositoryBuildService : */ fun shouldDropRepository(manualStagingRepositoryId: String?) { if (manualStagingRepositoryId != null) { - stagingRepositoryId = manualStagingRepositoryId + publishId = manualStagingRepositoryId + } else { + if (parameters.sonatypeHost.get().isCentralPortal) { + error("A deployment id needs to be provided with `--repository` when publishing through Central Portal") + } } endOfBuildActions += EndOfBuildAction.Drop( @@ -128,12 +173,18 @@ internal abstract class SonatypeRepositoryBuildService : internal fun publishingUrl(configCacheEnabled: Boolean): String { return if (parameters.versionIsSnapshot.get()) { - require(stagingRepositoryId == null) { + require(uploadId == null) { "Staging repositories are not supported for SNAPSHOT versions." } - "${parameters.sonatypeHost.get().rootUrl}/content/repositories/snapshots/" + + val host = parameters.sonatypeHost.get() + require(!host.isCentralPortal) { + "Snapshots are not supported when publishing through the central portal." + } + + "${host.rootUrl}/content/repositories/snapshots/" } else { - val stagingRepositoryId = requireNotNull(stagingRepositoryId) { + val stagingRepositoryId = requireNotNull(uploadId) { if (configCacheEnabled) { "Publishing releases to Maven Central is not supported yet with configuration caching enabled, because of " + "this missing Gradle feature: https://github.com/gradle/gradle/issues/22779" @@ -142,7 +193,12 @@ internal abstract class SonatypeRepositoryBuildService : } } - "${parameters.sonatypeHost.get().rootUrl}/service/local/staging/deployByRepositoryId/$stagingRepositoryId/" + val host = parameters.sonatypeHost.get() + if (host.isCentralPortal) { + "file://${parameters.rootBuildDirectory.get()}/publish/staging/$stagingRepositoryId" + } else { + "${host.rootUrl}/service/local/staging/deployByRepositoryId/$stagingRepositoryId/" + } } } @@ -163,14 +219,75 @@ internal abstract class SonatypeRepositoryBuildService : if (buildIsSuccess) { throw e } else { - logger.info("Failed processing $stagingRepositoryId staging repository after previous build failure", e) + logger.info("Failed processing $uploadId staging repository after previous build failure", e) } } } } private fun runEndOfBuildActions(actions: List) { - var stagingRepositoryId = stagingRepositoryId + if (parameters.sonatypeHost.get().isCentralPortal) { + runCentralPortalEndOfBuildActions(actions) + } else { + runNexusEndOfBuildActions(actions) + } + } + + private fun runCentralPortalEndOfBuildActions(actions: List) { + val uploadId = uploadId + + val closeActions = actions.filterIsInstance() + if (closeActions.isNotEmpty()) { + if (uploadId != null) { + val deploymentName = "${parameters.groupId.get()}-$uploadId" + val publishingType = if (actions.contains(EndOfBuildAction.ReleaseAfterClose)) { + "AUTOMATIC" + } else { + "USER_MANAGED" + } + + val directory = File(publishingUrl(false).substringAfter("://")) + val zipFile = File("${directory.absolutePath}.zip") + val out = ZipOutputStream(FileOutputStream(zipFile)) + directory.walkTopDown().forEach { + if (it.isDirectory) { + return@forEach + } + if (it.name.contains("maven-metadata")) { + return@forEach + } + + val entry = ZipEntry(it.toRelativeString(directory)) + out.putNextEntry(entry) + out.write(it.readBytes()) + out.closeEntry() + } + out.close() + + publishId = centralPortal.upload(deploymentName, publishingType, zipFile) + } else { + val publishId = publishId + if (publishId != null) { + centralPortal.publishDeployment(publishId) + } else if (closeActions.all { it.searchForRepositoryIfNoIdPresent }) { + error("A deployment id needs to be provided when publishing through Central Portal") + } + } + } + + val dropAction = actions.filterIsInstance().singleOrNull() + if (dropAction != null) { + val publishId = publishId + if (publishId != null) { + centralPortal.deleteDeployment(publishId) + } else if (dropAction.searchForRepositoryIfNoIdPresent) { + error("A deployment id needs to be provided when publishing through Central Portal") + } + } + } + + private fun runNexusEndOfBuildActions(actions: List) { + var stagingRepositoryId = uploadId ?: publishId val closeActions = actions.filterIsInstance() if (closeActions.isNotEmpty()) { @@ -206,6 +323,7 @@ internal abstract class SonatypeRepositoryBuildService : repositoryUsername: Provider, repositoryPassword: Provider, automaticRelease: Boolean, + rootBuildDirectory: Provider, ): Provider { val okhttpTimeout = project.providers.gradleProperty("SONATYPE_CONNECT_TIMEOUT_SECONDS") .map { it.toLong() } @@ -223,6 +341,7 @@ internal abstract class SonatypeRepositoryBuildService : it.parameters.automaticRelease.set(automaticRelease) it.parameters.okhttpTimeoutSeconds.set(okhttpTimeout) it.parameters.closeTimeoutSeconds.set(closeTimeout) + it.parameters.rootBuildDirectory.set(rootBuildDirectory) } project.serviceOf().onTaskCompletion(service) return service