Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/pull_request.yaml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 3 additions & 10 deletions .github/workflows/ci.yaml → .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
name: CI
name: release

on:
push:
branches: [ main ]
tags:
- '*'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'

jobs:
ci:
runs-on: ubuntu-latest

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 }}
Expand Down
22 changes: 4 additions & 18 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbstractPublishToMaven>().configureEach {
val signingTasks = tasks.withType<Sign>()
mustRunAfter(signingTasks)
Expand Down
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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" }
33 changes: 33 additions & 0 deletions src/main/kotlin/nmcp/NmcpAggregation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>

/**
* The central portal username
*/
abstract val password: Property<String>

/**
* 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<String>

/**
* A name for the publication (optional).
*
* Default: "${project.name}-${project.version}.zip"
*/
abstract val publicationName: Property<String>

/**
* The API endpoint to use (optional).
*
* Default: "https://central.sonatype.com/api/v1/".
*/
abstract val endpoint: Property<String>

/**
* Whether to verify the status of the deployment before returning from the task.
*
* Default: true.
*/
abstract val verifyStatus: Property<Boolean>

fun project(path: String) {
project.dependencies.add(configuration.name, project.dependencies.project(mapOf("path" to path)))
}
Expand Down
10 changes: 6 additions & 4 deletions src/main/kotlin/nmcp/NmcpExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
}

Expand Down
116 changes: 106 additions & 10 deletions src/main/kotlin/nmcp/NmcpPublishTask.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +30,9 @@ abstract class NmcpPublishTask : DefaultTask() {
@get:Input
abstract val password: Property<String>

@get:Input
abstract val verifyStatus: Property<Boolean>

@get:Input
@get:Optional
abstract val publicationType: Property<String>
Expand Down Expand Up @@ -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")
}
}
}
33 changes: 33 additions & 0 deletions src/main/kotlin/nmcp/NmcpSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,42 @@ package nmcp
import org.gradle.api.provider.Property

abstract class NmcpSpec {
/**
* The central portal username
*/
abstract val username: Property<String>

/**
* The central portal username
*/
abstract val password: Property<String>

/**
* 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<String>

/**
* A name for the publication (optional).
*
* Default: "${project.name}-${project.version}.zip"
*/
abstract val publicationName: Property<String>

/**
* The API endpoint to use (optional).
*
* Default: "https://central.sonatype.com/api/v1/".
*/
abstract val endpoint: Property<String>

/**
* Whether to verify the status of the deployment before returning from the task.
*
* Default: true.
*/
abstract val verifyStatus: Property<Boolean>
}
5 changes: 4 additions & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading