Skip to content

Commit

Permalink
Refactor KMP POM rewriting (#4072)
Browse files Browse the repository at this point in the history
Update KMP POM re-writer to be CC compatible.

Instead of editing the POM in-memory, instead modify the POM file that
the `generatePomFileForKotlinMultiplatformPublication` task produces.

Replace the Groovy XML utils with W3C Document classes, because the
Groovy XML utils don't preserve comments.

I added a project-local Maven repository, for easier testing of the
changes. Run `./gradlew publishAllPublicationsToRootBuildDirRepository`
and check `build/maven-repo`.

Relates to 

* #4067
* #3164
  • Loading branch information
aSemy committed Jun 8, 2024
1 parent 13f4cfd commit d4fc582
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ publishing {
password = System.getenv("OSSRH_PASSWORD") ?: ossrhPassword
}
}
maven(rootDir.resolve("build/maven-repo")) {
// Publish to a project-local directory, for easier verification of published artifacts
// Run ./gradlew publishAllPublicationsToRootBuildDirRepository, and check `$rootDir/build/maven-repo/`
name = "RootBuildDir"
}
}

publications.withType<MavenPublication>().configureEach {
Expand Down Expand Up @@ -75,7 +80,9 @@ publishing {
}
}

publishPlatformArtifactsInRootModule(project)
pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
publishPlatformArtifactsInRootModule(project)
}

//region Maven Central can't handle parallel uploads, so limit parallel uploads with a BuildService
abstract class MavenPublishLimiter : BuildService<BuildServiceParameters.None>
Expand Down
167 changes: 120 additions & 47 deletions buildSrc/src/main/kotlin/publishingUtils.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import groovy.util.Node
import groovy.util.NodeList
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.gradle.api.Project
import org.gradle.api.XmlProvider
import org.gradle.api.publish.PublishingExtension
import org.gradle.api.publish.maven.MavenPublication
import org.gradle.api.publish.maven.tasks.GenerateMavenPom
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.api.tasks.PathSensitivity.NAME_ONLY
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.named
import org.gradle.kotlin.dsl.withType
import org.gradle.plugins.signing.SigningExtension
import org.w3c.dom.Document
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList

//region manually define accessors, because IntelliJ _still_ doesn't index them properly :(
internal val Project.signing get() = extensions.getByType<SigningExtension>()
Expand All @@ -24,48 +30,115 @@ internal fun Project.publishing(configure: PublishingExtension.() -> Unit = {}):
* (see details in https://youtrack.jetbrains.com/issue/KT-39184#focus=streamItem-27-4115233.0-0)
*/
internal fun publishPlatformArtifactsInRootModule(project: Project) {
val platformPublication: MavenPublication =
project.publishing.publications.named<MavenPublication>("jvm").get()
val kmpPublication: MavenPublication =
project.publishing.publications.named<MavenPublication>("kotlinMultiplatform").get()

lateinit var platformXml: XmlProvider
platformPublication.pom?.withXml { platformXml = this }

// replace pom
kmpPublication.pom.withXml {
val xmlProvider = this
val root = xmlProvider.asNode()
// Remove the original content and add the content from the platform POM:
root.children().toList().forEach { root.remove(it as Node) }
platformXml.asNode().children().forEach { root.append(it as Node) }

// Adjust the self artifact ID, as it should match the root module's coordinates:
((root.get("artifactId") as NodeList).get(0) as Node).setValue(kmpPublication.artifactId)

// Set packaging to POM to indicate that there's no artifact:
root.appendNode("packaging", "pom")

// Remove the original platform dependencies and add a single dependency on the platform
// module:
val dependencies = (root.get("dependencies") as NodeList).get(0) as Node
dependencies.children().toList().forEach { dependencies.remove(it as Node) }
val singleDependency = dependencies.appendNode("dependency")
singleDependency.appendNode("groupId", platformPublication.groupId)
singleDependency.appendNode("artifactId", platformPublication.artifactId)
singleDependency.appendNode("version", platformPublication.version)
singleDependency.appendNode("scope", "compile")
}
val jvmPomTask = project.tasks.named<GenerateMavenPom>("generatePomFileForJvmPublication")

project.tasks.named<GenerateMavenPom>("generatePomFileForKotlinMultiplatformPublication").configure {

val jvmPom = jvmPomTask.map { it.destination }
inputs.file(jvmPom)
.withPropertyName("jvmPom")
.normalizeLineEndings()
.withPathSensitivity(NAME_ONLY)

doLast("re-write KMP common POM") {
val original = destination.readText()

val docFactory = DocumentBuilderFactory.newInstance()
val docBuilder = docFactory.newDocumentBuilder()

val jvmPomFile = jvmPom.get()

val jvmDoc = docBuilder.parse(jvmPomFile)
val jvmGroupId = jvmDoc.getElement("groupId").textContent
val jvmArtifactId = jvmDoc.getElement("artifactId").textContent
val jvmVersion = jvmDoc.getElement("version").textContent

val kmpPomDoc = docBuilder.parse(destination).apply {
// strip whitespace, otherwise pretty-printing output has blank lines
removeWhitespaceNodes()
// set standalone=true to prevent `standalone="no"` in the output
xmlStandalone = true
}

val kmpPom = kmpPomDoc.documentElement

val dependencies = kmpPom.getElement("dependencies")

// Remove the original platform dependencies...
while (dependencies.hasChildNodes()) {
dependencies.removeChild(dependencies.firstChild)
}
// instead, add a single dependency on the platform module
dependencies.appendChild(
kmpPomDoc.createElement("dependency") {
appendChild(kmpPomDoc.createElement("groupId", jvmGroupId))
appendChild(kmpPomDoc.createElement("artifactId", jvmArtifactId))
appendChild(kmpPomDoc.createElement("version", jvmVersion))
appendChild(kmpPomDoc.createElement("scope", "compile"))
}
)

// Set packaging to POM to indicate that there's no artifact
kmpPom.appendChild(
kmpPomDoc.createElement("packaging", "pom")
)

// Write the updated XML to the destination file
val transformer = TransformerFactory.newInstance().newTransformer().apply {
// pretty printing options
setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no")
setOutputProperty(OutputKeys.INDENT, "yes")
setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2")
}

project.tasks
.matching { it.name == "generatePomFileForKotlinMultiplatformPublication" }
.configureEach {
dependsOn("generatePomFileFor${platformPublication.name.capitalized()}Publication")
transformer.transform(DOMSource(kmpPomDoc), StreamResult(destination))

// Disable Config Cache to prevent error:
// Task `:[...]:generatePomFileForKotlinMultiplatformPublication` of type `GenerateMavenPom`:
// cannot serialize object of type 'DefaultMavenPublication', a subtype of 'Publication',
// as these are not supported with the configuration cache.
notCompatibleWithConfigurationCache("publishPlatformArtifactsInRootModule")
if (logger.isInfoEnabled) {
val updated = destination.readText()
logger.info(
"""
[$path] Re-wrote KMP POM
${"=".repeat(25)} original ${"=".repeat(25)}
$original
${"=".repeat(25)} updated ${"=".repeat(25)}
$updated
${"=".repeat(25)}==========${"=".repeat(25)}
""".trimIndent()
)
}
}
}
}


private fun Document.getElement(tagName: String): Node =
getElementsByTagName(tagName).item(0)
?: error("No element named '$tagName' in Document $this")

private fun Element.getElement(tagName: String): Node =
getElementsByTagName(tagName).item(0)
?: error("No element named '$tagName' in Element $this")

private fun Document.createElement(name: String, content: String): Element {
val element = createElement(name)
element.textContent = content
return element
}

private fun Document.createElement(name: String, configure: Element.() -> Unit = {}): Element =
createElement(name).apply(configure)

// https://stackoverflow.com/a/979606/4161471
private fun Node.removeWhitespaceNodes() {
val xpathFactory = XPathFactory.newInstance()

// XPath to find empty text nodes
val xpathExp = xpathFactory.newXPath().compile("//text()[normalize-space(.) = '']")
val emptyTextNodes = xpathExp.evaluate(this, XPathConstants.NODESET) as NodeList

// Remove each empty text node from document
for (i in 0 until emptyTextNodes.length) {
val emptyTextNode = emptyTextNodes.item(i)
emptyTextNode.getParentNode().removeChild(emptyTextNode)
}
}

0 comments on commit d4fc582

Please sign in to comment.