Skip to content

Commit 160338c

Browse files
committed
Merge branch 'main' of github.com:awslabs/aws-kotlin-repo-tools into asm-v2
2 parents 3aca141 + a4b8c95 commit 160338c

File tree

8 files changed

+353
-17
lines changed

8 files changed

+353
-17
lines changed

.github/actions/setup-kat/action.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,23 @@ description: >
55
runs:
66
using: composite
77
steps:
8-
- name: Install AWS CLI
8+
- name: Install AWS CLI (Linux)
9+
if: runner.os == 'Linux'
910
shell: bash
1011
run: |
1112
sudo snap install aws-cli --classic
13+
- name: Install AWS CLI (macOS)
14+
if: runner.os == 'macOS'
15+
shell: bash
16+
run: |
17+
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
18+
sudo installer -pkg AWSCLIV2.pkg -target /
19+
- name: Install AWS CLI (Windows)
20+
if: runner.os == 'Windows'
21+
shell: powershell
22+
run: |
23+
Invoke-WebRequest -Uri "https://awscli.amazonaws.com/AWSCLIV2.msi" -OutFile "AWSCLIV2.msi"
24+
Start-Process msiexec.exe -Wait -ArgumentList '/I AWSCLIV2.msi /quiet'
1225
- name: Set up kat
1326
shell: bash
1427
run: |

build-plugins/build-support/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
99
plugins {
1010
`kotlin-dsl`
1111
kotlin("jvm")
12+
alias(libs.plugins.kotlin.serialization)
1213
`java-gradle-plugin`
1314
}
1415

@@ -23,6 +24,9 @@ dependencies {
2324
compileOnly(gradleApi())
2425
implementation(libs.aws.sdk.s3)
2526
implementation(libs.aws.sdk.cloudwatch)
27+
implementation(libs.okhttp)
28+
implementation(libs.kotlinx.serialization.json)
29+
implementation(libs.kotlinx.coroutines.core)
2630
testImplementation(kotlin("test"))
2731
testImplementation(libs.kotlinx.coroutines.test)
2832
}

build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/dsl/Publish.kt

Lines changed: 25 additions & 15 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
}
@@ -30,6 +32,7 @@ private const val SIGNING_PASSWORD_PROP = "signingPassword"
3032
private const val SONATYPE_USERNAME_PROP = "sonatypeUsername"
3133
private const val SONATYPE_PASSWORD_PROP = "sonatypePassword"
3234

35+
// TODO Remove JReleaser environment variables when smithy-kotlin + aws-crt-kotlin are migrated to custom Sonatype integration
3336
private object EnvironmentVariables {
3437
const val GROUP_ID = "JRELEASER_PROJECT_JAVA_GROUP_ID"
3538
const val MAVEN_CENTRAL_USERNAME = "JRELEASER_MAVENCENTRAL_USERNAME"
@@ -40,6 +43,9 @@ private object EnvironmentVariables {
4043
const val GENERIC_TOKEN = "JRELEASER_GENERIC_TOKEN"
4144
}
4245

46+
private const val SIGNING_PUBLIC_KEY = "SIGNING_KEY"
47+
private const val SIGNING_SECRET_KEY = "SIGNING_PASSWORD"
48+
4349
internal val ALLOWED_PUBLICATION_NAMES = setOf(
4450
"common",
4551
"jvm",
@@ -73,6 +79,7 @@ internal val ALLOWED_KOTLIN_NATIVE_PUBLICATION_NAMES = setOf(
7379
private val ALLOWED_KOTLIN_NATIVE_GROUP_NAMES = setOf(
7480
"aws.sdk.kotlin.crt",
7581
"aws.smithy.kotlin",
82+
"com.sonatype.central.testing.amazon",
7683
)
7784

7885
// Optional override to the above set.
@@ -238,8 +245,8 @@ fun Project.configurePublishing(repoName: String, githubOrganization: String = "
238245
}
239246
}
240247

241-
val secretKey = System.getenv(EnvironmentVariables.GPG_SECRET_KEY)
242-
val passphrase = System.getenv(EnvironmentVariables.GPG_PASSPHRASE)
248+
val secretKey = System.getenv(SIGNING_PUBLIC_KEY)
249+
val passphrase = System.getenv(SIGNING_SECRET_KEY)
243250

244251
if (!secretKey.isNullOrBlank() && !passphrase.isNullOrBlank()) {
245252
apply(plugin = "signing")
@@ -253,6 +260,8 @@ fun Project.configurePublishing(repoName: String, githubOrganization: String = "
253260
tasks.withType<AbstractPublishToMaven>().configureEach {
254261
mustRunAfter(signingTasks)
255262
}
263+
} else {
264+
logger.info("Skipping signing configuration, $SIGNING_PUBLIC_KEY or $SIGNING_SECRET_KEY are not set")
256265
}
257266
}
258267

@@ -365,10 +374,21 @@ fun Project.configureJReleaser() {
365374
maven {
366375
mavenCentral {
367376
create("maven-central") {
368-
active = Active.ALWAYS // the Maven deployer default is ALWAYS, but MavenCentral is NEVER
369-
sign = false // Signing is done when publishing, see the 'configurePublishing' function
377+
active = Active.ALWAYS // the MavenDeployer default is ALWAYS, but MavenCentralDeployer is NEVER
370378
url = "https://central.sonatype.com/api/v1/publisher"
371379
stagingRepository(rootProject.layout.buildDirectory.dir("m2").get().toString())
380+
381+
maxRetries = 100
382+
retryDelay = 60 // seconds
383+
snapshotSupported = false // do not allow publication of snapshot artifacts
384+
applyMavenCentralRules = true
385+
sign = false // Signing is done when publishing, see the 'configurePublishing' function
386+
// all of the following should be enabled by applyMavenCentralRules but set them explicitly to be sure
387+
checksums = true
388+
sourceJar = true
389+
javadocJar = true
390+
verifyPom = true
391+
372392
artifacts {
373393
artifactOverride {
374394
artifactId = "version-catalog"
@@ -395,16 +415,6 @@ fun Project.configureJReleaser() {
395415
}
396416
}
397417
}
398-
maxRetries = 100
399-
retryDelay = 60 // seconds
400-
snapshotSupported = false // do not allow publication of snapshot artifacts
401-
applyMavenCentralRules = true
402-
// all of the following should be enabled by applyMavenCentralRules but set them explicitly to be sure
403-
sign = true
404-
checksums = true
405-
sourceJar = true
406-
javadocJar = true
407-
verifyPom = true
408418
}
409419
}
410420
}
@@ -426,7 +436,7 @@ internal fun isAvailableForPublication(project: Project, publication: MavenPubli
426436
// Standard publication
427437
} else if (publication.name in ALLOWED_KOTLIN_NATIVE_PUBLICATION_NAMES) {
428438
// Kotlin/Native publication
429-
if (overrideGroupNameValidation && publication.groupId !in ALLOWED_KOTLIN_NATIVE_PUBLICATION_NAMES) {
439+
if (overrideGroupNameValidation && publication.groupId !in ALLOWED_KOTLIN_NATIVE_GROUP_NAMES) {
430440
println("Overriding K/N publication, project=${project.name}; publication=${publication.name}; group=${publication.groupId}")
431441
} else {
432442
shouldPublish = shouldPublish && publication.groupId in ALLOWED_KOTLIN_NATIVE_GROUP_NAMES
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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 org.gradle.api.tasks.options.Option
13+
import kotlin.io.path.Path
14+
import kotlin.io.path.exists
15+
import kotlin.time.Duration
16+
import kotlin.time.Duration.Companion.minutes
17+
import kotlin.time.Duration.Companion.seconds
18+
19+
/**
20+
* Publishes a bundle to Sonatype Portal and waits for it to be validated (but not fully published).
21+
* States: PENDING, VALIDATING, VALIDATED, FAILED
22+
* https://central.sonatype.org/publish/publish-portal-api/
23+
*/
24+
abstract class SonatypeCentralPortalPublishTask : DefaultTask() {
25+
@get:InputFile
26+
abstract val bundle: RegularFileProperty
27+
28+
@Option(option = "bundle", description = "Path to bundle")
29+
fun setBundleFromOption(path: String) {
30+
check(Path(path).exists()) { "Bundle not found at $path" }
31+
bundle.set(Path(path).toFile())
32+
}
33+
34+
/** Max time to wait for final state. */
35+
@get:Input
36+
@get:Optional
37+
abstract val timeoutDuration: Property<Duration>
38+
39+
/** Poll interval. */
40+
@get:Input
41+
@get:Optional
42+
abstract val pollInterval: Property<Duration>
43+
44+
init {
45+
timeoutDuration.convention(45.minutes)
46+
pollInterval.convention(15.seconds)
47+
}
48+
49+
@TaskAction
50+
fun run() {
51+
val client = SonatypeCentralPortalClient.fromEnvironment()
52+
val file = bundle.asFile.get()
53+
54+
// 1) Upload
55+
val deploymentId = client.uploadBundle(file, file.name)
56+
logger.lifecycle("📤 Uploaded bundle; deploymentId=$deploymentId")
57+
58+
// 2) Wait for PUBLISHING (which comes after VALIDATING) or FAILED
59+
val result = client.waitForStatus(
60+
deploymentId = deploymentId,
61+
terminalStates = setOf("PUBLISHING", "FAILED"),
62+
pollInterval = pollInterval.get(),
63+
timeout = timeoutDuration.get(),
64+
) { _, new ->
65+
logger.lifecycle("📡 Status: $new (deploymentId=$deploymentId)")
66+
}
67+
68+
// 3) Evaluate
69+
when (result.deploymentState) {
70+
"PUBLISHING" -> logger.lifecycle("✅ Bundle validated by Maven Central")
71+
"FAILED" -> {
72+
val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ")
73+
?: "\n(no error details returned)"
74+
throw RuntimeException("❌ Sonatype deployment FAILED for ${result.deploymentId}$reasons")
75+
}
76+
else -> error("Unexpected terminal state: ${result.deploymentState}")
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)