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.
- 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.
Writing a Kotlin library
gradle init
cli command. $ 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
├── 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
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.- 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
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.
- 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
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.SNAPSHOT
versions, so if you have let's say a 0.1.0-SNAPSHOT
version it will get rejected as invalid.- Using
maven-publish
plugin, building and signing the artifacts locally on the file system and manually uploading them to Maven Central Portal. - Using
maven-publish
plugin and deploying to the legacy Maven Nexus repository - Using
maven-publish
,central-publishing-maven-plugin
and a local installation of Maven and then runningmvn deploy
. - Using community plugins, in my case Deepmedia Deployer, you can find the full list of them on this link.
build.gradle.kts
so let's do that.plugins {
...
// Add maven publish plugin
`maven-publish`
// Add signing plugin
`signing`
}
PGP Key
brew install gpg
gpg --gen-key
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
Configuring the key in Gradle properties
- Last 8 letters of the key ID
- Key passphrase
- File path to a secret keyring file
gradle.properties
as that gets committed to GIT and that way we would leak our private key passphrase to the public. brew install gradle
~/.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
gpg --keyring secring.gpg --export-secret-keys > /Users/yourusername/.gnupg/secring.gpg
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
gpg --armor --export-secret-keys your-key-id {keyID}
-----BEGIN PGP PUBLIC KEY BLOCK-----
...
-----END PGP PUBLIC KEY BLOCK-----
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!