55
66package org.jetbrains.compose.internal.publishing
77
8+ import io.ktor.client.*
9+ import io.ktor.client.engine.cio.*
10+ import io.ktor.client.plugins.*
11+ import io.ktor.client.plugins.HttpTimeout
12+ import io.ktor.client.request.accept
13+ import io.ktor.client.request.bearerAuth
14+ import io.ktor.client.request.forms.*
15+ import io.ktor.client.request.parameter
16+ import io.ktor.client.request.post
17+ import io.ktor.client.request.setBody
18+ import io.ktor.client.request.url
19+ import io.ktor.client.statement.bodyAsText
20+ import io.ktor.http.*
21+ import io.ktor.http.headers
22+ import io.ktor.utils.io.streams.asInput
23+ import kotlinx.coroutines.delay
24+ import kotlinx.coroutines.runBlocking
825import org.gradle.api.DefaultTask
926import org.gradle.api.provider.ListProperty
1027import org.gradle.api.provider.Property
1128import org.gradle.api.tasks.Internal
1229import org.gradle.api.tasks.TaskAction
1330import org.jetbrains.compose.internal.publishing.utils.*
31+ import java.io.FileInputStream
32+ import java.io.FileOutputStream
33+ import java.util.*
34+ import java.util.zip.ZipEntry
35+ import java.util.zip.ZipOutputStream
1436
1537@Suppress(" unused" ) // public api
1638abstract class UploadToSonatypeTask : DefaultTask () {
1739 // the task must always re-run anyway, so all inputs can be declared Internal
18- @get:Internal
19- abstract val sonatypeServer: Property <String >
2040
2141 @get:Internal
2242 abstract val user: Property <String >
@@ -25,73 +45,134 @@ abstract class UploadToSonatypeTask : DefaultTask() {
2545 abstract val password: Property <String >
2646
2747 @get:Internal
28- abstract val stagingProfileName: Property <String >
29-
30- @get:Internal
31- abstract val stagingDescription: Property <String >
48+ abstract val deployName: Property <String >
3249
3350 @get:Internal
34- abstract val autoCommitOnSuccess : Property <Boolean >
51+ abstract val publishAfterUploading : Property <Boolean >
3552
3653 @get:Internal
3754 abstract val modulesToUpload: ListProperty <ModuleToUpload >
3855
3956 @TaskAction
4057 fun run () {
41- SonatypeRestApiClient (
42- sonatypeServer = sonatypeServer.get(),
43- user = user.get(),
44- password = password.get(),
45- logger = logger
46- ).use { client -> run (client) }
58+ val deploymentBundle = createDeploymentBundle(modulesToUpload.get())
59+ runBlocking {
60+ HttpClient (CIO ) {
61+ install(HttpTimeout ) {
62+ requestTimeoutMillis = 5 * 60 * 1000 // 5 minutes
63+ connectTimeoutMillis = 60 * 1000 // 1 minute
64+ socketTimeoutMillis = 5 * 60 * 1000 // 5 minutes
65+ }
66+ install(HttpRequestRetry ) {
67+ retryOnExceptionOrServerErrors(maxRetries = 5 )
68+ exponentialDelay()
69+ }
70+ }.use { client ->
71+ client.publish(deploymentBundle)
72+ }
73+ }
4774 }
4875
49- private fun run (sonatype : SonatypeApi ) {
50- val stagingProfiles = sonatype.stagingProfiles()
51- val stagingProfileName = stagingProfileName.get()
52- val stagingProfile = stagingProfiles.data.firstOrNull { it.name == stagingProfileName }
53- ? : error(
54- " Cannot find staging profile '$stagingProfileName ' among existing staging profiles: " +
55- stagingProfiles.data.joinToString { " '${it.name} '" }
56- )
57- val modules = modulesToUpload.get()
76+ private fun createDeploymentBundle (modules : List <ModuleToUpload >): InputProvider {
77+ val zipFile = project.buildDir.resolve(" publishing/compose-deploy.zip" )
78+ zipFile.parentFile.mkdirs()
5879
59- validate(stagingProfile, modules)
80+ ZipOutputStream (FileOutputStream (zipFile)).use { zipOut ->
81+ val sourcesToDestinations = modules.map { it.localDir to it.mavenDirectory() }
6082
61- val stagingRepo = sonatype.createStagingRepo(
62- stagingProfile, stagingDescription.get()
63- )
64- try {
65- for (module in modules) {
66- sonatype.upload(stagingRepo, module)
67- }
68- if (autoCommitOnSuccess.get()) {
69- sonatype.closeStagingRepo(stagingRepo)
83+ for ((sourceDir, destDir) in sourcesToDestinations) {
84+ val files = sourceDir.listFiles() ? : continue
85+
86+ for (file in files) {
87+ if (file.isFile) {
88+ val entryPath = " $destDir /${file.name} "
89+ val entry = ZipEntry (entryPath)
90+ zipOut.putNextEntry(entry)
91+ file.inputStream().use { input ->
92+ input.copyTo(zipOut)
93+ }
94+ zipOut.closeEntry()
95+ }
96+ }
7097 }
71- } catch (e: Exception ) {
72- throw e
7398 }
74- }
7599
76- private fun validate (stagingProfile : StagingProfile , modules : List <ModuleToUpload >) {
77- val validationIssues = arrayListOf<Pair <ModuleToUpload , ModuleValidator .Status .Error >>()
78- for (module in modules) {
79- val status = ModuleValidator (stagingProfile, module).validate()
80- if (status is ModuleValidator .Status .Error ) {
81- validationIssues.add(module to status)
82- }
100+ logger.info(" Zip bundle is created at $zipFile " )
101+
102+ return InputProvider (zipFile.length()) {
103+ FileInputStream (zipFile).asInput()
83104 }
84- if (validationIssues.isNotEmpty()) {
85- val message = buildString {
86- appendLine(" Some modules violate Maven Central requirements:" )
87- for ((module, status) in validationIssues) {
88- appendLine(" * ${module.coordinate} (files: ${module.localDir} )" )
89- for (error in status.errors) {
90- appendLine(" * $error " )
105+ }
106+
107+ // By the doc https://central.sonatype.org/publish/publish-portal-api/
108+ private suspend fun HttpClient.publish (deploymentBundle : InputProvider ) {
109+ val publishAfterUploading = publishAfterUploading.get()
110+
111+ val bearerToken = Base64 .getEncoder().encode(
112+ " ${user.get()} :${password.get()} " .toByteArray()
113+ ).toString(Charsets .UTF_8 )
114+
115+ logger.info(" Start uploading ${deployName.get()} " )
116+
117+ val response = submitForm {
118+ url(" https://central.sonatype.com/api/v1/publisher/upload" )
119+ parameter(" name" , deployName.get())
120+ parameter(" publishingType" , if (publishAfterUploading) " AUTOMATIC" else " USER_MANAGED" )
121+ bearerAuth(bearerToken)
122+ setBody(
123+ MultiPartFormDataContent (
124+ formData {
125+ append(" bundle" , deploymentBundle, headers {
126+ append(HttpHeaders .ContentType , ContentType .Application .OctetStream .contentType)
127+ append(HttpHeaders .ContentDisposition , " filename=\" bundle.zip\" " )
128+ })
91129 }
130+ )
131+ )
132+ var lastUploadLogTime = 0L
133+ onUpload { bytesSentTotal, contentLength ->
134+ val currentTime = System .currentTimeMillis()
135+ if (currentTime - lastUploadLogTime >= 5000 ) { // 5 seconds debounce
136+ logger.info(" Sent $bytesSentTotal bytes from $contentLength " )
137+ lastUploadLogTime = currentTime
92138 }
93139 }
94- error(message)
95140 }
141+
142+ if (response.status != HttpStatusCode .Created ) {
143+ error(" Deployment failed (${response.status} ):\n ${response.bodyAsText()} " )
144+ }
145+
146+ val deploymentId = response.bodyAsText().trim()
147+ logger.info(" Successfully uploaded ${deploymentId.take(4 )} " )
148+
149+ val endStatus = if (publishAfterUploading) " PUBLISHED" else " VALIDATED"
150+
151+ while (true ) {
152+ logger.info(" Checking the status of the deployment..." )
153+
154+ val statusResponse = post {
155+ bearerAuth(bearerToken)
156+ accept(ContentType .Application .Json )
157+ url(" https://central.sonatype.com/api/v1/publisher/status" )
158+ parameter(" id" , deploymentId)
159+ }
160+
161+ if (statusResponse.status != HttpStatusCode .OK ) {
162+ error(" Deployment failed (${statusResponse.status} ):\n ${statusResponse.bodyAsText()} " )
163+ }
164+
165+ if (statusResponse.bodyAsText().contains(endStatus)) break
166+ if (statusResponse.bodyAsText().contains(" FAILED" )) {
167+ error(" Deployment failed (${statusResponse.status} ):\n ${statusResponse.bodyAsText()} " )
168+ }
169+
170+ delay(5000 )
171+ }
172+
173+ logger.info(" Successfully published" )
96174 }
97- }
175+
176+ private fun ModuleToUpload.mavenDirectory () =
177+ groupId.replace(" ." , " /" ) + " /" + artifactId + " /" + version
178+ }
0 commit comments