Engineering

Publishing a KMP library commits you to every target you listed

Dropping a target in a minor release breaks consumers at resolution time.

MobileByteSensei 10 min read
kmppublishingbuild

TL;DR

A multiplatform library's target list is public API. Each target you publish becomes a variant in the module metadata, and a consumer compiling for that target resolves against it.

Remove one — because it was unused, or awkward, or you dropped a dependency that supported it — and every consumer building for that target fails at resolution, with an error that talks about variant attributes rather than about your removal. We did it in a minor release and broke four consumers in an afternoon.

Treat adding a target as a feature, removing one as a breaking change, and publish the list as a documented matrix so nobody has to infer it from a failed build.

The case this post is about

A library published for six targets:

// 1.4.0
kotlin {
    jvm()
    androidTarget { publishLibraryVariants("release") }
    iosArm64(); iosSimulatorArm64(); iosX64()
    js(IR) { browser() }
    wasmJs { browser() }
}

Then a dependency we used internally dropped its iosX64 artifact (the Intel simulator target), and rather than vendoring the two functions we needed, we dropped iosX64 from our own target list and released it as 1.5.0 — a minor, because "nothing was removed from the API".

The symptom

Four consumer reports in an afternoon, all the same:

Could not resolve io.example:toolkit:1.5.0.
Required by:
    project :shared
  > No matching variant of io.example:toolkit:1.5.0 was found.
    The consumer was configured to find a library for use during 'kotlin-metadata',
    preferably optimized for non-jvm, as well as attribute
    'org.jetbrains.kotlin.native.target' with value 'ios_x64' but:
      - Variant 'iosArm64ApiElements' declares a library for 'ios_arm64'
      - Variant 'iosSimulatorArm64ApiElements' declares a library for 'ios_simulator_arm64'
      …

Three things about that error:

It does not say "the author removed iosX64". It lists the variants that do exist and leaves the reader to notice which one is absent. Two of the four consumers opened issues asking whether they had misconfigured their build.

It is a resolution failure, so it blocks the whole build — not just the iOS target. A Gradle resolution error on one configuration fails the configuration phase for everything.

And it hit CI, not developers. The affected consumers were running iosX64 in CI for Intel macOS runners while developing on Apple Silicon. Nobody's laptop reproduced it.

Who was affected and why:

4 consumers reported
  2 × building iosX64 for Intel CI runners
  1 × supporting an older Xcode that needed iosX64
  1 × had `iosX64()` in their build file and did not know why   ← copied from a template

The fourth is the interesting one: they did not need the target and could not easily tell, so for them the fix was a one-line deletion they were afraid to make.

Why the obvious fix failed

"It is a minor release; nothing was removed from the API." The target list is API. "No declarations were removed" was true and irrelevant — the artifact a consumer needs no longer exists.

"iosX64 is deprecated anyway; Intel Macs are old." True as a direction and not a reason to break a published contract in a minor. Deprecating a target is a process — announce, deprecate, remove in a major — not a judgement you apply unilaterally.

"Consumers can exclude the dependency for that target." There is no useful way to tell Gradle "use this library except on iosX64" when your shared code calls it from commonMain. The suggestion makes the consumer's build worse.

"Re-publish 1.5.1 with the target restored." What we did within three hours, and it required vendoring the two functions from the upstream library that had dropped the artifact — the work we had avoided in the first place, now done under pressure.

The actual mechanism

A multiplatform publication is a root module plus one variant per target, described in Gradle module metadata:

// toolkit-1.4.0.module (abridged)
{ "variants": [
  { "name": "jvmApiElements",                 "attributes": { "org.jetbrains.kotlin.platform.type": "jvm" } },
  { "name": "iosArm64ApiElements",            "attributes": { "…native.target": "ios_arm64" } },
  { "name": "iosX64ApiElements",              "attributes": { "…native.target": "ios_x64" } },
  …
] }

Resolution is attribute matching. A consumer compiling iosX64 requests native.target = ios_x64, and Gradle looks for a variant declaring it. If none exists, resolution fails — there is no fallback, and correctly so: a ios_arm64 artifact is not usable for an ios_x64 compilation.

So three properties follow:

Each published target is a promise. Not "we support it" in prose — a variant a build will resolve against.

Because the promise is a list in a file, keeping it is checkable — compare what the release is about to publish against what the last release did:

/** A published target, identified the way Gradle matches it — by attribute, not by name. */
data class Variant(val name: String, val target: String)

fun variants(moduleJson: JsonObject): Set<Variant> =
    moduleJson["variants"]!!.jsonArray.map { v ->
        val attrs = v.jsonObject["attributes"]!!.jsonObject
        Variant(
            name = v.jsonObject["name"]!!.jsonPrimitive.content,
            // native.target is the precise one; platform.type is the fallback for jvm/js/wasm
            target = (attrs["org.jetbrains.kotlin.native.target"]
                ?: attrs["org.jetbrains.kotlin.platform.type"])!!.jsonPrimitive.content,
        )
    }.toSet()

fun checkRelease(previous: JsonObject, next: JsonObject, bumpsMajor: Boolean) {
    val before = variants(previous).map { it.target }.toSet()
    val after = variants(next).map { it.target }.toSet()

    val dropped = before - after
    val added = after - before

    // An ADDED target breaks nobody — report it, never block on it. A gate that fires
    // on additions is a gate somebody turns off before the release where it mattered.
    if (added.isNotEmpty()) println("publish: new targets ${added.sorted()}")

    if (dropped.isEmpty()) return
    val detail = dropped.sorted().joinToString { "  $it" }
    if (!bumpsMajor) {
        error("publish: target(s) REMOVED without a major bump:\n$detail\n" +
            "Every consumer compiling these targets fails to resolve, with no fallback.")
    }
    println("publish: target(s) removed in a major release — intended:\n$detail")
}

On the named case — 1.4.0 against the 1.4.1 that was queued to go out:

$ ./gradlew checkPublication

publish: new targets [wasm_js]

> Task :checkPublication FAILED
  publish: target(s) REMOVED without a major bump:
    ios_x64
  Every consumer compiling these targets fails to resolve, with no fallback.

ios_x64 had been dropped by deleting one line from the target block during an unrelated cleanup, and the addition of wasmJs in the same commit made the diff look like a gain. The asymmetry is the whole reason this runs on the published metadata rather than on the build file: the build file shows an edit, and the metadata shows a broken promise.

Removal is a hard break at configuration time. Earlier and louder than an ABI break, and it affects the whole build rather than one compilation.

And the error names attributes, not your decision. The consumer sees a variant-mismatch message and has to infer that the library changed, which is why two of them suspected their own build first.

A multiplatform publication as a root module plus one variant per target, with resolution performed by attribute matching so a consumer requesting ios_x64 finds no variant and fails at configuration time — and an error message that lists the variants present rather than naming the removed one

The target list is API. Adding a target is a feature; removing one is a breaking change, and it breaks earlier and more broadly than removing a function.

The fix

Treat the matrix as a contract: publish it, check it in CI, and deprecate before removing.

1. The target list in one place, with a documented policy:

## Supported targets (1.5.x)
| target                | since | status      |
|-----------------------|-------|-------------|
| jvm                   | 1.0.0 | supported   |
| android (release)     | 1.0.0 | supported   |
| iosArm64              | 1.0.0 | supported   |
| iosSimulatorArm64     | 1.2.0 | supported   |
| iosX64                | 1.0.0 | deprecated — removal in 2.0.0 |
| js (IR, browser)      | 1.0.0 | supported   |
| wasmJs (browser)      | 1.4.0 | experimental |

Policy: adding a target is MINOR. Removing one is MAJOR, announced at least
one minor release in advance.

The deprecated row with a named removal version is the mechanism we skipped. It costs a line and gives consumers a release cycle to react.

2. A CI check that the published variants match the declared matrix:

#!/usr/bin/env bash
# scripts/check-published-targets.sh — run on every PR
declared=$(sort docs/targets.txt)
built=$(./gradlew -q printTargets | sort)
diff <(echo "$declared") <(echo "$built") \
  || { echo "❌ target list changed — this is a MAJOR change. Update docs/targets.txt deliberately."; exit 1; }
// build.gradle.kts
tasks.register("printTargets") {
    doLast { kotlin.targets.map { it.name }.sorted().forEach(::println) }
}
$ ./scripts/check-published-targets.sh
- iosX64
❌ target list changed — this is a MAJOR change.

That diff in a pull request is the control. Our 1.5.0 would not have merged.

3. A consumer-side smoke project that resolves every target, because the declaration check verifies intent and not resolvability:

// publishing-smoke/build.gradle.kts — a tiny consumer with ALL supported targets
kotlin {
    jvm(); androidTarget(); iosArm64(); iosSimulatorArm64(); iosX64(); js(IR){browser()}; wasmJs{browser()}
    sourceSets.commonMain.dependencies { implementation("io.example:toolkit:$versionUnderTest") }
}
# resolve against the LOCAL publication before releasing
$ ./gradlew publishToMavenLocal
$ ./gradlew -p publishing-smoke assemble -PversionUnderTest=1.5.0
  FAILED — No matching variant … 'ios_x64'

The real consumer error, reproduced before publishing rather than by four users afterwards.

4. And the deprecation path, done properly for 2.0.0:

// 1.6.0 — still published, with a warning
iosX64 {
    compilations.all {
        compilerOptions.configure {
            freeCompilerArgs.add("-Xwarn-on-use")   // illustrative; the real signal is the changelog
        }
    }
}

In practice the useful warning is not in the compiler — it is in the release notes, the README matrix, and a deprecation notice in the module description, because a consumer resolving a variant does not read your source.

How we proved it

That the check catches the removal:

re-apply the iosX64 removal on a branch
$ ./scripts/check-published-targets.sh
  - iosX64
  exit 1

That the smoke project reproduces the consumer error locally:

$ ./gradlew publishToMavenLocal -Pversion=1.5.0-local
$ ./gradlew -p publishing-smoke assemble -PversionUnderTest=1.5.0-local
  Could not resolve io.example:toolkit:1.5.0-local
    … 'org.jetbrains.kotlin.native.target' with value 'ios_x64' but: …

Identical to what the four consumers saw, available before release in 90 seconds.

The published variants, verified from the artifact rather than the build script:

$ curl -s …/toolkit/1.5.1/toolkit-1.5.1.module | grep -o '"name": "[a-zA-Z0-9]*ApiElements"' | sort -u
"name": "androidReleaseApiElements"
"name": "iosArm64ApiElements"
"name": "iosSimulatorArm64ApiElements"
"name": "iosX64ApiElements"          ← restored
"name": "jsApiElements"
"name": "jvmApiElements"
"name": "wasmJsApiElements"

Reading the module metadata is the ground truth; the build script is intent.

And the incident timeline, for honesty about the cost:

14:02  1.5.0 published
14:40  first consumer issue
15:10  second and third
15:25  cause identified
17:08  1.5.1 published with iosX64 restored (two functions vendored)
       1.5.0 marked as "do not use" in the release notes; it cannot be unpublished

Three hours, four affected consumers, and 1.5.0 remains in the registry forever — Maven Central does not permit deletion, which is the right policy and means the broken release is permanently resolvable by anyone who pins it.

One limit: the smoke project has to list every supported target, so it is a file that must be kept in sync with the matrix — which is itself a thing that can drift. We gate it with the same printTargets diff, so the smoke project and the library are checked against one list. Two files and one source of truth is better than two sources, and it is not zero maintenance.

What this generalizes to

The set of platforms you publish for is part of your API. Not the documented part — the mechanically-resolved part. The same is true of Python wheel tags, npm os/cpu fields, container architectures, and minimum OS versions. Each is a promise a build resolves against.

A resolution failure is earlier and broader than a compile failure. It fails the configuration phase, so it blocks everything, and the error speaks the dependency resolver's vocabulary rather than naming your change. That combination makes it expensive to diagnose from the consumer side.

Verify against the published artifact, not the build script. The module metadata is what consumers resolve. A 90-second smoke project that consumes your own local publication catches a class of error that no amount of building your own library will.

Deprecate with a named removal version. "We will remove this in 2.0.0" in a matrix gives consumers a cycle to respond and turns a break into a migration. Removing something because it seemed unused is how you discover who was using it.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS