diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 591cb77..c6d6ab8 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v3 - run: | ./gradlew build - ./gradlew -p tests/jvm zipAggregation --configuration-cache + ./gradlew -p tests/jvm nmcpZipAggregation --configuration-cache ./gradlew -p tests/jvm build ./gradlew -p tests/kmp publishAggregationToCentralPortal --configuration-cache ./gradlew -p tests/kmp build diff --git a/build.gradle.kts b/build.gradle.kts index 48c399b..afa33a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.ggp) alias(libs.plugins.nmcp) alias(libs.plugins.compat) + alias(libs.plugins.serialization) id("maven-publish") id("signing") } @@ -81,8 +82,11 @@ dependencies { implementation(libs.json) implementation(libs.okio) implementation(libs.okhttp) + implementation(libs.xmlutil) implementation(libs.gratatouille.runtime) implementation(libs.okhttp.logging.interceptor) + + testImplementation(libs.kotlin.test) compileOnly(libs.gradle.min) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e0dfac..96ecf99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,9 +11,13 @@ okhttp-logging-interceptor = "com.squareup.okhttp3:logging-interceptor:4.12.0" mockwebserver = "com.squareup.okhttp3:mockwebserver:4.12.0" gradle-min = "dev.gradleplugins:gradle-api:8.0" gratatouille-runtime = { module = "com.gradleup.gratatouille:gratatouille-runtime", version.ref = "gratatouille" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } +xmlutil = "io.github.pdvrieze.xmlutil:serialization:0.91.1" + [plugins] kgp = { id = "org.jetbrains.kotlin.jvm", version.ref = "kgp" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kgp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ggp = { id = "com.gradleup.gratatouille", version.ref = "gratatouille" } nmcp = { id = "com.gradleup.nmcp", version = "0.1.5" } diff --git a/src/main/kotlin/nmcp/CentralPortalOptions.kt b/src/main/kotlin/nmcp/CentralPortalOptions.kt index dabe8df..bfdf652 100644 --- a/src/main/kotlin/nmcp/CentralPortalOptions.kt +++ b/src/main/kotlin/nmcp/CentralPortalOptions.kt @@ -27,7 +27,8 @@ abstract class CentralPortalOptions { /** * A name for the publication (optional). * - * Default: "${project.name}-${project.version}.zip" + * By default, it generates a name from the deployment contents. If the deployment contains several publications, it will + * show the common parts (typically groupId and version). */ abstract val publicationName: Property diff --git a/src/main/kotlin/nmcp/NmcpAggregationExtension.kt b/src/main/kotlin/nmcp/NmcpAggregationExtension.kt index 4bb22aa..7a26276 100644 --- a/src/main/kotlin/nmcp/NmcpAggregationExtension.kt +++ b/src/main/kotlin/nmcp/NmcpAggregationExtension.kt @@ -1,81 +1,12 @@ package nmcp -import gratatouille.GExtension -import javax.inject.Inject -import nmcp.internal.configureAttributes -import nmcp.internal.nmcpConsumerConfigurationName -import nmcp.internal.task.registerPublishReleaseTask import org.gradle.api.Action -import org.gradle.api.Project -import org.gradle.api.file.ArchiveOperations -import org.gradle.api.tasks.bundling.Zip - -@GExtension(pluginId = "com.gradleup.nmcp.aggregation") -abstract class NmcpAggregationExtension(private val project: Project) { - internal val spec = project.objects.newInstance(CentralPortalOptions::class.java) - - @get:Inject - abstract val archiveOperations: ArchiveOperations - - internal val consumerConfiguration = project.configurations.create(nmcpConsumerConfigurationName) { - it.isCanBeResolved = true - it.isCanBeConsumed = false - - it.configureAttributes(project) - } - - init { - val operations = archiveOperations - val layout = project.layout - val files = project.files(consumerConfiguration) - - val zipTaskProvider = project.tasks.register("zipAggregation", Zip::class.java) { - it.archiveFileName.set("aggregation.zip") - it.destinationDirectory.set(layout.buildDirectory.dir("nmcp/zip")) - it.from(files.elements.map { - it.map { - operations.zipTree(it) - } - }) - } - - project.registerPublishReleaseTask( - taskName = "publishAggregationToCentralPortal", - inputFile = zipTaskProvider.flatMap { it.archiveFile }, - artifactId = project.provider { "${project.name}" }, - spec = spec - ) - } +interface NmcpAggregationExtension { /** * Configures the central portal parameters */ - fun centralPortal(action: Action) { - action.execute(spec) - } - - /** - * Applies the `com.gradleup.nmcp` plugin to every project that also applies `maven-publish`. - * - * This function is not compatible with breaking project isolation. To be compatible with project isolation, - * add each subproject to the `nmcpAggregation` configuration dependencies. - */ - fun publishAllProjectsProbablyBreakingProjectIsolation(action: Action) { - check(project === project.rootProject) { - "publishAllProjectsProbablyBreakingProjectIsolation() must be called from root project" - } - - project.allprojects { aproject -> - aproject.pluginManager.withPlugin("maven-publish") { - aproject.pluginManager.apply("com.gradleup.nmcp") - - aproject.extensions.configure(NmcpExtension::class.java) { - action.execute(it.centralPortalOptions) - } - consumerConfiguration.dependencies.add(aproject.dependencies.create(aproject)) - } - } - } + fun centralPortal(action: Action) /** * Applies the `com.gradleup.nmcp` plugin to every project that also applies `maven-publish`. @@ -83,9 +14,5 @@ abstract class NmcpAggregationExtension(private val project: Project) { * This function is not compatible with breaking project isolation. To be compatible with project isolation, * add each subproject to the `nmcpAggregation` configuration dependencies. */ - fun publishAllProjectsProbablyBreakingProjectIsolation() { - publishAllProjectsProbablyBreakingProjectIsolation { } - } + fun publishAllProjectsProbablyBreakingProjectIsolation() } - - diff --git a/src/main/kotlin/nmcp/NmcpExtension.kt b/src/main/kotlin/nmcp/NmcpExtension.kt index 0db1361..c1df5cf 100644 --- a/src/main/kotlin/nmcp/NmcpExtension.kt +++ b/src/main/kotlin/nmcp/NmcpExtension.kt @@ -1,164 +1,20 @@ package nmcp -import gratatouille.GExtension -import gratatouille.capitalizeFirstLetter -import java.net.URI -import nmcp.internal.configureAttributes -import nmcp.internal.nmcpProducerConfigurationName -import nmcp.internal.task.registerPublishReleaseTask -import nmcp.internal.task.registerPublishSnapshotTask -import nmcp.internal.withRequiredPlugin import org.gradle.api.Action -import org.gradle.api.Project -import org.gradle.api.credentials.PasswordCredentials -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.tasks.bundling.Zip - -@GExtension(pluginId = "com.gradleup.nmcp") -open class NmcpExtension(private val project: Project) { - internal val centralPortalOptions = project.objects.newInstance(CentralPortalOptions::class.java) - // Lifecycle tasks to publish all the publications in the given project - private val publishAllPublicationsToCentralPortal = project.tasks.register("publishAllPublicationsToCentralPortal") - private val publishAllPublicationsToCentralSnapshots = project.tasks.register("publishAllPublicationsToCentralSnapshots") - - init { - project.configurations.create(nmcpProducerConfigurationName) { - it.isCanBeConsumed = true - it.isCanBeResolved = false - // See https://github.com/GradleUp/nmcp/issues/2 - it.isVisible = false - - it.configureAttributes(project) - } - - project.withRequiredPlugin("maven-publish") { - val publishing = project.extensions.getByType(PublishingExtension::class.java) - publishing.publications.configureEach { - registerInternal(it.name) - } - /** - * Not sure how to configure username/password lazily, do it once the build script is evaluated - */ - project.afterEvaluate { - if(centralPortalOptions.username.isPresent) { - publishing.addSnapshotsRepo(centralPortalOptions) - } - } - } - } +interface NmcpExtension { /** - * This creates a new repository for each publication so that the publications do not overlap each other - * and can use an isolated directory. + * Configures publishing all the publications all at once in a single deployment to the Central Portal. * - * See https://github.com/GradleUp/nmcp/issues/34#issuecomment-2827704768 + * - Adds `nmcpPublishAllPublicationsToCentralPortal` + * - Adds `nmcpPublishAllPublicationsToCentralPortalSnapshots` */ - private fun registerInternal(publicationName: String) { - val capitalized = publicationName.capitalizeFirstLetter() - - val publishing = project.extensions.getByType(PublishingExtension::class.java) - val m2Dir = project.layout.buildDirectory.dir("nmcp/m2$capitalized") - val repoName = "nmcp$capitalized" - publishing.apply { - repositories.apply { - maven { - it.name = repoName - it.url = project.uri(m2Dir) - } - } - } - - val publication = publishing.publications.findByName(publicationName) - if (publication == null) { - val candidates = publishing.publications.map { it.name } - error("Nmcp: cannot find publication '$publicationName'. Candidates are: '${candidates.joinToString()}'") - } - - val publishToNmcpTaskProvider = project.tasks.named("publish${capitalized}PublicationTo${repoName.capitalizeFirstLetter()}Repository") - - publishToNmcpTaskProvider.configure { - // This is mostly an internal task, hide it from `./gradlew --tasks` - it.group = null - it.doFirst { - m2Dir.get().asFile.apply { - deleteRecursively() - mkdirs() - } - } - } - - val publishAllToNmcpTaskProvider = project.tasks.named("publishAllPublicationsTo${repoName.capitalizeFirstLetter()}Repository") - publishAllToNmcpTaskProvider.configure { - // This is mostly an internal task, hide it from `./gradlew --tasks` - it.group = null - } - - - - val zipTaskProvider = project.tasks.register("zip${capitalized}Publication", Zip::class.java) { - it.dependsOn(publishToNmcpTaskProvider) - it.from(m2Dir) - it.eachFile { - // Exclude maven-metadata files, or the bundle is not recognized - // See https://slack-chats.kotlinlang.org/t/16407246/anyone-tried-the-https-central-sonatype-org-publish-publish-#c8738fe5-8051-4f64-809f-ca67a645216e - if (it.name.startsWith("maven-metadata")) { - it.exclude() - } - } - it.destinationDirectory.set(project.layout.buildDirectory.dir("nmcp/zip")) - it.archiveFileName.set("publication$capitalized.zip") - } - - val artifactId = if (publication is MavenPublication) { - project.provider { publication.artifactId } - } else { - project.provider { "${project.name}"} - } - val publishRelease = project.registerPublishReleaseTask( - taskName = "publish${capitalized}PublicationToCentralPortal", - inputFile = zipTaskProvider.flatMap { it.archiveFile }, - artifactId = artifactId, - spec = centralPortalOptions - ) - val publishSnapshots = project.tasks.register("publish${capitalized}PublicationToCentralSnapshots") { - if (!centralPortalOptions.username.isPresent) { - it.doFirst { - error("centralPortalOptions.username must be set in each subproject to publish to central snapshots. See https://github.com/GradleUp/nmcp/issues/73 for more information.") - } - } else { - it.dependsOn("publish${capitalized}PublicationTo${nmcpCentralSnapshotsRepoName.capitalizeFirstLetter()}Repository") - } - } - - publishAllPublicationsToCentralSnapshots.configure { - it.dependsOn(publishSnapshots) - } - publishAllPublicationsToCentralPortal.configure { - it.dependsOn((publishRelease)) - } - - project.artifacts.add(nmcpProducerConfigurationName, zipTaskProvider) - } + fun publishAllPublicationsToCentralPortal(action: Action) /** - * Configures the central portal parameters + * Configures publishing a single publication to the Central Portal. + * + * - Adds `nmcpPublish${publicationName.capitalized()}PublicationToCentralPortal` */ - fun centralPortal(action: Action) { - action.execute(centralPortalOptions) - } -} - -private val nmcpCentralSnapshotsRepoName = "nmcpCentralSnapshots" -internal fun PublishingExtension.addSnapshotsRepo(centralPortalOptions: CentralPortalOptions) { - repositories { - it.maven { - it.name = nmcpCentralSnapshotsRepoName - it.url = URI("https://central.sonatype.com/repository/maven-snapshots") - it.credentials { - it.username = centralPortalOptions.username.get() - it.password = centralPortalOptions.password.get() - } - } - } + fun publishToCentralPortal(publicationName: String, action: Action) } diff --git a/src/main/kotlin/nmcp/internal/DefaultNmcpAggregationExtension.kt b/src/main/kotlin/nmcp/internal/DefaultNmcpAggregationExtension.kt new file mode 100644 index 0000000..0e10ec2 --- /dev/null +++ b/src/main/kotlin/nmcp/internal/DefaultNmcpAggregationExtension.kt @@ -0,0 +1,48 @@ +package nmcp.internal + +import nmcp.CentralPortalOptions +import nmcp.NmcpAggregationExtension +import nmcp.internal.task.KindAggregation +import nmcp.internal.task.registerNmcpGuessComponentsTask +import nmcp.internal.task.registerPublishToCentralPortalTasks +import org.gradle.api.Action +import org.gradle.api.Project + +abstract class DefaultNmcpAggregationExtension(private val project: Project) : NmcpAggregationExtension { + + internal val consumerConfiguration = project.configurations.create(nmcpConsumerConfigurationName) { + it.isCanBeResolved = true + it.isCanBeConsumed = false + + it.configureAttributes(project) + } + + override fun centralPortal(action: Action) { + val centralPortalOptions = project.objects.newInstance(CentralPortalOptions::class.java) + action.execute(centralPortalOptions) + + val guessVersion = project.registerNmcpGuessComponentsTask( + inputFiles = consumerConfiguration + ) + project.registerPublishToCentralPortalTasks( + deploymentKind = KindAggregation, + inputFiles = consumerConfiguration, + defaultDeploymentName = guessVersion.flatMap { it.outfileFile }.map { it.asFile.readText() }, + spec = centralPortalOptions + ) + } + + override fun publishAllProjectsProbablyBreakingProjectIsolation() { + check(project === project.rootProject) { + "publishAllProjectsProbablyBreakingProjectIsolation() must be called from root project" + } + + project.subprojects { aproject -> + aproject.pluginManager.withPlugin("maven-publish") { + aproject.pluginManager.apply("com.gradleup.nmcp") + + consumerConfiguration.dependencies.add(aproject.dependencies.create(aproject)) + } + } + } +} diff --git a/src/main/kotlin/nmcp/internal/DefaultNmcpExtension.kt b/src/main/kotlin/nmcp/internal/DefaultNmcpExtension.kt new file mode 100644 index 0000000..3d175e5 --- /dev/null +++ b/src/main/kotlin/nmcp/internal/DefaultNmcpExtension.kt @@ -0,0 +1,132 @@ +package nmcp.internal + +import gratatouille.capitalizeFirstLetter +import nmcp.CentralPortalOptions +import nmcp.NmcpExtension +import nmcp.internal.task.KindAll +import nmcp.internal.task.KindSingle +import nmcp.internal.task.registerNmcpGuessComponentsTask +import nmcp.internal.task.registerPublishToCentralPortalTasks +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication + +open class DefaultNmcpExtension(private val project: Project): NmcpExtension { + private val publicationFiles = mutableMapOf() + + init { + project.configurations.create(nmcpProducerConfigurationName) { + it.isCanBeConsumed = true + it.isCanBeResolved = false + // See https://github.com/GradleUp/nmcp/issues/2 + it.isVisible = false + + it.configureAttributes(project) + } + + project.withRequiredPlugin("maven-publish") { + val publishing = project.extensions.getByType(PublishingExtension::class.java) + publishing.publications.configureEach { + registerPublicationTasks(it.name) + } + } + } + + private fun filesFor(publicationName: String?): ConfigurableFileCollection { + return publicationFiles.getOrPut(publicationName) { project.files() } + } + + /** + * Create tasks that export the artifacts, signatures and checksums + */ + private fun registerPublicationTasks(publicationName: String) { + val publishing = project.extensions.getByType(PublishingExtension::class.java) + + val publication = publishing.publications.findByName(publicationName) + if (publication == null) { + val candidates = publishing.publications.map { it.name } + error("Nmcp: cannot find publication '$publicationName'. Candidates are: '${candidates.joinToString()}'") + } + + check(publication is MavenPublication) { + error("Nmcp only supports MavenPublication (found ${publication.javaClass.simpleName})") + } + val capitalized = publicationName.capitalizeFirstLetter() + + val m2Dir = project.layout.buildDirectory.dir("nmcp/m2$capitalized") + val repoName = "nmcp$capitalized" + publishing.apply { + repositories.apply { + maven { + it.name = repoName + it.url = project.uri(m2Dir) + } + } + } + + val publishToNmcpTaskProvider = project.tasks.named("publish${capitalized}PublicationTo${repoName.capitalizeFirstLetter()}Repository") + + publishToNmcpTaskProvider.configure { + // This is mostly an internal task, hide it from `./gradlew --tasks` + it.group = null + it.doFirst { + m2Dir.get().asFile.apply { + deleteRecursively() + mkdirs() + } + } + } + + val publishAllToNmcpTaskProvider = project.tasks.named("publishAllPublicationsTo${repoName.capitalizeFirstLetter()}Repository") + publishAllToNmcpTaskProvider.configure { + // This is mostly an internal task, hide it from `./gradlew --tasks` + it.group = null + } + + val m2Files = project.files() + m2Files.builtBy(publishAllToNmcpTaskProvider) + m2Files.from(project.fileTree(m2Dir)) + + project.artifacts.add(nmcpProducerConfigurationName, m2Dir) { + it.builtBy(publishToNmcpTaskProvider) + } + + filesFor(publicationName).from(m2Files) + + filesFor(null).from(m2Files) + } + + override fun publishAllPublicationsToCentralPortal(action: Action) { + val centralPortalOptions = project.objects.newInstance(CentralPortalOptions::class.java) + action.execute(centralPortalOptions) + + val guessVersion = project.registerNmcpGuessComponentsTask( + inputFiles = filesFor(null) + ) + project.registerPublishToCentralPortalTasks( + deploymentKind = KindAll, + inputFiles = filesFor(null), + defaultDeploymentName = guessVersion.flatMap { it.outfileFile }.map { it.asFile.readText() }, + spec = centralPortalOptions + ) + } + + override fun publishToCentralPortal( + publicationName: String, + action: Action, + ) { + val centralPortalOptions = project.objects.newInstance(CentralPortalOptions::class.java) + action.execute(centralPortalOptions) + + val publication = project.extensions.getByType(PublishingExtension::class.java).publications.findByName(publicationName) ?: error("Nmcp: Cannot find publication '$publicationName'") + publication as MavenPublication + project.registerPublishToCentralPortalTasks( + deploymentKind = KindSingle(publicationName), + inputFiles = filesFor(publicationName), + defaultDeploymentName = project.provider { "${publication.groupId}:${publication.artifactId}:${publication.version}" }, + spec = centralPortalOptions + ) + } +} diff --git a/src/main/kotlin/nmcp/internal/layout.kt b/src/main/kotlin/nmcp/internal/layout.kt new file mode 100644 index 0000000..f5b6e53 --- /dev/null +++ b/src/main/kotlin/nmcp/internal/layout.kt @@ -0,0 +1,48 @@ +package nmcp.internal + +import kotlin.collections.get +import org.apache.tools.ant.taskdefs.BuildNumber + +internal data class Gav( + val groupId: String, + val artifactId: String, + val version: String, +) { + companion object { + fun from(gavPath: String): Gav { + val versionIndex = gavPath.lastIndexOf('/') + check(versionIndex != -1) { + "Nmcp: invalid maven path '$gavPath' (expected group/artifact/version)" + } + val version = gavPath.substring(versionIndex + 1) + val artifactIndex = gavPath.lastIndexOf('/', versionIndex - 1) + check(artifactIndex != -1) { + "Nmcp: invalid maven path '$gavPath' (expected group/artifact/version)" + } + val artifact = gavPath.substring(artifactIndex + 1, versionIndex) + val group = gavPath.substring(0, artifactIndex) + + check(group.isNotEmpty()) { + "Nmcp: empty groupId in '$gavPath'" + } + check(artifact.isNotEmpty()) { + "Nmcp: empty artifactId in '$gavPath'" + } + check(version.isNotEmpty()) { + "Nmcp: empty version in '$gavPath'" + } + return Gav(group.toGroupId(), artifact, version) + } + } +} + +internal fun String.replaceBuildNumber(artifactId: String, snapshotVersion: String, newBuildNumber: Int): String { + // module1-0.0.3-20250623.104441-1.jar.asc + val versionWithoutSnapshot = snapshotVersion.replace("-SNAPSHOT","") + return replace(Regex("(${artifactId}-$versionWithoutSnapshot-[0-9]{8}\\.[0-9]{6}-)[0-9]+(.*)")) { + "${it.groupValues[1]}$newBuildNumber${it.groupValues[2]}" + } +} + +internal fun String.toPath() = this.replace('.', '/') +internal fun String.toGroupId() = this.replace('/', '.') diff --git a/src/main/kotlin/nmcp/internal/metadata.kt b/src/main/kotlin/nmcp/internal/metadata.kt new file mode 100644 index 0000000..6d0703b --- /dev/null +++ b/src/main/kotlin/nmcp/internal/metadata.kt @@ -0,0 +1,81 @@ +package nmcp.internal + +import kotlinx.serialization.Serializable +import kotlinx.serialization.StringFormat +import nl.adaptivity.xmlutil.serialization.XML +import nl.adaptivity.xmlutil.serialization.XmlChildrenName +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +@Serializable +@XmlSerialName("metadata") +internal data class VersionMetadata( + val modelVersion: String = "1.1.0", + @XmlElement + val groupId: String, + @XmlElement + val artifactId: String, + val versioning: Versioning, + @XmlElement + val version: String, +) { + @Serializable + @XmlSerialName("versioning") + data class Versioning( + @XmlElement + val lastUpdated: String, + val snapshot: Snapshot, + @XmlChildrenName("snapshotVersion") + val snapshotVersions: List, + ) + + @Serializable + @XmlSerialName("snapshot") + data class Snapshot( + @XmlElement + val timestamp: String, + @XmlElement + val buildNumber: Int, + ) + + @Serializable + @XmlSerialName("snapshotVersion") + data class SnapshotVersion( + @XmlElement + val classifier: String?, + @XmlElement + val extension: String, + @XmlElement + val value: String, + @XmlElement + val updated: String, + ) +} + +@Serializable +@XmlSerialName("metadata") +internal data class ArtifactMetadata( + @XmlElement + val groupId: String, + @XmlElement + val artifactId: String, + val versioning: Versioning, +) { + @Serializable + @XmlSerialName("versioning") + data class Versioning( + @XmlElement + val latest: String, + @XmlElement + val release: String?, // Maybe null if the element is missing or empty if empty + @XmlElement + @XmlChildrenName("version") + val versions: List, + @XmlElement + val lastUpdated: String, + ) +} + +val xml: StringFormat = XML { + indent = 2 +} diff --git a/src/main/kotlin/nmcp/internal/task/okhttp.kt b/src/main/kotlin/nmcp/internal/okhttp.kt similarity index 89% rename from src/main/kotlin/nmcp/internal/task/okhttp.kt rename to src/main/kotlin/nmcp/internal/okhttp.kt index 28a32bf..bc79cff 100644 --- a/src/main/kotlin/nmcp/internal/task/okhttp.kt +++ b/src/main/kotlin/nmcp/internal/okhttp.kt @@ -1,4 +1,4 @@ -package nmcp.internal.task +package nmcp.internal import java.time.Duration import okhttp3.OkHttpClient diff --git a/src/main/kotlin/nmcp/internal/task/nmcpGuessComponents.kt b/src/main/kotlin/nmcp/internal/task/nmcpGuessComponents.kt new file mode 100644 index 0000000..9f1d6d4 --- /dev/null +++ b/src/main/kotlin/nmcp/internal/task/nmcpGuessComponents.kt @@ -0,0 +1,47 @@ +package nmcp.internal.task + +import gratatouille.GInputFiles +import gratatouille.GOutputFile +import gratatouille.GTask +import nmcp.internal.Gav + +@GTask +fun nmcpGuessComponents( + inputFiles: GInputFiles, + outfileFile: GOutputFile, +) { + + val gavs = inputFiles.mapNotNull { + if (!it.normalizedPath.endsWith(".pom")) { + return@mapNotNull null + } + + Gav.from(it.normalizedPath.substringBeforeLast('/')) + } + + val groups = gavs.map { it.groupId }.distinct() + val artifacts = gavs.map { it.artifactId }.distinct() + val versions = gavs.map { it.version }.distinct() + + val name = buildString { + if (groups.size == 1) { + append(groups.single()) + } else { + append("multiple-groups") + } + append(':') + if (artifacts.size == 1) { + append(artifacts.single()) + } else { + append("multiple-artifacts") + } + append(':') + if (versions.size == 1) { + append(versions.single()) + } else { + append("multiple-versions") + } + } + + outfileFile.writeText(name) +} diff --git a/src/main/kotlin/nmcp/internal/task/nmcpPublishFileByFile.kt b/src/main/kotlin/nmcp/internal/task/nmcpPublishFileByFile.kt new file mode 100644 index 0000000..1a93263 --- /dev/null +++ b/src/main/kotlin/nmcp/internal/task/nmcpPublishFileByFile.kt @@ -0,0 +1,188 @@ +package nmcp.internal.task + +import gratatouille.FileWithPath +import gratatouille.GInputFiles +import gratatouille.GLogger +import gratatouille.GTask +import java.security.MessageDigest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import nmcp.internal.ArtifactMetadata +import nmcp.internal.FilesystemTransport +import nmcp.internal.Gav +import nmcp.internal.HttpTransport +import nmcp.internal.Transport +import nmcp.internal.VersionMetadata +import nmcp.internal.filterFiles +import nmcp.internal.put +import nmcp.internal.replaceBuildNumber +import nmcp.internal.toPath +import nmcp.internal.xml +import okio.ByteString.Companion.toByteString + +@GTask(pure = false) +fun nmcpPublishFileByFile( + logger: GLogger, + url: String, + username: String?, + password: String?, + inputFiles: GInputFiles, +) { + val credentials = if (username != null) { + check(!password.isNullOrBlank()) { + "Ncmp: password is missing" + } + nmcp.internal.Credentials(username, password) + } else { + null + } + val transport = when { + url.startsWith("http://") || url.startsWith("https://") -> { + HttpTransport(url, credentials, logger) + } + url.startsWith("file://") -> { + FilesystemTransport(url.substring("file://".length)) + } + else -> { + error("Nmcp: unsupported url '$url'") + } + } + + inputFiles + .filterFiles() + .groupBy { + it.normalizedPath.substringBeforeLast('/') + }.forEach { (gavPath, files) -> + val gav = Gav.from(gavPath) + val version = gav.version + + if (files.all { it.normalizedPath.substringAfterLast('/').startsWith("maven-metadata") }) { + /** + * Update the [artifact metadata](https://maven.apache.org/repositories/metadata.html). + * + * See https://repo1.maven.org/maven2/com/apollographql/apollo/apollo-api-jvm/maven-metadata.xml for an example. + */ + val localArtifactMetadataFile = files.firstOrNull {it.normalizedPath.substringAfterLast('/') == "maven-metadata.xml" } + if (localArtifactMetadataFile == null) { + error("Nmcp: cannot find artifact maven-metadata.xml in '${gav.groupId.toPath()}/${gav.artifactId}'") + } + val artifactMetadataPath = localArtifactMetadataFile.normalizedPath + + val localArtifactMetadata = xml.decodeFromString(localArtifactMetadataFile.file.readText()) + val remoteArtifactMetadata = transport.get(artifactMetadataPath) + + val existingVersions = if (remoteArtifactMetadata != null) { + xml.decodeFromString(remoteArtifactMetadata.use { it.readUtf8() }).versioning.versions + } else { + emptyList() + } + + /** + * See https://github.com/gradle/gradle/blob/cb0c615fb8e3690971bb7f89ad80f58943360624/platforms/software/maven/src/main/java/org/gradle/api/publish/maven/internal/publisher/AbstractMavenPublisher.java#L116. + */ + val versions = existingVersions.toMutableList() + if (!versions.none { it == gav.version }) { + versions.add(gav.version) + } + val newArtifactMetadata = localArtifactMetadata.copy( + versioning = localArtifactMetadata.versioning.copy( + versions = versions, + ), + ) + + val bytes = encodeToXml(newArtifactMetadata).toByteArray() + transport.put(artifactMetadataPath, bytes) + setOf("md5", "sha1", "sha256", "sha512").forEach { + transport.put("$artifactMetadataPath.$it", bytes.digest(it.uppercase())) + } + + return@forEach + } + /** + * This is a proper directory containing artifacts + */ + if (version.endsWith("-SNAPSHOT")) { + /** + * This is a snapshot: + * - update the [version metadata](https://maven.apache.org/repositories/metadata.html). + * - path the file names to include the new build number. + * + * See https://s01.oss.sonatype.org/content/repositories/snapshots/com/apollographql/apollo/apollo-api-jvm/maven-metadata.xml for an example. + * + * For snapshots, it's not 100% clear who owns the metadata as the repository might expire some snapshot and therefore need to rewrite the + * metadata to keep things consistent. This means, there are 2 possibly concurrent writers to maven-metadata.xml: the repository and the + * publisher. Hopefully it's not too much of a problem in practice. + * + * See https://github.com/gradle/gradle/blob/d1ee068b1ee7f62ffcbb549352469307781af72e/platforms/software/maven/src/main/java/org/gradle/api/publish/maven/internal/publisher/MavenRemotePublisher.java#L70. + */ + val versionMetadataPath = "$gavPath/maven-metadata.xml" + val localVersionMetadataFile = files.firstOrNull { + it.normalizedPath == versionMetadataPath + } + if (localVersionMetadataFile == null) { + error("Nmcp: cannot find version maven-metadata.xml in '$gavPath'") + } + + val localVersionMetadata = + xml.decodeFromString(localVersionMetadataFile.file.readText()) + val remoteVersionMetadata = transport.get(versionMetadataPath) + + val buildNumber = if (remoteVersionMetadata == null) { + 1 + } else { + xml.decodeFromString(remoteVersionMetadata.use { it.readUtf8() }).versioning.snapshot.buildNumber + 1 + } + + val newVersionMetadata = localVersionMetadata.copy( + versioning = localVersionMetadata.versioning.copy( + snapshot = localVersionMetadata.versioning.snapshot.copy(buildNumber = buildNumber), + ), + ) + + val renamedFiles = files.mapNotNull { + if (it.file.name.startsWith("maven-metadata.xml")) { + return@mapNotNull null + } + val newName = it.file.name.replaceBuildNumber(gav.artifactId, gav.version, buildNumber) + FileWithPath(it.file, "$gavPath/$newName") + } + + transport.uploadFiles(renamedFiles) + + val bytes = encodeToXml(newVersionMetadata).toByteArray() + transport.put(versionMetadataPath, bytes) + setOf("md5", "sha1", "sha256", "sha512").forEach { + transport.put("$versionMetadataPath.$it", bytes.digest(it.uppercase())) + } + } else { + /** + * Not a snapshot, plainly update all the files + */ + transport.uploadFiles(files) + } + } +} + +private fun Transport.uploadFiles(filesWithPath: List) { + filesWithPath.forEach { + put(it.normalizedPath, it.file) + } +} + +/** + * Helper function to add the ` encodeToXml(t: T): String { + return "\n" + xml.encodeToString(t) +} + +internal fun String.removeDot(): String = replace(".", "") + +private fun ByteArray.digest(name: String): String { + val md = MessageDigest.getInstance(name) + + md.update(this, 0, size) + val digest = md.digest() + + return digest.toByteString().hex() +} diff --git a/src/main/kotlin/nmcp/internal/task/publishRelease.kt b/src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt similarity index 70% rename from src/main/kotlin/nmcp/internal/task/publishRelease.kt rename to src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt index ee1cc14..4fe5dda 100644 --- a/src/main/kotlin/nmcp/internal/task/publishRelease.kt +++ b/src/main/kotlin/nmcp/internal/task/nmcpPublishWithPublisherApi.kt @@ -3,6 +3,7 @@ package nmcp.internal.task import gratatouille.GInputFile import gratatouille.GLogger import gratatouille.GTask +import gratatouille.capitalizeFirstLetter import java.net.SocketTimeoutException import kotlin.time.Duration import kotlinx.serialization.json.Json @@ -17,15 +18,16 @@ import okhttp3.RequestBody.Companion.toRequestBody import okio.Buffer import okio.ByteString import org.gradle.api.Project -import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider -import org.gradle.api.tasks.TaskProvider import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource.Monotonic.markNow +import nmcp.internal.client +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.bundling.Zip @GTask(pure = false) -fun publishRelease( +fun nmcpPublishWithPublisherApi( logger: GLogger, username: String?, password: String?, @@ -203,24 +205,79 @@ private fun verifyStatus( } } -internal fun Project.registerPublishReleaseTask( - taskName: String, - inputFile: Provider, - artifactId: Provider, +internal sealed interface DeploymentKind +internal object KindAggregation: DeploymentKind +internal object KindAll: DeploymentKind +internal class KindSingle(val name: String): DeploymentKind + +internal fun Project.registerPublishToCentralPortalTasks( + deploymentKind: DeploymentKind, + inputFiles: FileCollection, + defaultDeploymentName: Provider, spec: CentralPortalOptions, -): TaskProvider { - val defaultPublicationName = artifactId.map { "${project.group}:${it}:${project.version}.zip" } - return registerPublishReleaseTask( - taskName = taskName, - inputFile = inputFile, +) { + val zipTaskName: String = when(deploymentKind) { + KindAggregation -> "nmcpZipAggregation" + KindAll -> "nmcpZipAllPublications" + is KindSingle -> "nmcpZip${deploymentKind.name.capitalizeFirstLetter()}Publication" + } + val releaseTaskName: String = when(deploymentKind) { + KindAggregation -> "nmcpPublishAggregationToCentralPortal" + KindAll -> "nmcpPublishAllPublicationsToCentralPortal" + is KindSingle -> "nmcpPublish${deploymentKind.name.capitalizeFirstLetter()}PublicationToCentralPortal" + } + val snapshotTaskName: String = when(deploymentKind) { + KindAggregation -> "nmcpPublishAggregationToCentralPortalSnapshots" + KindAll -> "nmcpPublishAllPublicationsToCentralPortalSnapshots" + is KindSingle -> "nmcpPublish${deploymentKind.name.capitalizeFirstLetter()}PublicationToCentralPortalSnapshots" + } + val zipName: String = when(deploymentKind) { + KindAggregation -> "aggregation.zip" + KindAll -> "allPublications.zip" + is KindSingle -> "${deploymentKind.name.capitalizeFirstLetter()}Publication.zip" + } + val lifecycleTaskName: String? = when(deploymentKind) { + KindAggregation -> "publishAggregationToCentralPortal" + KindAll -> "publishAllPublicationsToCentralPortal" + is KindSingle -> null + } + + val zipTaskProvider = tasks.register(zipTaskName, Zip::class.java) { + it.from(inputFiles) + it.destinationDirectory.set(project.layout.buildDirectory.dir("nmcp/zip")) + it.archiveFileName.set(zipName) + it.eachFile { + // Exclude maven-metadata files, or the bundle is not recognized + // See https://slack-chats.kotlinlang.org/t/16407246/anyone-tried-the-https-central-sonatype-org-publish-publish-#c8738fe5-8051-4f64-809f-ca67a645216e + if (it.name.startsWith("maven-metadata")) { + it.exclude() + } + } + } + val task = registerNmcpPublishWithPublisherApiTask( + taskName = releaseTaskName, + inputFile = zipTaskProvider.flatMap { it.archiveFile }, username = spec.username, password = spec.password, - publicationName = spec.publicationName.orElse(defaultPublicationName), + publicationName = spec.publicationName.orElse(defaultDeploymentName), publishingType = spec.publishingType, baseUrl = spec.baseUrl, validationTimeoutSeconds = spec.validationTimeout.map { it.seconds }, publishingTimeoutSeconds = spec.publishingTimeout.map { it.seconds }, + ) + + if (lifecycleTaskName != null) { + project.tasks.register(lifecycleTaskName) { + it.dependsOn(task) + } + } + registerNmcpPublishFileByFileTask( + taskName = snapshotTaskName, + username = spec.username, + password = spec.password, + url = project.provider { "https://central.sonatype.com/repository/maven-snapshots/" }, + inputFiles = inputFiles, ) } diff --git a/src/main/kotlin/nmcp/internal/task/publishSnapshot.kt b/src/main/kotlin/nmcp/internal/task/publishSnapshot.kt deleted file mode 100644 index 0893cd2..0000000 --- a/src/main/kotlin/nmcp/internal/task/publishSnapshot.kt +++ /dev/null @@ -1,61 +0,0 @@ -package nmcp.internal.task - -import gratatouille.GInputFile -import gratatouille.GLogger -import gratatouille.GTask -import java.util.zip.ZipInputStream -import okhttp3.Credentials -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okio.buffer -import okio.source - -@GTask(pure = false) -fun publishSnapshot( - logger: GLogger, - username: String?, - password: String?, - version: String, - inputFile: GInputFile, -) { - check(!username.isNullOrBlank()) { - "Ncmp: username is missing" - } - check(!password.isNullOrBlank()) { - "Ncmp: password is missing" - } - check(version.endsWith("-SNAPSHOT")) { - "Ncmp: Cannot publish snapshot version '$version' without -SNAPSHOT suffix" - } - - val okHttpClient = client.newBuilder() - .addInterceptor { chain -> - val builder = chain.request().newBuilder() - builder.addHeader("Authorization", Credentials.basic(username, password)) - builder.addHeader("Accept", "application/json") - builder.addHeader("Content-Type", "application/json") - builder.addHeader("User-Agent", "vespene") - chain.proceed(builder.build()) - }.build() - - ZipInputStream(inputFile.inputStream()).use { - while (true) { - val entry = it.nextEntry ?: break - if (entry.isDirectory) continue - val relativePath = entry.name - logger.debug("Nmcp: uploading $relativePath...") - - val url = "https://central.sonatype.com/repository/maven-snapshots/$relativePath" - val request = Request.Builder() - .put(it.source().buffer().readByteArray().toRequestBody("application/octet-stream".toMediaType())) - .url(url) - .build() - - val uploadResponse = okHttpClient.newCall(request).execute() - check(uploadResponse.isSuccessful) { - "Cannot put $url:\n${uploadResponse.body?.string()}" - } - } - } -} diff --git a/src/main/kotlin/nmcp/internal/transport.kt b/src/main/kotlin/nmcp/internal/transport.kt new file mode 100644 index 0000000..1664029 --- /dev/null +++ b/src/main/kotlin/nmcp/internal/transport.kt @@ -0,0 +1,148 @@ +package nmcp.internal + +import gratatouille.GLogger +import java.io.File +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import okio.BufferedSource +import okio.ByteString +import okio.buffer +import okio.sink +import okio.source + +internal interface Transport { + fun get(path: String): BufferedSource? + fun put(path: String, body: Content) +} + +internal fun Transport.put(path: String, body: String) { + put(path, object : Content { + override fun writeTo(sink: BufferedSink) { + sink.writeUtf8(body) + } + }) +} + +internal fun Transport.put(path: String, body: ByteArray) { + put(path, object : Content { + override fun writeTo(sink: BufferedSink) { + sink.write(body) + } + }) +} + +internal fun Transport.put(path: String, body: File) { + put(path, object : Content { + override fun writeTo(sink: BufferedSink) { + body.source().use { + sink.writeAll(it) + } + } + }) +} + +interface Content { + fun writeTo(sink: BufferedSink) +} + +internal class Credentials(val username: String, val password: String) + +internal class HttpTransport( + baseUrl: String, + private val credentials: Credentials?, + private val logger: GLogger, +) : Transport { + private val client = nmcp.internal.client.newBuilder() + .addInterceptor { chain -> + val builder = chain.request().newBuilder() + if (credentials != null) { + builder.addHeader( + "Authorization", + okhttp3.Credentials.basic(credentials.username, credentials.password), + ) + } + builder.addHeader("Accept", "application/json") + builder.addHeader("User-Agent", "nmcp") + chain.proceed(builder.build()) + } + .build() + + private val baseUrl = baseUrl.toHttpUrl() + + override fun get(path: String): BufferedSource? { + val url = baseUrl.newBuilder() + .addPathSegments(path) + .build() + + logger.lifecycle("Nmcp: get '$url'") + + val response = Request.Builder() + .get() + .url(url) + .build() + .let { + client.newCall(it).execute() + } + + if (response.code == 404) { + return null + } + check(response.isSuccessful) { + "Nmcp: cannot GET '$url' (statusCode=${response.code}):\n${response.body!!.string()}" + } + + return response.body!!.source() + } + + override fun put(path: String, body: Content) { + val url = baseUrl.newBuilder() + .addPathSegments(path) + .build() + + logger.lifecycle("Nmcp: put '$url'") + + val response = Request.Builder() + .put(object : RequestBody() { + override fun contentType(): MediaType { + return "application/octet-stream".toMediaType() + } + override fun writeTo(sink: BufferedSink) { + body.writeTo(sink) + } + }) + .url(url) + .build() + .let { + client.newCall(it).execute() + } + + check(response.isSuccessful) { + "Nmcp: cannot PUT '$url' (statusCode=${response.code}):\n${response.body!!.string()}" + } + } +} + +internal class FilesystemTransport( + private val basePath: String, +): Transport { + override fun get(path: String): BufferedSource? { + val file = File(basePath).resolve(path) + if (!file.exists()) { + return null + } + return file.source().buffer() + } + + override fun put(path: String, body: Content) { + File(basePath).resolve(path).apply { + parentFile.mkdirs() + sink().buffer().use { + body.writeTo(it) + } + } + } +} diff --git a/src/main/kotlin/nmcp/internal/utils.kt b/src/main/kotlin/nmcp/internal/utils.kt index 12623c2..3d657f1 100644 --- a/src/main/kotlin/nmcp/internal/utils.kt +++ b/src/main/kotlin/nmcp/internal/utils.kt @@ -1,5 +1,7 @@ package nmcp.internal +import gratatouille.FileWithPath +import gratatouille.GInputFiles import org.gradle.api.Named import org.gradle.api.Project import org.gradle.api.attributes.Attribute @@ -30,7 +32,14 @@ internal val usageValue = "nmcp" internal fun HasConfigurableAttributes<*>.configureAttributes(project: Project) { attributes { - it.attribute(Attribute.of(attribute, Named::class.java), project.objects.named(Named::class.java, attributeValue)) + it.attribute( + Attribute.of(attribute, Named::class.java), + project.objects.named(Named::class.java, attributeValue), + ) it.attribute(USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, usageValue)) } } + +internal fun GInputFiles.filterFiles(): List { + return filter { it.file.isFile } +} diff --git a/src/main/kotlin/nmcp/plugins.kt b/src/main/kotlin/nmcp/plugins.kt new file mode 100644 index 0000000..bbfe8f9 --- /dev/null +++ b/src/main/kotlin/nmcp/plugins.kt @@ -0,0 +1,16 @@ +package nmcp + +import gratatouille.GPlugin +import nmcp.internal.DefaultNmcpAggregationExtension +import nmcp.internal.DefaultNmcpExtension +import org.gradle.api.Project + +@GPlugin(id = "com.gradleup.nmcp") +fun nmcp(project: Project) { + project.extensions.create(NmcpExtension::class.java, "nmcp", DefaultNmcpExtension::class.java, project) +} + +@GPlugin(id = "com.gradleup.nmcp.aggregation") +fun nmcpAggregation(project: Project) { + project.extensions.create(NmcpAggregationExtension::class.java, "nmcpAggregation", DefaultNmcpAggregationExtension::class.java, project) +} diff --git a/src/test/kotlin/LayoutTest.kt b/src/test/kotlin/LayoutTest.kt new file mode 100644 index 0000000..f8b1341 --- /dev/null +++ b/src/test/kotlin/LayoutTest.kt @@ -0,0 +1,29 @@ +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import nmcp.internal.Gav +import nmcp.internal.replaceBuildNumber + +class LayoutTest { + @Test + fun gavAreParsedSuccessfully() { + assertEquals(Gav("com.example", "foo", "0.0.0"), Gav.from("com/example/foo/0.0.0")) + assertEquals(Gav("com.example", "bar", "1.0.0-alpha.1"), Gav.from("com/example/bar/1.0.0-alpha.1")) + assertEquals(Gav("group", "foo", "1.0.0"), Gav.from("group/foo/1.0.0")) + } + + @Test + fun invalidGav() { + assertFails { Gav.from("example") } + assertFails { Gav.from("com/example") } + assertFails { Gav.from("com/example/") } + assertFails { Gav.from("com//1.0.0") } + assertFails { Gav.from("//") } + } + + @Test + fun replaceBuildNumber() { + val fileName = "module1-0.0.3-20250623.104441-1.jar.asc" + assertEquals("module1-0.0.3-20250623.104441-42.jar.asc", fileName.replaceBuildNumber("module1", "0.0.3-SNAPSHOT", 42)) + } +} diff --git a/src/test/kotlin/MetadataTest.kt b/src/test/kotlin/MetadataTest.kt new file mode 100644 index 0000000..ef6b950 --- /dev/null +++ b/src/test/kotlin/MetadataTest.kt @@ -0,0 +1,256 @@ +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.serialization.decodeFromString +import nl.adaptivity.xmlutil.serialization.XML +import nmcp.internal.ArtifactMetadata +import nmcp.internal.VersionMetadata +import nmcp.internal.task.encodeToXml +import nmcp.internal.xml + +class MetadataTest { + @Test + fun artifactMetadataIsDecodedSuccessfully() { + // language=xml + val xmlData = """ + + + com.apollographql.apollo + apollo-api-jvm + + 5.0.0-SNAPSHOT + + + 4.1.2-SNAPSHOT + 5.0.0-SNAPSHOT + + 20250618175334 + + + """.trimIndent() + + xml.decodeFromString(xmlData).apply { + assertEquals("com.apollographql.apollo", groupId) + assertEquals("apollo-api-jvm", artifactId) + versioning.apply { + assertEquals("5.0.0-SNAPSHOT", latest) + assertEquals("", release) + assertEquals( + listOf( + "4.1.2-SNAPSHOT", + "5.0.0-SNAPSHOT", + ), + versions, + ) + assertEquals("20250618175334", lastUpdated) + } + } + } + + @Test + fun artifactMetadataIsEncodedSuccessfully() { + /** + * Note that should probably be + * See https://github.com/pdvrieze/xmlutil/issues/290 + */ + // language=xml + val xmlData = """ + + + com.apollographql.apollo + apollo-api-jvm + + 5.0.0-SNAPSHOT + + + 4.1.2-SNAPSHOT + 5.0.0-SNAPSHOT + + 20250618175334 + + + """.trimIndent() + val metadata = ArtifactMetadata( + groupId = "com.apollographql.apollo", + artifactId = "apollo-api-jvm", + versioning = ArtifactMetadata.Versioning( + latest = "5.0.0-SNAPSHOT", + release = "", + versions = listOf( + "4.1.2-SNAPSHOT", + "5.0.0-SNAPSHOT", + ), + lastUpdated = "20250618175334", + ), + ) + + val result = encodeToXml(metadata) + + assertEquals(xmlData, result,) + } + + @Test + fun versionMetadataIsDecodedSuccessfully() { + // language=xml + val metadata = """ + + + com.apollographql.apollo + apollo-api-jvm + + 20250618175232 + + 20250618.175232 + 62 + + + + jar + 5.0.0-20250618.175232-62 + 20250618175232 + + + module + 5.0.0-20250618.175232-62 + 20250618175232 + + + pom + 5.0.0-20250618.175232-62 + 20250618175232 + + + javadoc + jar + 5.0.0-20250618.175232-62 + 20250618175232 + + + sources + jar + 5.0.0-20250618.175232-62 + 20250618175232 + + + + 5.0.0-SNAPSHOT + + """.trimIndent() + + xml.decodeFromString(metadata).apply { + assertEquals("com.apollographql.apollo", groupId) + assertEquals("apollo-api-jvm", artifactId) + versioning.apply { + assertEquals("20250618175232", lastUpdated) + snapshot.apply { + assertEquals("20250618.175232", this.timestamp) + assertEquals(62, this.buildNumber) + } + assertEquals( + listOf( + VersionMetadata.SnapshotVersion(null, extension = "jar", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(null, extension = "module", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(null, extension = "pom", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(classifier = "javadoc", extension = "jar", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(classifier = "sources", extension = "jar", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + ), + snapshotVersions, + ) + assertEquals("5.0.0-SNAPSHOT", version) + } + } + } + + @Test + fun versionMetadataIsEncodedSuccessfully() { + // language=xml + val xmlData = """ + + + com.apollographql.apollo + apollo-api-jvm + + 20250618175334 + + 20250618.175232 + 62 + + + + jar + 5.0.0-20250618.175232-62 + 20250618175232 + + + module + 5.0.0-20250618.175232-62 + 20250618175232 + + + pom + 5.0.0-20250618.175232-62 + 20250618175232 + + + javadoc + jar + 5.0.0-20250618.175232-62 + 20250618175232 + + + sources + jar + 5.0.0-20250618.175232-62 + 20250618175232 + + + + 5.0.0-SNAPSHOT + + """.trimIndent() + + val metadata = VersionMetadata( + groupId = "com.apollographql.apollo", + artifactId = "apollo-api-jvm", + versioning = VersionMetadata.Versioning( + lastUpdated = "20250618175334", + snapshot = VersionMetadata.Snapshot( + timestamp = "20250618.175232", + buildNumber = 62 + ), + snapshotVersions = listOf( + VersionMetadata.SnapshotVersion(null, extension = "jar", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(null, extension = "module", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(null, extension = "pom", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(classifier = "javadoc", extension = "jar", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + VersionMetadata.SnapshotVersion(classifier = "sources", extension = "jar", value = "5.0.0-20250618.175232-62", updated = "20250618175232"), + ), + ), + modelVersion = "1.1.0", + version = "5.0.0-SNAPSHOT" + ) + + val result = encodeToXml(metadata) + + assertEquals(xmlData, result,) + } + + @Test + fun releaseMightByMissing() { + // language=xml + val xmlData = """ + + + net.mbonnin.tnmcp + module1 + + 0.0.5-SNAPSHOT + 20250514205236 + + + """.trimIndent() + + val data = xml.decodeFromString(xmlData) + assertNull(data.versioning.release) + } +} diff --git a/tests/jvm/build.gradle.kts b/tests/jvm/build.gradle.kts index 0e0936a..f896953 100644 --- a/tests/jvm/build.gradle.kts +++ b/tests/jvm/build.gradle.kts @@ -1,3 +1,5 @@ +import kotlin.test.assertEquals +import kotlin.test.assertNull import nmcp.NmcpAggregationExtension plugins { @@ -8,16 +10,14 @@ plugins { buildscript { dependencies { classpath("com.gradleup.nmcp:nmcp") + classpath("org.jetbrains.kotlin:kotlin-test:2.0.0") } } apply(plugin = "com.gradleup.nmcp.aggregation") -val projectGroup = "net.mbonnin.tnmcp" -val projectVersion = "0.0.3" - -group = projectGroup -version = projectVersion +group = "net.mbonnin.tnmcp" +version = "0.0.3" subprojects { val project = this @@ -32,12 +32,16 @@ subprojects { publishing.publications { create("default", MavenPublication::class.java) { from(project.components.findByName("java")) - artifact(tasks.register("emptySources", Jar::class.java) { - archiveClassifier = "sources" - }) - artifact(tasks.register("emptyDocs", Jar::class.java) { - archiveClassifier = "javadoc" - }) + artifact( + tasks.register("emptySources", Jar::class.java) { + archiveClassifier = "sources" + }, + ) + artifact( + tasks.register("emptyDocs", Jar::class.java) { + archiveClassifier = "javadoc" + }, + ) groupId = project.rootProject.group.toString() version = project.rootProject.version.toString() @@ -85,10 +89,8 @@ subprojects { } extensions.getByType().apply { - publishAllProjectsProbablyBreakingProjectIsolation { - username = System.getenv("MAVEN_CENTRAL_USERNAME") - password = System.getenv("MAVEN_CENTRAL_PASSWORD") - } + publishAllProjectsProbablyBreakingProjectIsolation() + centralPortal { username = System.getenv("MAVEN_CENTRAL_USERNAME") password = System.getenv("MAVEN_CENTRAL_PASSWORD") @@ -102,75 +104,78 @@ extensions.getByType().apply { * This task assumes that no signing key is present (GPG_PRIVATE_KEY must be empty). If it fails, double check your environment variables. */ val checkZip = tasks.register("checkZip") { - inputs.file(tasks.named("zipAggregation").flatMap { (it as Zip).archiveFile }) + inputs.file(tasks.named("nmcpZipAggregation").flatMap { (it as Zip).archiveFile }) doLast { val paths = mutableListOf() zipTree(inputs.files.singleFile).visit { paths.add(this.path) } - check( - paths.sorted().equals( - listOf( - "net", - "net/mbonnin", - "net/mbonnin/tnmcp", - "net/mbonnin/tnmcp/module1", - "net/mbonnin/tnmcp/module1/${project.version}", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.md5", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.sha1", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.sha256", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.sha512", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.md5", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.sha1", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.sha256", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.sha512", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.md5", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.sha1", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.sha256", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.sha512", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.md5", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.sha1", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.sha256", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.sha512", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.md5", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.sha1", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.sha256", - "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.sha512", - "net/mbonnin/tnmcp/module2", - "net/mbonnin/tnmcp/module2/${project.version}", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.md5", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.sha1", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.sha256", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.sha512", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.md5", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.sha1", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.sha256", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.sha512", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.md5", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.sha1", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.sha256", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.sha512", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.md5", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.sha1", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.sha256", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.sha512", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.md5", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.sha1", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.sha256", - "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.sha512" - ) - ) + check(System.getenv("GPG_PRIVATE_KEY") == null) { + "checkZip doesn't working if signing is enabled" + } +// println(paths.sorted().joinToString(",\n")) + assertEquals( + listOf( + "net", + "net/mbonnin", + "net/mbonnin/tnmcp", + "net/mbonnin/tnmcp/module1", + "net/mbonnin/tnmcp/module1/${project.version}", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.md5", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.sha1", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.sha256", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-javadoc.jar.sha512", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.md5", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.sha1", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.sha256", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}-sources.jar.sha512", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.md5", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.sha1", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.sha256", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.jar.sha512", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.md5", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.sha1", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.sha256", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.module.sha512", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.md5", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.sha1", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.sha256", + "net/mbonnin/tnmcp/module1/${project.version}/module1-${project.version}.pom.sha512", + "net/mbonnin/tnmcp/module2", + "net/mbonnin/tnmcp/module2/${project.version}", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.md5", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.sha1", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.sha256", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-javadoc.jar.sha512", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.md5", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.sha1", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.sha256", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}-sources.jar.sha512", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.md5", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.sha1", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.sha256", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.jar.sha512", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.md5", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.sha1", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.sha256", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.module.sha512", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.md5", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.sha1", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.sha256", + "net/mbonnin/tnmcp/module2/${project.version}/module2-${project.version}.pom.sha512", + ), + paths.sorted() ) } } diff --git a/tests/jvm/module1/build.gradle.kts b/tests/jvm/module1/build.gradle.kts index e69de29..58b52b0 100644 --- a/tests/jvm/module1/build.gradle.kts +++ b/tests/jvm/module1/build.gradle.kts @@ -0,0 +1,10 @@ +import nmcp.NmcpExtension + +afterEvaluate { + extensions.getByType(NmcpExtension::class.java).apply { + publishAllPublicationsToCentralPortal { + username = System.getenv("MAVEN_CENTRAL_USERNAME") + password = System.getenv("MAVEN_CENTRAL_PASSWORD") + } + } +}