From 011aa94599920aa8e78efd1d388522a35599a970 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 26 Apr 2026 14:30:31 +0200 Subject: [PATCH] Add `nmcpPublishCentralPortalDeployment` task Part of #250. --- nmcp-tasks/api/nmcp-tasks.api | 10 ++ .../internal/task/nmcpPublishDeployment.kt | 53 ++++++++ .../task/nmcpPublishWithPublisherApi.kt | 120 +---------------- .../main/kotlin/nmcp/internal/task/portal.kt | 126 ++++++++++++++++++ .../main/kotlin/nmcp/transport/transport.kt | 3 +- nmcp/src/main/kotlin/nmcp/internal/utils.kt | 18 ++- scripts/integration-tests.sh | 1 + tests/kmp/build.gradle.kts | 11 +- 8 files changed, 219 insertions(+), 123 deletions(-) create mode 100644 nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishDeployment.kt create mode 100644 nmcp-tasks/src/main/kotlin/nmcp/internal/task/portal.kt diff --git a/nmcp-tasks/api/nmcp-tasks.api b/nmcp-tasks/api/nmcp-tasks.api index 78c5c17..ed35a7c 100644 --- a/nmcp-tasks/api/nmcp-tasks.api +++ b/nmcp-tasks/api/nmcp-tasks.api @@ -18,6 +18,16 @@ public final class nmcp/internal/task/NmcpCheckFilesEntryPoint$Companion { public final fun run (Ljava/util/List;Ljava/io/File;Z)V } +public final class nmcp/internal/task/NmcpPublishDeploymentEntryPoint { + public static final field Companion Lnmcp/internal/task/NmcpPublishDeploymentEntryPoint$Companion; + public fun ()V + public static final fun run (Ljava/util/function/BiConsumer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)V +} + +public final class nmcp/internal/task/NmcpPublishDeploymentEntryPoint$Companion { + public final fun run (Ljava/util/function/BiConsumer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)V +} + public final class nmcp/internal/task/NmcpPublishFileByFileToFileSystemEntryPoint { public static final field Companion Lnmcp/internal/task/NmcpPublishFileByFileToFileSystemEntryPoint$Companion; public fun ()V diff --git a/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishDeployment.kt b/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishDeployment.kt new file mode 100644 index 0000000..0d2396e --- /dev/null +++ b/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishDeployment.kt @@ -0,0 +1,53 @@ +package nmcp.internal.task + +import gratatouille.tasks.GLogger +import gratatouille.tasks.GTask +import kotlin.time.Duration.Companion.seconds +import nmcp.transport.Success +import nmcp.transport.executeWithRetries +import nmcp.transport.nmcpClient +import okhttp3.Request +import okhttp3.RequestBody + +@GTask(pure = false) +internal fun nmcpPublishDeployment( + logger: GLogger, + username: String?, + password: String?, + deploymentId: String?, + baseUrl: String?, + publishingTimeoutSeconds: Long? +) { + check(!deploymentId.isNullOrBlank()) { + "Nmcp: deploymentId is missing" + } + + val token = toBearerToken(username, password) + + @Suppress("NAME_SHADOWING") + val baseUrl = baseUrl ?: "https://central.sonatype.com/" + val url = baseUrl + "api/v1/publisher/deployment/$deploymentId" + + logger.lifecycle("Publishing previously uploaded deployment bundle '$deploymentId'") + val request = Request.Builder() + .post(RequestBody.EMPTY) + .addHeader("Authorization", "Bearer $token") + .url(url) + .build() + val result = executeWithRetries(logger, nmcpClient, request) + + if (result !is Success) { + error("Cannot publish deployment '$deploymentId' to maven central: ($result)}") + } + + logger.lifecycle("Nmcp: deployment bundle '$deploymentId' moved to 'publishing' status.") + + val timeout = publishingTimeoutSeconds?.seconds ?: 0.seconds + if (timeout.isPositive()) { + logger.lifecycle("Nmcp: waiting for publication...") + waitForStatus(setOf(PUBLISHED), timeout, logger, deploymentId, baseUrl, token) + logger.lifecycle("Nmcp: deployment is published.") + } else { + logger.lifecycle("Nmcp: deployment is publishing... Check the central portal UI to verify its status.") + } +} diff --git a/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt b/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt index 10b4cca..34c8a28 100644 --- a/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt +++ b/nmcp-tasks/src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt @@ -3,14 +3,8 @@ package nmcp.internal.task import gratatouille.tasks.GInputFile import gratatouille.tasks.GLogger import gratatouille.tasks.GTask -import java.net.SocketTimeoutException -import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -import kotlin.time.TimeSource.Monotonic.markNow -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import nmcp.transport.Success import nmcp.transport.executeWithRetries import nmcp.transport.nmcpClient @@ -18,9 +12,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody -import okhttp3.RequestBody.Companion.toRequestBody import okio.Buffer -import okio.ByteString import okio.use @GTask(pure = false) @@ -35,16 +27,7 @@ internal fun nmcpPublishWithPublisherApi( publishingTimeoutSeconds: Long?, inputFile: GInputFile, ) { - check(!username.isNullOrBlank()) { - "Nmcp: username is missing" - } - check(!password.isNullOrBlank()) { - "Nmcp: password is missing" - } - - val token = "$username:$password".let { - Buffer().writeUtf8(it).readByteString().base64() - } + val token = toBearerToken(username, password) val body = MultipartBody.Builder() .addFormDataPart( @@ -79,13 +62,13 @@ internal fun nmcpPublishWithPublisherApi( val timeout1 = validationTimeoutSeconds?.seconds ?: 10.minutes if (timeout1.isPositive()) { logger.lifecycle("Nmcp: waiting for validation...") - waitFor(setOf(VALIDATED, PUBLISHING, PUBLISHED), timeout1, logger, deploymentId, baseUrl, token) + waitForStatus(setOf(VALIDATED, PUBLISHING, PUBLISHED), timeout1, logger, deploymentId, baseUrl, token) val timeout2 = publishingTimeoutSeconds?.seconds ?: 0.seconds if (publishingType == "AUTOMATIC") { if (timeout2.isPositive()) { logger.lifecycle("Nmcp: deployment is validated, waiting for publication...") - waitFor(setOf(PUBLISHED), timeout2, logger, deploymentId, baseUrl, token) + waitForStatus(setOf(PUBLISHED), timeout2, logger, deploymentId, baseUrl, token) logger.lifecycle("Nmcp: deployment is published.") } else { logger.lifecycle("Nmcp: deployment is publishing... Check the central portal UI to verify its status.") @@ -94,104 +77,9 @@ internal fun nmcpPublishWithPublisherApi( check(publishingTimeoutSeconds == null) { "Nmcp: 'publishingTimeout' has no effect if 'publishingType' is USER_MANAGED. Either set 'publishingType = AUTOMATIC' or remove 'publishingTimeout'" } - logger.lifecycle("Nmcp: deployment has passed validation, publish it manually from the Central Portal UI.") + logger.lifecycle("Nmcp: deployment has passed validation, publish it manually from the Central Portal UI or call './gradlew nmcpPublishCentralPortalDeployment -PnmcpDeploymentId=$deploymentId'.") } } else { logger.lifecycle("Nmcp: deployment is validating... Check the central portal UI to verify its status.") } } - -private fun waitFor( - target: Set, - timeout: Duration, - logger: GLogger, - deploymentId: String, - baseUrl: String, - token: String, -) { - val pollingInterval = 5.seconds - val mark = markNow() - while (true) { - check(mark.elapsedNow() < timeout) { - "Nmcp: timeout while checking deployment '$deploymentId'. You might need to check the deployment status on the Central Portal UI (see $baseUrl), or you could increase the timeout." - } - - val status = verifyStatus( - logger = logger, - deploymentId = deploymentId, - baseUrl = baseUrl, - token = token, - ) - if (status is FAILED) { - error("Nmcp: deployment has failed:\n${status.error}") - } else if (status in target) { - return - } else { - logger.lifecycle("Nmcp: deployment status is '$status', will try again in ${pollingInterval.inWholeSeconds}s (${(timeout - mark.elapsedNow()).inWholeSeconds.seconds} left)...") - // Wait for the next attempt to reduce the load on the Central Portal API - Thread.sleep(pollingInterval.inWholeMilliseconds) - continue - } - } -} - -private sealed interface Status - -// A deployment is uploaded and waiting for processing by the validation service -private data object PENDING : Status - -// A deployment is being processed by the validation service -private data object VALIDATING : Status - -// A deployment has passed validation and is waiting on a user to manually publish via the Central Portal UI -private data object VALIDATED : Status - -// A deployment has been either automatically or manually published and is being uploaded to Maven Central -private data object PUBLISHING : Status - -// A deployment has successfully been uploaded to Maven Central -private data object PUBLISHED : Status - -// A deployment has encountered an error -private class FAILED(val error: String) : Status - -private fun verifyStatus( - logger: GLogger, - deploymentId: String, - baseUrl: String, - token: String, -): Status { - val request = Request.Builder() - .post(ByteString.EMPTY.toRequestBody()) - .addHeader("Authorization", "Bearer $token") - .url(baseUrl + "api/v1/publisher/status?id=$deploymentId") - .build() - val result = executeWithRetries(logger, nmcpClient, request) - if (result !is Success) { - error("Cannot verify deployment $deploymentId status ($result)") - } - - val str = result.body.use { it.readUtf8() } - val element = Json.parseToJsonElement(str) - check(element is JsonObject) { - "Nmcp: unexpected status response for deployment $deploymentId: $str" - } - - val state = element["deploymentState"] - check(state is JsonPrimitive && state.isString) { - "Nmcp: unexpected deploymentState for deployment $deploymentId: $state" - } - - return when (state.content) { - "PENDING" -> PENDING - "VALIDATING" -> VALIDATING - "VALIDATED" -> VALIDATED - "PUBLISHING" -> PUBLISHING - "PUBLISHED" -> PUBLISHED - "FAILED" -> { - FAILED(element["errors"].toString()) - } - else -> error("Nmcp: unexpected deploymentState for deployment $deploymentId: $state") - } - -} diff --git a/nmcp-tasks/src/main/kotlin/nmcp/internal/task/portal.kt b/nmcp-tasks/src/main/kotlin/nmcp/internal/task/portal.kt new file mode 100644 index 0000000..f547207 --- /dev/null +++ b/nmcp-tasks/src/main/kotlin/nmcp/internal/task/portal.kt @@ -0,0 +1,126 @@ +package nmcp.internal.task + +import gratatouille.tasks.GLogger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource.Monotonic.markNow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import nmcp.transport.Success +import nmcp.transport.executeWithRetries +import nmcp.transport.nmcpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer +import okio.ByteString +import okio.use + +internal fun waitForStatus( + target: Set, + timeout: Duration, + logger: GLogger, + deploymentId: String, + baseUrl: String, + token: String, +) { + val pollingInterval = 5.seconds + val mark = markNow() + while (true) { + check(mark.elapsedNow() < timeout) { + "Nmcp: timeout while checking deployment '$deploymentId'. You might need to check the deployment status on the Central Portal UI (see $baseUrl), or you could increase the timeout." + } + + val status = verifyStatus( + logger = logger, + deploymentId = deploymentId, + baseUrl = baseUrl, + token = token, + ) + if (status is FAILED) { + error("Nmcp: deployment has failed:\n${status.error}") + } else if (status in target) { + return + } else { + logger.lifecycle("Nmcp: deployment status is '$status', will try again in ${pollingInterval.inWholeSeconds}s (${(timeout - mark.elapsedNow()).inWholeSeconds.seconds} left)...") + // Wait for the next attempt to reduce the load on the Central Portal API + Thread.sleep(pollingInterval.inWholeMilliseconds) + continue + } + } +} + +internal sealed interface Status + +// A deployment is uploaded and waiting for processing by the validation service +internal data object PENDING : Status + +// A deployment is being processed by the validation service +internal data object VALIDATING : Status + +// A deployment has passed validation and is waiting on a user to manually publish via the Central Portal UI +internal data object VALIDATED : Status + +// A deployment has been either automatically or manually published and is being uploaded to Maven Central +internal data object PUBLISHING : Status + +// A deployment has successfully been uploaded to Maven Central +internal data object PUBLISHED : Status + +// A deployment has encountered an error +internal class FAILED(val error: String) : Status + +internal fun verifyStatus( + logger: GLogger, + deploymentId: String, + baseUrl: String, + token: String, +): Status { + val request = Request.Builder() + .post(ByteString.EMPTY.toRequestBody()) + .addHeader("Authorization", "Bearer $token") + .url(baseUrl + "api/v1/publisher/status?id=$deploymentId") + .build() + val result = executeWithRetries(logger, nmcpClient, request) + if (result !is Success) { + error("Cannot verify deployment $deploymentId status ($result)") + } + + val str = result.body.use { it.readUtf8() } + val element = Json.parseToJsonElement(str) + check(element is JsonObject) { + "Nmcp: unexpected status response for deployment $deploymentId: $str" + } + + val state = element["deploymentState"] + check(state is JsonPrimitive && state.isString) { + "Nmcp: unexpected deploymentState for deployment $deploymentId: $state" + } + + return when (state.content) { + "PENDING" -> PENDING + "VALIDATING" -> VALIDATING + "VALIDATED" -> VALIDATED + "PUBLISHING" -> PUBLISHING + "PUBLISHED" -> PUBLISHED + "FAILED" -> { + FAILED(element["errors"].toString()) + } + else -> error("Nmcp: unexpected deploymentState for deployment $deploymentId: $state") + } + +} + +internal fun toBearerToken(username: String?, password: String?): String { + check(!username.isNullOrBlank()) { + "Nmcp: username is missing" + } + check(!password.isNullOrBlank()) { + "Nmcp: password is missing" + } + + val token = "$username:$password".let { + Buffer().writeUtf8(it).readByteString().base64() + } + return token +} diff --git a/nmcp-tasks/src/main/kotlin/nmcp/transport/transport.kt b/nmcp-tasks/src/main/kotlin/nmcp/transport/transport.kt index f57d728..8be8d23 100644 --- a/nmcp-tasks/src/main/kotlin/nmcp/transport/transport.kt +++ b/nmcp-tasks/src/main/kotlin/nmcp/transport/transport.kt @@ -195,7 +195,7 @@ internal fun executeWithRetries(logger: GLogger, client: OkHttpClient, request: return result } - logger.lifecycle("Nmcp: put '${request.url}' failed (${result}), retrying... (attempt ${attempt + 1}/${attemptCount})") + logger.lifecycle("Nmcp: ${request.method} '${request.url}' failed (${result}), retrying... (attempt ${attempt + 1}/${attemptCount})") Thread.sleep(2.0.pow(attempt.toDouble()).toLong() * 1_000) attempt++ } @@ -262,4 +262,3 @@ internal class FilesystemTransport( } } } - diff --git a/nmcp/src/main/kotlin/nmcp/internal/utils.kt b/nmcp/src/main/kotlin/nmcp/internal/utils.kt index e8e9f7c..61b9468 100644 --- a/nmcp/src/main/kotlin/nmcp/internal/utils.kt +++ b/nmcp/src/main/kotlin/nmcp/internal/utils.kt @@ -4,6 +4,7 @@ import gratatouille.capitalizeFirstLetter import java.io.File import nmcp.CentralPortalOptions import nmcp.internal.task.registerNmcpCheckFilesTask +import nmcp.internal.task.registerNmcpPublishDeploymentTask import nmcp.internal.task.registerNmcpPublishFileByFileToFileSystemTask import nmcp.internal.task.registerNmcpPublishFileByFileToSnapshotsTask import nmcp.internal.task.registerNmcpPublishWithPublisherApiTask @@ -93,7 +94,7 @@ internal fun Project.registerPublishToCentralPortalTasks( } - val task = registerNmcpPublishWithPublisherApiTask( + val releaseTask = registerNmcpPublishWithPublisherApiTask( taskName = releaseTaskName, inputFile = zipTaskProvider.flatMap { it.archiveFile }, username = spec.username, @@ -107,7 +108,18 @@ internal fun Project.registerPublishToCentralPortalTasks( project.tasks.register(centralPortalLifecycleTaskName) { it.group = PUBLISH_TASK_GROUP it.description = "$description to the Central Releases repository." - it.dependsOn(task) + it.dependsOn(releaseTask) + } + + if (kind == Kind.aggregation) { + registerNmcpPublishDeploymentTask( + taskName = "nmcpPublishCentralPortalDeployment", + username = spec.username, + password = spec.password, + baseUrl = spec.baseUrl, + deploymentId = project.providers.gradleProperty("nmcpDeploymentId"), + publishingTimeoutSeconds = spec.publishingTimeout.map { it.seconds }, + ) } val centralSnapshots = registerNmcpPublishFileByFileToSnapshotsTask( @@ -145,7 +157,7 @@ internal fun Project.registerPublishToCentralPortalTasks( * This gives feedback to the user before compiling all projects. */ project.gradle.taskGraph.whenReady { - if (it.hasTask(taskPath(project, task.name))) { + if (it.hasTask(taskPath(project, releaseTask.name))) { val publishingType = spec.publishingType.orNull val validValues = listOf("AUTOMATIC", "USER_MANAGED") check(publishingType == null || publishingType in validValues) { diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 51e179f..0106a10 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -3,4 +3,5 @@ set -e set -x ./gradlew -p tests/jvm build ./gradlew -p tests/kmp publishAggregationToCentralPortal --configuration-cache +./gradlew -p tests/kmp nmcpPublishCentralPortalDeployment -PnmcpDeploymentId=599ab6f5-dd08-4e7b-ae5a-85b45031715a --configuration-cache ./gradlew -p tests/kmp build diff --git a/tests/kmp/build.gradle.kts b/tests/kmp/build.gradle.kts index d8d0b49..d2f2ffe 100644 --- a/tests/kmp/build.gradle.kts +++ b/tests/kmp/build.gradle.kts @@ -21,8 +21,15 @@ buildscript { } val mockServer = MockWebServer() -mockServer.enqueue(MockResponse()) -mockServer.enqueue(MockResponse().setBody("{\"deploymentState\": \"PUBLISHED\"}")) + +if (gradle.startParameter.taskNames.contains("publishAggregationToCentralPortal")) { + mockServer.enqueue(MockResponse().setBody("599ab6f5-dd08-4e7b-ae5a-85b45031715a")) + mockServer.enqueue(MockResponse().setBody("{\"deploymentState\": \"VALIDATED\"}")) +} else if (gradle.startParameter.taskNames.contains("nmcpPublishCentralPortalDeployment")) { + mockServer.enqueue(MockResponse()) + mockServer.enqueue(MockResponse().setBody("{\"deploymentState\": \"PUBLISHED\"}")) +} +mockServer.start() nmcpAggregation { centralPortal {