Recipes¶
Build-logic patterns from real multi-module adoptions. Each is a few lines in a convention plugin, keyed off registered() / onRegistered rather than KGP internals.
Gate compilation on inert modules¶
A narrowed lane can leave a module with zero registered targets, and KGP's commonMain metadata compilation fails on a platform-less module. Gate it right after supports { }:
if (kmpTargets.registered().isEmpty()) {
tasks.withType(KotlinCompilationTask::class.java).configureEach { enabled = false }
}
When a commonMain codegen processor is at risk under narrowing, see the next recipe.
commonMain KSP needs two targets¶
A processor that generates into commonMain runs on the commonMain metadata compilation (kspCommonMainKotlinMetadata). That task exists only when ≥2 platform targets share commonMain — exactly the rule for KGP's compileCommonMainKotlinMetadata. So a single-target lane (one leaf — android, jvm, or even iosArm64 alone) has no kspCommonMainKotlinMetadata: the processor only emits into the per-target KSP dir, which the commonMain metadata compilation can't see. The failure ranges from unresolved references to a green build missing codegen.
Native presence is not the trigger — target count is: jvm,js (no native) gets the route, while iosArm64 alone (native) does not.
Two fixes:
- Single target on purpose (e.g. an Android-only lane): keep the codegen-consuming code in the target's own source set (
androidMain), notcommonMain. The per-target generated dir is already on that compilation's path. K2 forbidscommonMainfrom referencing platform-generated symbols, so moving the code out ofcommonMainis the only correct option here — not a workaround inside it. - Want it common: register a second target so the shared commonMain metadata compilation exists.
To warn when a lane silently drops the route, gate right after supports { } on a single registered target:
if (kmpTargets.registered().size < 2) {
logger.warn(
"ksp: ':${project.name}' generates commonMain code but only one target is " +
"registered — the commonMain metadata route is absent for this selection. " +
"Move the code to the target source set, or register a second target."
)
}
kmpTargetsInfo shows what registered per lane, and doctor flags the single-target + KSP case directly.
Per-target configuration wiring¶
The canonical onRegistered use — per-target configurations with the Gradle name pre-mangled, rename-proof because configurationName follows targetName:
Never snapshot kotlin.targets eagerly
kotlin.targets.forEach { … } in a convention plugin sees only what registered before that line ran. onRegistered replays past registrations and fires for future ones.
Selection-gated eager AGP application¶
androidTarget needs an Android Gradle plugin applied before supports { } (the ordering rule). Applying AGP to every module wastes configuration time and creates Android tasks/configurations that are useless under android-less lanes. A library convention applies it only when Android is selected:
// in the LIBRARY convention plugin, BEFORE the supports { } block
val androidSelected = KmpTarget.Jvm.Android in kmpTargets.resolvedSelection()
if (androidSelected) {
pluginManager.apply("com.android.library")
// configure AGP here, between apply and supports { } — also gated (see below)
extensions.configure<com.android.build.api.dsl.LibraryExtension> {
namespace = "com.example.${project.name}"
}
}
kmpTargets.supports {
androidTarget + jvm // androidTarget registers iff AGP was applied above
}
The gate is KmpTarget.Jvm.Android in resolvedSelection(): the same value registration intersects against, so gate and registration never disagree.
Apply AGP before supports { }, never after
supports { } checks for AGP at that instant and skips androidTarget if absent. AGP applied after — or in afterEvaluate — misses registration with no error outside strict mode.
Gate downstream AGP reads with the same predicate
Any read of AGP-owned configuration (android { }, compileSdk, a LibraryExtension lookup) crashes under a non-Android selection if it runs unconditionally. Keep it inside the same if (androidSelected).
com.android.application modules are exempt
An app module gets AGP from its own plugin block. Do not apply com.android.library to it and do not gate its AGP behind the selection — this recipe is for library modules only.
A companion plugin that must run only when AGP is present should react to AGP instead of re-testing the selection:
pluginManager.withPlugin("com.android.library") {
pluginManager.apply("org.gradle.android.cache-fix")
}
Confirm per lane with kmpTargetsInfo. There is no plugin hook for this — the gate is one if over resolvedSelection(), and the application-vs-library exemption is a build-logic decision (#76).
Lane-agnostic CI invocations¶
Aggregate tasks (build, check) only cover registered targets, so the same invocation works in every lane:
Avoid hardcoding per-target task names (:m:iosArm64Test): they break under renames and lane changes. For explicit compile/test umbrellas, opt into kmpCompileAll / kmpTestAll.
Selection vs the dependency graph¶
Selection is intersected per module. If :app supports jvm + iosArm64 and depends on :lib that only supports jvm, an iosArm64 build of :app fails at variant resolution — :lib has no iOS variant. A module's supported set must cover every target its dependents build. Check both sides with kmpTargetsInfo and widen :lib's supports { } (or narrow :app's).
The asymmetric case: android and the jvm fallback¶
- Symptom: Gradle's attribute-resolution error on a
*CompileDependencyFilesconfiguration, listing producer variants and consumer attributes, with no mention of the selection. - Cause: an android consumer resolves against a producer's
jvmvariant when the producer ships noandroidTarget(the jvm fallback). A pure-androidselection (kmptargets.targets=android) stops those producers from registeringjvm, removing the variant the android modules were resolving against. - Fix: co-select
jvmwithandroid:
For a large android repo this is usually the right lane: android,jvm, not bare android.
The conflict usually originates in external jvm-only libraries, which the plugin cannot model. Doctor surfaces the project(...)-edge form of the gap; for external dependencies, co-selecting jvm is the rule.
Binary-compatibility validator + selection¶
BCV writes one ABI dump per target. A narrowed lane running apiCheck/apiDump sees only the registered subset — committing dumps from a narrowed lane deletes the other targets' dumps. Run BCV tasks only from a full-selection lane (kmptargets.targets=all or your repo's full set), and gate apiDump in CI to that lane.
Troubleshooting first¶
Symptom → cause → fix pairs for all of the above: Troubleshooting.