Skip to content
Go back

Homologated: Publishing Your Kotlin Multiplatform Library to Maven Central

by KMP Bits

KMP Bits Cover

In motorsport, homologation is the moment a car stops being a prototype and becomes official. The FIA scrutineers inspect it, certify it against the regulations, and once it passes, any licensed team in the world can run it. Before homologation, the car exists. It works, it might even be better than what’s already on the grid. But it’s not in the registry. It’s just in your workshop.

Publishing a Kotlin Multiplatform library to Maven Central is your homologation moment. Until you do it, your library is a mavenLocal() entry that only you can use. After it, anyone can add one line to their libs.versions.toml and pull it in without cloning anything.

I went through this with KMP Splash, my Gradle plugin and runtime library for automating splash screen setup in KMP projects. The plugin generated the right files. The logic worked. The moment I wanted anyone else to drop it into their build without cloning the repo, I hit the wall: signing, coordinates, POM metadata, Central Portal registration. None of it fails with obvious error messages. This article walks through exactly what I did, what tripped me up, and what the setup looks like in 2026.


What you’re actually publishing

Before touching Gradle, it helps to understand what Maven Central expects to receive. It’s not just your .jar or .klib. Central requires:

The coordinates you pick (groupId:artifactId:version) are permanent once published. io.github.kmpbits:splash-runtime:<version> cannot be changed or deleted after it goes live. Pick them deliberately.


The tooling

I use com.vanniktech.maven.publish. It handles the multiplatform publication setup, the POM, the source and doc jars, and the signing configuration in one coherent plugin. I’ve tried the raw maven-publish DSL, and while it works, the vanniktech plugin removes about 200 lines of boilerplate and gets the KMP targets right automatically.

Start with libs.versions.toml:

# libs.versions.toml

[versions]
mavenPublish = "0.30.0"

[plugins]
mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }

Then in your library module’s build.gradle.kts:

// splash-runtime/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.mavenPublish)
}

No need to apply maven-publish or signing manually. The vanniktech plugin wires both.


Setting up your publication block

In the same build.gradle.kts, add the configuration block:

// splash-runtime/build.gradle.kts

mavenPublishing {
    coordinates(
        groupId = "io.github.kmpbits",
        artifactId = "splash-runtime",
        version = "<version>"
    )

    pom {
        name.set("KMP Splash Runtime")
        description.set("Cross-platform splash screen runtime for Kotlin Multiplatform apps.")
        url.set("https://github.com/kmpbits/KMP-Splash")

        licenses {
            license {
                name.set("Apache-2.0")
                url.set("https://opensource.org/licenses/Apache-2.0")
            }
        }

        developers {
            developer {
                id.set("kmpbits")
                name.set("KMP Bits")
                email.set("kmpbits@gmail.com")
            }
        }

        scm {
            url.set("https://github.com/kmpbits/KMP-Splash")
            connection.set("scm:git:git://github.com/kmpbits/KMP-Splash.git")
            developerConnection.set("scm:git:ssh://git@github.com/kmpbits/KMP-Splash.git")
        }
    }

    publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
    signAllPublications()
}

SonatypeHost.CENTRAL_PORTAL points at the new portal. The old OSSRH Nexus path is being retired — if you’re starting fresh in 2026, this is the one to use.

signAllPublications() tells the plugin you want GPG signing on every artifact. That requires a key, which is the next step.

A quick note on Gradle plugins: KMP Splash also ships a Gradle plugin (splash-plugin) that users apply in their build files. Gradle plugins can be published to the Gradle Plugin Portal separately using the com.gradle.plugin-publish plugin. The runtime library and the build plugin are two distinct publications — this article covers the runtime side, which goes to Maven Central. The plugin side follows a different flow through the Portal.


GPG signing setup

This is where most people get stuck. You need a GPG keypair, and Maven Central needs the public key to be on a public keyserver so it can verify your signatures.

Generate a key if you don’t have one:

gpg --gen-key

Use a real name and email. Once generated, find your key ID:

gpg --list-secret-keys --keyid-format=long

You’ll see output like:

sec   ed25519/ABCDEF1234567890 2026-01-01 [SC]

The part after the / is your key ID. Publish the public key to a keyserver:

gpg --keyserver keyserver.ubuntu.com --send-keys ABCDEF1234567890

Then export the secret key in ASCII armor format:

gpg --export-secret-keys --armor ABCDEF1234567890 > secret.asc

A quick note: Add secret.asc to .gitignore before you do anything else. This file is your signing identity — if it ends up in a commit, rotate the key.

For local publishing, put the signing configuration in ~/.gradle/gradle.properties:

# ~/.gradle/gradle.properties

signing.keyId=ABCDEF12             # last 8 characters of your full key ID
signing.password=your_passphrase
signing.secretKeyRingFile=/absolute/path/to/secret.asc

The keyId being the last 8 characters is something Gradle documentation isn’t always clear about. I got this wrong the first time and spent twenty minutes staring at a “secret key not found” error before figuring it out.

For CI/CD, the vanniktech plugin also reads ORG_GRADLE_PROJECT_signingInMemoryKey, ORG_GRADLE_PROJECT_signingInMemoryKeyPassword, and ORG_GRADLE_PROJECT_signingInMemoryKeyId. Export your secret key as base64, store it as a secret in your CI provider, and inject those three environment variables into the publishing job.


Registering on the Central Portal

Go to central.sonatype.com and create an account. Once logged in, claim your namespace under your profile.

The group ID you claim has to match your GitHub username (io.github.yourname) or a domain you own. For GitHub-based namespaces, Sonatype verifies ownership by checking that a public repository named OSSRH-<your-token> exists under your account. They show you the exact repo name to create, you create it, and then you verify. It takes a few minutes.

Once your namespace is verified, generate a user token from the Central Portal settings. This gives you a username and password pair that the Gradle plugin uses to authenticate.

Add them to ~/.gradle/gradle.properties:

# ~/.gradle/gradle.properties

mavenCentralUsername=your_token_username
mavenCentralPassword=your_token_password

For CI, inject these as environment variables under the names ORG_GRADLE_PROJECT_mavenCentralUsername and ORG_GRADLE_PROJECT_mavenCentralPassword.


Publishing

With the plugin configured, signing ready, and credentials in place, publishing is one command:

./gradlew publishAllPublicationsToMavenCentralRepository

This uploads everything to a staging deployment on Central Portal. Head to central.sonatype.com/publishing/deployments to see it. From there you can publish it, which pushes it to the public index, or drop it if something’s wrong. You can’t undo a publish, but you can review the deployment first.

The first time you publish, give it 15 to 30 minutes for the library to appear in search. Subsequent versions sync faster.


Automating releases with GitHub Actions

Running the publish task manually works, but a tag-triggered workflow is cleaner: push a v1.0.0 tag, the workflow fires, and the release lands on Central without touching your local machine.

First, encode your secret GPG key as base64 — this is what you’ll store as a secret in GitHub:

gpg --export-secret-keys --armor ABCDEF1234567890 | base64

Then add five secrets to your GitHub repository under Settings > Secrets and variables > Actions:

Secret nameValue
MAVEN_CENTRAL_USERNAMEToken username from Central Portal
MAVEN_CENTRAL_PASSWORDToken password from Central Portal
SIGNING_KEY_IDLast 8 chars of your GPG key ID
SIGNING_KEYBase64-encoded secret key from above
SIGNING_PASSWORDYour GPG passphrase

Then create the workflow:

# .github/workflows/publish.yml

name: Publish to Maven Central

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: macos-latest  # macOS required for iOS targets

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'zulu'

      - name: Publish
        env:
          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
          ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }}
          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}
          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
        run: ./gradlew publishAllPublicationsToMavenCentralRepository

macos-latest is required if your library includes iOS targets — the Kotlin/Native compiler needs Xcode present to produce the iOS klibs. If your library is JVM-only or Android-only, ubuntu-latest is faster and cheaper.

The v* tag pattern means any tag starting with v triggers the workflow. Push v1.0.0 to release, push v1.0.1 to patch. The vanniktech plugin reads the version from your coordinates() block, so keep it in sync with the tag.

A quick note: This workflow only covers publishing. For running tests, linting, and build validation on every pull request, the setup is a separate concern. I covered that side in Every Commit on the Clock: CI/CD for KMP with GitHub Actions.


Gotchas

1. Snapshot versions don’t go to Central.

If your version ends in -SNAPSHOT, the vanniktech plugin will route it to a snapshot repository, not Central. Keep the version clean (no -SNAPSHOT suffix) for anything you want on Central. If you want a snapshot repo alongside, configure it separately.

2. The POM must be complete.

Central validates the POM on upload. A missing license, missing developer block, or missing SCM block will cause the deployment to be rejected. Sometimes the error is clear. Often it’s a generic “validation failed” with no line number. Fill every field in the pom {} block before your first attempt.

3. Signing failures are usually a key ID mismatch.

“Secret key not found” almost always means signing.keyId has too many characters. It wants the last 8 of the full ID, not the whole thing.

4. Multiplatform publications need all targets to build.

If your library includes iOS targets, the publishAllPublications task will fail on a machine without Xcode. For CI, either run on a macOS runner or configure the publishing task to skip targets that can’t be built on Linux. The vanniktech plugin has Kotlin filter support for this.

5. Once published, it’s permanent.

You can drop a deployment before publishing it. Once it’s out, you can deprecate a version but not remove it. This is by design: reproducible builds depend on coordinates staying stable. Think through your versioning before hitting publish on anything you’re not sure about.


Wrapping up

Publishing a KMP library to Maven Central isn’t complicated once the pieces are in place. The vanniktech plugin handles most of the ceremony, and the Central Portal is a much cleaner experience than the old Nexus setup. The friction lives in the signing configuration and namespace verification, and both of those are one-time costs. After the first release, every subsequent version is a single Gradle command.

Your library is built, it works, and it belongs on the grid. Time to get it homologated. 🏁


The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
The Pit Crew: Advanced Ktor Client Configuration for KMP