Writing and Publishing a Kotlin library

In this article with the longest intro in the whole history of internet blogging, we will cover how to write and publish a Kotlin library on Maven Central Portal using Gradle as our build system.


Table Of Contents:
 
I haven't written in a long while, I'll be honest I got discouraged when ChatGPT appeared. I thought to myself, why invest time and effort into writing articles for them to be scraped, chewed, and spat out by ChatGPT? 
It would be cool if it gave references for its answers when you think about it...but then I remembered Garbage In-Garbage Out, and suddenly my morale went up! So here I am, back at the "writing desk", I shall have the last laugh.
 
Anyhow, I got offered a Kotlin position in a different team a couple of months ago. I've always played around with different languages in my spare time, so I'm used to learning and picking up new stuff, I mean you have to be if you want to last in this industry, but working in a new language day-to-day is a bit different than playing around with it. 
It was a bit scary at first, but going out of our comfort zone is what makes us grow, so in a way it was also a no-brainer decision. 
 
Compared to PHP, Kotlin is a whole new ball game, a strongly typed compiled language, riding on the shoulder of a giant, JVM. A completely new ecosystem. Thankfully just before I joined I played around with Kotlin Multi Platform, built a parking payment app, and did a talk at an internal company event, so I had the opportunity to somewhat familiarise myself with it.
 
As a newbie in the Kotlin world, I'd say my biggest gripes are:
  • The lack of documentation, not for Kotlin as a language itself, but for the libraries, even the established ones,
  • The complexity of the Gradle build system, it just does so many things, and you have so many options and so many different ways of doing the same thing that you get overwhelmed, you end up in sort of an analysis paralysis situation,
  • Publishing libraries/packages -> This is what prompted me to write this article, it was by far the most frustrating thing of all.

From my, now 8 years of experience as a software developer, I can tell you the best way to learn a new language or technology is to build something with it, so I decided to build a Hetzner API Client
 
If you don't know what Hetzner is, it's a cheap cloud provider based in Germany, you can get dedicated servers for "pennies" there, forget about AWS.
I'm a big fan of theirs, it is the infrastructure of choice for all my side projects since I started doing programming in 2015, in fact, this very website is hosted on it, you can read more about it in this article. I was ahead of my time, I was preaching them long before DHH started. 
 
Okay, enough rambling, let's get to the point.
 

Writing a Kotlin library

Thankfully, Gradle is an outlier when it comes to the docs, they are pretty extensive, and to top it off they have a project boilerplate and a tutorial that follows it which can be accessed at this link.
 
We can either download the project boilerplate, or we can install Gradle on our machine and set up the project ourselves by running the gradle init cli command. 
 
I've opted for the latter so I will share the options I picked in the cli wizard with you:
$ gradle init

Select type of build to generate:
  1: Application
  2: Library
  3: Gradle plugin
  4: Basic (build structure only)
Enter selection (default: Application) [1..4] 2

Select implementation language:
  1: Java
  2: Kotlin
  3: Groovy
  4: Scala
  5: C++
  6: Swift
Enter selection (default: Java) [1..6] 2

Enter target Java version (min: 7, default: 21): 21

Project name (default: demo): hetznerkloud

Select application structure:
  1: Single application project
  2: Application and library project
Enter selection (default: Single application project) [1..2] 1

Select build script DSL:
  1: Kotlin
  2: Groovy
Enter selection (default: Kotlin) [1..2] 1

Select test framework:
  1: JUnit 4
  2: TestNG
  3: Spock
  4: JUnit Jupiter
Enter selection (default: JUnit Jupiter) [1..4] 4

Generate build using new APIs and behavior? (default: no) [yes, no] no
 
This will give us the following folder structure and files:
├── gradle 
│   ├── libs.versions.toml 
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew 
├── gradlew.bat 
├── settings.gradle.kts 
└── lib
    ├── build.gradle.kts 
    └── src
        ├── main
        │   └── kotlin 
        │       └── demo
        │           └── Library.kt
        └── test
            └── kotlin 
                └── demo
                    └── LibraryTest.kt
                    
 
A detailed explanation of what each of these represents is in the Gradle tutorial so I won't go more into depth, I will just point out that it uses the libs.versions.toml approach for managing dependencies, and it will have math3 and guava libraries included by default. They can be safely removed if you don't have a use for them like I didn't.
 
What I did require for my needs is:
  • Ktor client as an HTTP client
  • Kotlinx Serialization plugin for JSON marshalling
  • Kotest for testing
  • Detekt - static analysis tool
  • Konsist - another code analysis tool
  • I did not explicitly require Ktlint because I'm using Intellij IDEA which has that out of the box, but if you are using a different IDE I highly recommend it for fixing code style issues and automating the application of Kotlin style conventions

 

Publishing a Kotlin library

So let's say you wrote your amazing code, and you want to share it with the world, what to do?
Publishing a library is a three-step process, the first step is to generate the artifacts and metadata that will then be signed by your PGP private key and finally deployed to the package repository.
 
Now here is where things start to get messy. Broadly speaking the Maven Repository is the main JVM package repository and there are 2 ways of publishing and deploying your libraries there:
  • Maven Central Portal repository using the Publisher API
  • Nexus OSS repository using maven-publish Gradle plugin -> legacy repository, new accounts created after March 2024 actually won't be able to publish here

 

Things start to get even messier, the maven-publish Gradle plugin currently doesn't support automated publishing to the new Maven Central Portal, so you will either have to upload your signed artifacts manually through their Web UI interface or use community plugins, but we'll speak about that in more detail later.
Additionally, hashtag fun trivia, unlike the legacy repository, the new one does not support the SNAPSHOT versions, so if you have let's say a 0.1.0-SNAPSHOT version it will get rejected as invalid.
 
Connecting the dots gif
 
All this messiness can be distilled into at least 4 approaches to publishing:
  1. Using maven-publish plugin, building and signing the artifacts locally on the file system and manually uploading them to Maven Central Portal.
  2. Using maven-publish plugin and deploying to the legacy Maven Nexus repository
  3. Using maven-publishcentral-publishing-maven-plugin and a local installation of Maven and then running mvn deploy.
  4. Using community plugins, in my case Deepmedia Deployer, you can find the full list of them on this link.
 
What all of these have in common is that we will need to have the following Gradle plugins added to your build.gradle.kts so let's do that.
plugins {  
    ...
    // Add maven publish plugin
    `maven-publish`   
    // Add signing plugin
    `signing` 
}
 

PGP Key

Before we get deep into the weeds of publishing let's first create and distribute an OpenGP key pair. We will need the private key to sign our artifacts, and we will distribute the public key so that repositories and the consumers of our library can verify that the artifacts haven't been tampered with. You can read more about it in the Maven Central Portal docs.
 
For this, we will need GnuPG, or GPG for short. If you're using macOS like myself, you can use Brew to install it:
 
brew install gpg
 
The next step is to generate the key by running:
gpg --gen-key
 
Now run gpg --list-keys to get the ID of your key, and finally distribute it, by uploading it to one of the key servers:
  • keyserver.ubuntu.com
  • keys.openpgp.org
  • pgp.mit.edu
 
gpg --keyserver keyserver.ubuntu.com --send-keys {keyID}
 

Signing

Now that we have the keys, there are three ways we can sign the code with them, but for the purposes of this article, we will cover only two of them, for the third one you can read the Gradle docs.
 

Configuring the key in Gradle properties

One way of signing is to add the following three configurations to the Gradle properties file:
  • Last 8 letters of the key ID
  • Key passphrase 
  • File path to a secret keyring file
 
We shouldn't store your PGP key information in the project's gradle.properties as that gets committed to GIT and that way we would leak our private key passphrase to the public. 
It is recommended to store these in the global gradle.properties file on our local machine/build server, again if you're using macOS like myself you can install Gradle with Brew:
 
brew install gradle
 
The global properties will be located in the home directory ~/.gradle/gradle.properties. Add the following configs there:
# last 8 letters of the key ID
signing.keyId=A123456B

# passprhase you chose during key generation
signing.password=YOURPASS

signing.secretKeyRingFile=/Users/yourusername/.gnupg/secring.gpg
 
To generate the secret ring file run the following GPG command:
gpg --keyring secring.gpg --export-secret-keys > /Users/yourusername/.gnupg/secring.gpg
 
Now all we need to do is add a signing block in the build.gradle.kts, just make sure it's after the publishing block that we will cover in the next section, otherwise, the build will fail.
 
signing {  
    // mavenKotlin is the name of the publication
    // that you set in the publishing block
    sign(publishing.publications["mavenKotlin"])  
}

 

In memory key

The other way is to load the key from a file or ENV variable and store it in memory, but do note that with this approach we need the PGP key to be in the armor format, for that we need to run this gpg command:
 
gpg --armor --export-secret-keys your-key-id {keyID}
 
Which will give us output like this:
-----BEGIN PGP PUBLIC KEY BLOCK-----

...

-----END PGP PUBLIC KEY BLOCK-----
 
Now what we need to do is either store that in a file or an ENV variable and pass it to the useInMemoryPgpKeys function, as well as the key passphrase.
signing {
    // load the key and password from file or ENV
    useInMemoryPgpKeys(signingKey, signingPassword)
    
    sign(publishing.publications["mavenKotlin"])  
}
 

Publish using Web UI 

With this approach, we will build the artifacts locally and upload them manually through Maven Central Platform Web UI. The build gradle file should look something like this:

publishing {
    publications {
        create<MavenPublication>("mavenKotlin") {
            artifactId = rootProject.name
            groupId = "tech.s-co"
            from(components["java"])
            versionMapping {
                usage("java-api") {
                    fromResolutionOf("runtimeClasspath")
                }
                usage("java-runtime") {
                    fromResolutionResult()
                }
            }
            pom {
                name = rootProject.name
                description = """
                    Hetzner Cloud API Kotlin library
                    https://docs.hetzner.cloud
                """.trimIndent()
                url = "https://github.com/sasa-b/hetznerkloud"
                licenses {
                    license {
                        name = "The Apache License, Version 2.0"
                        url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
                    }
                }
                developers {
                    developer {
                        id = "sasa-b"
                        name = "Sasha Blagojevic"
                        email = "sasa.blagojevic@mail.com"
                    }
                }

                scm {
                    url = "https://github.com/sasa-b/hetznerkloud"
                    connection = "scm:git:git://github.com/sasa-b/hetznerkloud"
                    developerConnection = "scm:git:ssh://github.com/sasa-b/hetznerkloud"
                }
            }
        }
    }

    repositories {
        maven {
            name = "file"
            url = uri(layout.buildDirectory.dir("repo"))
        }
    }
}

signing {
    sign(publishing.publications["mavenKotlin"])
}

The groupId is your organization namespace (in the format of the reverse DNS) and the artifactId is your library's name. We could probably do with fewer options as Gradle has sane defaults, but I decided to go with the full example from the Gradle docs as a template.
Now when we run the ./gradlew tasks cli command we should see an output similar to this:

publish - Publishes all publications produced by this project.
publishAllPublicationsToFileRepository - Publishes all Maven publications produced by this project to the file repository.
publishMavenKotlinPublicationToFileRepository - Publishes Maven publication 'mavenKotlin' to Maven repository 'file'.
publishMavenKotlinPublicationToMavenLocal - Publishes Maven publication 'mavenKotlin' to the local Maven repository.
publishToMavenLocal - Publishes all Maven publications produced by this project to the local Maven cache.

Running either ./gradlew publish or ./gradle publishMavenKotlinPublicationToFileRepository will generate the artifacts and sign them in /lib/build/repo folder, now all we have to do is ZIP them and upload them through the Maven Central Portal Web interface by going to Deployments -> Publish Component.

SIDENOTE: You will need to create an account beforehand. If you use your GitHub account you will automatically get a verified organization namespace in the format of io.github.{yourusername}, if you want to use a different namespace you will have to verify the ownership of the domain by adding a TXT DNS record in the following format TXT @ {secret} in the admin panel of your domain provider. While you're at it generate an API token you will need it later.

The repositories block is what defines where our signed artifacts end up. If you want to read more about the Repositories you can refer to Gradle docs.

repositories {
    maven {
        name = "file"
        url = uri(layout.buildDirectory.dir("repo"))
    }
}

For example, if we wanted to upload them to an S3 bucket, the repositories block would look like this:

repositories {
    maven {
       url = uri("s3://your-s3-bucket/snapshots")
         credentials(AwsCredentials::class) {
              accessKey = System.getenv("AWS_ACCESS_KEY")
              secretKey = System.getenv("AWS_SECRET_KEY")
         }
     }
}

This conveniently leads us to the next approach.

 

Publishing using Nexux OSS legacy repository

All we need to do is change the repositories block to something like this and that oughta do the job! The rest is the same as in the first approach.

repositories {
    maven {
        val releasesRepoUrl = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/")
        val snapshotsRepoUrl = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")
        url = if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl

        credentials {
           username = System.getenv("OSSRH_USERNAME")
           password = System.getenv("OSSRH_PASSWORD")
         }
     }
 }

 

Publishing using Maven

Again, this approach has the identical config as the publishing through Web UI, the only difference being that we need to add the Maven Central publishing plugin to the dependencies block.

dependencies {
    ...
    implementation("org.sonatype.central:central-publishing-maven-plugin:latest.version")
}

I haven't actually fully tested this one because it requires a local installation of Maven and I really couldn't be bothered, so your mileage may vary, but in theory, what we need to do is:

  • Install Maven on your machine
  • Add a settings.xml file containing the Maven Central Platform credentials to ~/.m2/settings.xml, you can read more about it in Maven docs
  • Publish the artifacts as with the first approach by running ./gradlew publish
  • Run mvn deploy cli command

 

Publishing using Deployer

The cutting-edge approach that does all the nitty-gritty stuff for you. For the minimal setup to make things work you can read the amazing blog post by the library's authors. I went down a slightly different route of combining the maven-publish and this plugin. But first things, first, we need to add it to the plugins block.

plugins {
     ...
     id("io.deepmedia.tools.deployer:latest.version")
}

If you opt for using just this plugin, you don't need to include maven-publish and signing plugin as they come bundled. The next step is to add the deployer block, if you want a minimal setup this should be enough:

deployer {
    // 1. Artifact definition.
    // https://opensource.deepmedia.io/deployer/artifacts
    content { 
        fromJava()
    }
    
    // 2. Project details.
    // https://opensource.deepmedia.io/deployer/configuration
    projectInfo {
        description = "A sample project to showcase Maven Central publications."
        url = "https://github.com/sample-company/SampleProject"
        scm.fromGithub("sample-company", "SampleProject")
        license(apache2)
        developer("sampleUser", "sample@sample-company.com", "SampleCompany", "https://sample-company.com")
        groupId = "com.sample-company"
    }

    // 3. Central Portal configuration.
    // https://opensource.deepmedia.io/deployer/repos/central-portal
    centralPortalSpec {
        signing.key = secret("SIGNING_KEY")
        signing.password = secret("SIGNING_PASSPHRASE")
        auth.user = secret("UPLOAD_USERNAME")
        auth.password = secret("UPLOAD_PASSWORD")
    }
}

But, if you like to make it hard on yourself, like me, then you will end up with something like this:

deployer {
    // Load properties from gradle.properties first (already done by default)

    // 1. Artifact definition.
    // https://opensource.deepmedia.io/deployer/artifacts
    content {
        component {
            fromMavenPublication("mavenKotlin")
        }
    }

    // 2. Project details.
    // https://opensource.deepmedia.io/deployer/configuration
    projectInfo {
        description = """
            Hetzner Cloud API Kotlin library
            https://docs.hetzner.cloud
        """.trimIndent()
        url = "https://github.com/sasa-b/hetznerkloud"
        scm.fromGithub("sasa-b", "hetznerkloud")
        license(apache2)
        developer(
            name = "Sasha Blagojevic",
            email = "sasa.blagojevic@mail.com",
            url = "https://s-co.tech",
        )
        artifactId = rootProject.name
        groupId = group.toString()
    }

    // 3. Central Portal configuration.
    // https://opensource.deepmedia.io/deployer/repos/central-portal
    centralPortalSpec {
        signing.key = secret("SIGNING_KEY")
        signing.password = secret("SIGNING_PASSPHRASE")
        auth.user = secret("MAVEN_USERNAME")
        auth.password = secret("MAVEN_PASSWORD")
        
        // Does not automatically make the new version public, requires a manual step in web ui
        allowMavenCentralSync = false
    }

    githubSpec {
        owner.set("sasa-b")
        repository.set("hetznerkloud")

        // Personal GitHub username and a personal access token linked to it
        auth.user.set(secret("GITHUB_USER"))
        auth.token.set(secret("GITHUB_TOKEN"))
    }

    release {
        release.description.set("Deployer release v$version")
    }
}

And now run ./gradlew deployAll.

What this is doing, is taking the artifacts published by the publisher block and deploying them directly to Maven Central Portal and Github Packages repositories. Isn't that amazing? 

What's great about this plugin is that it also gives you this nice utility function secret that loads the requested keys from either ENV or local.properties file <- just make sure to add this file to .gitignore and to provide the PGP key in armor format with explicit \n characters for new lines:

SIGNING_KEY=\n-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n..............................\n-----END PGP PRIVATE KEY BLOCK-----\n

Don't embarrass yourself like I did.

 

If you've made it so far, congrats! You are either very curious or very, very bored. In case it's the former I have a quest for you, go figure out how to publish to Jitpack and write a blog post linking back to mine, because I'm done...I need a beer...

Cheers!

Last updated: 22 days ago 142