Publishing a KMP library commits you to every target you listed
Dropping a target in a minor release breaks consumers at resolution time.
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.

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.
Related
- Adding a parameter with a default broke the ABI — the other half of a publication contract
- A dependency that resolves in commonMain and only exists on Android — the consumer's side of variant matching
- The dependency with one maintainer, and the week it went away — why the upstream artifact vanished
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".