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} .\n Response: ${body.take(800 )} " )
192+ }
193+ }
0 commit comments