Skip to content

Commit b818cd2

Browse files
authored
feat: add Sonatype Publish Portal API integration (#145)
* Remove `signing = true` configuration * Add Sonatype Publish Portal API integration * lint * remove comment
1 parent 2eba0f7 commit b818cd2

File tree

6 files changed

+304
-0
lines changed

6 files changed

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

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)