diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 742cfca..87ed7c3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ on: types: [published] jobs: SONATYPE_UPLOAD: - name: Sonatype Upload + name: Maven Central Upload runs-on: ubuntu-latest env: SIGNING_KEY: ${{ secrets.SIGNING_KEY }} @@ -20,7 +20,7 @@ jobs: java-version: 17 distribution: temurin cache: gradle - - name: Publish to Sonatype - run: ./gradlew deploySonatype + - name: Publish to Maven Central + run: ./gradlew deployNexus - name: Publish to GitHub Packages run: ./gradlew deployGithub diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index f2da171..370794b 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -23,5 +23,5 @@ jobs: java-version: 17 distribution: temurin cache: gradle - - name: Publish sonatype snapshot - run: ./gradlew deploySonatypeSnapshot \ No newline at end of file + - name: Publish nexus snapshot + run: ./gradlew deployNexusSnapshot \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 58b3769..e008dc6 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/.idea/runConfigurations/deploySonatypeSnapshot.xml b/.idea/runConfigurations/deployCentralPortal.xml similarity index 80% rename from .idea/runConfigurations/deploySonatypeSnapshot.xml rename to .idea/runConfigurations/deployCentralPortal.xml index 69fe5c2..4637607 100644 --- a/.idea/runConfigurations/deploySonatypeSnapshot.xml +++ b/.idea/runConfigurations/deployCentralPortal.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/.idea/runConfigurations/deploySonatype.xml b/.idea/runConfigurations/deployNexus.xml similarity index 81% rename from .idea/runConfigurations/deploySonatype.xml rename to .idea/runConfigurations/deployNexus.xml index b84da24..0d7471a 100644 --- a/.idea/runConfigurations/deploySonatype.xml +++ b/.idea/runConfigurations/deployNexus.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/.idea/runConfigurations/deployNexusSnapshot.xml b/.idea/runConfigurations/deployNexusSnapshot.xml new file mode 100644 index 0000000..839d4c2 --- /dev/null +++ b/.idea/runConfigurations/deployNexusSnapshot.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/README.md b/README.md index ca1f56a..64fefe2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ A lightweight, handy Gradle plugin to deploy your maven packages (for example, Android AARs, Java JARs, Kotlin KLibs) to different kinds of repositories. It supports publishing to: - local directories, to use them as local maven repositories in other projects -- [Maven Central](https://central.sonatype.org/) repository via Sonatype's OSSRH +- [Maven Central](https://central.sonatype.com/) repository via Sonatype's OSSRH +- [Maven Central](https://central.sonatype.com/) repository via Sonatype's [Central Portal](https://central.sonatype.org/register/central-portal/) - Other Sonatype Nexus repositories - [GitHub Packages](https://docs.github.com/en/packages) @@ -26,7 +27,7 @@ pluginManagement { // build.gradle.kts of deployable modules plugins { - id("io.deepmedia.tools.deployer") version "0.12.0" + id("io.deepmedia.tools.deployer") version "0.13.0" } ``` diff --git a/deployer/build.gradle.kts b/deployer/build.gradle.kts index 0894093..7d11202 100644 --- a/deployer/build.gradle.kts +++ b/deployer/build.gradle.kts @@ -3,7 +3,7 @@ plugins { `kotlin-dsl` `java-gradle-plugin` - id("io.deepmedia.tools.deployer") version "0.12.0-rc1" + id("io.deepmedia.tools.deployer") version "0.12.0" kotlin("plugin.serialization") version "1.9.23" } @@ -46,7 +46,7 @@ gradlePlugin { } group = "io.deepmedia.tools.deployer" -version = "0.12.0" // on change, update both docs and README +version = "0.13.0-rc1" // on change, update both docs and README deployer { verbose = true @@ -75,15 +75,15 @@ deployer { // directory.set(layout.buildDirectory.get().dir("inspect")) } - // use "deploySonatype" to deploy to OSSRH / maven central - sonatypeSpec { + // use "deployNexus" to deploy to OSSRH / maven central + nexusSpec { auth.user = secret("SONATYPE_USER") auth.password = secret("SONATYPE_PASSWORD") syncToMavenCentral = true } - // use "deploySonatypeSnapshot" to deploy to sonatype snapshots repo - sonatypeSpec("snapshot") { + // use "deployNexusSnapshot" to deploy to sonatype snapshots repo + nexusSpec("snapshot") { auth.user = secret("SONATYPE_USER") auth.password = secret("SONATYPE_PASSWORD") repositoryUrl = ossrhSnapshots1 @@ -99,4 +99,22 @@ deployer { token = secret("GHUB_PERSONAL_ACCESS_TOKEN") } } + + // Just for testing centralPortal + /* centralPortalSpec { + auth.user = secret("CENTRAL_PORTAL_USERNAME") + auth.password = secret("CENTRAL_PORTAL_PASSWORD") + allowMavenCentralSync = false + projectInfo.groupId.set("io.github.natario1") + content { + inherit.set(false) + component { + fromMavenPublication("pluginMaven", clone = true) + packaging.set("jar") + kotlinSources() + emptyDocs() + } + + } + } */ } diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerExtension.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerExtension.kt index 1ce8587..4750e74 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerExtension.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerExtension.kt @@ -19,6 +19,9 @@ open class DeployerExtension @Inject constructor(private val objects: ObjectFact internal val mavenCentralSync = objects.newInstance() internal fun mavenCentralSync(action: Action) { action.execute(mavenCentralSync) } + internal val centralPortalSettings = objects.newInstance() + internal fun centralPortalSettings(action: Action) { action.execute(centralPortalSettings) } + @Deprecated("DeployerExtension now *is* the default spec. Simply use `this`.") val defaultSpec get() = this @@ -37,6 +40,7 @@ open class DeployerExtension @Inject constructor(private val objects: ObjectFact registerFactory(LocalDeploySpec::class.java) { LocalDeploySpec(objects, it).apply { fallback(this@DeployerExtension) } } registerFactory(GithubDeploySpec::class.java) { GithubDeploySpec(objects, it).apply { fallback(this@DeployerExtension) } } registerFactory(SonatypeDeploySpec::class.java) { SonatypeDeploySpec(objects, it).apply { fallback(this@DeployerExtension) } } + registerFactory(CentralPortalDeploySpec::class.java) { CentralPortalDeploySpec(objects, it).apply { fallback(this@DeployerExtension) } } } private fun specName(current: String, default: String): String { @@ -47,6 +51,7 @@ open class DeployerExtension @Inject constructor(private val objects: ObjectFact specs.register(specName(name, "local"), LocalDeploySpec::class.java, configure) } + @Deprecated("sonatypeSpec is ambiguous. Use either nexusSpec (for OSSRH) or centralPortalSpec (for Central Portal).") fun sonatypeSpec(name: String = "sonatype", configure: Action = Action { }) { specs.register(specName(name, "sonatype"), SonatypeDeploySpec::class.java, configure) } @@ -58,4 +63,8 @@ open class DeployerExtension @Inject constructor(private val objects: ObjectFact fun githubSpec(name: String = "github", configure: Action = Action { }) { specs.register(specName(name, "github"), GithubDeploySpec::class.java, configure) } + + fun centralPortalSpec(name: String = "centralPortal", configure: Action = Action { }) { + specs.register(specName(name, "centralPortal"), CentralPortalDeploySpec::class.java, configure) + } } \ No newline at end of file diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerPlugin.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerPlugin.kt index c9bec1c..b412c15 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerPlugin.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/DeployerPlugin.kt @@ -4,8 +4,9 @@ import io.deepmedia.tools.deployer.specs.GithubDeploySpec import io.deepmedia.tools.deployer.specs.LocalDeploySpec import io.deepmedia.tools.deployer.specs.SonatypeDeploySpec import io.deepmedia.tools.deployer.model.* -import io.deepmedia.tools.deployer.ossrh.OssrhService -import io.deepmedia.tools.deployer.specs.NexusDeploySpec +import io.deepmedia.tools.deployer.central.ossrh.OssrhService +import io.deepmedia.tools.deployer.central.portal.CentralPortalService +import io.deepmedia.tools.deployer.specs.CentralPortalDeploySpec import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.repositories.MavenArtifactRepository @@ -54,6 +55,13 @@ class DeployerPlugin : Plugin { parameters.verboseLogging.set(deployer.verbose) } } + if (this is CentralPortalDeploySpec) { + target.gradle.sharedServices.registerIfAbsent(CentralPortalService.Name, CentralPortalService::class) { + parameters.timeout.set(deployer.centralPortalSettings.timeout.map { it.inWholeMilliseconds }) + parameters.pollingDelay.set(deployer.centralPortalSettings.pollingDelay.map { it.inWholeMilliseconds }) + parameters.verboseLogging.set(deployer.verbose) + } + } val deployThis = registerDeployTasks(log.child(name), this, target) deployAll.configure { dependsOn(deployThis) } @@ -71,6 +79,7 @@ class DeployerPlugin : Plugin { is LocalDeploySpec -> "local maven repository" is SonatypeDeploySpec -> "Sonatype/Nexus repository" is GithubDeploySpec -> "GitHub packages" + is CentralPortalDeploySpec -> "Central Portal uploads" else -> error("Unexpected spec type: ${spec::class}") } })." diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhClient.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhClient.kt similarity index 97% rename from deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhClient.kt rename to deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhClient.kt index af51e06..9377a77 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhClient.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhClient.kt @@ -1,13 +1,11 @@ -package io.deepmedia.tools.deployer.ossrh +package io.deepmedia.tools.deployer.central.ossrh -import com.android.tools.r8.internal.Bo import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* -import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.Serializable diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhInfo.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhInfo.kt similarity index 89% rename from deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhInfo.kt rename to deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhInfo.kt index 5223efb..d60f18b 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhInfo.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhInfo.kt @@ -1,4 +1,4 @@ -package io.deepmedia.tools.deployer.ossrh +package io.deepmedia.tools.deployer.central.ossrh internal data class OssrhInfo( val server: OssrhServer, diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhInvocation.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhInvocation.kt similarity index 96% rename from deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhInvocation.kt rename to deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhInvocation.kt index 34b3eac..552734e 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhInvocation.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhInvocation.kt @@ -1,15 +1,11 @@ -package io.deepmedia.tools.deployer.ossrh +package io.deepmedia.tools.deployer.central.ossrh import io.deepmedia.tools.deployer.Logger import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.time.withTimeout import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds internal class OssrhInvocation( logger: Logger, diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhServer.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhServer.kt similarity index 95% rename from deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhServer.kt rename to deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhServer.kt index 4d42b4c..3b61dd3 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhServer.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhServer.kt @@ -1,4 +1,4 @@ -package io.deepmedia.tools.deployer.ossrh +package io.deepmedia.tools.deployer.central.ossrh // OSSRH: Nexus-powered repositories provided by Sonatype to OSS projects for diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhService.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhService.kt similarity index 96% rename from deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhService.kt rename to deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhService.kt index 31f3fb5..04381ac 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/ossrh/OssrhService.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/ossrh/OssrhService.kt @@ -1,11 +1,10 @@ -package io.deepmedia.tools.deployer.ossrh +package io.deepmedia.tools.deployer.central.ossrh import io.deepmedia.tools.deployer.Logger import kotlinx.coroutines.* import org.gradle.api.provider.Property import org.gradle.api.services.BuildService import org.gradle.api.services.BuildServiceParameters -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalClient.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalClient.kt new file mode 100644 index 0000000..1cdab98 --- /dev/null +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalClient.kt @@ -0,0 +1,110 @@ +package io.deepmedia.tools.deployer.central.portal + +import com.android.tools.r8.internal.Bo +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.* +import io.ktor.util.cio.* +import io.ktor.utils.io.* +import io.ktor.utils.io.core.* +import io.ktor.utils.io.jvm.javaio.* +import io.ktor.utils.io.streams.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.gradle.api.file.Directory +import java.io.File + +/** + * https://central.sonatype.org/publish/publish-portal-api/#verify-status-of-the-deployment + * + * PENDING: A deployment is uploaded and waiting for processing by the validation service + * VALIDATING: A deployment is being processed by the validation service + * VALIDATED: A deployment has passed validation and is waiting on a user to manually publish via the Central Portal UI + * PUBLISHING: A deployment has been either automatically or manually published and is being uploaded to Maven Central + * PUBLISHED: A deployment has successfully been uploaded to Maven Central + * FAILED: A deployment has encountered an error (additional context will be present in an errors field) + */ +@Serializable +internal data class CentralPortalDeployment( + val deploymentId: String, + val deploymentState: String, + val errors: JsonElement? = null +) + +internal class CentralPortalClient { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + private val http = HttpClient(CIO) { + engine { + requestTimeout = 0 + } + defaultRequest { + url("https://central.sonatype.com/api/v1/") + } + expectSuccess = true + install(ContentNegotiation) { + json(json) + } + } + + // https://ktor.io/docs/client-requests.html#upload_file + // https://central.sonatype.org/publish/publish-portal-api/#uploading-a-deployment-bundle + suspend fun createDeployment(info: CentralPortalInfo, file: File): String { + return http.post("publisher/upload") { + bearerAuth("${info.username}:${info.password}".encodeBase64()) + parameter("name", "Auto-generated by MavenDeployer Gradle plugin.") + parameter("publishingType", if (info.allowSync) "AUTOMATIC" else "USER_MANAGED") + + setBody(MultiPartFormDataContent(formData { + val size = file.length() + val provider = ChannelProvider(size) { file.readChannel() } + append( + key = "bundle", + value = provider, + headers = Headers.build { + this[HttpHeaders.ContentDisposition] = "filename=${file.name.escapeIfNeeded()}" + this[HttpHeaders.ContentType] = ContentType.Application.OctetStream.toString() + } + ) + // This requires a readBytes() + /* append( + key = "bundle", + filename = file.name, + contentType = ContentType.Application.OctetStream, + size = size, + bodyBuilder = { writeFully(file.readBytes()) } + ) */ + })) + }.body() + } + + suspend fun getDeployment(info: CentralPortalInfo, deploymentId: String): CentralPortalDeployment { + return http.post("publisher/status") { + parameter("id", deploymentId) + bearerAuth("${info.username}:${info.password}".encodeBase64()) + }.body() + } + + /** + * Can be called when deployment status is FAILED or VALIDATED. + */ + suspend fun deleteDeployment(info: CentralPortalInfo, deploymentId: String) { + http.delete("publisher/deployment/$deploymentId") { + bearerAuth("${info.username}:${info.password}".encodeBase64()) + } + } + +} \ No newline at end of file diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalInfo.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalInfo.kt new file mode 100644 index 0000000..eed1d85 --- /dev/null +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalInfo.kt @@ -0,0 +1,7 @@ +package io.deepmedia.tools.deployer.central.portal + +internal data class CentralPortalInfo( + val username: String, + val password: String, + val allowSync: Boolean +) \ No newline at end of file diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalInvocation.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalInvocation.kt new file mode 100644 index 0000000..74e21fe --- /dev/null +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalInvocation.kt @@ -0,0 +1,109 @@ +package io.deepmedia.tools.deployer.central.portal + +import io.deepmedia.tools.deployer.Logger +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.Directory +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.time.Duration + +internal class CentralPortalInvocation( + logger: Logger, + private val client: CentralPortalClient, + private val info: CentralPortalInfo, + private val timeout: Duration, + private val pollDelay: Duration, +) { + + private data class State( + val users: Int, // >= 1 + val directories: Set, + ) + + private fun State?.addingUser(directory: File): State = when (this) { + null -> State(1, setOf(directory)) + else -> this.copy(users = users + 1, directories = directories + directory) + } + + private fun State?.removingUser(): State? = when (this?.users) { + null -> null + 1 -> null + else -> this.copy(users = users - 1) + } + + private val log = logger + private val state = MutableStateFlow(null) + + fun append(directory: File) { + log { "append: $directory..." } + val state = state.updateAndGet { it.addingUser(directory) }!! + log { "append: ${state.users} users" } + } + + suspend fun release(buildDir: File) { + val state = state.getAndUpdate { it.removingUser() }!! + if (state.users == 1) { + + val archiveHash = state.directories.map { it.path.hashCode() }.hashCode().toUInt() + log { "release: ZIP ($archiveHash)" } + val archiveFile = buildDir + .let { File(it, "deployer") } + .let { File(it, "centralPortal") } + .let { File(it, "$archiveHash-upload.zip") } + archiveFile.parentFile.mkdirs() + archiveFile.delete() + ZipOutputStream(archiveFile.outputStream()).use { zipStream -> + state.directories.forEach { mavenBaseDir -> + mavenBaseDir.walkTopDown().forEach { mavenEntry -> + if (mavenEntry.isFile) { + zipStream.putNextEntry(ZipEntry(mavenBaseDir.toPath().relativize(mavenEntry.toPath()).toString())) + mavenEntry.inputStream().use { it.copyTo(zipStream) } + zipStream.closeEntry() + } + } + } + } + + log { "release: UPLOAD (${archiveFile.length()} bytes)" } + val deploymentId = client.createDeployment(info, archiveFile) + val target = if (info.allowSync) "PUBLISHED" else "VALIDATED" + + log { "release: WAIT" } + withTimeoutOrNull(timeout) { + while (true) { + val deployment = client.getDeployment(info, deploymentId) + log { "release: polling for '$target' state, found ${deployment.deploymentState}" } + if (deployment.deploymentState == "FAILED") { + runCatching { client.deleteDeployment(info, deploymentId) } + log { "release: ${deployment.errors?.let { Json.encodeToString(it) }}" } + error("Deployment validation failed: ${deployment.errors?.let { Json.encodeToString(it) }}") + } else if (deployment.deploymentState == target) { + break + } else { + delay(pollDelay) + continue + } + } + } ?: error("Deployment did not transition to $target state after $timeout.") + } else { + log { "release: not the last user (${state.users})" } + // Can't do this. + // Waiting for siblings is OK during creation (where the first child does the job), but when releasing, + // all subprojects would have to wait for the slowest one to arrive and do the work. + // Since we are using runBlocking in OssrhService, this means that: + // - in Gradle parallel mode, this leaves to thread starvation when there are enough subprojects + // - in no-parallel mode, the build should hang completely. + // state.release.await() + } + } +} + diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalService.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalService.kt new file mode 100644 index 0000000..3d3453e --- /dev/null +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/central/portal/CentralPortalService.kt @@ -0,0 +1,57 @@ +package io.deepmedia.tools.deployer.central.portal + +import io.deepmedia.tools.deployer.Logger +import kotlinx.coroutines.* +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.Directory +import org.gradle.api.provider.Property +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import java.io.File +import kotlin.time.Duration.Companion.milliseconds + + +internal abstract class CentralPortalService : BuildService, AutoCloseable { + companion object { + const val Name = "centralPortal" + } + + interface Params : BuildServiceParameters { + val timeout: Property + val pollingDelay: Property + val verboseLogging: Property + } + + private val client = CentralPortalClient() + private val job = SupervisorJob() + private val invocations = mutableMapOf() + private val lock = Any() + private val log by lazy { Logger(parameters.verboseLogging, listOf("CentralPortal")) } + + private fun invocation(info: CentralPortalInfo): CentralPortalInvocation = synchronized(lock) { + invocations.getOrPut(info) { + log { "Creating invocation..." } + CentralPortalInvocation( + logger = log, + client = client, + info = info, + timeout = parameters.timeout.get().milliseconds, + pollDelay = parameters.pollingDelay.get().milliseconds + ) + } + } + + fun initialize(info: CentralPortalInfo, localRepository: File) { + val invocation = invocation(info) + invocation.append(localRepository) + } + + fun finalize(info: CentralPortalInfo, buildDir: File) { + val invocation = invocation(info) + runBlocking(job + Dispatchers.Default) { invocation.release(buildDir) } + } + + override fun close() { + job.cancel() + } +} \ No newline at end of file diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/model/Secret.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/model/Secret.kt index 1e77fe3..1953b13 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/model/Secret.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/model/Secret.kt @@ -43,8 +43,7 @@ private fun File.localProperties(): Properties? { val child = File(this, "local.properties") if (child.exists()) { val properties = Properties() - val stream = FileInputStream(child) - properties.load(stream) + child.inputStream().use { properties.load(it) } return properties } return parentFile?.localProperties() diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/CentralPortal.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/CentralPortal.kt new file mode 100644 index 0000000..497713d --- /dev/null +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/CentralPortal.kt @@ -0,0 +1,197 @@ +package io.deepmedia.tools.deployer.specs + +import io.deepmedia.tools.deployer.Logger +import io.deepmedia.tools.deployer.dump +import io.deepmedia.tools.deployer.model.AbstractDeploySpec +import io.deepmedia.tools.deployer.model.Auth +import io.deepmedia.tools.deployer.model.DeploySpec +import io.deepmedia.tools.deployer.model.Secret +import io.deepmedia.tools.deployer.central.ossrh.OssrhInfo +import io.deepmedia.tools.deployer.central.ossrh.OssrhService +import io.deepmedia.tools.deployer.central.ossrh.OssrhServer +import io.deepmedia.tools.deployer.central.portal.CentralPortalInfo +import io.deepmedia.tools.deployer.central.portal.CentralPortalService +import io.deepmedia.tools.deployer.tasks.isDocsJar +import io.deepmedia.tools.deployer.tasks.isSourcesJar +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.gradle.api.artifacts.repositories.MavenArtifactRepository +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.ProjectLayout +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.publish.maven.MavenArtifactSet +import org.gradle.api.publish.maven.MavenPom +import org.gradle.api.publish.maven.internal.publication.MavenPomInternal +import org.gradle.api.services.ServiceReference +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.maven +import org.gradle.kotlin.dsl.property +import org.gradle.kotlin.dsl.register +import java.io.File +import javax.inject.Inject +import kotlin.math.abs +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + + +open class CentralPortalSettings @Inject constructor(objects: ObjectFactory) { + val pollingDelay: Property = objects.property().convention(15.seconds) + fun pollingDelay(milliseconds: Long) { pollingDelay.set(milliseconds.milliseconds) } + + val timeout: Property = objects.property().convention(15.minutes) + fun timeout(milliseconds: Long) { timeout.set(milliseconds.milliseconds) } +} + +class CentralPortalDeploySpec internal constructor(objects: ObjectFactory, name: String) + : AbstractDeploySpec(objects, name, CentralPortalAuth::class) { + + val allowMavenCentralSync: Property = objects.property() + .convention(true) + .apply { finalizeValueOnRead() } + + override fun registerRepository(target: Project, repositories: RepositoryHandler): MavenArtifactRepository { + val storageRepoName = "${this@CentralPortalDeploySpec.name}Storage" + val storageRepoDir = target.layout.buildDirectory.get().dir("deployer").dir(storageRepoName) + return repositories.maven(storageRepoDir) { + name = storageRepoName + } + } + + override fun registerInitializationTask(target: Project, name: String, repo: MavenArtifactRepository): TaskProvider<*> { + return target.tasks.register(name, CentralPortalInitializationTask::class).apply { + configure { + this.auth.set(this@CentralPortalDeploySpec.auth) + this.storageRepository.set(repo.url.path) + this.allowSync.set(allowMavenCentralSync) + } + } + } + + @Suppress("UNCHECKED_CAST") + override fun registerFinalizationTask(target: Project, name: String, init: TaskProvider<*>?): TaskProvider<*>? { + init as TaskProvider + return target.tasks.register(name, CentralPortalFinalizationTask::class).apply { + configure { + info.set(init.flatMap { it.info }) + rootBuildDir.set(target.rootProject.layout.buildDirectory.get().asFile.absolutePath) + } + } + } + + override fun readSignCredentials(target: Project): Pair { + val result = super.readSignCredentials(target) ?: error("Signing is mandatory for Central Portal deployments. Please add spec.signing.key and spec.signing.password.") + return result + } + + override fun validateMavenArtifacts(target: Project, artifacts: MavenArtifactSet, log: Logger) { + super.validateMavenArtifacts(target, artifacts, log) + fun err(type: String): String { + return "Central Portal requires a $type jar artifact. Please add it to your component; you may use utilities like emptyDocs() and emptySources(). " + + "Available artifacts: " + artifacts.dump() + } + require(artifacts.any { it.isSourcesJar }) { err("sources") } + require(artifacts.any { it.isDocsJar }) { err("javadoc") } + } + + // https://central.sonatype.org/pages/requirements.html + override fun validateMavenPom(pom: MavenPom) { + super.validateMavenPom(pom) + require(pom.url.isPresent) { + "Sonatype POM requires a project url. Please add it to spec.projectInfo.url." + } + pom as MavenPomInternal + require(pom.licenses.isNotEmpty()) { + "Sonatype POM requires at least one license. Please add it to spec.projectInfo.licenses." + } + require(pom.developers.isNotEmpty()) { + "Sonatype POM requires at least one developer. Please add it to spec.projectInfo.developers." + } + require(pom.scm.connection.isPresent && pom.scm.developerConnection.isPresent && pom.scm.url.isPresent) { + "Sonatype POM requires complete SCM info. Please add it to spec.projectInfo.scm." + } + } +} + +open class CentralPortalAuth @Inject constructor(objects: ObjectFactory) : Auth() { + val user: Property = objects.property().apply { finalizeValueOnRead() } + val password: Property = objects.property().apply { finalizeValueOnRead() } + + override fun fallback(to: Auth) { + if (to is SonatypeAuth) { + user.convention(to.user) + password.convention(to.password) + } + } +} + +/** + * Note: We want [CentralPortalInitializationTask] and [CentralPortalFinalizationTask] to always run + * regardless of task cache and up to date checks. There are two options: + * + * 1. Add `outputs.upToDateWhen { false }` explicitly to tell Gradle this task is never up-to-date. + * Turns out that if a task normally declares no outputs, adding the lambda above has important consequences. + * It tells Gradle that this task has output logic and enables input fingerprinting, which in turn + * activates the requirement that all input properties must be serializable. + * So, with that line we start to get errors on [CentralPortalAuth], [CentralPortalInfo]... + * To fix they may be split in their individual component (e.g. secret key) but second option is easier. + * + * 2. Remember that when a task has no declared outputs, Gradle already considers it never up-to-date. + * So doing nothing seems to be the best solution, as not having outputs also means Gradle won't bother + * doing input fingerprinting and we don't have to split the current inputs into many primitive properties. + */ +internal abstract class CentralPortalInitializationTask @Inject constructor( + objects: ObjectFactory, + private val providers: ProviderFactory, + private val layout: ProjectLayout +) : DefaultTask() { + @get:Input val auth: Property = objects.property() + @get:Input val allowSync: Property = objects.property() + @get:Input val storageRepository: Property = objects.property() + @get:Internal val info: Property = objects.property() + + @Suppress("UnstableApiUsage") + @get:ServiceReference(CentralPortalService.Name) + abstract val service: Property + + @TaskAction + fun execute() { + val auth = auth.get() + val username = auth.user.get().resolve(providers, layout, "spec.auth.user") + val password = auth.password.get().resolve(providers, layout, "spec.auth.password") + val info = CentralPortalInfo(username, password, allowSync.get()) + val storageRepository = File(storageRepository.get()) + storageRepository.delete() // Delete any stale data + this.service.get().initialize(info, storageRepository) + this.info.set(info) + } +} + +/** + * See comments on [CentralPortalInitializationTask]. + */ +internal abstract class CentralPortalFinalizationTask @Inject constructor( + objects: ObjectFactory, +) : DefaultTask() { + @get:Input + val info: Property = objects.property() + + @get:Input + val rootBuildDir: Property = objects.property() + + @Suppress("UnstableApiUsage") + @get:ServiceReference(CentralPortalService.Name) + abstract val service: Property + + @TaskAction + fun execute() { + service.get().finalize(info.get(), File(rootBuildDir.get())) + } +} \ No newline at end of file diff --git a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/Sonatype.kt b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/Sonatype.kt index 3906248..27a9ef2 100644 --- a/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/Sonatype.kt +++ b/deployer/src/main/kotlin/io/deepmedia/tools/deployer/specs/Sonatype.kt @@ -6,9 +6,9 @@ import io.deepmedia.tools.deployer.model.AbstractDeploySpec import io.deepmedia.tools.deployer.model.Auth import io.deepmedia.tools.deployer.model.DeploySpec import io.deepmedia.tools.deployer.model.Secret -import io.deepmedia.tools.deployer.ossrh.OssrhInfo -import io.deepmedia.tools.deployer.ossrh.OssrhService -import io.deepmedia.tools.deployer.ossrh.OssrhServer +import io.deepmedia.tools.deployer.central.ossrh.OssrhInfo +import io.deepmedia.tools.deployer.central.ossrh.OssrhService +import io.deepmedia.tools.deployer.central.ossrh.OssrhServer import io.deepmedia.tools.deployer.tasks.isDocsJar import io.deepmedia.tools.deployer.tasks.isSourcesJar import org.gradle.api.DefaultTask diff --git a/docs/index.mdx b/docs/index.mdx index eda78a4..4850205 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -13,7 +13,8 @@ docs: `MavenDeployer` is a lightweight, handy Gradle plugin to deploy your maven packages (for example, Android AARs, Java JARs, Kotlin KLibs) to different kinds of repositories. It supports publishing to: - local directories, to use them as local maven repositories in other projects -- [Maven Central](https://central.sonatype.org/) repository via Sonatype's OSSRH +- [Maven Central](https://central.sonatype.com/) repository via Sonatype's OSSRH +- [Maven Central](https://central.sonatype.com/) repository via Sonatype's [Central Portal](https://central.sonatype.org/register/central-portal/) - Other Sonatype Nexus repositories - [GitHub Packages](https://docs.github.com/en/packages) diff --git a/docs/install.mdx b/docs/install.mdx index 697ee48..04b2c47 100644 --- a/docs/install.mdx +++ b/docs/install.mdx @@ -19,10 +19,12 @@ pluginManagement { // build.gradle.kts of deployable modules plugins { - id("io.deepmedia.tools.deployer") version "0.12.0" + id("io.deepmedia.tools.deployer") version "LATEST_VERSION" } ``` +Replace `"LATEST_VERSION"` with the latest version number, {version}. + ## Snapshots The plugin uses an older version of itself to publish itself to the Maven Central repository diff --git a/docs/repos/central-portal.mdx b/docs/repos/central-portal.mdx new file mode 100644 index 0000000..9f7b652 --- /dev/null +++ b/docs/repos/central-portal.mdx @@ -0,0 +1,71 @@ +--- +title: Central Portal +--- + +# Central Portal + +[Central Portal](https://central.sonatype.org/register/central-portal/) is Sonatype's new publishing mechanism +that serves as a modern entry point to the [Maven Central](https://central.sonatype.com/) repository. + +> The portal is a recent addition in the Maven Central publishing ecosystem. If you have pushed to Maven Central +> before, you likely need to use our [Nexus support](sonatype). + +Add a new spec with `centralPortalSpec {}` and start configuring it: + +```kotlin +deployer { + // Common configuration... + project.description.set("Handy tool to publish maven packages in different repositories.") + + centralPortalSpec { + // Take these credentials from the Generate User Token page at https://central.sonatype.com/account + auth.user.set(secret("CENTRAL_PORTAL_USER")) + auth.password.set(secret("CENTRAL_PORTAL_PASSWORD")) + + // Signing is required + signing.key.set(secret("SIGNING_KEY")) + signing.password.set(secret("SIGNING_PASSWORD")) + } + + // If needed, you can add other named specs and configure them differently. + // Each spec gets its own deploy* task. + centralPortalSpec("foo") { + ... + } +} +``` + +## Maven Central sync + +Central Portal deployments, when properly configured, will be synced to [Maven Central](https://central.sonatype.com/). +For this to happen you generally need three steps: + +1. Build maven packages with a very strict set of rules (for example, signing is mandatory) +2. Upload them, for example through the web interface at https://central.sonatype.com +3. Wait for validation, then finalize the deployment for it to be synced to Maven Central + +Deployer plugin will take care of these steps for you: + +- It validates your artifacts and POM file locally, to ensure they won't be rejected by the backend +- It fails with clear errors if any issue is found +- It uses Sonatype's REST APIs to upload your artifacts (even if they belong to different specs!) +- It uses Sonatype's REST APIs to finalize the deployment after validation + +### Disallowing finalization + +If you'd rather do the third step on your own (for example, because you want to check the files in the +web interface before they get synced), you have the option to do so: + +```kotlin +deployer { + ... + centralPortalSpec { + allowMavenCentralSync = false + } +} +``` + +When `allowMavenCentralSync` is set to false, the deploy task (e.g. `deployCentralPortal`) will be considered +successful if the remote validation succeeds, and all that's left for Maven Central sync is that you finalize +the deployment manually through Sonatype's [web interface](https://central.sonatype.com). + diff --git a/docs/repos/index.mdx b/docs/repos/index.mdx index 23fd977..02f48cb 100644 --- a/docs/repos/index.mdx +++ b/docs/repos/index.mdx @@ -3,6 +3,7 @@ title: Repositories docs: - local - sonatype + - central-portal - github --- @@ -13,6 +14,7 @@ exposed through subclasses of the `DeploySpec` type, so that, for example, a `lo `LocalDeploySpec` properties. `MavenDeployer` supports publishing to: -- local directories, to use them as local maven repositories in other projects [[docs](local)] -- Sonatype Nexus repositories, with support for syncing to [Maven Central](https://central.sonatype.org/) [[docs](sonatype)] -- [GitHub Packages](https://docs.github.com/en/packages) [[docs](github)] +- [local directories](local), to use them as local maven repositories in other projects +- Sonatype's [Nexus](sonatype) repositories, with support for syncing OSSRH projects to [Maven Central](https://central.sonatype.org/) +- Sonatype's [Central Portal](central-portal) uploads, a modern entry point for Maven Central +- [GitHub Packages](github) diff --git a/docs/repos/sonatype.mdx b/docs/repos/sonatype.mdx index bd5fded..5c0b6ad 100644 --- a/docs/repos/sonatype.mdx +++ b/docs/repos/sonatype.mdx @@ -1,10 +1,16 @@ --- -title: Sonatype & Maven Central +title: Nexus & OSSRH --- -# Sonatype & Maven Central +# Nexus & OSSRH -Add a new spec with `sonatypeSpec {}` or `nexusSpec {}`. It adds a mandatory property called `repositoryUrl`, which is the remote URL +Nexus deployments let you upload packages to a remote Sonatype Nexus repository. Notably, these +are commonly used for OSSRH projects to become part of the [Maven Central](https://central.sonatype.com/) repo. + +> Sonatype recently devised a new entry point for Maven Central called the [Central Portal](https://central.sonatype.org/register/central-portal/). +> If you are a new publisher or an old publisher who decided to migrate to Central Portal, please [check the docs](central-portal). + +Add a new spec with `nexusSpec {}`. It adds a mandatory property called `repositoryUrl`, which is the remote URL of the sonatype repo. This defaults to `https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/`, one of the OSSRH urls that can be synced to Maven Central. @@ -13,7 +19,7 @@ deployer { // Common configuration... project.description.set("Handy tool to publish maven packages in different repositories.") - sonatypeSpec { + nexusSpec { // Target URL. You can use `ossrh`, `ossrh1`, `ossrhSnapshots`, `ossrhSnapshots1` or your own URL repositoryUrl.set(ossrh1) @@ -27,7 +33,7 @@ deployer { } // If needed, you can add other named specs. - sonatypeSpec("snapshot") { + nexusSpec("snapshot") { repositoryUrl.set(ossrhSnapshots1) release.version.set("latest-SNAPSHOT") ... @@ -37,7 +43,7 @@ deployer { ## Maven Central sync -Sonatype's OSSRH projects are allowed to sync artifacts with the [Maven Central](https://central.sonatype.org/) repository. +Sonatype's OSSRH projects are allowed to sync artifacts with the [Maven Central](https://central.sonatype.com/) repository. The process is generally tricky, because you have to: - Build maven packages with a very strict set of rules (for example, signing is mandatory) @@ -50,7 +56,7 @@ Deployer streamlines the process so that only the first step is really your resp First, enable maven central sync using `syncToMavenCentral`: ```kotlin -sonatypeSpec { +nexusSpec { syncToMavenCentral = true auth.user = secret("SONATYPE_USER") diff --git a/docs/usage.mdx b/docs/usage.mdx index e7214fb..0dc7c43 100644 --- a/docs/usage.mdx +++ b/docs/usage.mdx @@ -45,12 +45,12 @@ deployer { release.version = "1.0.0" // our default ... - sonatypeSpec { + nexusSpec { // release.version is 1.0.0 ... } - sonatypeSpec("snapshot") { + nexusSpec("snapshot") { // snapshot publishing. Override the default version release.version = "1.0.0-SNAPSHOT" ... @@ -61,11 +61,11 @@ deployer { ## Tasks For each spec, the plugin will register a gradle task named `deploy`. The spec type is either local, -github, or sonatype. The name defaults to `""` but can be configured. In addition, an extra task called `deployAll` will be +github, nexus or centralPortal. The name defaults to `""` but can be configured. In addition, an extra task called `deployAll` will be generated, running all deployments at once. In the example above, the following tasks are generated: -- `deploySonatype` -- `deploySonatypeSnapshot` +- `deployNexus` +- `deployNexusSnapshot` - `deployAll` > **Note**: Use ./gradlew tasks --group='Deployment' to list all deploy tasks.