Browse Source
- new API requires creating a zip
- API code is copied and adapted from
[compose-hot-reload](799b90b76f/buildSrc/src/main/kotlin/PublishToMavenCentralTask.kt (L39))
- [CI change](https://jetbrains.team/p/ui/reviews/31/timeline)
## Testing
1. Configure Maven Space Token in
https://public.jetbrains.space/p/compose/edit/applications, write it in
cli/build.gradle.kts
2. Use Maven Central token, write it in cli/gradle.properties
3.
```
..\gradlew reuploadArtifactsToMavenCentral --info --stacktrace -Pmaven.central.coordinates=org.jetbrains.compose*:*:1.8.0,org.jetbrains.compose.material:material-navigation*:2.9.0-beta02,org.jetbrains.compose.material3.adaptive:*:1.1.0 -Pmaven.central.deployName="Compose1.8.0 and associated libs" --rerun-tasks
..\gradlew reuploadArtifactsToMavenCentral --info --stacktrace -Pmaven.central.coordinates=org.jetbrains.skiko*:*:0.9.16 -Pmaven.central.deployName="Skiko 0.9.16" --rerun-tasks
```
downloads packages from Space, creates a zip, and uploads as a new
(non-published) deployment.
With failed state, because signing was disabled:
<img width="305" alt="image"
src="https://github.com/user-attachments/assets/0a32e185-e43b-4783-9145-84aba64d41c0"
/>
I will check on a valid uploading on Skiko after the merge
## Release Notes
N/A
pull/5352/head
v1.9.0+dev2620
12 changed files with 142 additions and 465 deletions
@ -1,77 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import okhttp3.* |
|
||||||
import okhttp3.internal.http.RealResponseBody |
|
||||||
import okio.Buffer |
|
||||||
import org.gradle.api.logging.Logger |
|
||||||
import java.net.URL |
|
||||||
import java.time.Duration |
|
||||||
import java.util.concurrent.atomic.AtomicLong |
|
||||||
|
|
||||||
internal class RestApiClient( |
|
||||||
private val serverUrl: String, |
|
||||||
private val user: String, |
|
||||||
private val password: String, |
|
||||||
private val logger: Logger, |
|
||||||
) : AutoCloseable { |
|
||||||
private val okClient by lazy { |
|
||||||
OkHttpClient.Builder() |
|
||||||
.readTimeout(Duration.ofMinutes(1)) |
|
||||||
.build() |
|
||||||
} |
|
||||||
|
|
||||||
fun buildRequest(urlPath: String, configure: Request.Builder.() -> Unit): Request = |
|
||||||
Request.Builder().apply { |
|
||||||
addHeader("Authorization", Credentials.basic(user, password)) |
|
||||||
url(URL("$serverUrl/$urlPath")) |
|
||||||
configure() |
|
||||||
}.build() |
|
||||||
|
|
||||||
fun <T> execute( |
|
||||||
request: Request, |
|
||||||
retries: Int = 5, |
|
||||||
delaySec: Long = 10, |
|
||||||
processResponse: (ResponseBody) -> T |
|
||||||
): T { |
|
||||||
val message = "Remote request #${globalRequestCounter.incrementAndGet()}" |
|
||||||
val startTimeNs = System.nanoTime() |
|
||||||
logger.info("$message: ${request.method} '${request.url}'") |
|
||||||
val delayMs = delaySec * 1000 |
|
||||||
|
|
||||||
for (i in 1..retries) { |
|
||||||
try { |
|
||||||
return okClient.newCall(request).execute().use { response -> |
|
||||||
val endTimeNs = System.nanoTime() |
|
||||||
logger.info("$message: finished in ${(endTimeNs - startTimeNs)/1_000_000} ms") |
|
||||||
|
|
||||||
if (!response.isSuccessful) |
|
||||||
throw RequestError(request, response) |
|
||||||
|
|
||||||
val responseBody = response.body ?: RealResponseBody(null, 0, Buffer()) |
|
||||||
processResponse(responseBody) |
|
||||||
} |
|
||||||
} catch (e: Exception) { |
|
||||||
if (i == retries) { |
|
||||||
throw RuntimeException("$message: failed all $retries attempts, see nested exception for details", e) |
|
||||||
} |
|
||||||
logger.info("$message: retry #$i of $retries failed. Retrying in $delayMs ms\n${e.message}") |
|
||||||
Thread.sleep(delayMs) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
error("Unreachable") |
|
||||||
} |
|
||||||
|
|
||||||
override fun close() { |
|
||||||
okClient.connectionPool.evictAll() |
|
||||||
} |
|
||||||
|
|
||||||
companion object { |
|
||||||
private val globalRequestCounter = AtomicLong() |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType |
|
||||||
|
|
||||||
internal object Json { |
|
||||||
val mediaType = "application/json".toMediaType() |
|
||||||
} |
|
||||||
@ -1,115 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonRootName |
|
||||||
import org.jetbrains.compose.internal.publishing.ModuleToUpload |
|
||||||
import java.io.File |
|
||||||
|
|
||||||
internal class ModuleValidator( |
|
||||||
private val stagingProfile: StagingProfile, |
|
||||||
private val module: ModuleToUpload, |
|
||||||
) { |
|
||||||
private val errors = arrayListOf<String>() |
|
||||||
private var status: Status? = null |
|
||||||
|
|
||||||
sealed class Status { |
|
||||||
object OK : Status() |
|
||||||
class Error(val errors: List<String>) : Status() |
|
||||||
} |
|
||||||
|
|
||||||
fun validate(): Status { |
|
||||||
if (status == null) { |
|
||||||
validateImpl() |
|
||||||
status = if (errors.isEmpty()) Status.OK |
|
||||||
else Status.Error(errors) |
|
||||||
} |
|
||||||
|
|
||||||
return status!! |
|
||||||
} |
|
||||||
|
|
||||||
private fun validateImpl() { |
|
||||||
if (!module.groupId.startsWith(stagingProfile.name)) { |
|
||||||
errors.add("Module's group id '${module.groupId}' does not match staging repo '${stagingProfile.name}'") |
|
||||||
} |
|
||||||
|
|
||||||
val pomFile = artifactFile(extension = "pom") |
|
||||||
val pom = when { |
|
||||||
pomFile.exists() -> |
|
||||||
try { |
|
||||||
// todo: validate POM |
|
||||||
Xml.deserialize<Pom>(pomFile.readText()) |
|
||||||
} catch (e: Exception) { |
|
||||||
errors.add("Cannot deserialize $pomFile: $e") |
|
||||||
null |
|
||||||
} |
|
||||||
else -> null |
|
||||||
} |
|
||||||
|
|
||||||
val mandatoryFiles = arrayListOf(pomFile) |
|
||||||
if (pom != null && pom.packaging != "pom") { |
|
||||||
mandatoryFiles.add(artifactFile(extension = pom.packaging ?: "jar")) |
|
||||||
mandatoryFiles.add(artifactFile(extension = "jar", classifier = "sources")) |
|
||||||
mandatoryFiles.add(artifactFile(extension = "jar", classifier = "javadoc")) |
|
||||||
} |
|
||||||
|
|
||||||
val nonExistingFiles = mandatoryFiles.filter { !it.exists() } |
|
||||||
if (nonExistingFiles.isNotEmpty()) { |
|
||||||
errors.add("Some necessary files do not exist: [${nonExistingFiles.map { it.name }.joinToString()}]") |
|
||||||
} |
|
||||||
|
|
||||||
// signatures and checksums should not be signed themselves |
|
||||||
val skipSignatureCheckExtensions = setOf("asc", "md5", "sha1", "sha256", "sha512") |
|
||||||
val unsignedFiles = module.listFiles() |
|
||||||
.filter { |
|
||||||
it.extension !in skipSignatureCheckExtensions && !it.resolveSibling(it.name + ".asc").exists() |
|
||||||
} |
|
||||||
if (unsignedFiles.isNotEmpty()) { |
|
||||||
errors.add("Some files are not signed: [${unsignedFiles.map { it.name }.joinToString()}]") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun artifactFile(extension: String, classifier: String? = null): File { |
|
||||||
val fileName = buildString { |
|
||||||
append("${module.artifactId}-${module.version}") |
|
||||||
if (classifier != null) |
|
||||||
append("-$classifier") |
|
||||||
append(".$extension") |
|
||||||
} |
|
||||||
return module.localDir.resolve(fileName) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
@JsonRootName("project") |
|
||||||
private data class Pom( |
|
||||||
var groupId: String? = null, |
|
||||||
var artifactId: String? = null, |
|
||||||
var packaging: String? = null, |
|
||||||
var name: String? = null, |
|
||||||
var description: String? = null, |
|
||||||
var url: String? = null, |
|
||||||
var scm: Scm? = null, |
|
||||||
var licenses: List<License>? = null, |
|
||||||
var developers: List<Developer>? = null, |
|
||||||
) { |
|
||||||
internal data class Scm( |
|
||||||
var connection: String?, |
|
||||||
var developerConnection: String?, |
|
||||||
var url: String?, |
|
||||||
) |
|
||||||
|
|
||||||
internal data class License( |
|
||||||
var name: String? = null, |
|
||||||
var url: String? = null |
|
||||||
) |
|
||||||
|
|
||||||
internal data class Developer( |
|
||||||
var name: String? = null, |
|
||||||
var organization: String? = null, |
|
||||||
var organizationUrl: String? = null |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,28 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import okhttp3.Request |
|
||||||
import okhttp3.Response |
|
||||||
|
|
||||||
internal class RequestError( |
|
||||||
val request: Request, |
|
||||||
val response: Response, |
|
||||||
responseBody: String |
|
||||||
) : RuntimeException("${request.url}: returned ${response.code}\n${responseBody.trim()}") |
|
||||||
|
|
||||||
internal fun RequestError(request: Request, response: Response): RequestError { |
|
||||||
var responseBodyException: Throwable? = null |
|
||||||
val responseBody = try { |
|
||||||
response.body?.string() ?: "" |
|
||||||
} catch (t: Throwable) { |
|
||||||
responseBodyException = t |
|
||||||
"" |
|
||||||
} |
|
||||||
return RequestError(request, response, responseBody).apply { |
|
||||||
if (responseBodyException != null) addSuppressed(responseBodyException) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonRootName |
|
||||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper |
|
||||||
import org.jetbrains.compose.internal.publishing.ModuleToUpload |
|
||||||
|
|
||||||
interface SonatypeApi { |
|
||||||
fun upload(repo: StagingRepo, module: ModuleToUpload) |
|
||||||
fun stagingProfiles(): StagingProfiles |
|
||||||
fun createStagingRepo(profile: StagingProfile, description: String): StagingRepo |
|
||||||
fun closeStagingRepo(repo: StagingRepo) |
|
||||||
} |
|
||||||
|
|
||||||
@JsonRootName("stagingProfile") |
|
||||||
data class StagingProfile( |
|
||||||
var id: String = "", |
|
||||||
var name: String = "", |
|
||||||
) |
|
||||||
|
|
||||||
@JsonRootName("stagingProfiles") |
|
||||||
class StagingProfiles( |
|
||||||
@JacksonXmlElementWrapper |
|
||||||
var data: List<StagingProfile> |
|
||||||
) |
|
||||||
|
|
||||||
data class StagingRepo( |
|
||||||
val id: String, |
|
||||||
val description: String, |
|
||||||
val profile: StagingProfile |
|
||||||
) { |
|
||||||
constructor( |
|
||||||
response: PromoteResponse, |
|
||||||
profile: StagingProfile |
|
||||||
) : this( |
|
||||||
id = response.data.stagedRepositoryId!!, |
|
||||||
description = response.data.description, |
|
||||||
profile = profile |
|
||||||
) |
|
||||||
|
|
||||||
@JsonRootName("promoteRequest") |
|
||||||
data class PromoteRequest(var data: PromoteData) |
|
||||||
@JsonRootName("promoteResponse") |
|
||||||
data class PromoteResponse(var data: PromoteData) |
|
||||||
data class PromoteData(var stagedRepositoryId: String? = null, var description: String) |
|
||||||
} |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull |
|
||||||
import okhttp3.Request |
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody |
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody |
|
||||||
import okhttp3.ResponseBody |
|
||||||
import org.apache.tika.Tika |
|
||||||
import org.gradle.api.logging.Logger |
|
||||||
import org.jetbrains.compose.internal.publishing.ModuleToUpload |
|
||||||
import java.io.Closeable |
|
||||||
import java.io.File |
|
||||||
|
|
||||||
// https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API |
|
||||||
class SonatypeRestApiClient( |
|
||||||
sonatypeServer: String, |
|
||||||
user: String, |
|
||||||
password: String, |
|
||||||
private val logger: Logger, |
|
||||||
) : SonatypeApi, Closeable { |
|
||||||
private val client = RestApiClient(sonatypeServer, user, password, logger) |
|
||||||
|
|
||||||
private fun buildRequest(urlPath: String, builder: Request.Builder.() -> Unit): Request = |
|
||||||
client.buildRequest(urlPath, builder) |
|
||||||
|
|
||||||
private fun <T> Request.execute(processResponse: (ResponseBody) -> T): T = |
|
||||||
client.execute(this, processResponse = processResponse) |
|
||||||
|
|
||||||
override fun close() { |
|
||||||
client.close() |
|
||||||
} |
|
||||||
|
|
||||||
override fun upload(repo: StagingRepo, module: ModuleToUpload) { |
|
||||||
for (file in module.localDir.listFiles()!!) { |
|
||||||
uploadFile(repo, module, file) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun uploadFile(repo: StagingRepo, module: ModuleToUpload, file: File) { |
|
||||||
val fileType = Tika().detect(file.name) |
|
||||||
logger.info("Uploading $file (detected type='$fileType', length=${file.length()})") |
|
||||||
val deployUrl = "service/local/staging/deployByRepositoryId/${repo.id}" |
|
||||||
val groupUrl = module.groupId.replace(".", "/") |
|
||||||
val coordinateUrl = "$groupUrl/${module.artifactId}/${module.version}" |
|
||||||
val uploadUrlPath = "$deployUrl/$coordinateUrl/${file.name}" |
|
||||||
|
|
||||||
buildRequest(uploadUrlPath) { |
|
||||||
header("Content-type", fileType) |
|
||||||
put(file.asRequestBody(fileType.toMediaTypeOrNull())) |
|
||||||
}.execute { } |
|
||||||
} |
|
||||||
|
|
||||||
override fun stagingProfiles(): StagingProfiles = |
|
||||||
buildRequest("service/local/staging/profiles") { |
|
||||||
get() |
|
||||||
}.execute { responseBody -> |
|
||||||
Xml.deserialize(responseBody.string()) |
|
||||||
} |
|
||||||
|
|
||||||
override fun createStagingRepo(profile: StagingProfile, description: String): StagingRepo { |
|
||||||
logger.info("Creating sonatype staging repository for `${profile.id}` with description `$description`") |
|
||||||
val response = |
|
||||||
buildRequest("service/local/staging/profiles/${profile.id}/start") { |
|
||||||
val promoteRequest = StagingRepo.PromoteRequest( |
|
||||||
StagingRepo.PromoteData(description = description) |
|
||||||
) |
|
||||||
post(Xml.serialize(promoteRequest).toRequestBody(Xml.mediaType)) |
|
||||||
}.execute { responseBody -> |
|
||||||
Xml.deserialize<StagingRepo.PromoteResponse>(responseBody.string()) |
|
||||||
} |
|
||||||
return StagingRepo(response, profile) |
|
||||||
} |
|
||||||
|
|
||||||
override fun closeStagingRepo(repo: StagingRepo) { |
|
||||||
logger.info("Closing repository '${repo.id}'") |
|
||||||
buildRequest("service/local/staging/bulk/close") { |
|
||||||
val request = "{\"data\":{\"stagedRepositoryIds\":[\"${repo.id}\"]}}" |
|
||||||
post(request.toRequestBody(Json.mediaType)) |
|
||||||
.addHeader("Accept", Json.mediaType.toString()) |
|
||||||
}.execute { responseBody -> |
|
||||||
logger.info("Finished closing repository '${repo.id}': '${responseBody.string()}'") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. |
|
||||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
|
||||||
*/ |
|
||||||
|
|
||||||
package org.jetbrains.compose.internal.publishing.utils |
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature |
|
||||||
import com.fasterxml.jackson.databind.MapperFeature |
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper |
|
||||||
import com.fasterxml.jackson.dataformat.xml.JacksonXmlModule |
|
||||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper |
|
||||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule |
|
||||||
import okhttp3.MediaType.Companion.toMediaType |
|
||||||
|
|
||||||
internal object Xml { |
|
||||||
val mediaType = "application/xml".toMediaType() |
|
||||||
|
|
||||||
fun serialize(value: Any): String = |
|
||||||
kotlinXmlMapper.writeValueAsString(value) |
|
||||||
|
|
||||||
inline fun <reified T> deserialize(xml: String): T = |
|
||||||
kotlinXmlMapper.readValue(xml, T::class.java) |
|
||||||
|
|
||||||
private val kotlinXmlMapper: ObjectMapper = |
|
||||||
XmlMapper(JacksonXmlModule().apply { |
|
||||||
setDefaultUseWrapper(false) |
|
||||||
}).registerKotlinModule() |
|
||||||
.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true) |
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue