diff --git a/README.md b/README.md index af33c904..f27a9ecf 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,12 @@ and targets that do not, such as GitHub Packages. ### Minimal -Add `MAVEN_CENTRAL_USERNAME` and `MAVEN_CENTRAL_PASSWORD` to your environment +If you use the legacy Nexus-based publishing, +add `MAVEN_CENTRAL_USERNAME` and `MAVEN_CENTRAL_PASSWORD` to your environment. +If you use the new Maven Central Portal, +add `MAVEN_CENTRAL_PORTAL_USERNAME` and `MAVEN_CENTRAL_PORTAL_PASSWORD` +(obtain these credentials from the portal user settings page). +The plugin can also work in mixed mode, with some publications going to the portal and some to Nexus. ```kotlin plugins { @@ -216,6 +221,56 @@ Consequently, at the moment, the plugin only preconfigures the POM file and the * For every repository with an associated Sonatype Nexus instance, additional tasks are generated to control the creation, upload, closure, and release of staging repositories. +### Maven Central Portal publishing + +Maven Central recently introduced a new portal for managing the artifacts, +which will progressively replace the Nexus-based publishing. +Publishing on the Central Portal goes through the following steps: +1. the publication is uploaded to project-local maven repository +2. the local repository is zipped +3. the bundle is then uploaded to the portal and validated +4. once the validation is complete, the bundle is released or dropped + +The lifecycle is summarized in the following diagram: +```mermaid +flowchart LR + jar --o signPublication1Publication + sourcesJar --o signPublication1Publication + javadocJar --o signPublication1Publication + jar --o signPublication2Publication + sourcesJar --o signPublication2Publication + javadocJar --o signPublication2Publication + signPublication1Publication --o publishPublication1PublicationToProjectLocalRepository + signPublication2Publication --o publishPublication2PublicationToProjectLocalRepository + generatePomFileForPublication1Publication --o signPublication1Publication + generatePomFileForPublication2Publication --o signPublication2Publication + publishPublication1PublicationToProjectLocalRepository --o zipMavenCentralPortalPublication + publishPublication2PublicationToProjectLocalRepository --o zipMavenCentralPortalPublication + zipMavenCentralPortalPublication --o validateMavenCentralPortalPublication + validateMavenCentralPortalPublication --o dropMavenCentralPortalPublication + validateMavenCentralPortalPublication --o releaseMavenCentralPortalPublication +``` + +In short, select the publications you wish to publish, +and use the `uploadPublicationToProjectLocalRepository` task to enqueue them for upload, +then use the `zipMavenCentralPortalPublication` to create a bundle. +Now, you can interact with the portal using the (`validate`/`release`/`drop`)`MavenCentralPortalPublication` tasks. +A typical invocation could be: + +```console +$ ./gradlew uploadAllPublicationsToProjectLocalRepository zipMavenCentralPortalPublication releaseMavenCentralPortalPublication +``` + +If you already have an uploaded bundle and want to manage it using this plugin, +set the `publishDeploymentId` property to the deployment ID of the bundle you want to manage, e.g.: + +```console +$ ./gradlew -PpublishDeploymentId=8697a629-c07d-4349-9a3f-0f52f3ba74fb dropMavenCentralPortalPublication +``` + +If `publishDeploymentId` is set, +no upload will be performed. + ### Non-Nexus publishing Launching the `publish[PublicationName]PublicationTo[RepositoryName]Repository` triggers the creation of the required components, @@ -230,7 +285,7 @@ flowchart LR generatePomFileForPublicationNamePublication --o signPublicationNamePublication ``` -### Nexus publishing +### Sonatype Nexus publishing Nexus publishing is a bit more elaborate. It requires to select: @@ -326,8 +381,6 @@ jobs: If you use publish-on-central in your project, please consider providing a pull request with a link to your project: it will provide useful use cases for newcomers to look at. - - ### Java, simple project, kts build file * [**gson-extras**](https://github.com/DanySK/gson-extras): extra goodies for Google Gson * [**JIRF**](https://github.com/DanySK/jirf/): an implicit reflective factory diff --git a/build.gradle.kts b/build.gradle.kts index 8e89b042..513c1bc5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { api(gradleKotlinDsl()) api(libs.kotlin.gradlePlugin) api(libs.nexus.publish) + api(libs.maven.central.api) implementation(libs.kotlinx.coroutines) implementation(libs.fuel) testImplementation(libs.testkit) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ffae3a2b..757b1222 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ kotest-junit5-jvm = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref kotest-assertions-core-jvm = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinx-coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0" +maven-central-api = "org.danilopianini:maven-central-portal-kotlin-api-jvm:2.2.1" nexus-publish = { module = "io.github.gradle-nexus:publish-plugin", version.ref = "nexus-publish" } testkit = { module = "io.github.mirko-felice.testkit:core", version.ref = "testkit" } diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/InitializeNexusClient.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/InitializeNexusClient.kt index 22377338..1a33ea7a 100644 --- a/src/main/kotlin/org/danilopianini/gradle/mavencentral/InitializeNexusClient.kt +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/InitializeNexusClient.kt @@ -32,7 +32,6 @@ open class InitializeNexusClient NexusStatefulOperation( project = project, nexusUrl = nexusUrl, - group = project.group.toString(), user = repoToConfigure.user, password = repoToConfigure.password, timeOut = repoToConfigure.nexusTimeOut, diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/NexusStatefulOperation.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/NexusStatefulOperation.kt index 65d9be67..34af314b 100644 --- a/src/main/kotlin/org/danilopianini/gradle/mavencentral/NexusStatefulOperation.kt +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/NexusStatefulOperation.kt @@ -25,13 +25,20 @@ data class NexusStatefulOperation( private val password: Provider, private val timeOut: Duration, private val connectionTimeOut: Duration, - private val group: String, ) { /** * Repository description. */ val description by lazy { project.run { "$group:$name:$version" } } + private val group: String by lazy { + project.group.toString().apply { + check(isNotBlank()) { + "Project $project has no group set" + } + } + } + /** * The NexusClient. */ diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/ProjectExtensions.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/ProjectExtensions.kt index 80ed0f0e..bf230042 100644 --- a/src/main/kotlin/org/danilopianini/gradle/mavencentral/ProjectExtensions.kt +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/ProjectExtensions.kt @@ -1,7 +1,10 @@ package org.danilopianini.gradle.mavencentral import io.github.gradlenexus.publishplugin.internal.StagingRepository.State.CLOSED +import kotlinx.coroutines.runBlocking import org.danilopianini.gradle.mavencentral.MavenPublicationExtensions.signingTasks +import org.danilopianini.gradle.mavencentral.PublishPortalDeployment.Companion.DROP_TASK_NAME +import org.danilopianini.gradle.mavencentral.PublishPortalDeployment.Companion.RELEASE_TASK_NAME import org.gradle.api.Action import org.gradle.api.DefaultTask import org.gradle.api.Plugin @@ -21,7 +24,6 @@ import org.gradle.kotlin.dsl.the import org.gradle.kotlin.dsl.withType import org.gradle.plugins.signing.Sign import org.jetbrains.kotlin.gradle.dsl.KotlinJsProjectExtension -import java.net.URI import kotlin.reflect.KClass internal object ProjectExtensions { @@ -82,7 +84,7 @@ internal object ProjectExtensions { publishing.repositories { repository -> repository.maven { mavenArtifactRepository -> mavenArtifactRepository.name = repoToConfigure.name - mavenArtifactRepository.url = repoToConfigure.url.map { URI(it) }.get() + mavenArtifactRepository.url = repoToConfigure.url.get() if (mavenArtifactRepository.url.scheme != "file") { mavenArtifactRepository.credentials { credentials -> credentials.username = repoToConfigure.user.orNull @@ -320,4 +322,83 @@ internal object ProjectExtensions { ) } } + + internal fun Project.setupMavenCentralPortal() { + configureRepository(Repository.projectLocalRepository(project)) + val zipMavenCentralPortal = + tasks.register( + checkNotNull(ZipMavenCentralPortalPublication::class.simpleName) + .replaceFirstChar { it.lowercase() }, + ) + val portalDeployment = + PublishPortalDeployment( + project = project, + baseUrl = "https://central.sonatype.com/", + user = + project.propertyWithDefaultProvider { + System.getenv("MAVEN_CENTRAL_PORTAL_USERNAME") + ?: project.properties["mavenCentralPortalUsername"]?.toString() + ?: project.properties["centralPortalUsername"]?.toString() + ?: project.properties["centralUsername"]?.toString() + }, + password = + project.propertyWithDefaultProvider { + System.getenv("MAVEN_CENTRAL_PORTAL_PASSWORD") + ?: project.properties["mavenCentralPortalPassword"]?.toString() + ?: project.properties["centralPortalPassword"]?.toString() + ?: project.properties["centralPassword"]?.toString() + }, + zipTask = zipMavenCentralPortal, + ) + tasks.register("saveMavenCentralPortalDeploymentId") { save -> + val fileName = "maven-central-portal-bundle-id" + val file = rootProject.layout.buildDirectory.map { it.asFile.resolve(fileName) } + save.group = PublishingPlugin.PUBLISH_TASK_GROUP + save.description = "Saves the Maven Central Portal deployment ID locally in ${file.get().absolutePath}" + save.dependsOn(zipMavenCentralPortal) + save.outputs.file(file) + save.doLast { + file.get().writeText("${portalDeployment.fileToUpload}=${portalDeployment.deploymentId}\n") + } + } + val validate = + tasks.register(PublishPortalDeployment.VALIDATE_TASK_NAME) { validate -> + validate.group = PublishingPlugin.PUBLISH_TASK_GROUP + validate.description = "Validates the Maven Central Portal publication, uploading if needed" + validate.mustRunAfter(zipMavenCentralPortal) + validate.doLast { + runBlocking { + portalDeployment.validate() + } + } + } + tasks.register(DROP_TASK_NAME) { drop -> + drop.group = PublishingPlugin.PUBLISH_TASK_GROUP + drop.description = "Drops the Maven Central Portal publication" + drop.mustRunAfter(validate) + drop.mustRunAfter(zipMavenCentralPortal) + drop.doLast { + runBlocking { + portalDeployment.drop() + } + } + } + tasks.register(RELEASE_TASK_NAME) { release -> + release.group = PublishingPlugin.PUBLISH_TASK_GROUP + release.description = "Releases the Maven Central Portal publication" + release.mustRunAfter(validate) + release.mustRunAfter(zipMavenCentralPortal) + release.doLast { + runBlocking { + portalDeployment.release() + } + } + } + gradle.taskGraph.whenReady { taskGraph -> + val allTasks = taskGraph.allTasks.map { it.name }.toSet() + check(RELEASE_TASK_NAME !in allTasks || DROP_TASK_NAME !in allTasks) { + "Task $RELEASE_TASK_NAME and $DROP_TASK_NAME cannot be executed together" + } + } + } } diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentral.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentral.kt index 3e0536e9..360a60df 100644 --- a/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentral.kt +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentral.kt @@ -6,6 +6,7 @@ import org.danilopianini.gradle.mavencentral.ProjectExtensions.addSourcesArtifac import org.danilopianini.gradle.mavencentral.ProjectExtensions.configureJavadocJarTaskForKtJs import org.danilopianini.gradle.mavencentral.ProjectExtensions.configureRepository import org.danilopianini.gradle.mavencentral.ProjectExtensions.registerTaskIfNeeded +import org.danilopianini.gradle.mavencentral.ProjectExtensions.setupMavenCentralPortal import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.publish.PublishingExtension @@ -47,14 +48,13 @@ class PublishOnCentral : Plugin { publications { publications -> val name = "${component.name}$PUBLICATION_NAME" if (publications.none { it.name == name }) { - publications.create(name, MavenPublication::class.java) { publication -> + publications.register(name, MavenPublication::class.java) { publication -> createdPublications += publication publication.from(component) project.addSourcesArtifactIfNeeded(publication, sourcesJarTask) if (javadocJarTask is JavadocJar) { publication.artifact(javadocJarTask) } - publication.configurePomForMavenCentral(extension) publication.pom.packaging = "jar" project.configure { sign(publication) @@ -64,20 +64,31 @@ class PublishOnCentral : Plugin { } } } - publications.withType().configureEach { publication -> - if (extension.autoConfigureAllPublications.getOrElse(true) && publication !in createdPublications) { - publication.configurePomForMavenCentral(extension) - if (publication.signingTasks(project).isEmpty()) { - project.configure { - sign(publication) + publications + .withType() + .configureEach { publication -> + if (extension.autoConfigureAllPublications.getOrElse(true) || publication in createdPublications) { + project.logger.info( + "Populating data of publication {} in {}, group {}", + publication.name, + project, + project.group, + ) + publication.configurePomForMavenCentral(extension) + if (publication.signingTasks(project).isEmpty()) { + project.configure { + sign(publication) + } } } } - } } project.tasks.withType().configureEach { publish -> publish.mustRunAfter(project.tasks.withType()) } + // Maven Central Portal + project.setupMavenCentralPortal() + // Initialize Central if needed project.afterEvaluate { if (extension.configureMavenCentral.getOrElse(true)) { project.configureRepository(extension.mavenCentral) diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentralExtension.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentralExtension.kt index 195b7180..94a8df4e 100644 --- a/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentralExtension.kt +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishOnCentralExtension.kt @@ -109,8 +109,7 @@ open class PublishOnCentralExtension( name: String = repositoryNameFromURL(url), configurator: Repository.() -> Unit = { }, ) { - val repo = Repository.fromProject(project, name) - repo.url.set(url) + val repo = Repository.fromProject(project, name, url) repo.apply(configurator) project.afterEvaluate { it.configureRepository(repo) } } diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishPortalDeployment.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishPortalDeployment.kt new file mode 100644 index 00000000..1455daf9 --- /dev/null +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/PublishPortalDeployment.kt @@ -0,0 +1,232 @@ +package org.danilopianini.gradle.mavencentral + +import io.ktor.client.request.forms.InputProvider +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.streams.asInput +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.danilopianini.centralpublisher.api.PublishingApi +import org.danilopianini.centralpublisher.api.PublishingApi.PublishingTypeApiV1PublisherUploadPost.AUTOMATIC +import org.danilopianini.centralpublisher.api.PublishingApi.PublishingTypeApiV1PublisherUploadPost.USER_MANAGED +import org.danilopianini.centralpublisher.impl.infrastructure.HttpResponse +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles.DeploymentState.FAILED +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles.DeploymentState.PENDING +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles.DeploymentState.PUBLISHED +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles.DeploymentState.PUBLISHING +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles.DeploymentState.VALIDATED +import org.danilopianini.centralpublisher.impl.models.DeploymentResponseFiles.DeploymentState.VALIDATING +import org.gradle.api.Project +import org.gradle.api.internal.ConventionTask +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Lazy class acting as a container for stateful operations on Maven Central Portal. + */ +data class PublishPortalDeployment( + private val project: Project, + private val baseUrl: String, + private val user: Provider, + private val password: Provider, + private val zipTask: TaskProvider, +) { + /** + * The Publishing portal client. + */ + val client: PublishingApi by lazy { + PublishingApi(baseUrl).apply { + setUsername(user.get()) + setPassword(password.get()) + } + } + + /** + * THe zip file to upload. + */ + val fileToUpload: File by lazy { + val outputFiles = + zipTask + .get() + .outputs.files + .toList() + check(outputFiles.size == 1) { + "Expected a single output file, found ${outputFiles.size}: ${outputFiles.map { it.absolutePath }}" + } + outputFiles + .first() + .apply { + check(exists() && isFile) { + "File ${this.absolutePath} does not exist or is not a file, did task ${zipTask.name} run?" + } + } + } + + /** + * Uploads a bundle to the Central Portal, returning the upload id. + */ + @JvmOverloads + suspend fun upload( + bundle: File, + name: String = bundle.name, + releaseAfterUpload: Boolean = false, + ): String { + val response = + client.apiV1PublisherUploadPost( + name, + if (releaseAfterUpload) AUTOMATIC else USER_MANAGED, + InputProvider(bundle.length()) { bundle.inputStream().asInput() }, + ) + return when (response.status) { + OK, CREATED -> { + project.logger.lifecycle("Bundle from file ${bundle.path} uploaded successfully") + response.body() + } + INTERNAL_SERVER_ERROR -> error("Error on bundle upload") + else -> maybeUnauthorized("upload", response) + } + } + + /** + * Lazily computed staging repository descriptor. + */ + val deploymentId: String by lazy { + when (val idFromProperty = project.properties[PUBLISH_DEPLOYMENT_ID_PROPERTY_NAME]) { + null -> { + runBlocking { upload(fileToUpload) } + } + else -> + idFromProperty.toString().also { + project.logger.lifecycle("Using existing deployment id {}", it) + } + } + } + + private suspend fun deploymentStatus(): DeploymentResponseFiles { + val response = client.apiV1PublisherStatusPost(deploymentId) + val body = + when (response.status) { + OK -> response.typedBody(typeInfo()) + INTERNAL_SERVER_ERROR -> error("Error on deployment $deploymentId status query") + else -> maybeUnauthorized("deployment status check", response) + } + return body + } + + /** + * Validates the deployment. + */ + tailrec suspend fun validate(waitAmongRetries: Duration = waitingTime) { + project.logger.lifecycle("Validating deployment {} on Central Portal at {}", deploymentId, baseUrl) + val responseBody = deploymentStatus() + when (responseBody.deploymentState) { + PENDING, VALIDATING -> { + delay(waitAmongRetries) + validate(waitAmongRetries * 2) + } + VALIDATED, PUBLISHING, PUBLISHED -> project.logger.lifecycle("Deployment {} validated", deploymentId) + FAILED -> error("Deployment $deploymentId validation FAILED") + null -> error("Unexpected/unknown deployment state null for deployment $deploymentId") + } + } + + /** + * Releases the deployment. + */ + tailrec suspend fun release(waitAmongRetries: Duration = waitingTime): Unit = + when (deploymentStatus().deploymentState) { + null -> error("Unexpected/unknown deployment state null for deployment $deploymentId") + PENDING, VALIDATING, PUBLISHING -> { + delay(waitAmongRetries) + release(waitAmongRetries * 2) + } + PUBLISHED -> + project.logger.lifecycle("Deployment {} has been already released", deploymentId) + VALIDATED -> { + project.logger.lifecycle("Releasing deployment {}", deploymentId) + val releaseResponse = client.apiV1PublisherDeploymentDeploymentIdPost(deploymentId) + when (releaseResponse.status) { + NO_CONTENT -> project.logger.lifecycle("Deployment {} released", deploymentId) + NOT_FOUND -> error("Deployment $deploymentId not found. $releaseResponse") + INTERNAL_SERVER_ERROR -> + error("Internal server error when releasing $deploymentId: $releaseResponse") + else -> maybeUnauthorized("deployment release", releaseResponse) + } + } + FAILED -> error("Deployment $deploymentId validation FAILED") + } + + /** + * Drops the repository. Must be called after close(). + */ + tailrec suspend fun drop(waitAmongRetries: Duration = waitingTime): Unit = + when (deploymentStatus().deploymentState) { + null -> error("Unexpected/unknown deployment state null for deployment $deploymentId") + PENDING, VALIDATING, PUBLISHING -> { + delay(waitAmongRetries) + drop(waitAmongRetries * 2) + } + PUBLISHED -> + error("Deployment $deploymentId has been published already and cannot get dropped") + FAILED, VALIDATED -> { + project.logger.lifecycle("Dropping deployment {}", deploymentId) + val releaseResponse = client.apiV1PublisherDeploymentDeploymentIdDelete(deploymentId) + when (releaseResponse.status) { + NO_CONTENT -> project.logger.lifecycle("Deployment {} dropped", deploymentId) + NOT_FOUND -> error("Deployment $deploymentId not found. $releaseResponse") + INTERNAL_SERVER_ERROR -> + error("Internal server error when dropping $deploymentId: $releaseResponse") + else -> maybeUnauthorized("deployment release", releaseResponse) + } + } + } + + /** + * Constants for the Central Portal Deployments. + */ + companion object { + /** + * The property name for the deployment id. + */ + const val PUBLISH_DEPLOYMENT_ID_PROPERTY_NAME = "publishDeploymentId" + + /** + * The bundle validation task name. + */ + const val VALIDATE_TASK_NAME = "validateMavenCentralPortalPublication" + + /** + * The bundle drop task name. + */ + const val DROP_TASK_NAME = "dropMavenCentralPortalPublication" + + /** + * The bundle release task name. + */ + const val RELEASE_TASK_NAME = "releaseMavenCentralPortalPublication" + private const val OK = 200 + private const val CREATED = 201 + private const val NO_CONTENT = 204 + private const val BAD_REQUEST = 400 + private const val UNAUTHORIZED = 401 + private const val FORBIDDEN = 403 + private const val NOT_FOUND = 404 + private const val INTERNAL_SERVER_ERROR = 500 + + private val waitingTime: Duration = 1.seconds + + private fun maybeUnauthorized( + action: String, + response: HttpResponse<*>, + ): Nothing = + when (response.status) { + BAD_REQUEST -> error("Authentication failure, make sure that your credentials are correct") + UNAUTHORIZED -> error("No active session or not authenticated, check your credentials") + FORBIDDEN -> error("User unauthorized to perform the $action action") + else -> error("Unexpected response $response") + } + } +} diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/Repository.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/Repository.kt index dcfc0f67..ed07f2c2 100644 --- a/src/main/kotlin/org/danilopianini/gradle/mavencentral/Repository.kt +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/Repository.kt @@ -2,7 +2,9 @@ package org.danilopianini.gradle.mavencentral import org.gradle.api.Project import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider import org.gradle.kotlin.dsl.property +import java.net.URI import java.time.Duration /** @@ -14,13 +16,23 @@ import java.time.Duration */ data class Repository( var name: String, - val url: Property, + val url: Provider, val user: Property, val password: Property, val nexusUrl: String? = null, val nexusTimeOut: Duration = Duration.ofMinutes(1), val nexusConnectTimeOut: Duration = Duration.ofMinutes(1), ) { + constructor( + name: String, + url: Property, + user: Property, + password: Property, + nexusUrl: String? = null, + nexusTimeOut: Duration = Duration.ofMinutes(1), + nexusConnectTimeOut: Duration = Duration.ofMinutes(1), + ) : this (name, url.map { URI.create(it) }, user, password, nexusUrl, nexusTimeOut, nexusConnectTimeOut) + /** * Same as [name], but capitalized. */ @@ -53,10 +65,25 @@ data class Repository( fun fromProject( project: Project, name: String, + url: String, ): Repository = Repository( name = name, - url = project.objects.property(), + url = project.objects.property().value(url), + user = project.objects.property(), + password = project.objects.property(), + ) + + /** + * Creates a [Repository] local to the build folder. + */ + fun projectLocalRepository(project: Project): Repository = + Repository( + name = "ProjectLocal", + url = + project.layout.buildDirectory + .dir("project-local-repository") + .map { it.asFile.toURI() }, user = project.objects.property(), password = project.objects.property(), ) diff --git a/src/main/kotlin/org/danilopianini/gradle/mavencentral/ZipMavenCentralPortalPublication.kt b/src/main/kotlin/org/danilopianini/gradle/mavencentral/ZipMavenCentralPortalPublication.kt new file mode 100644 index 00000000..da3c0e9e --- /dev/null +++ b/src/main/kotlin/org/danilopianini/gradle/mavencentral/ZipMavenCentralPortalPublication.kt @@ -0,0 +1,23 @@ +package org.danilopianini.gradle.mavencentral + +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository +import org.gradle.api.publish.plugins.PublishingPlugin +import org.gradle.api.tasks.bundling.Zip +import org.gradle.kotlin.dsl.withType +import javax.inject.Inject + +/** + * A Zip task that creates a zip file containing the local Maven repository. + */ +open class ZipMavenCentralPortalPublication + @Inject + constructor() : Zip() { + init { + group = PublishingPlugin.PUBLISH_TASK_GROUP + description = "Creates a zip file containing the project-local Maven repository" + from(Repository.projectLocalRepository(project).url) + archiveBaseName.set(project.name + "-maven-central-portal") + destinationDirectory.set(project.layout.buildDirectory.dir("maven-central-portal")) + mustRunAfter(project.tasks.withType()) + } + } diff --git a/src/test/resources/org/danilopianini/gradle/test/test0/build.gradle.kts b/src/test/resources/org/danilopianini/gradle/test/test0/build.gradle.kts index dc2de951..5d388839 100644 --- a/src/test/resources/org/danilopianini/gradle/test/test0/build.gradle.kts +++ b/src/test/resources/org/danilopianini/gradle/test/test0/build.gradle.kts @@ -17,3 +17,6 @@ publishOnCentral { } } +tasks.withType().configureEach { + enabled = false +} diff --git a/src/test/resources/org/danilopianini/gradle/test/test0/test.yaml b/src/test/resources/org/danilopianini/gradle/test/test0/test.yaml index 7a797267..8764b38e 100644 --- a/src/test/resources/org/danilopianini/gradle/test/test0/test.yaml +++ b/src/test/resources/org/danilopianini/gradle/test/test0/test.yaml @@ -32,14 +32,16 @@ tests: - tasks output: contains: + - zipMavenCentralPortalPublication - publishPluginMavenPublicationToGithubRepository + - publishPluginMavenPublicationToProjectLocalRepository - uploadJavaOSSRHToMavenCentralNexus - uploadPluginMavenToMavenCentralNexus - releaseStagingRepositoryOnMavenCentral - dropStagingRepositoryOnMavenCentral doesntContain: - uploadJavaMavenToGithubNexus - - description: "sources and javadoc tasks get greated" + - description: "sources and javadoc tasks get created" configuration: tasks: - tasks @@ -68,3 +70,23 @@ tests: - name: build/libs/test-publish-on-central-0.1.0-javadoc.jar - name: build/libs/test-publish-on-central-0.1.0-sources.jar - name: build/libs/test-publish-on-central-0.1.0.jar + - description: "the portal publication is created" + configuration: + tasks: + - publishAllPublicationsToProjectLocalRepository + - zipMavenCentralPortalPublication + expectation: + outcomes: + success: + - publishAllPublicationsToProjectLocalRepository + - zipMavenCentralPortalPublication + files: + existing: + - name: build/maven-central-portal/test-publish-on-central-maven-central-portal-0.1.0.zip + - name: build/project-local-repository/io/github/danysk/test-publish-on-central/0.1.0/test-publish-on-central-0.1.0.jar + - name: build/project-local-repository/io/github/danysk/test-publish-on-central/0.1.0/test-publish-on-central-0.1.0-javadoc.jar + - name: build/project-local-repository/io/github/danysk/test-publish-on-central/0.1.0/test-publish-on-central-0.1.0-sources.jar + - name: build/project-local-repository/io/github/danysk/test-publish-on-central/maven-metadata.xml + contentRegex: + - '\s*test-publish-on-central' + - '\s*io.github.danysk'