Skip to content

Commit 3fccb45

Browse files
committed
Hand-written integration to Sonatype Publish API
1 parent 2d54907 commit 3fccb45

File tree

5 files changed

+278
-61
lines changed

5 files changed

+278
-61
lines changed

build.gradle.kts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import aws.sdk.kotlin.gradle.dsl.configureLinting
66
import aws.sdk.kotlin.gradle.dsl.configureMinorVersionStrategyRules
77
import aws.sdk.kotlin.gradle.util.typedProp
8+
import kotlin.time.Duration.Companion.minutes
9+
import kotlin.time.Duration.Companion.seconds
810

911
buildscript {
1012
// NOTE: buildscript classpath for the root project is the parent classloader for the subprojects, we
@@ -34,8 +36,8 @@ buildscript {
3436
}
3537

3638
plugins {
37-
`dokka-convention`
3839
`publishing-convention`
40+
`dokka-convention`
3941
// ensure the correct version of KGP ends up on our buildscript classpath
4042
id(libs.plugins.kotlin.multiplatform.get().pluginId) apply false
4143
id(libs.plugins.kotlin.jvm.get().pluginId) apply false
@@ -109,3 +111,9 @@ val lintPaths = listOf(
109111

110112
configureLinting(lintPaths)
111113
configureMinorVersionStrategyRules(lintPaths)
114+
115+
tasks.register<SonatypePortalPublishTask>("publishToCentralPortal") {
116+
bundle.set(rootProject.layout.buildDirectory.file("aws-sdk-kotlin-bundle.tar.gz"))
117+
wait.set(45.minutes) // TODO Refine once we get data
118+
pollInterval.set(15.seconds) // TODO refine?
119+
}

buildSrc/build.gradle.kts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
plugins {
77
alias(libs.plugins.kotlin.jvm)
88
`kotlin-dsl`
9+
kotlin("plugin.serialization") version "2.2.20"
910
}
1011

1112
repositories {
@@ -17,8 +18,8 @@ dependencies {
1718
implementation(libs.dokka.gradle.plugin)
1819
implementation(libs.kotlin.gradle.plugin)
1920
implementation(libs.vanniktech.maven.publish)
20-
}
21-
22-
dependencies {
21+
implementation(libs.okhttp)
22+
implementation(libs.kotlinx.serialization.json)
23+
implementation(libs.kotlinx.coroutines.core)
2324
implementation(libs.jsoup)
2425
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import kotlinx.serialization.*
2+
import kotlinx.serialization.json.*
3+
import okhttp3.*
4+
import okhttp3.MediaType.Companion.toMediaType
5+
import okhttp3.RequestBody.Companion.asRequestBody
6+
import okhttp3.HttpUrl.Companion.toHttpUrl
7+
import okhttp3.RequestBody.Companion.toRequestBody
8+
import java.io.File
9+
import java.time.Duration
10+
import java.util.Base64
11+
import org.gradle.api.DefaultTask
12+
import org.gradle.api.file.RegularFileProperty
13+
import org.gradle.api.provider.*
14+
import org.gradle.api.tasks.*
15+
import kotlin.time.Clock
16+
import kotlin.time.ExperimentalTime
17+
18+
const val CENTRAL_PORTAL_USERNAME = "SONATYPE_CENTRAL_PORTAL_USERNAME"
19+
const val CENTRAL_PORTAL_PASSWORD = "SONATYPE_CENTRAL_PORTAL_PASSWORD"
20+
const val CENTRAL_PORTAL_BASE_URL = "https://central.sonatype.com"
21+
22+
/**
23+
* Publishes a Central Publisher bundle to Sonatype Portal and waits for completion.
24+
*
25+
* Docs referenced:
26+
* - Auth header is "Bearer <base64(username:password)>"
27+
* - Upload: POST /api/v1/publisher/upload (multipart, part name "bundle", octet-stream)
28+
* - Poll: POST /api/v1/publisher/status?id=<deploymentId>
29+
* - Publish (USER_MANAGED only): POST /api/v1/publisher/deployment/<deploymentId>
30+
* States: PENDING, VALIDATING, VALIDATED, PUBLISHING, PUBLISHED, FAILED
31+
*
32+
* https://central.sonatype.org/publish/publish-portal-api/
33+
*/
34+
abstract class SonatypePortalPublishTask : DefaultTask() {
35+
36+
@get:InputFile
37+
abstract val bundle: RegularFileProperty
38+
39+
/** Max time to wait for final state. */
40+
@get:Input
41+
@get:Optional
42+
abstract val wait: Property<kotlin.time.Duration>
43+
44+
/** Initial poll delay millis, will back off up to maxPollDelayMillis. */
45+
@get:Input
46+
@get:Optional
47+
abstract val pollInterval: Property<kotlin.time.Duration>
48+
49+
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
50+
private val client = OkHttpClient.Builder()
51+
.connectTimeout(Duration.ofSeconds(30))
52+
.readTimeout(Duration.ofSeconds(60))
53+
.writeTimeout(Duration.ofSeconds(60))
54+
.retryOnConnectionFailure(true)
55+
.build()
56+
57+
@Serializable
58+
private 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+
@TaskAction
67+
fun run() {
68+
val bundle = bundle.asFile.get()
69+
require(bundle.isFile && bundle.length() > 0L) { "Bundle does not exist or is empty: $bundle" }
70+
71+
val username = System.getenv(CENTRAL_PORTAL_USERNAME).takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_USERNAME not configured")
72+
val password = System.getenv(CENTRAL_PORTAL_PASSWORD).takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_PASSWORD not configured")
73+
val authHeader = buildAuthHeader(username, password)
74+
75+
// 1) Upload
76+
val deploymentId = uploadBundle(authHeader, bundle, bundle.name)
77+
78+
// 2) Poll until PUBLISHED or FAILED
79+
val targetDoneStates = setOf("PUBLISHED", "FAILED")
80+
val status = waitForStatus(authHeader, deploymentId, targetDoneStates)
81+
82+
// 4) Evaluate terminal state
83+
when (status.deploymentState) {
84+
"PUBLISHED" -> {
85+
logger.lifecycle("✅ Published to Maven Central. PURLs: ${status.purls ?: emptyList()}")
86+
}
87+
"FAILED" -> {
88+
val reasons = status.errors?.values?.joinToString("\n- ", prefix = "\n- ") ?: "\n(no error details returned)"
89+
throw RuntimeException("❌ Sonatype deployment FAILED for ${status.deploymentId}$reasons")
90+
}
91+
else -> throw IllegalStateException("Unexpected terminal state: ${status.deploymentState}")
92+
}
93+
}
94+
95+
private fun buildAuthHeader(user: String, password: String): String {
96+
val b64 = Base64.getEncoder().encodeToString("$user:$password".toByteArray(Charsets.UTF_8))
97+
return "Bearer $b64" // Per docs, prefer Bearer; UserToken also accepted but not recommended
98+
}
99+
100+
private fun uploadBundle(
101+
auth: String,
102+
bundle: File,
103+
name: String,
104+
): String {
105+
val url = HttpUrl.Builder()
106+
.scheme("https")
107+
.host(CENTRAL_PORTAL_BASE_URL.toHttpUrl().host)
108+
.addPathSegments("api/v1/publisher/upload")
109+
.addQueryParameter("name", name)
110+
.addQueryParameter("publishingType", "AUTOMATIC")
111+
.build()
112+
113+
val body = MultipartBody.Builder()
114+
.setType(MultipartBody.FORM)
115+
.addFormDataPart(
116+
"bundle",
117+
bundle.name,
118+
bundle.asRequestBody("application/octet-stream".toMediaType())
119+
)
120+
.build()
121+
122+
val request = Request.Builder()
123+
.url(url)
124+
.header("Authorization", auth)
125+
.post(body)
126+
.build()
127+
128+
client.newCall(request).execute().use { resp ->
129+
// 201 expected with plaintext body = deploymentId
130+
if (!resp.isSuccessful) {
131+
throw httpError("upload", resp)
132+
}
133+
val id = resp.body?.string()?.trim().orEmpty()
134+
if (resp.code != 201 || id.isEmpty()) {
135+
throw RuntimeException("Upload returned ${resp.code} but no deploymentId body; body=${id.take(200)}")
136+
}
137+
logger.lifecycle("📤 Uploaded bundle; deploymentId=$id")
138+
return id
139+
}
140+
}
141+
142+
@OptIn(ExperimentalTime::class) // for Clock.System.now()
143+
private fun waitForStatus(
144+
auth: String,
145+
deploymentId: String,
146+
terminalStates: Set<String>,
147+
): StatusResponse {
148+
val deadline = Clock.System.now() + wait.get()
149+
150+
var lastState: String? = null
151+
while (Clock.System.now() < deadline) {
152+
val status = getStatus(auth, deploymentId)
153+
if (status.deploymentState != lastState) {
154+
logger.lifecycle("📡 Status: ${status.deploymentState} (deploymentId=${status.deploymentId})")
155+
lastState = status.deploymentState
156+
}
157+
if (status.deploymentState in terminalStates) return status
158+
159+
Thread.sleep(pollInterval.get().inWholeMilliseconds)
160+
}
161+
throw RuntimeException("Timed out waiting for deployment $deploymentId to reach one of $terminalStates")
162+
}
163+
164+
private fun getStatus(auth: String, id: String): StatusResponse {
165+
val url = HttpUrl.Builder()
166+
.scheme("https")
167+
.host(CENTRAL_PORTAL_BASE_URL.toHttpUrl().host)
168+
.addPathSegments("api/v1/publisher/status")
169+
.addQueryParameter("id", id)
170+
.build()
171+
172+
val request = Request.Builder()
173+
.url(url)
174+
.header("Authorization", auth)
175+
.post("".toRequestBody(null)) // per docs, POST with id query param
176+
.build()
177+
178+
client.newCall(request).execute().use { resp ->
179+
if (!resp.isSuccessful) throw httpError("status", resp)
180+
val payload = resp.body?.string().orEmpty()
181+
return try {
182+
json.decodeFromString<StatusResponse>(payload)
183+
} catch (e: Exception) {
184+
throw RuntimeException("Failed to parse status JSON (HTTP ${resp.code}): ${payload.take(400)}", e)
185+
}
186+
}
187+
}
188+
189+
private fun httpError(context: String, resp: Response): RuntimeException {
190+
val body = resp.body?.string().orEmpty()
191+
return RuntimeException("HTTP error during $context: ${resp.code}.\nResponse: ${body.take(800)}")
192+
}
193+
}

buildSrc/src/main/kotlin/publishing-convention.gradle.kts

Lines changed: 68 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,80 +3,91 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import com.vanniktech.maven.publish.JavadocJar
7-
import com.vanniktech.maven.publish.KotlinMultiplatform
8-
import com.vanniktech.maven.publish.VersionCatalog
9-
import com.vanniktech.maven.publish.MavenPublishBaseExtension
10-
116
plugins {
12-
id("com.vanniktech.maven.publish")
7+
id("maven-publish")
138
}
149

15-
extensions.configure<MavenPublishBaseExtension> {
16-
println("Configuring gradle-maven-publish-plugin for ${project.name}")
17-
// Configure publications
18-
// https://vanniktech.github.io/gradle-maven-publish-plugin/what/
19-
if (project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
20-
println(" Configuring KotlinMultiplatform publication")
21-
configure(KotlinMultiplatform(javadocJar = JavadocJar.Empty(), sourcesJar = true))
22-
}
23-
if (project.plugins.hasPlugin("version-catalog")) {
24-
println(" Configuring VersionCatalog publication")
25-
configure(VersionCatalog())
26-
}
10+
private val SIGNING_KEY_PROP = "signingKey"
11+
private val SIGNING_PASSWORD_PROP = "signingPassword"
2712

28-
// Configure Maven Central
29-
// Setting `automaticRelease` to false enables running `./gradlew publishToMavenCentral` to stage a deployment
30-
// For a full release, run `./gradlew publishAndReleaseToMavenCentral`
31-
publishToMavenCentral(automaticRelease = false)
32-
signAllPublications()
33-
34-
pom {
35-
name = project.name
36-
description = project.description
37-
url = "https://github.com/aws/aws-sdk-kotlin"
38-
licenses {
39-
license {
40-
name = "Apache-2.0"
41-
url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
42-
}
43-
}
44-
developers {
45-
developer {
46-
id.set("aws-sdk-kotlin")
47-
name.set("AWS SDK Kotlin Team")
48-
}
49-
}
50-
scm {
51-
connection.set("scm:git:git://github.com/aws/aws-sdk-kotlin.git")
52-
developerConnection.set("scm:git:ssh://github.com/aws/aws-sdk-kotlin.git")
53-
url.set("https://github.com/aws/aws-sdk-kotlin")
13+
// FIXME: create a real "javadoc" JAR from Dokka output
14+
val javadocJar = tasks.register<Jar>("emptyJar") {
15+
archiveClassifier.set("javadoc")
16+
destinationDirectory.set(layout.buildDirectory.dir("libs"))
17+
from()
18+
}
19+
20+
plugins.apply("maven-publish")
21+
22+
extensions.configure<PublishingExtension> {
23+
repositories {
24+
maven {
25+
name = "testLocal"
26+
url = rootProject.layout.buildDirectory.dir("m2").get().asFile.toURI()
5427
}
5528
}
5629

57-
// TODO Confirm this works with the new plugin
58-
tasks.withType<AbstractPublishToMaven>().configureEach {
59-
onlyIf {
60-
isAvailableForPublication(project, publication).also {
61-
if (!it) {
62-
println("Skipping publication, project=${project.name}; publication=${publication.name}; group=${publication.groupId}")
63-
logger.warn("Skipping publication, project=${project.name}; publication=${publication.name}; group=${publication.groupId}")
30+
publications.all {
31+
if (this !is MavenPublication) return@all
32+
33+
project.afterEvaluate {
34+
pom {
35+
name.set(project.name)
36+
description.set(project.description)
37+
url.set("https://github.com/aws/aws-sdk-kotlin")
38+
licenses {
39+
license {
40+
name.set("Apache-2.0")
41+
url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
42+
}
43+
}
44+
developers {
45+
developer {
46+
id.set("aws-sdk-kotlin")
47+
name.set("AWS SDK Kotlin Team")
48+
}
6449
}
50+
scm {
51+
connection.set("scm:git:git://github.com/aws/aws-sdk-kotlin.git")
52+
developerConnection.set("scm:git:ssh://github.com/aws/aws-sdk-kotlin.git")
53+
url.set("https://github.com/aws/aws-sdk-kotlin")
54+
}
55+
56+
artifact(javadocJar)
6557
}
6658
}
6759
}
6860

69-
publishing {
70-
repositories {
71-
maven {
72-
name = "testLocal"
73-
url = rootProject.layout.buildDirectory.dir("m2").get().asFile.toURI()
74-
}
61+
if (project.hasProperty(SIGNING_KEY_PROP) && project.hasProperty(SIGNING_PASSWORD_PROP)) {
62+
apply(plugin = "signing")
63+
extensions.configure<SigningExtension> {
64+
useInMemoryPgpKeys(
65+
project.property(SIGNING_KEY_PROP) as String,
66+
project.property(SIGNING_PASSWORD_PROP) as String,
67+
)
68+
sign(publications)
69+
}
70+
71+
// FIXME - workaround for https://github.com/gradle/gradle/issues/26091
72+
val signingTasks = tasks.withType<Sign>()
73+
tasks.withType<AbstractPublishToMaven>().configureEach {
74+
mustRunAfter(signingTasks)
7575
}
7676
}
77+
}
7778

79+
tasks.withType<AbstractPublishToMaven>().configureEach {
80+
onlyIf {
81+
isAvailableForPublication(project, publication).also {
82+
if (!it) {
83+
logger.warn("Skipping publication, project=${project.name}; publication=${publication.name}; group=${publication.groupId}")
84+
}
85+
}
86+
}
7887
}
7988

89+
90+
8091
// TODO Remove once commonized in aws-kotlin-repo-tools
8192
internal val ALLOWED_PUBLICATION_NAMES = setOf(
8293
"common",

0 commit comments

Comments
 (0)