Skip to content

Commit c48ba17

Browse files
committed
Add Sonatype Publish Portal API integration
1 parent 933097e commit c48ba17

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-0
lines changed

build-plugins/build-support/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ dependencies {
2323
compileOnly(gradleApi())
2424
implementation(libs.aws.sdk.s3)
2525
implementation(libs.aws.sdk.cloudwatch)
26+
implementation(libs.okhttp)
27+
implementation(libs.kotlinx.serialization.json)
28+
implementation(libs.kotlinx.coroutines.core)
2629
testImplementation(kotlin("test"))
2730
testImplementation(libs.kotlinx.coroutines.test)
2831
}

build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/dsl/Publish.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import org.jreleaser.gradle.plugin.JReleaserExtension
1919
import org.jreleaser.model.Active
2020
import java.time.Duration
2121

22+
// FIXME Relocate this file to `aws.sdk.kotlin.gradle.publishing`
23+
2224
private object Properties {
2325
const val SKIP_PUBLISHING = "skipPublish"
2426
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package aws.sdk.kotlin.gradle.publishing
7+
8+
import java.io.File
9+
import java.util.Base64
10+
import java.time.Duration
11+
import kotlinx.serialization.Serializable
12+
import kotlinx.serialization.json.Json
13+
import kotlin.time.ExperimentalTime
14+
import okhttp3.*
15+
import okhttp3.MediaType.Companion.toMediaType
16+
import okhttp3.RequestBody.Companion.asRequestBody
17+
import okhttp3.RequestBody.Companion.toRequestBody
18+
import okhttp3.HttpUrl.Companion.toHttpUrl
19+
import kotlin.time.Clock
20+
21+
/**
22+
* A client used for interacting with the Sonatype Publish Portal API
23+
* https://central.sonatype.org/publish/publish-portal-api/
24+
*/
25+
class SonatypeCentralPortalClient(
26+
private val authHeader: String,
27+
private val client: OkHttpClient = OkHttpClient.Builder()
28+
.connectTimeout(Duration.ofSeconds(30))
29+
.readTimeout(Duration.ofSeconds(60))
30+
.writeTimeout(Duration.ofSeconds(60))
31+
.retryOnConnectionFailure(true)
32+
.build(),
33+
private val json: Json = Json { ignoreUnknownKeys = true; prettyPrint = true }
34+
) {
35+
companion object {
36+
const val CENTRAL_PORTAL_USERNAME = "SONATYPE_CENTRAL_PORTAL_USERNAME"
37+
const val CENTRAL_PORTAL_PASSWORD = "SONATYPE_CENTRAL_PORTAL_PASSWORD"
38+
const val CENTRAL_PORTAL_BASE_URL = "https://central.sonatype.com"
39+
40+
fun buildAuthHeader(user: String, password: String): String {
41+
val b64 = Base64.getEncoder().encodeToString("$user:$password".toByteArray(Charsets.UTF_8))
42+
return "Bearer $b64"
43+
}
44+
45+
/** Helper to create a client using env vars for creds. */
46+
fun fromEnvironment(): SonatypeCentralPortalClient {
47+
val user = System.getenv(CENTRAL_PORTAL_USERNAME)?.takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_USERNAME not configured")
48+
val pass = System.getenv(CENTRAL_PORTAL_PASSWORD)?.takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_PASSWORD not configured")
49+
return SonatypeCentralPortalClient(buildAuthHeader(user, pass))
50+
}
51+
}
52+
53+
private val apiBase = CENTRAL_PORTAL_BASE_URL.toHttpUrl()
54+
55+
@Serializable
56+
data class StatusResponse(
57+
val deploymentId: String,
58+
val deploymentName: String? = null,
59+
val deploymentState: String,
60+
val purls: List<String>? = null,
61+
val errors: Map<String, List<String>>? = null,
62+
)
63+
64+
/** Uploads a bundle and returns deploymentId. */
65+
fun uploadBundle(bundle: File, deploymentName: String): String {
66+
require(bundle.isFile && bundle.length() > 0L) { "Bundle does not exist or is empty: $bundle" }
67+
68+
val url = apiBase.newBuilder()
69+
.addPathSegments("api/v1/publisher/upload")
70+
.addQueryParameter("name", deploymentName)
71+
.addQueryParameter("publishingType", "AUTOMATIC") // set USER_MANAGED to upload the deployment, but not release it
72+
.build()
73+
74+
val body = MultipartBody.Builder()
75+
.setType(MultipartBody.FORM)
76+
.addFormDataPart(
77+
"bundle",
78+
bundle.name,
79+
bundle.asRequestBody("application/octet-stream".toMediaType()),
80+
)
81+
.build()
82+
83+
val request = Request.Builder()
84+
.url(url)
85+
.header("Authorization", authHeader)
86+
.post(body)
87+
.build()
88+
89+
client.newCall(request).execute().use { resp ->
90+
if (!resp.isSuccessful) throw httpError("upload", resp)
91+
val id = resp.body?.string()?.trim().orEmpty()
92+
if (resp.code != 201 || id.isEmpty()) {
93+
throw RuntimeException("Upload returned ${resp.code} but no deploymentId body; body=$id")
94+
}
95+
return id
96+
}
97+
}
98+
99+
/** Returns current deployment status. */
100+
fun getStatus(deploymentId: String): StatusResponse {
101+
val url = apiBase.newBuilder()
102+
.addPathSegments("api/v1/publisher/status")
103+
.addQueryParameter("id", deploymentId)
104+
.build()
105+
106+
val request = Request.Builder()
107+
.url(url)
108+
.header("Authorization", authHeader)
109+
.post("".toRequestBody(null))
110+
.build()
111+
112+
client.newCall(request).execute().use { resp ->
113+
if (!resp.isSuccessful) throw httpError("status", resp)
114+
val payload = resp.body?.string().orEmpty()
115+
return try {
116+
json.decodeFromString<StatusResponse>(payload)
117+
} catch (e: Exception) {
118+
throw RuntimeException("Failed to parse status JSON (HTTP ${resp.code}): $payload", e)
119+
}
120+
}
121+
}
122+
123+
/** Polls until one of [terminalStates] is reached, returning the final StatusResponse. */
124+
@OptIn(ExperimentalTime::class) // for Clock.System.now()
125+
fun waitForStatus(
126+
deploymentId: String,
127+
terminalStates: Set<String>,
128+
pollInterval: kotlin.time.Duration,
129+
timeout: kotlin.time.Duration,
130+
onStateChange: (old: String?, new: String) -> Unit = { _, _ -> }
131+
): StatusResponse {
132+
val deadline = Clock.System.now() + timeout
133+
var lastState: String? = null
134+
135+
while (Clock.System.now() < deadline) {
136+
val status = getStatus(deploymentId)
137+
if (status.deploymentState != lastState) {
138+
onStateChange(lastState, status.deploymentState)
139+
lastState = status.deploymentState
140+
}
141+
if (status.deploymentState in terminalStates) return status
142+
Thread.sleep(pollInterval.inWholeMilliseconds)
143+
}
144+
throw RuntimeException("Timed out waiting for deployment $deploymentId to reach one of $terminalStates")
145+
}
146+
147+
private fun httpError(context: String, resp: Response): RuntimeException {
148+
val body = resp.body?.string().orEmpty()
149+
return RuntimeException("HTTP error during $context: ${resp.code}.\nResponse: $body")
150+
}
151+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package aws.sdk.kotlin.gradle.publishing
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.file.RegularFileProperty
5+
import org.gradle.api.provider.Property
6+
import org.gradle.api.tasks.*
7+
import kotlin.time.Duration
8+
import kotlin.time.Duration.Companion.seconds
9+
import kotlin.time.Duration.Companion.minutes
10+
11+
/**
12+
* Publishes a bundle to Sonatype Portal and waits for completion.
13+
* States: PENDING, VALIDATING, VALIDATED, FAILED
14+
* https://central.sonatype.org/publish/publish-portal-api/
15+
*/
16+
abstract class SonatypeCentralPortalPublishTask : DefaultTask() {
17+
@get:InputFile
18+
abstract val bundle: RegularFileProperty
19+
20+
/** Max time to wait for final state. */
21+
@get:Input
22+
@get:Optional
23+
abstract val timeoutDuration: Property<Duration>
24+
25+
/** Poll interval. */
26+
@get:Input
27+
@get:Optional
28+
abstract val pollInterval: Property<Duration>
29+
30+
init {
31+
timeoutDuration.convention(45.minutes)
32+
pollInterval.convention(15.seconds)
33+
}
34+
35+
@TaskAction
36+
fun run() {
37+
val client = SonatypeCentralPortalClient.fromEnvironment()
38+
val file = bundle.asFile.get()
39+
40+
// 1) Upload
41+
val deploymentId = client.uploadBundle(file, file.name)
42+
logger.lifecycle("📤 Uploaded bundle; deploymentId=$deploymentId")
43+
44+
// 2) Wait for VALIDATED or FAILED
45+
val result = client.waitForStatus(
46+
deploymentId = deploymentId,
47+
terminalStates = setOf("VALIDATED", "FAILED"),
48+
pollInterval = pollInterval.get(),
49+
timeout = timeoutDuration.get(),
50+
) { _, new ->
51+
logger.lifecycle("📡 Status: $new (deploymentId=$deploymentId)")
52+
}
53+
54+
// 3) Evaluate
55+
when (result.deploymentState) {
56+
"VALIDATED" -> logger.lifecycle("✅ Bundle validated by Maven Central")
57+
"FAILED" -> {
58+
val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ")
59+
?: "\n(no error details returned)"
60+
throw RuntimeException("❌ Sonatype deployment FAILED for ${result.deploymentId}$reasons")
61+
}
62+
else -> error("Unexpected terminal state: ${result.deploymentState}")
63+
}
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package aws.sdk.kotlin.gradle.publishing
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.provider.Property
5+
import org.gradle.api.tasks.Input
6+
import org.gradle.api.tasks.Optional
7+
import org.gradle.api.tasks.TaskAction
8+
import org.gradle.api.tasks.options.Option
9+
import kotlin.time.Duration
10+
import kotlin.time.Duration.Companion.minutes
11+
import kotlin.time.Duration.Companion.seconds
12+
13+
/**
14+
* Waits for a given deploymentId to enter the PUBLISHED state
15+
* https://central.sonatype.org/publish/publish-portal-api/
16+
*/
17+
abstract class SonatypeCentralPortalWaitForPublicationTask: DefaultTask() {
18+
@get:Input
19+
abstract val deploymentId: Property<String>
20+
21+
@Option(option = "deploymentId", description = "Deployment ID to wait for")
22+
fun setDeploymentId(id: String) {
23+
deploymentId.set(id)
24+
}
25+
26+
/** Max time to wait for final state. */
27+
@get:Input
28+
@get:Optional
29+
abstract val timeoutDuration: Property<Duration>
30+
31+
/** Poll interval. */
32+
@get:Input
33+
@get:Optional
34+
abstract val pollInterval: Property<Duration>
35+
36+
init {
37+
timeoutDuration.convention(90.minutes)
38+
pollInterval.convention(30.seconds)
39+
}
40+
41+
@TaskAction
42+
fun run() {
43+
val client = SonatypeCentralPortalClient.fromEnvironment()
44+
val deploymentId = deploymentId.get().takeIf { it.isNotBlank() } ?: error("deploymentId not configured")
45+
46+
val result = client.waitForStatus(
47+
deploymentId,
48+
setOf("PUBLISHED", "FAILED"),
49+
pollInterval.get(),
50+
timeoutDuration.get()
51+
) { _, newState ->
52+
logger.lifecycle("📡 Status: $newState (deploymentId=$deploymentId)")
53+
}
54+
55+
when (result.deploymentState) {
56+
"PUBLISHED" -> {
57+
logger.lifecycle("🚀 Deployment PUBLISHED (deploymentId=$deploymentId)")
58+
}
59+
"FAILED" -> {
60+
val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ") ?: "\n(no error details returned)"
61+
throw RuntimeException("❌ Sonatype publication FAILED for $deploymentId$reasons")
62+
}
63+
else -> error("Unexpected terminal state: ${result.deploymentState}")
64+
}
65+
}
66+
}

gradle/libs.versions.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ smithy-gradle-plugin-version = "1.3.0"
1010
junit-version = "5.10.1"
1111
coroutines-version = "1.10.2"
1212
slf4j-version = "2.0.17"
13+
okhttp-version = "5.1.0"
14+
kotlinx-serialization-version = "1.9.0"
1315

1416
[libraries]
1517
aws-sdk-cloudwatch = { module = "aws.sdk.kotlin:cloudwatch", version.ref = "aws-sdk-version" }
@@ -22,8 +24,11 @@ jreleaser-plugin = { module = "org.jreleaser:jreleaser-gradle-plugin", version.r
2224
smithy-model = { module = "software.amazon.smithy:smithy-model", version.ref = "smithy-version" }
2325
smithy-gradle-base-plugin = { module = "software.amazon.smithy.gradle:smithy-base", version.ref = "smithy-gradle-plugin-version" }
2426
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-version" }
27+
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines-version" }
2528
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-version" }
2629
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j-version" }
30+
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp-version" }
31+
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" }
2732

2833
[plugins]
2934
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" }

0 commit comments

Comments
 (0)