Creating gradle plugins for Kotlin libraries
Why share publishing logic
As you build more and more applications or services you are inevitably going to run into similar problens more than once. Even the solo developer or the small team of developers is going to very quickly run into the need to share their code. In the case of Kotlin and Android this is often done through the Gradle build system and creating shared modules. Just starting off it is intuitive to make a new module in your current project to house your shared code. This works for a single project but doesn’t address the case of using this shared module in another project. Another problem arises once you want to consume a shared library on another developer’s machine or a CI instance. To scale into building a family of applications it would be best to host your packages in a shared artifactory such as the central Maven repository or an internal s3 bucket. It might seem like overkill to modularize your packages in such a way if you are a small team, and it can seem like the overhead required to maintain such a artifactory is not worth the investment. With some upfront preparation you can greatly simplify your library publishing logic and make it trivial to package new components for use within your organization.
Practical use case
To demonstrate how a lack of upfront planning get can out of hand we can take the example of a typical Kotlin full stack application composed of client and server. The first form of the project is very simple, there exists a microservice and client mobile app each using a share model package for server responses. In this case the project lives in a single git repo and is wired together internally in a file hierarchy.
Lets say we are happy with the server architecture and choose to reuse some of the base classes for another microservice for the same application. Again this is fine to all live within the same file hierarchy.
Things start to spiral quickly when we have new requirements to reuse both the base server and a base server client in a new fullstack application. The new architecture looks like this:
At this stage both client app one and client app two could not live in a single git repo, and this means that base server and base client could not either. These two server adjacent packages would need to be versioned in case one app does not need to update, and hosted in a shared location since the CI instance for each app would need to access them separately. The problem worsens as more packages are created and it is obvious some work needs to be done to simplify engaging with this ecosystem.
Gradle plugins
Since gradle artifacts are commonly published with the maven-publish
plugin it made sense to consolidate publishing logic into another component at the plugin level, namely, another plugin. This plugin would have defined goals with the intention of making it easy to deploy a new package. The goals for this plugin are:
- Apply metadata common across all libraries such as
groupId
and other organization identifiers - Make it easy to configure package specific attributes such as
artifactId
andversion
- Handle the packaging of javadoc and source jars
- Handle any of: JVM, Android, and Kotlin Multiplatform modules
- Configure access to the shared artifactory (maven, sonatype, s3)
Plugin setup
The basic plugin that configures maven publishing looks like this:
1// build.gradle.kts
2
3plugins {
4 kotlin("jvm") version "1.9.0"
5
6 `java-gradle-plugin`
7 `maven-publish`
8}
9
10group = "org.yourorg"
11version = "1.0-SNAPSHOT"
12
13gradlePlugin {
14 plugins {
15 create("libraryPlugin") {
16 id = "org.yourorg.library.plugin"
17 displayName = "Your Org Kotlin publishing plugin"
18 description =
19 "Configure a kotlin module (JVM, Android, or Multiplatform) with standard parameters to be published to a shared repository"
20 implementationClass = "org.yourorg.PublishingPlugin"
21 }
22 }
23}
1// PublishingPlugin.kt
2
3class PublishingPlugin : Plugin<Project> {
4 override fun apply(target: Project) {
5 // Apply the core publishing plugin
6 target.plugins.apply("org.gradle.maven-publish")
7
8 // Add a target artifactory
9 target.extensions.configure(PublishingExtension::class.java) { extension ->
10 extension.repositories {
11 it.maven { repo ->
12 repo.url = URI.create("s3://yourorg-repo.s3.us-west-2.amazonaws.com")
13 repo.credentials(AwsCredentials::class.java) { aws ->
14 aws.accessKey = System.getenv("AWS_ACCESS_KEY_ID")
15 aws.secretKey = System.getenv("AWS_SECRET_ACCESS_KEY")
16 }
17 }
18 }
19 }
20 }
21
22 // The gradle api in kotlin can be a little obtuse to work with, I recommend using something like this
23 // as an alternative to Project.extensions.configure(Extension::class.java) {}
24 inline fun <reified T> Project.configure(crossinline onConfigure: T.() -> Unit) {
25 extensions.configure(T::class.java) {
26 it.onConfigure()
27 }
28 }
29}
Certain values are going to be library-specific and can’t be known at plugin development time. Things like artifactId, library version, javadoc location etc. must be configured at a later point and accessed lazily. We can leverage a custom extension to pass these values in the consuming project.
1/**
2 * Configure library publishing parameters
3 */
4interface YourOrgLibraryExtension {
5 val version: Property<String>
6 val artifactId: Property<String>
7 val groupId: Property<String>
8 val author: Property<String>
9}
Load this into the host project before any other application logic. For this particular usecase the groupId
is always the same but is still exposed as a Property
in case customization is needed.
1class PublishingPlugin : Plugin<Project> {
2
3 private lateinit var libraryExtension: YourOrgLibraryExtension
4
5 override fun apply(target: Project) {
6 target.plugins.apply("org.gradle.maven-publish")
7
8 libraryExtension = target.extensions.create("library", YourOrgLibraryExtension::class.java)
9 libraryExtension.groupId.set("com.yourorg")
10
11 ...
12 }
13}
Since we want to handle the most common types of Kotlin modules we should proceed next based on what type of module we are being applied to. We can check the currently active plugins to determine this. The order of these is important since a multiplatform library could also have the android plugin, and an android library could have the jvm plugin.
1// PublishingPlugin.kt
2
3 when {
4 target.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") -> target.configureMultiplatform()
5 target.plugins.hasPlugin("com.android.library") -> target.configureAndroidLibrary()
6 target.plugins.hasPlugin("org.jetbrains.kotlin.jvm") -> target.configureJvmLibrary()
7 else -> throw IllegalStateException("Publishing plugin must be applied to a multiplatform, JVM, or Android kotlin module")
8}
Multiplatform libraries have some additional work since we use a custom artifactId in the extension instead of defaulting to the project name. We configure the new artifactId based on the publications created by the multiplatform plugin. Extra consideration is needed for the android components, see the multiplatform docs for more details.
1private fun Project.configureMultiplatform() {
2 logger.info("Configuring Multiplatform library project")
3 project.configure<KotlinMultiplatformExtension> {
4 if (project.plugins.hasPlugin("com.android.library")) {
5 androidTarget {
6 publishLibraryVariants("release", "debug")
7 }
8 }
9 }
10 project.configure<PublishingExtension> {
11 publications.withType(MavenPublication::class.java) { publication ->
12 afterEvaluate {
13 publication.artifactId = if (publication.name == "kotlinMultiplatform") {
14 libraryExtension.artifactId.get()
15 } else if (publication.name.startsWith("android")) {
16 if (publication.name.endsWith("Debug")) {
17 libraryExtension.artifactId.get() + "-android-debug"
18 } else {
19 libraryExtension.artifactId.get() + "-android"
20 }
21 } else {
22 publication.artifactId.replace(project.name, libraryExtension.artifactId.get())
23 }
24 }
25 }
26 }
27}
JVM and Android configuration is straightforward.
1private fun Project.configureJvmLibrary() {
2 // Can be omitted if you are not publishing sources or javadoc
3 configure<JavaPluginExtension> {
4 withSourcesJar()
5 }
6 afterEvaluate { project ->
7 logger.info("Configuring JVM library project")
8 project.configure<PublishingExtension> {
9 publications {
10 it.register("release", MavenPublication::class.java) { pub ->
11 pub.artifactId = libraryExtension.artifactId.get()
12 pub.from(project.components.getByName("java"))
13 }
14 }
15 }
16 }
17 }
18
19 private fun Project.configureAndroidLibrary() {
20 logger.info("Configuring Android library project")
21 afterEvaluate { project ->
22 project.configure<PublishingExtension> {
23 publications {
24 it.register("release", MavenPublication::class.java) { pub ->
25 pub.artifactId = libraryExtension.artifactId.get()
26 pub.from(project.components.getByName("release"))
27 }
28 }
29 }
30 }
31 }
Lastly we need to use the values provided in the extension class after our specific Kotlin publications have been registered.
1override fun apply(target: Project) {
2 ...
3
4 target.afterEvaluate { project ->
5 project.group = libraryExtension.groupId.get()
6 project.version = libraryExtension.version.get()
7 }
Additional data
The last bit we want to standardize across our libraries is the data contained in the .pom
file. This is shared configuration no matter the type of Kotlin module being produced so add this after all other post-evaluation logic.
1 target.afterEvaluate { project ->
2 ...
3
4 target.configurePom(libraryExtension)
5 }
6 }
7
8 private fun Project.configurePom(extension: YourOrgLibraryExtension) {
9 configure<PublishingExtension> {
10 publications.withType(MavenPublication::class.java) { pub ->
11 pub.pom { pom ->
12 pom.organization {
13 it.url.set("https://yourorg.com")
14 it.name.set("YourOrg,LLC")
15 }
16 pom.developers {
17 if (extension.author.isPresent) {
18 it.developer { developer ->
19 developer.name.set(extension.author.get())
20 }
21 }
22 }
23 }
24 }
25 }
26 }
Using our plugin
With this we apply our plugin to any new kotlin project and test our publication logic.
1// New project settings.grade.kts
2pluginManagement {
3 includeBuild("../example-publishing-plugin")
4 // or maven artifactory
5}
1// New project build.gradle.kts
2plugins {
3 kotlin("multiplatform") version "1.9.0"
4 id("com.android.library") version "7.4.0"
5 id("org.yourorg.library.publishing")
6}
7
8library {
9 version.set("1.0.0")
10 artifactId.set("example-models")
11 author.set("Some Developer")
12}
13
14kotlin {
15 // Your targets
16}
Once the project finishes syncing you can view the individual publish tasks in your IDE sidebar or by running ./gradlew tasks | grep publish
. Applying the plugin and configuring the extension is all that is needed to get a new package publishing with standard organizational metadata. This implementation code and a sample showing how to include dokka javadoc files can be viewed at https://github.com/kadahlin/publishing-plugin/tree/main.