Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build-plugins/build-support/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ dependencies {
compileOnly(gradleApi())
implementation(libs.aws.sdk.s3)
implementation(libs.aws.sdk.cloudwatch)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import org.jreleaser.gradle.plugin.JReleaserExtension
import org.jreleaser.model.Active
import java.time.Duration

// FIXME Relocate this file to `aws.sdk.kotlin.gradle.publishing`

private object Properties {
const val SKIP_PUBLISHING = "skipPublish"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package aws.sdk.kotlin.gradle.publishing

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.time.Duration
import java.util.Base64
import kotlin.time.Clock
import kotlin.time.ExperimentalTime

/**
* A client used for interacting with the Sonatype Publish Portal API
* https://central.sonatype.org/publish/publish-portal-api/
*/
class SonatypeCentralPortalClient(
private val authHeader: String,
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(30))
.readTimeout(Duration.ofSeconds(60))
.writeTimeout(Duration.ofSeconds(60))
.retryOnConnectionFailure(true)
.build(),
private val json: Json = Json {
ignoreUnknownKeys = true
prettyPrint = true
},
) {
companion object {
const val CENTRAL_PORTAL_USERNAME = "SONATYPE_CENTRAL_PORTAL_USERNAME"
const val CENTRAL_PORTAL_PASSWORD = "SONATYPE_CENTRAL_PORTAL_PASSWORD"
const val CENTRAL_PORTAL_BASE_URL = "https://central.sonatype.com"

fun buildAuthHeader(user: String, password: String): String {
val b64 = Base64.getEncoder().encodeToString("$user:$password".toByteArray(Charsets.UTF_8))
return "Bearer $b64"
}

fun fromEnvironment(): SonatypeCentralPortalClient {
val user = System.getenv(CENTRAL_PORTAL_USERNAME)?.takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_USERNAME not configured")
val pass = System.getenv(CENTRAL_PORTAL_PASSWORD)?.takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_PASSWORD not configured")
return SonatypeCentralPortalClient(buildAuthHeader(user, pass))
}
}

private val apiBase = CENTRAL_PORTAL_BASE_URL.toHttpUrl()

@Serializable
data class StatusResponse(
val deploymentId: String,
val deploymentName: String? = null,
val deploymentState: String,
val purls: List<String>? = null,
val errors: Map<String, List<String>>? = null,
)

/** Uploads a bundle and returns deploymentId. */
fun uploadBundle(bundle: File, deploymentName: String): String {
require(bundle.isFile && bundle.length() > 0L) { "Bundle does not exist or is empty: $bundle" }

val url = apiBase.newBuilder()
.addPathSegments("api/v1/publisher/upload")
.addQueryParameter("name", deploymentName)
.addQueryParameter("publishingType", "AUTOMATIC") // set USER_MANAGED to upload the deployment, but not release it
.build()

val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"bundle",
bundle.name,
bundle.asRequestBody("application/octet-stream".toMediaType()),
)
.build()

val request = Request.Builder()
.url(url)
.header("Authorization", authHeader)
.post(body)
.build()

client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw httpError("upload", resp)
val id = resp.body?.string()?.trim().orEmpty()
if (resp.code != 201 || id.isEmpty()) {
throw RuntimeException("Upload returned ${resp.code} but no deploymentId body; body=$id")
}
return id
}
}

/** Returns current deployment status. */
fun getStatus(deploymentId: String): StatusResponse {
val url = apiBase.newBuilder()
.addPathSegments("api/v1/publisher/status")
.addQueryParameter("id", deploymentId)
.build()

val request = Request.Builder()
.url(url)
.header("Authorization", authHeader)
.post("".toRequestBody(null))
.build()

client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw httpError("status", resp)
val payload = resp.body?.string().orEmpty()
return try {
json.decodeFromString<StatusResponse>(payload)
} catch (e: Exception) {
throw RuntimeException("Failed to parse status JSON (HTTP ${resp.code}): $payload", e)
}
}
}

/** Polls until one of [terminalStates] is reached, returning the final StatusResponse. */
@OptIn(ExperimentalTime::class) // for Clock.System.now()
fun waitForStatus(
deploymentId: String,
terminalStates: Set<String>,
pollInterval: kotlin.time.Duration,
timeout: kotlin.time.Duration,
onStateChange: (old: String?, new: String) -> Unit = { _, _ -> },
): StatusResponse {
val deadline = Clock.System.now() + timeout
var lastState: String? = null

while (Clock.System.now() < deadline) {
val status = getStatus(deploymentId)
if (status.deploymentState != lastState) {
onStateChange(lastState, status.deploymentState)
lastState = status.deploymentState
}
if (status.deploymentState in terminalStates) return status
Thread.sleep(pollInterval.inWholeMilliseconds)
}
throw RuntimeException("Timed out waiting for deployment $deploymentId to reach one of $terminalStates")
}

private fun httpError(context: String, resp: Response): RuntimeException {
val body = resp.body?.string().orEmpty()
return RuntimeException("HTTP error during $context: ${resp.code}.\nResponse: $body")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package aws.sdk.kotlin.gradle.publishing

import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

/**
* Publishes a bundle to Sonatype Portal and waits for it to be validated (but not fully published).
* States: PENDING, VALIDATING, VALIDATED, FAILED
* https://central.sonatype.org/publish/publish-portal-api/
*/
abstract class SonatypeCentralPortalPublishTask : DefaultTask() {
@get:InputFile
abstract val bundle: RegularFileProperty

/** Max time to wait for final state. */
@get:Input
@get:Optional
abstract val timeoutDuration: Property<Duration>

/** Poll interval. */
@get:Input
@get:Optional
abstract val pollInterval: Property<Duration>

init {
timeoutDuration.convention(45.minutes)
pollInterval.convention(15.seconds)
}

@TaskAction
fun run() {
val client = SonatypeCentralPortalClient.fromEnvironment()
val file = bundle.asFile.get()

// 1) Upload
val deploymentId = client.uploadBundle(file, file.name)
logger.lifecycle("📤 Uploaded bundle; deploymentId=$deploymentId")

// 2) Wait for VALIDATED or FAILED
val result = client.waitForStatus(
deploymentId = deploymentId,
terminalStates = setOf("VALIDATED", "FAILED"),
pollInterval = pollInterval.get(),
timeout = timeoutDuration.get(),
) { _, new ->
logger.lifecycle("📡 Status: $new (deploymentId=$deploymentId)")
}

// 3) Evaluate
when (result.deploymentState) {
"VALIDATED" -> logger.lifecycle("✅ Bundle validated by Maven Central")
"FAILED" -> {
val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ")
?: "\n(no error details returned)"
throw RuntimeException("❌ Sonatype deployment FAILED for ${result.deploymentId}$reasons")
}
else -> error("Unexpected terminal state: ${result.deploymentState}")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package aws.sdk.kotlin.gradle.publishing

import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

/**
* Waits for a given deploymentId to enter the PUBLISHED state
* https://central.sonatype.org/publish/publish-portal-api/
*/
abstract class SonatypeCentralPortalWaitForPublicationTask : DefaultTask() {
@get:Input
abstract val deploymentId: Property<String>

@Option(option = "deploymentId", description = "Deployment ID to wait for")
fun setDeploymentId(id: String) {
deploymentId.set(id)
}

/** Max time to wait for final state. */
@get:Input
@get:Optional
abstract val timeoutDuration: Property<Duration>

/** Poll interval. */
@get:Input
@get:Optional
abstract val pollInterval: Property<Duration>

init {
timeoutDuration.convention(90.minutes)
pollInterval.convention(30.seconds)
}

@TaskAction
fun run() {
val client = SonatypeCentralPortalClient.fromEnvironment()
val deploymentId = deploymentId.get().takeIf { it.isNotBlank() } ?: error("deploymentId not configured")

val result = client.waitForStatus(
deploymentId,
setOf("PUBLISHED", "FAILED"),
pollInterval.get(),
timeoutDuration.get(),
) { _, newState ->
logger.lifecycle("📡 Status: $newState (deploymentId=$deploymentId)")
}

when (result.deploymentState) {
"PUBLISHED" -> {
logger.lifecycle("🚀 Deployment PUBLISHED (deploymentId=$deploymentId)")
}
"FAILED" -> {
val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ") ?: "\n(no error details returned)"
throw RuntimeException("❌ Sonatype publication FAILED for $deploymentId$reasons")
}
else -> error("Unexpected terminal state: ${result.deploymentState}")
}
}
}
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ smithy-gradle-plugin-version = "1.3.0"
junit-version = "5.10.1"
coroutines-version = "1.10.2"
slf4j-version = "2.0.17"
okhttp-version = "5.1.0"
kotlinx-serialization-version = "1.9.0"

[libraries]
aws-sdk-cloudwatch = { module = "aws.sdk.kotlin:cloudwatch", version.ref = "aws-sdk-version" }
Expand All @@ -22,8 +24,11 @@ jreleaser-plugin = { module = "org.jreleaser:jreleaser-gradle-plugin", version.r
smithy-model = { module = "software.amazon.smithy:smithy-model", version.ref = "smithy-version" }
smithy-gradle-base-plugin = { module = "software.amazon.smithy.gradle:smithy-base", version.ref = "smithy-gradle-plugin-version" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-version" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines-version" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-version" }
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j-version" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp-version" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" }
Expand Down
Loading