Publishing Multiplatform Libraries (Maven Central, SwiftPM, npm)

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

An app is built once and shipped to stores; a library is built once and consumed by many projects you never see, on platforms you may not use yourself. That changes what "done" means. A multiplatform library must be resolvable from a Kotlin Multiplatform (KMP) project, from a plain JVM or Android project, from an Xcode project that knows nothing about Gradle, and from a JavaScript bundler, and its public API must stay compatible when you release again. This page walks through the whole pipeline for one small library, from the Gradle publications to the Maven Central Portal, the Swift Package Manager (SwiftPM) distribution, npm, API quality gates and generated documentation.

The page targets the section baseline: Kotlin 2.4.20, Android through com.android.kotlin.multiplatform.library (AGP 9.4), Xcode 26.4, the vanniktech Maven Publish plugin 0.37.0 and Dokka 2.2.0. Stability is stated where it is not Stable: ABI validation in the Kotlin Gradle plugin (KGP) is Experimental, Swift export is Alpha, and Kotlin/Wasm is Beta. The worked example is com.example:trail-geo, a small library of geodesic distance and elevation-profile helpers extracted from TrailMate Everywhere (package com.example.trailmate), the running example introduced in What Kotlin Multiplatform Is and When to Use It. Gradle basics are in Gradle Configuration and Compatibility, and testing the library is in Testing Multiplatform Code. The release automation that runs these steps is in CI/CD for KMP with GitHub Actions. Publishing an app to a store is a different job; see Publishing KMP Apps to Google Play, the App Store, Desktop and the Web.

The Publishing Landscape

One library, four channels. Each consumer type needs a different artifact, and each artifact has a different distribution mechanism.

Consumer Artifact Where it lives Gradle task

KMP, JVM and Android projects

Maven artifacts plus Gradle module metadata

Maven Central

publishToMavenCentral

Xcode and SwiftPM projects

A zipped XCFramework referenced by Package.swift

A GitHub release

assembleTrailGeoReleaseXCFramework

JavaScript and TypeScript projects

An npm package with .d.ts files

npmjs.com

publishJsPackageToNpmjsRegistry

Library users reading docs

Static HTML from the public API

GitHub Pages

dokkaGenerate

The key design decision is that Maven Central is the single source of truth for Kotlin consumers. KMP projects resolve it through the Gradle module metadata and never need to know which targets exist. SwiftPM and npm are additional views of the same build for ecosystems that do not speak Maven.

flowchart LR TAG["Git tag v1.0.0
GitHub release"] --> CI["macOS CI runner
macos-latest, Xcode 26.4 pinned"] CI --> CHECK["checkKotlinAbi
allTests"] CHECK --> MAVEN["publishToMavenCentral
signed publications"] CHECK --> XCF["assembleTrailGeoReleaseXCFramework
zip + swift package compute-checksum"] CHECK --> DOKKA["dokkaGenerate
build/dokka/html"] MAVEN --> CENTRAL[("Maven Central
com.example:trail-geo")] XCF --> REL["GitHub release asset
TrailGeo.xcframework.zip"] REL --> PKG["Package.swift
binaryTarget url + checksum"] DOKKA --> PAGES["GitHub Pages
API reference"] PKG --> SWIFT["Xcode projects
add package by URL"] CENTRAL --> KMPUSERS["KMP, JVM, Android projects"]

A tag triggers one macOS job because the Apple targets can only be built there; the same job produces every artifact, so all channels carry the same version built from the same commit.

The Library Module

trail-geo is a single multiplatform module with no UI. Its targets are the ones TrailMate Everywhere needs plus the JVM and JS for wider reach.

// trail-geo/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
    alias(libs.plugins.vanniktechMavenPublish)           // com.vanniktech.maven.publish 0.37.0
    alias(libs.plugins.dokka)                            // org.jetbrains.dokka 2.2.0
}

group = "com.example"
version = "1.0.0"

kotlin {
    explicitApi() // every public declaration needs an explicit visibility and return type

    androidLibrary {
        namespace = "com.example.trailgeo"
        compileSdk = 36
        minSdk = 24
    }
    jvm()
    js { browser(); binaries.library(); generateTypeScriptDefinitions() }
    @OptIn(ExperimentalWasmDsl::class)
    wasmJs { browser() }

    val xcf = XCFramework("TrailGeo")
    listOf(iosArm64(), iosSimulatorArm64(), macosArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "TrailGeo"
            xcf.add(this)
        }
    }

    sourceSets {
        commonMain.dependencies { /* no third-party dependencies: keep a library lean */ }
        commonTest.dependencies { implementation(kotlin("test")) }
    }
}

Conventions that matter for a library, as opposed to an app: no app plugin and no androidApp (see The Android Target and AGP 9); a group and a version in the build script (they become the Maven coordinates and must not end in -SNAPSHOT); few dependencies, since each is imposed on every consumer; and explicitApi(), covered below.

Gradle Publishing With maven-publish

Gradle’s maven-publish plugin turns the outputs of a build into publications: a set of files (a main artifact, sources, a POM, and Gradle module metadata) uploaded to a repository. The Kotlin Gradle plugin applies and configures maven-publish for you when it is present; each KMP target contributes its own publication, so you rarely write a publications block by hand.

The Root Publication and the Per-Target Publications

A multiplatform build creates one root publication named kotlinMultiplatform plus one publication per target.

Publication Example artifact for trail-geo Contents

Root (kotlinMultiplatform)

com.example:trail-geo

Gradle module metadata and the common metadata klib; the artifact consumers declare

jvm

com.example:trail-geo-jvm

The JVM jar

Android

com.example:trail-geo-android

The AAR of the Android variant

iosArm64, iosSimulatorArm64, macosArm64

trail-geo-iosarm64, trail-geo-iossimulatorarm64, trail-geo-macosarm64

One klib each

js and wasmJs

trail-geo-js, trail-geo-wasm-js

JS and Wasm klibs

Consumers never add the per-target artifacts. A KMP project writes one line in commonMain and Gradle picks the right target artifact for each of its own targets.

// sharedLogic/build.gradle.kts -- the consumer side: one dependency, resolved per target
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.example:trail-geo:1.0.0")
        }
    }
}

The mechanism that makes this work is Gradle module metadata: a trail-geo-1.0.0.module file next to the POM that lists every variant (per target, per usage: API or runtime) and the attributes (org.jetbrains.kotlin.platform.type, target name) Gradle uses to match a consumer’s target to the right file. The root POM only redirects. This is why a KMP library cannot be resolved well by Maven or by Gradle versions that ignore module metadata, and why the JVM and Android publications also stand on their own: a plain Java project can depend on com.example:trail-geo-jvm directly. See Gradle — Gradle Module Metadata.

Publishing From a Single macOS Host

Apple klibs and frameworks need Apple tooling to link, and Kotlin/Native tiers also decide which targets build where (see How KMP Compiles: Kotlin/JVM, Kotlin/Native, Kotlin/JS and Kotlin/Wasm). The official Maven Central tutorial therefore states the rule bluntly: Apple targets must be built on macOS, so release builds run on one macOS machine that also builds the JVM, Android, JS and Wasm publications. That is why the publishing workflow uses macos-latest and publishes everything in one Gradle invocation.

The single-host rule has a second reason: a Central deployment is one bundle, and the root publication’s module metadata would reference files that do not exist yet if Linux and macOS jobs published separately.

For libraries that only need klib artifacts of Apple targets (no frameworks), the API guidelines point out that cross-compilation allows producing them from any host; that is useful for local experiments, but a release should still come from the macOS pipeline so the XCFramework and the Maven artifacts are built together.

Android Library Variants

With the legacy com.android.library and androidTarget(), an Android library had build types and product flavors, and you had to opt variants in with androidTarget with publishLibraryVariants("release"); without it no Android publication was created. See Kotlin docs — Set up multiplatform library publishing.

The com.android.kotlin.multiplatform.library plugin removes the problem: it has a single variant with no build types or flavors, so there is nothing to choose, and the Android publication is created automatically. The trade-off is that a library needing flavors or build-type-specific dependencies has to model them with source sets or separate modules. If your library ships Android resources, set a unique namespace, as above, so that the generated R class does not collide with another library’s. See Android Developers — Publish your library.

Publishing to Maven Central

Maven Central is where Gradle and Maven projects look for dependencies by default, which makes it the right home for a Kotlin library. Since 2024 you publish through the Central Portal (central.sonatype.com), which replaced the older OSSRH staging process. There are four pieces: a verified namespace, a signing key, a portal token, and a publishing plugin that glues them to Gradle.

The Namespace

Maven coordinates are groupId:artifactId:version. Central only lets you publish under a groupId you have proven you own, called your namespace. Registering com.example also allows com.example.trailgeo and any deeper subgroup.

Namespace style Example Verification

Domain you own

com.example

Add a DNS TXT record containing the verification key

GitHub account

io.github.<user>

Create a public repository named after the verification key

The domain example.com used here is a placeholder; a real library uses a domain or a GitHub namespace the author controls. Verification happens once; afterwards you can delete the TXT record or temporary repository. See Maven Central — Register a namespace.

GPG Signing

Central rejects unsigned artifacts. A PGP key pair lets consumers verify that an artifact came from you: the private key signs, the public key (uploaded to a keyserver such as keyserver.ubuntu.com) verifies.

# shell: one-time key setup (run on the maintainer's machine, never in CI)
gpg --full-generate-key                       # accept the defaults, set a passphrase
gpg --list-keys                               # note the long key ID
gpg --keyserver keyserver.ubuntu.com --send-keys <KEY_ID>   # publish the public key
gpg --armor --export-secret-keys <KEY_ID> > key.gpg         # export the private key for CI

The Kotlin Gradle plugin also has generatePgpKeys and uploadPublicPgpKey tasks that do the same without a local gpg. In both cases the exported private key is a secret: store it in a password manager or the CI secret store, never in the repository. Details of the Central requirements are at Maven Central — GPG requirements.

The vanniktech Plugin

com.vanniktech.maven.publish (0.37.0) configures publications, sources and javadoc jars, POM metadata, signing and the upload to the Central Portal. It is the plugin the official Kotlin tutorial recommends.

// trail-geo/build.gradle.kts
mavenPublishing {
    publishToMavenCentral()   // upload to the Central Portal (staging, then manual release)
    signAllPublications()     // sign every publication with the in-memory GPG key

    coordinates(group.toString(), "trail-geo", version.toString())

    pom {
        name = "TrailGeo"
        description = "Geodesic distance and elevation-profile helpers for Kotlin Multiplatform."
        inceptionYear = "2026"
        url = "https://github.com/example/trail-geo/"
        licenses {
            license {
                name = "The Apache License, Version 2.0"
                url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
            }
        }
        developers {
            developer {
                id = "example"
                name = "Example Maintainer"
                url = "https://github.com/example/"
            }
        }
        scm {
            url = "https://github.com/example/trail-geo/"
            connection = "scm:git:git://github.com/example/trail-geo.git"
            developerConnection = "scm:git:ssh://git@github.com/example/trail-geo.git"
        }
    }
}

What each call does:

Call Effect

publishToMavenCentral()

Adds the publishToMavenCentral task. The deployment is uploaded and validated; you then press Publish in the portal

publishToMavenCentral(automaticRelease = true)

Same, but releases without manual approval (also available as publishAndReleaseToMavenCentral)

signAllPublications()

Signs every artifact; reads signingInMemoryKey* Gradle properties

pom block

The POM metadata Central requires: name, description, URL, license, developers, SCM

The plugin reads its credentials from Gradle properties (mavenCentralUsername, mavenCentralPassword, signingInMemoryKeyId, signingInMemoryKeyPassword, signingInMemoryKey), which CI supplies as ORG_GRADLE_PROJECT_* environment variables so that no secret appears in a file.

The user token comes from the Central Portal’s token page; it is a username and password pair that is separate from your portal login. The values above are placeholders.

Checks Before Releasing

Run ./gradlew checkSigningConfiguration (key uploaded to a keyserver), ./gradlew checkPomFileForMavenPublication (POM meets Central requirements) and ./gradlew publishToMavenLocal before the first real upload; a Central validation failure after a long macOS build wastes time. Inspect ~/.m2/repository/com/example for the root artifact, the .module file, every target artifact and the .asc signatures, then build a throwaway KMP project against mavenLocal() for each target. See Kotlin docs — Publish your library to Maven Central.

Releasing From CI

The release workflow runs on a macOS runner when a GitHub release is published. The full pipeline, with caching and the XCFramework job, is in CI/CD for KMP with GitHub Actions; this is the Maven part.

# .github/workflows/publish.yml -- trail-geo repository
name: Publish
on:
  release:
    types: [released]
jobs:
  maven-central:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v7
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '26.4'
      - uses: actions/setup-java@v6
        with: { distribution: zulu, java-version: 21 }
      - run: ./gradlew publishToMavenCentral --no-configuration-cache
        env:
          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY_CONTENTS }}
          ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }}
          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}

The publication task runs with --no-configuration-cache because the plugin does not support it, and a released Central version is immutable: run every check above before pressing Publish.

An XCFramework Release for SwiftPM

iOS developers using Xcode usually do not want a Gradle dependency; they want to add a package by URL. For a library that exposes an API to Swift, the standard distribution is a binary Swift package: a Package.swift whose binaryTarget points at a zipped XCFramework hosted on a GitHub release. An XCFramework bundles the framework slices for device and simulator in one directory (a legacy "fat framework" cannot hold both Arm slices), and it is what Apple’s tooling expects. See Apple Developer — Creating a multiplatform binary framework bundle.

Building and Packaging

The XCFramework("TrailGeo") helper in the module script registers the assemble tasks. Debug and release variants exist; a release uses the release one.

# shell: from the trail-geo repository root, on macOS
./gradlew :trail-geo:assembleTrailGeoReleaseXCFramework
cd trail-geo/build/XCFrameworks/release
zip -r TrailGeo.xcframework.zip TrailGeo.xcframework
swift package compute-checksum TrailGeo.xcframework.zip

swift package compute-checksum prints a SHA-256 value that SwiftPM verifies on download, so a tampered or replaced zip fails to resolve. Note the checksum for the next step.

Package.swift

The manifest lives at the repository root, on the tagged commit. The checksum below is a placeholder.

// Package.swift -- trail-geo repository root
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "TrailGeo",
    platforms: [.iOS(.v15), .macOS(.v12)],
    products: [
        .library(name: "TrailGeo", targets: ["TrailGeo"])
    ],
    targets: [
        .binaryTarget(
            name: "TrailGeo",
            url: "https://github.com/example/trail-geo/releases/download/v1.0.0/TrailGeo.xcframework.zip",
            checksum: "<OUTPUT_OF_swift_package_compute-checksum>"
        )
    ]
)

In the real file the // swift-tools-version line must be the very first line; the path comment above is only for this page. The iOS app (or another library) then adds the package in Xcode with File > Add Package Dependencies and imports the module in Swift.

// iosApp/iosApp/Trail/TrailDistanceView.swift
import SwiftUI
import TrailGeo

struct TrailDistanceView: View {
    let start: GeoPoint
    let end: GeoPoint

    var body: some View {
        let km = TrailGeoMath.shared.distanceKm(from: start, to: end)
        Text("\(km, specifier: "%.1f") km")
    }
}

Automating the three manual steps (build, zip and checksum, then update Package.swift and the tag) is the job of the release workflow. Because the checksum is only known after the zip exists, the usual pattern is: build the zip in CI, compute the checksum, commit the updated Package.swift to the release tag, and upload the zip as a release asset. Tooling such as KMMBridge automates this; see iOS Integration Options.

Alternatives and Their Status

Other routes exist: Swift export (Alpha) generates an idiomatic Swift API without Objective-C headers, CocoaPods is Stable but the documentation steers new projects toward SwiftPM, and a binary SwiftPM package (above) is the simplest for consumers at the cost of hosting and versioning the zip yourself. Swift export and SwiftPM import are Alpha at the baseline, so expect API and Gradle DSL changes between releases and do not make them the only distribution of a library you promise to keep stable. See Objective-C, Swift Interop and Swift Export and Apple Platforms: Swift Packages and Modularization. For the consuming side from inside a KMP project, see Kotlin docs — Add a Swift package dependency.

Publishing to npm

If trail-geo should be usable from a TypeScript web app, publish the js target as an npm package. The Kotlin/JS output is a JavaScript module; generateTypeScriptDefinitions() (set above) produces the .d.ts file, and @JsExport marks what is visible.

Publishing uses the npm-publish Gradle plugin (3.7.0), configured in the module that owns the js target.

// trail-geo/build.gradle.kts
plugins {
    kotlin("npm-publish") version "3.7.0"
}

npmPublish {
    organization = "example-org"            // the npm scope, without the @ sign
    registries {
        npmjs {
            authToken = System.getenv("NPM_TOKEN") // only needed outside Trusted Publishing
        }
    }
    packages {
        named("js") {
            packageName = "trail-geo"
            version = project.version.toString()
            readme = file("../README.md")
            packageJson {
                license = "Apache-2.0"
                homepage = "https://github.com/example/trail-geo#readme"
                description = "Geodesic distance and elevation-profile helpers"
                keywords = listOf("kotlin", "kotlin-js", "geo", "trails")
            }
        }
    }
}

The official tutorial documents the js target. A Wasm package (Beta) is not covered by it, so check the plugin’s current behavior before promising an npm artifact for wasmJs.

Trusted Publishing

A long-lived NPM_TOKEN stored as a secret is exactly what an attacker wants. npm Trusted Publishing replaces it with a short-lived OpenID Connect (OIDC) token issued to a specific GitHub repository and workflow: you register the repository and workflow file in the package settings on npmjs.com, and the workflow proves its identity at publish time. No token exists to leak.

# .github/workflows/publish.yml -- npm job in the trail-geo repository
permissions:
  id-token: write   # lets the job request an OIDC token for npm Trusted Publishing
  contents: read
jobs:
  npm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: zulu
          java-version: 21
      - uses: gradle/actions/setup-gradle@v6
      - name: Publish to npm
        run: ./gradlew :trail-geo:publishJsPackageToNpmjsRegistry

The npm job does not need macOS, since a JS package has no Apple parts; running it on Linux in parallel with the macOS job is both cheaper and faster. Trusted Publishing is configured per package in the npm settings, so publish the first version once (by hand or with a token) before attaching a trusted publisher. The general OIDC mechanism is described in GitHub Docs — About security hardening with OpenID Connect.

API Guidelines for Multiplatform Libraries

Publishing is the easy half; the harder half is publishing an API you can live with. JetBrains' library guidelines for multiplatform authors give these rules.

Guideline In practice for trail-geo

Maximize your reach

Support every target your dependencies allow (here Android, JVM, iOS, macOS, JS and Wasm); a missing target excludes every project that needs it

Design APIs for common code

Put as much API as possible in commonMain; use intermediate source sets (for example nonJvm) before platform-specific ones

Behave the same everywhere

The same input must give the same result on every target; expect/actual implementations must be behaviorally identical

Design for Swift users

Avoid exposing Kotlin-only constructs that translate poorly; name things for both languages

Designing for Swift users is the part Kotlin developers forget, because the API they test against is Kotlin. An Objective-C header is generated from your public API, and Kotlin features without a native counterpart (inline value classes, default arguments, extension properties on generics, top-level functions) surface awkwardly. Concrete steps:

  • Prefer a small facade object or class over many top-level functions: top-level functions become members of a generated <File>Kt class.

  • Prefer simple parameter types and sealed result types over exceptions; only @Throws-annotated Kotlin exceptions reach Swift as errors.

  • Use @ObjCName (Experimental, opt in with ExperimentalObjCName) or @HiddenFromObjC (Experimental, opt in with ExperimentalObjCRefinement) to rename or hide declarations for Swift only.

// trail-geo/src/commonMain/kotlin/com/example/trailgeo/GeoMath.kt
package com.example.trailgeo

import kotlin.experimental.ExperimentalObjCName
import kotlin.native.ObjCName
import kotlin.math.*

private fun Double.toRad(): Double = this * PI / 180.0

public data class GeoPoint(public val latitude: Double, public val longitude: Double)

/** Geodesic helpers; one facade object keeps the generated Swift API small. */
@OptIn(ExperimentalObjCName::class)
@ObjCName("TrailGeoMath")
public object GeoMath {
    private const val EARTH_RADIUS_KM = 6371.0088

    /** Great-circle distance in kilometers between two points, using the haversine formula. */
    public fun distanceKm(from: GeoPoint, to: GeoPoint): Double {
        val dLat = (to.latitude - from.latitude).toRad()
        val dLon = (to.longitude - from.longitude).toRad()
        val a = sin(dLat / 2).pow(2) +
            cos(from.latitude.toRad()) * cos(to.latitude.toRad()) * sin(dLon / 2).pow(2)
        return 2 * EARTH_RADIUS_KM * asin(sqrt(a))
    }
}

Finally, klibs.io is the JetBrains-run search index for multiplatform libraries. A complete POM, a clear README and a documented target list make a library easier to find there. See klibs.io and Dependencies, klibs.io, cinterop and SwiftPM Import.

Explicit API Mode

Library authors often expose a type by accident: forgetting a visibility modifier makes a declaration public, and after publication it is part of your contract forever. Explicit API mode is a compiler mode that requires an explicit visibility modifier on declarations that would otherwise be implicitly public, and explicit return types on public functions and properties.

// trail-geo/build.gradle.kts
kotlin {
    explicitApi()            // errors: missing visibility or type is a compile error
    // explicitApiWarning()  // use this instead while migrating an existing library
}

The mode is Stable. It applies to the module’s main source set, so the commonTest tests are not affected. Use internal aggressively: anything not meant for consumers belongs there, because internal declarations are not part of the public API surface that the ABI check below compares.

ABI Validation in the Kotlin Gradle Plugin

Explicit API protects against adding accidental API. Binary compatibility validation protects against breaking the API you already published. The Kotlin Gradle plugin generates an Application Binary Interface (ABI) dump of the public API for every target and compares it with a reference dump committed to the repository. A pull request that removes or changes a public signature then fails the build until someone consciously updates the dump.

This feature is built into KGP and replaces the separate Binary Compatibility Validator plugin. It is Experimental at the baseline, so it requires an opt-in and its DSL may change.

// trail-geo/build.gradle.kts
kotlin {
    @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class)
    abiValidation()   // creates checkKotlinAbi, updateKotlinAbi and the dump tasks
}
Task Purpose

checkKotlinAbi

Compares the current ABI with the reference dump; fails on differences. Runs as part of check

updateKotlinAbi

Rewrites the reference dump after you decide a change is intended

The daily workflow: run ./gradlew updateKotlinAbi once to create the baseline dump and commit it; afterwards ./gradlew checkKotlinAbi fails and prints the removed or changed declarations when a contributor changes a public function, and updateKotlinAbi is run again only if the break is intended and reviewed in the pull request. See Kotlin docs — Binary compatibility validation in the Kotlin Gradle plugin.

The dump is plain text and is meant to be reviewed: a reviewer sees "this PR removes GeoMath.distanceKm`" without reading the Kotlin diff. The `filters block (excluded/included, by name or annotation) keeps non-public helpers out of the dump.

In a multiplatform project, if the host cannot compile every target, KGP infers ABI changes for the missing targets from the available ones, which avoids false failures on a Linux laptop; this behavior is controlled by keepLocallyUnsupportedTargets. The rest of the compatibility guidance, including how to deprecate before removing and how to use opt-in annotations for experimental API, is in Kotlin docs — Backward compatibility guidelines for library authors. Gate checkKotlinAbi in the pull request workflow, as shown in CI/CD for KMP with GitHub Actions.

Documentation With Dokka 2

Dokka is the documentation engine for Kotlin; it turns KDoc comments into HTML (or Javadoc). Dokka 2.2.0 uses the Gradle plugin v2 mode, which is the only mode supported at the baseline; the old dokkaHtml task names belong to Dokka 1.

// trail-geo/build.gradle.kts
plugins {
    id("org.jetbrains.dokka") version "2.2.0"
}

dokka {
    moduleName.set("TrailGeo")
    dokkaSourceSets.configureEach {
        includes.from("Module.md")
    }
}

dokkaGenerate builds every format; dokkaGeneratePublicationHtml builds only HTML. The output trail-geo/build/dokka/html is a static site, so any static host works. GitHub Pages is the cheapest for an open-source library.

# .github/workflows/docs.yml -- trail-geo repository
name: Docs
on:
  release:
    types: [released]
permissions:
  pages: write
  id-token: write
  contents: read
jobs:
  docs:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: zulu
          java-version: 21
      - uses: gradle/actions/setup-gradle@v6
      - run: ./gradlew :trail-geo:dokkaGenerate
      - uses: actions/configure-pages@v6
      - uses: actions/upload-pages-artifact@v5
        with:
          path: trail-geo/build/dokka/html
      - id: deployment
        uses: actions/deploy-pages@v5

Documentation generation does not link binaries, so it runs on Linux. Enable GitHub Actions as the Pages source in the repository settings once. Linking the published URL from the POM url, the README and the Package.swift description closes the loop. See Kotlin docs — Dokka.

Versioning

Versioning ties the channels together. Use semantic versioning: a binary-incompatible change (anything checkKotlinAbi reports) is a major version, a new API is a minor version, and a fix is a patch. Keeping the same version across Maven, SwiftPM and npm means a bug report that says "1.2.0" means the same code everywhere.

Common Mistakes

  • Publishing from Linux. The Apple publications are missing or fail; run the release on macos-latest.

  • Adding per-target artifacts as dependencies. KMP consumers depend only on the root artifact.

  • Forgetting the POM metadata. Central validation rejects the deployment with a list of missing fields; run checkPomFileForMavenPublication first.

  • Treating a Central release as undoable. Published versions are permanent; ship a fix as a new version.

  • Committing the signing key or portal token. Use Gradle properties and CI secrets only.

  • Hosting the XCFramework without a checksum, or changing the zip after computing it. SwiftPM refuses it.

  • Using JVM APIs in commonMain. Math.toRadians and String.format compile on the JVM only; use kotlin.math.

  • Updating the ABI dump to silence a failure without a version bump.

  • Making Swift export or SwiftPM import (Alpha) your only iOS distribution.

  • Using Dokka 1 task names such as dokkaHtml with Dokka 2.