diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml new file mode 100644 index 0000000..a812b3a --- /dev/null +++ b/.github/workflows/pull_request.yaml @@ -0,0 +1,20 @@ +name: pull_request + +on: + push: + branches: [ main ] + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - run: | + ./gradlew build + ./gradlew -p tests/kmp build + ./gradlew -p tests/jvm build diff --git a/.github/workflows/ci.yaml b/.github/workflows/release.yaml similarity index 61% rename from .github/workflows/ci.yaml rename to .github/workflows/release.yaml index d7cf834..7f99e35 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/release.yaml @@ -1,14 +1,9 @@ -name: CI +name: release on: push: - branches: [ main ] tags: - '*' - pull_request: - paths-ignore: - - 'docs/**' - - '*.md' jobs: ci: @@ -16,10 +11,8 @@ jobs: steps: - uses: actions/checkout@v3 - - run: | - ./gradlew ci - ./gradlew -p tests/kmp build - ./gradlew -p tests/jvm build + - run: | + ./gradlew publishAllPublicationsToOssStagingRepository env: OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} OSSRH_USER: ${{ secrets.OSSRH_USER }} diff --git a/build.gradle.kts b/build.gradle.kts index 36adafc..21c1861 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,26 +87,12 @@ signing { dependencies { - implementation("com.squareup.okio:okio:3.8.0") - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation(libs.json) + implementation(libs.okio) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) } -fun isTag(): Boolean { - val ref = System.getenv("GITHUB_REF") - - return ref?.startsWith("refs/tags/") == true -} - -tasks.register("ci") - -if (isTag()) { - rootProject.tasks.named("ci") { - dependsOn(tasks.named("publishAllPublicationsToOssStagingRepository")) - } -} - - tasks.withType().configureEach { val signingTasks = tasks.withType() mustRunAfter(signingTasks) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 582e6f7..55cd296 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,11 @@ [versions] kgp = "2.1.20" +[libraries] +json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1" +okio = "com.squareup.okio:okio:3.8.0" +okhttp = "com.squareup.okhttp3:okhttp:4.12.0" +okhttp-logging-interceptor = "com.squareup.okhttp3:logging-interceptor:4.12.0" +mockwebserver = "com.squareup.okhttp3:mockwebserver:4.12.0" [plugins] kgp = { id = "org.jetbrains.kotlin.jvm", version.ref = "kgp" } \ No newline at end of file diff --git a/src/main/kotlin/nmcp/NmcpAggregation.kt b/src/main/kotlin/nmcp/NmcpAggregation.kt index 19baa23..3698108 100644 --- a/src/main/kotlin/nmcp/NmcpAggregation.kt +++ b/src/main/kotlin/nmcp/NmcpAggregation.kt @@ -9,12 +9,45 @@ abstract class NmcpAggregation @Inject constructor( private val configuration: Configuration, private val project: Project, ) { + /** + * The central portal username + */ abstract val username: Property + + /** + * The central portal username + */ abstract val password: Property + + /** + * The publication type. + * One of: + * - "AUTOMATIC": the deployment is automatically published. + * - "USER_MANAGED": the deployment is validated but not published. It must be published manually from the Central Portal UI. + */ abstract val publicationType: Property + + /** + * A name for the publication (optional). + * + * Default: "${project.name}-${project.version}.zip" + */ abstract val publicationName: Property + + /** + * The API endpoint to use (optional). + * + * Default: "https://central.sonatype.com/api/v1/". + */ abstract val endpoint: Property + /** + * Whether to verify the status of the deployment before returning from the task. + * + * Default: true. + */ + abstract val verifyStatus: Property + fun project(path: String) { project.dependencies.add(configuration.name, project.dependencies.project(mapOf("path" to path))) } diff --git a/src/main/kotlin/nmcp/NmcpExtension.kt b/src/main/kotlin/nmcp/NmcpExtension.kt index 420c4ce..04d00ec 100644 --- a/src/main/kotlin/nmcp/NmcpExtension.kt +++ b/src/main/kotlin/nmcp/NmcpExtension.kt @@ -69,11 +69,12 @@ open class NmcpExtension(private val project: Project) { val publishTaskProvider = project.tasks.register("publish${capitalized}PublicationToCentralPortal", NmcpPublishTask::class.java) { it.inputFile.set(zipTaskProvider.flatMap { it.archiveFile }) - it.username.set(spec.username) - it.password.set(spec.password) + it.username.set(spec.username.orElse(project.provider { error("Nmcp: username must not be empty")})) + it.password.set(spec.password.orElse(project.provider { error("Nmcp: password must not be empty")})) it.publicationType.set(spec.publicationType) it.publicationName.set(spec.publicationName.orElse("${project.name}-${project.version}.zip")) it.endpoint.set(spec.endpoint) + it.verifyStatus.set(spec.verifyStatus) } publishAllPublicationsToCentralPortal.configure { @@ -153,11 +154,12 @@ open class NmcpExtension(private val project: Project) { project.tasks.register("publishAggregatedPublicationToCentralPortal", NmcpPublishTask::class.java) { it.inputFile.set(zipTaskProvider.flatMap { it.archiveFile }) - it.username.set(aggregation.username) - it.password.set(aggregation.password) + it.username.set(aggregation.username.orElse(project.provider { error("Nmcp: username must not be empty")})) + it.password.set(aggregation.password.orElse(project.provider { error("Nmcp: password must not be empty")})) it.publicationType.set(aggregation.publicationType) it.publicationName.set(aggregation.publicationName.orElse("${project.name}-${project.version}.zip")) it.endpoint.set(aggregation.endpoint) + it.verifyStatus.set(aggregation.verifyStatus.orElse(true)) } } diff --git a/src/main/kotlin/nmcp/NmcpPublishTask.kt b/src/main/kotlin/nmcp/NmcpPublishTask.kt index fa3764e..a360886 100644 --- a/src/main/kotlin/nmcp/NmcpPublishTask.kt +++ b/src/main/kotlin/nmcp/NmcpPublishTask.kt @@ -1,18 +1,23 @@ package nmcp +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody -import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.RequestBody.Companion.toRequestBody import okio.Buffer +import okio.ByteString import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* import org.gradle.work.DisableCachingByDefault + @DisableCachingByDefault abstract class NmcpPublishTask : DefaultTask() { @get:InputFile @@ -25,6 +30,9 @@ abstract class NmcpPublishTask : DefaultTask() { @get:Input abstract val password: Property + @get:Input + abstract val verifyStatus: Property + @get:Input @get:Optional abstract val publicationType: Property @@ -62,23 +70,111 @@ abstract class NmcpPublishTask : DefaultTask() { val publicationType = publicationType.orElse("USER_MANAGED").get() - Request.Builder() + val endpoint = endpoint.getOrElse("https://central.sonatype.com/api/v1/") + + val deploymentId = Request.Builder() .post(body) .addHeader("Authorization", "UserToken $token") - .url(endpoint.getOrElse("https://central.sonatype.com/api/v1/publisher/upload") + "?publishingType=$publicationType") + .url(endpoint + "publisher/upload?publishingType=$publicationType") .build() .let { - OkHttpClient.Builder() - .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.HEADERS - }) - .build() - .newCall(it).execute() + client.newCall(it).execute() }.use { if (!it.isSuccessful) { - error("Cannot publish to maven central (status='${it.code}'): ${it.body?.string()}") + error("Cannot deploy to maven central (status='${it.code}'): ${it.body?.string()}") } + + it.body!!.string() } + + logger.lifecycle("Nmcp: deployment bundle '$deploymentId' uploaded.") + + if (verifyStatus.get()) { + logger.lifecycle("Nmcp: verifying deployment status...") + while (true) { + when (val status = verifyStatus(deploymentId = deploymentId, endpoint = endpoint, token = token)) { + PENDING, + VALIDATING, + PUBLISHING -> { + // Come back later + Thread.sleep(2000) + } + + VALIDATED -> { + logger.lifecycle("Deployment has passed validation, publish it manually from the Central Portal UI.") + break + } + + PUBLISHED -> { + logger.lifecycle("Deployment is published.") + break + } + + is FAILED -> { + error("Deployment has failed:\n${status.error}") + } + } + } + } } +} + +private sealed interface Status + +// A deployment is uploaded and waiting for processing by the validation service +private object PENDING : Status +// A deployment is being processed by the validation service +private object VALIDATING : Status + +// A deployment has passed validation and is waiting on a user to manually publish via the Central Portal UI +private object VALIDATED : Status + +// A deployment has been either automatically or manually published and is being uploaded to Maven Central +private object PUBLISHING : Status + +// A deployment has successfully been uploaded to Maven Central +private object PUBLISHED : Status + +// A deployment has encountered an error +private class FAILED(val error: String) : Status + +private val client = OkHttpClient.Builder().build() + +private fun verifyStatus(deploymentId: String, endpoint: String, token: String): Status { + Request.Builder() + .post(ByteString.EMPTY.toRequestBody()) + .addHeader("Authorization", "UserToken $token") + .url(endpoint + "publisher/status?id=$deploymentId") + .build() + .let { + client.newCall(it).execute() + }.use { + if (!it.isSuccessful) { + error("Cannot verify deployment status (HTTP status='${it.code}'): ${it.body?.string()}") + } + + val str = it.body!!.string() + val element = Json.parseToJsonElement(str) + check(element is JsonObject) { + "Nmcp: unexpected status response: $str" + } + + val state = element["deploymentState"] + check(state is JsonPrimitive && state.isString) { + "Nmcp: unexpected status: $state" + } + + return when (state.content) { + "PENDING" -> PENDING + "VALIDATING" -> VALIDATING + "VALIDATED" -> VALIDATED + "PUBLISHING" -> PUBLISHING + "PUBLISHED" -> PUBLISHED + "FAILED" -> { + FAILED(element["errors"].toString()) + } + else -> error("Nmcp: unexpected status: $state") + } + } } \ No newline at end of file diff --git a/src/main/kotlin/nmcp/NmcpSpec.kt b/src/main/kotlin/nmcp/NmcpSpec.kt index 6b56a5e..126e0bc 100644 --- a/src/main/kotlin/nmcp/NmcpSpec.kt +++ b/src/main/kotlin/nmcp/NmcpSpec.kt @@ -3,9 +3,42 @@ package nmcp import org.gradle.api.provider.Property abstract class NmcpSpec { + /** + * The central portal username + */ abstract val username: Property + + /** + * The central portal username + */ abstract val password: Property + + /** + * The publication type. + * One of: + * - "AUTOMATIC": the deployment is automatically published. + * - "USER_MANAGED": the deployment is validated but not published. It must be published manually from the Central Portal UI. + */ abstract val publicationType: Property + + /** + * A name for the publication (optional). + * + * Default: "${project.name}-${project.version}.zip" + */ abstract val publicationName: Property + + /** + * The API endpoint to use (optional). + * + * Default: "https://central.sonatype.com/api/v1/". + */ abstract val endpoint: Property + + /** + * Whether to verify the status of the deployment before returning from the task. + * + * Default: true. + */ + abstract val verifyStatus: Property } \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index ca4f609..1b6b2a5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,8 +3,11 @@ Integration tests that check the contents of the zip bundle. To run from the root of the repo: ```shell -./gradlew -p tests/kmp checkZip ./gradlew -p tests/jvm checkZip +./gradlew -p tests/kmp build ``` +* `tests/jvm` can be used to deploy to the real Central Portal. +* `tests/kmp` uses a mockserver to verify the contents uploaded without having to do a real upload every time. + Open `tests/kmp` or `tests/jvm` in IntelliJ for development \ No newline at end of file diff --git a/tests/jvm/build.gradle.kts b/tests/jvm/build.gradle.kts index 6ed0e10..f7b9b36 100644 --- a/tests/jvm/build.gradle.kts +++ b/tests/jvm/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("base") alias(libs.plugins.kgp).apply(false) id("com.gradleup.nmcp").version("0.0.8") } @@ -80,7 +81,7 @@ nmcp { } } -tasks.register("checkZip") { +val checkZip = tasks.register("checkZip") { inputs.file(tasks.named("zipAggregationPublication").flatMap { (it as Zip).archiveFile }) doLast { @@ -153,3 +154,7 @@ tasks.register("checkZip") { ) } } + +tasks.named("build") { + dependsOn(checkZip) +} \ No newline at end of file diff --git a/tests/kmp/build.gradle.kts b/tests/kmp/build.gradle.kts index ebb813d..4e20893 100644 --- a/tests/kmp/build.gradle.kts +++ b/tests/kmp/build.gradle.kts @@ -12,8 +12,8 @@ buildscript { dependencies { classpath("build-logic:build-logic") - classpath("com.squareup.okhttp3:mockwebserver:4.12.0") - classpath("com.squareup.okhttp3:okhttp:4.12.0") + classpath(libs.mockwebserver) + classpath(libs.okhttp) } } @@ -23,6 +23,7 @@ plugins { val mockServer = MockWebServer() mockServer.enqueue(MockResponse()) +mockServer.enqueue(MockResponse().setBody("{\"deploymentState\": \"PUBLISHED\"}")) nmcp { publishAggregation { @@ -264,4 +265,3 @@ tasks.configureEach { dependsOn("publishAggregatedPublicationToCentralPortal") } } -