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 |
|
Xcode and SwiftPM projects |
A zipped XCFramework referenced by |
A GitHub release |
|
JavaScript and TypeScript projects |
An npm package with |
npmjs.com |
|
Library users reading docs |
Static HTML from the public API |
GitHub Pages |
|
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.
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 ( |
|
Gradle module metadata and the common metadata klib; the artifact consumers declare |
|
|
The JVM jar |
Android |
|
The AAR of the Android variant |
|
|
One klib each |
|
|
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 |
|
Add a DNS TXT record containing the verification key |
GitHub account |
|
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 |
|---|---|
|
Adds the |
|
Same, but releases without manual approval (also available as |
|
Signs every artifact; reads |
|
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 |
Behave the same everywhere |
The same input must give the same result on every target; |
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
objector class over many top-level functions: top-level functions become members of a generated<File>Ktclass. -
Prefer simple parameter types and sealed result types over exceptions; only
@Throws-annotated Kotlin exceptions reach Swift as errors. -
Use
@ObjCName(Experimental, opt in withExperimentalObjCName) or@HiddenFromObjC(Experimental, opt in withExperimentalObjCRefinement) 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 |
|---|---|
|
Compares the current ABI with the reference dump; fails on differences. Runs as part of |
|
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
checkPomFileForMavenPublicationfirst. -
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.toRadiansandString.formatcompile on the JVM only; usekotlin.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
dokkaHtmlwith Dokka 2.