Skip to content

Commit 8fa3d2d

Browse files
romtsnclaude
andauthored
feat(matrix): Fetch AGP<->Kotlin compat dynamically (#1166)
* feat(matrix): Fetch AGP<->Kotlin compat dynamically Replace the static gradleToKotlin dict with a fetch of the AGP/Kotlin compatibility table from developer.android.com/build/kotlin-support. For each AGP version we now pick the highest Kotlin whose required AGP is <= ours, which is a more direct semantic fit and also catches new Kotlin minors automatically. The existing Kotlin->min-Gradle floor check (sourced from kotlinlang.org) stays in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(matrix): Skip Kotlin minors with no stable release The developer.android.com/build/kotlin-support table lists upcoming Kotlin minors before they ship (e.g. Kotlin 2.4 with AGP 9.1.0 while only 2.4.0-Beta* is on Maven Central). Resolve each Kotlin minor to its latest stable patch by reading kotlin-stdlib's maven-metadata.xml and drop rows whose minor has no stable release yet, so the generated matrix never references an unpublished version. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(deps): Bump KSP to 2.3.7 KSP 2.1.0-1.0.29 is pinned to Kotlin compiler 2.1.0 and fails with "ksp-2.1.0-1.0.29 is too old for kotlin-2.3.21" when the test matrix runs the android-instrumentation-sample against newer Kotlin versions. Bump to 2.3.7, which is the latest KSP2 release — KSP2 is decoupled from the Kotlin compiler version and works across the Kotlin versions the matrix now picks from developer.android.com/build/kotlin-support. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(deps): Swap in KSP2 only when matrix overrides Kotlin The pinned KSP (2.1.0-1.0.29, KSP1) is bound to the Kotlin 2.1 compiler and fails with "ksp is too old for kotlin-X" when the test matrix picks newer Kotlin versions from developer.android.com/build/kotlin-support. Bumping KSP directly breaks the default build because KSP2 requires Kotlin language version 2.0+, but the default Kotlin in Dependencies.kt is 1.8.20. Intercept plugin resolution in settings.gradle.kts: when VERSION_KOTLIN is set (i.e. the matrix is driving the build), use KSP2 2.3.7, which is decoupled from the Kotlin compiler. Otherwise leave the pinned KSP1 in place so default / pre-merge builds keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(deps): Pick KSP version via BuildPluginsVersion The settings.gradle.kts resolutionStrategy override didn't work because plugin-alias requests through the version catalog bypass it — Gradle rejected the rewrite with "the plugin is already on the classpath with a different version". Switch to the same pattern the other Kotlin plugins use: a `BuildPluginsVersion.KSP` field sourced from VERSION_KOTLIN (KSP2 2.3.7 when Kotlin 2.x, KSP1 2.1.0-1.0.29 otherwise), applied via `version BuildPluginsVersion.KSP` on both the root `apply false` and the sample's plugins block. Verified locally that both default builds (Kotlin 1.8.20, KSP1) and the matrix path (VERSION_KOTLIN=2.3.21, KSP2) resolve and run `kspStagingReleaseKotlin` successfully. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(matrix): Strip AGP pre-release and select Kotlin by value Two related bugs in the AGP -> Kotlin lookup: 1. `agpVersion >= minAgp` evaluates to false for pre-release AGP with the same major.minor.patch as a stable minAgp (semver rule: 9.3.0-alpha01 < 9.3.0). Strip the pre-release identifier before the comparison so a row that should match isn't skipped. 2. `maxByOrNull { it.first }` picks the highest minAgp, not the highest Kotlin. Works by coincidence today under monotonic data but breaks on ties (e.g. Kotlin 1.4 and 1.5 both require AGP 7.0). Switch to `it.second` to select by Kotlin version directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ref(matrix): Extract helpers and harden Kotlin major parsing Code cleanup from review: - Add fetchGooglesourceText() and parseVersionOrNull() helpers to collapse the repeated URL-base64-decode and try/catch-parse patterns across six call sites in the compat fetchers. - Parse the Kotlin major as an Int in BuildPluginsVersion.KSP instead of matching `startsWith("2.")`, so a future Kotlin "20.x" won't be misclassified as Kotlin 2.x. No behavior change; matrix output verified identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * drop ksp version from toml --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7afb6d commit 8fa3d2d

5 files changed

Lines changed: 129 additions & 49 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ plugins {
1111
alias(libs.plugins.kotlin) version BuildPluginsVersion.KOTLIN apply false
1212
alias(libs.plugins.kotlinAndroid) version BuildPluginsVersion.KOTLIN apply false
1313
alias(libs.plugins.kapt) version BuildPluginsVersion.KOTLIN apply false
14-
alias(libs.plugins.ksp) apply false
14+
alias(libs.plugins.ksp) version BuildPluginsVersion.KSP apply false
1515
alias(libs.plugins.composeCompiler) apply false
1616
alias(libs.plugins.androidApplication) version BuildPluginsVersion.AGP apply false
1717
alias(libs.plugins.androidLibrary) version BuildPluginsVersion.AGP apply false

buildSrc/src/main/java/Dependencies.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
object BuildPluginsVersion {
22
val AGP = System.getenv("VERSION_AGP") ?: "8.10.1"
33
val KOTLIN = System.getenv("VERSION_KOTLIN") ?: "1.8.20"
4+
// KSP1 (X.Y.Z-A.B.C) is bound to a specific Kotlin compiler version; KSP2 (e.g. 2.3.7) is
5+
// decoupled and supports Kotlin language version 2.0+. Default to KSP1 for the default
6+
// Kotlin 1.8.20, and switch to KSP2 when the matrix sets a Kotlin 2.x version.
7+
val KSP = System.getenv("VERSION_KOTLIN")
8+
?.substringBefore('.')
9+
?.toIntOrNull()
10+
?.let { if (it >= 2) "2.3.7" else null }
11+
?: "2.1.0-1.0.29"
412
}
513

614
object LibsVersion {

examples/android-instrumentation-sample/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion
33
plugins {
44
alias(libs.plugins.androidApplication) version BuildPluginsVersion.AGP
55
alias(libs.plugins.kotlinAndroid) version BuildPluginsVersion.KOTLIN
6-
alias(libs.plugins.ksp)
6+
alias(libs.plugins.ksp) version BuildPluginsVersion.KSP
77
id("io.sentry.android.gradle")
88
id("io.sentry.kotlin.compiler.gradle")
99
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
1717
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
1818
kotlinSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
1919
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
20-
ksp = { id = "com.google.devtools.ksp", version = "2.1.0-1.0.29" }
20+
ksp = { id = "com.google.devtools.ksp" }
2121
dokka = { id = "org.jetbrains.dokka", version = "1.9.20" }
2222
spotless = { id = "com.diffplug.spotless", version = "7.0.4" }
2323
groovyGradlePlugin = { id = "dev.gradleplugins.groovy-gradle-plugin", version = "1.7.1" }

scripts/generate-compat-matrix.main.kts

Lines changed: 118 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -85,29 +85,38 @@ class GenerateMatrix : CliktCommand() {
8585
throw ProgramResult(1)
8686
}
8787

88-
// TODO: for now this is manual, but we could try get it from Gradle's github in the future
89-
val gradleToKotlin =
90-
mapOf(
91-
"7.5".toVersion(strict = false) to "1.8.20",
92-
"9.0.0".toVersion(strict = false) to "2.1.0",
93-
"9.5.0-0".toVersion(strict = false) to "2.3.0",
94-
)
95-
// TODO: make it dynamic too
96-
val kotlinVersion = "2.1.0".toVersion()
88+
val agpToKotlin =
89+
try {
90+
fetchAgpKotlinCompatibility()
91+
} catch (e: Exception) {
92+
print(e.printStackTrace())
93+
echo("Error parsing AGP Kotlin compatibility")
94+
throw ProgramResult(1)
95+
}
96+
9797
val baseIncludes = buildList {
9898
for (entry in agpToGradle.entries) {
9999
add(
100100
buildMap {
101-
put("agp", entry.key.toString())
101+
val agpVersion = entry.key
102+
put("agp", agpVersion.toString())
102103
val gradle = entry.value
103104

104-
// Check if the Gradle version meets Kotlin's minimum requirement
105-
// Use the current Kotlin version's minimum requirement
105+
// Pick the latest Kotlin whose required AGP <= this AGP. Strip the pre-release
106+
// identifier so 9.3.0-alpha01 isn't treated as < 9.3.0 by semver rules, which would
107+
// skip a row that should match.
108+
val agpStable = Version(agpVersion.major, agpVersion.minor, agpVersion.patch)
109+
val kotlinVersion =
110+
agpToKotlin
111+
.filter { (minAgp, _) -> agpStable >= minAgp }
112+
.maxByOrNull { it.second }
113+
?.second
114+
115+
// Floor: if the chosen Kotlin requires a newer Gradle than AGP does, bump Gradle up
106116
val kotlinMinGradle =
107-
kotlinToGradleMap.entries
108-
.find { (kotlin, _) -> kotlin.inRange(kotlinVersion) }
109-
?.value
110-
?.min
117+
kotlinVersion?.let { kv ->
118+
kotlinToGradleMap.entries.find { (kotlin, _) -> kotlin.inRange(kv) }?.value?.min
119+
}
111120
val finalGradle =
112121
if (kotlinMinGradle != null && gradle < kotlinMinGradle) {
113122
echo(
@@ -127,9 +136,8 @@ class GenerateMatrix : CliktCommand() {
127136
)
128137
// TODO: if needed we can test against different Java versions
129138
put("java", "17")
130-
val kotlin = gradleToKotlin.entries.findLast { finalGradle >= it.key }?.value
131-
if (kotlin != null) {
132-
put("kotlin", kotlin)
139+
if (kotlinVersion != null) {
140+
put("kotlin", kotlinVersion.toString())
133141
}
134142
}
135143
)
@@ -296,23 +304,23 @@ class GenerateMatrix : CliktCommand() {
296304
agpVersions: List<Version>
297305
): Pair<Map<Version, Version>, Version> {
298306
val source =
299-
URL(
300-
"https://android.googlesource.com/platform/tools/adt/idea/+/refs/heads/mirror-goog-studio-main/build-common/src/com/android/tools/idea/gradle/util/CompatibleGradleVersion.kt?format=TEXT"
301-
)
302-
.readText()
303-
.let { String(java.util.Base64.getDecoder().decode(it)) }
307+
fetchGooglesourceText(
308+
"https://android.googlesource.com/platform/tools/adt/idea/+/refs/heads/mirror-goog-studio-main/build-common/src/com/android/tools/idea/gradle/util/CompatibleGradleVersion.kt?format=TEXT"
309+
)
304310

305311
// Enum entries: VERSION_X_Y_Z(GradleVersion.version("X.Y.Z")).
306312
// VERSION_FOR_DEV references a constant instead of a literal and is handled separately below.
307313
val enumRegex = Regex("""(VERSION_[A-Z0-9_]+)\s*\(\s*GradleVersion\.version\("([^"]+)"\)""")
308314
val enumToGradle = mutableMapOf<String, Version>()
309315
enumRegex.findAll(source).forEach { match ->
310316
val (enumName, versionStr) = match.destructured
311-
try {
312-
enumToGradle[enumName] = Version.parse(versionStr, strict = false)
313-
} catch (e: Throwable) {
314-
echo("Warning: could not parse Gradle version '$versionStr' for $enumName")
315-
}
317+
val v =
318+
parseVersionOrNull(versionStr)
319+
?: run {
320+
echo("Warning: could not parse Gradle version '$versionStr' for $enumName")
321+
return@forEach
322+
}
323+
enumToGradle[enumName] = v
316324
}
317325
// VERSION_FOR_DEV points at SdkConstants.GRADLE_LATEST_VERSION, which is declared in a
318326
// different file — fetch it so AGP pre-releases get the correct bleeding-edge Gradle version.
@@ -327,12 +335,11 @@ class GenerateMatrix : CliktCommand() {
327335
mapRegex.findAll(source).forEach { match ->
328336
val (agpStr, enumName) = match.destructured
329337
val agp =
330-
try {
331-
Version.parse(agpStr, strict = false)
332-
} catch (e: Throwable) {
333-
echo("Warning: could not parse AGP version '$agpStr'")
334-
return@forEach
335-
}
338+
parseVersionOrNull(agpStr)
339+
?: run {
340+
echo("Warning: could not parse AGP version '$agpStr'")
341+
return@forEach
342+
}
336343
val gradle = enumToGradle[enumName] ?: return@forEach
337344
agpToGradle[agp] = gradle
338345
}
@@ -356,23 +363,89 @@ class GenerateMatrix : CliktCommand() {
356363
return gradleVersions to latestGradle
357364
}
358365

366+
/**
367+
* Fetches the AGP -> Kotlin compatibility table from developer.android.com/build/kotlin-support,
368+
* and resolves each Kotlin minor to its latest stable patch on Maven Central. Rows whose Kotlin
369+
* minor has no stable release yet (e.g. Kotlin 2.4 while only 2.4.0-Beta* is published) are
370+
* dropped so the matrix never references an unreleased version.
371+
*/
372+
private fun fetchAgpKotlinCompatibility(): List<Pair<Version, Version>> {
373+
val html = URL("https://developer.android.com/build/kotlin-support").readText()
374+
val doc = Jsoup.parse(html)
375+
val table =
376+
doc.select("table").find { t ->
377+
val headers = t.select("th").map { it.text() }
378+
headers.any { it.contains("Kotlin version", ignoreCase = true) } &&
379+
headers.any { it.contains("Required AGP", ignoreCase = true) }
380+
} ?: error("Could not find AGP/Kotlin compatibility table")
381+
382+
val latestStablePatches = fetchLatestStableKotlinPatches()
383+
384+
// Cells may carry footnote markers like "8.13.19[1]"; extract the leading x.y[.z] token.
385+
val versionRegex = Regex("""\d+\.\d+(\.\d+)?""")
386+
val result = mutableListOf<Pair<Version, Version>>()
387+
for (row in table.select("tr").drop(1)) {
388+
val cells = row.select("td").map { it.text() }
389+
if (cells.size < 2) continue
390+
val kotlin = versionRegex.find(cells[0])?.value?.let(::parseVersionOrNull) ?: continue
391+
val agp = versionRegex.find(cells[1])?.value?.let(::parseVersionOrNull) ?: continue
392+
val stableKotlin = latestStablePatches[kotlin.major to kotlin.minor]
393+
if (stableKotlin == null) {
394+
echo("Warning: Kotlin ${kotlin.major}.${kotlin.minor} has no stable release yet, skipping")
395+
continue
396+
}
397+
result += agp to stableKotlin
398+
}
399+
if (result.isEmpty()) error("No rows parsed from AGP/Kotlin compatibility table")
400+
return result
401+
}
402+
403+
/**
404+
* Reads kotlin-stdlib's maven-metadata.xml and returns, per Kotlin major.minor, the highest
405+
* published stable patch version.
406+
*/
407+
private fun fetchLatestStableKotlinPatches(): Map<Pair<Int, Int>, Version> {
408+
val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
409+
val document =
410+
documentBuilder.parse(
411+
"https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/maven-metadata.xml"
412+
)
413+
document.documentElement.normalize()
414+
val versionNodes = document.documentElement.getElementsByTagName("version")
415+
val stable = mutableListOf<Version>()
416+
for (i in 0 until versionNodes.length) {
417+
val v = parseVersionOrNull(versionNodes.item(i).textContent) ?: continue
418+
if (v.isStable) stable += v
419+
}
420+
return stable.groupBy { it.major to it.minor }.mapValues { (_, vs) -> vs.max() }
421+
}
422+
359423
/**
360424
* Fetches the value of `SdkConstants.GRADLE_LATEST_VERSION` from Android Studio's source, used to
361425
* resolve `CompatibleGradleVersion.VERSION_FOR_DEV` for bleeding-edge AGP versions.
362426
*/
363427
private fun fetchGradleLatestVersion(): Version {
364428
val source =
365-
URL(
366-
"https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/common/src/main/java/com/android/SdkConstants.java?format=TEXT"
367-
)
368-
.readText()
369-
.let { String(java.util.Base64.getDecoder().decode(it)) }
429+
fetchGooglesourceText(
430+
"https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/common/src/main/java/com/android/SdkConstants.java?format=TEXT"
431+
)
370432
val match =
371433
Regex("""GRADLE_LATEST_VERSION\s*=\s*"([^"]+)"""").find(source)
372434
?: error("GRADLE_LATEST_VERSION not found in SdkConstants.java")
373435
return Version.parse(match.groupValues[1], strict = false)
374436
}
375437

438+
/** Decodes gitiles' `?format=TEXT` response (base64-encoded) to raw source text. */
439+
private fun fetchGooglesourceText(url: String): String =
440+
URL(url).readText().let { String(java.util.Base64.getDecoder().decode(it)) }
441+
442+
private fun parseVersionOrNull(s: String): Version? =
443+
try {
444+
Version.parse(s, strict = false)
445+
} catch (_: Throwable) {
446+
null
447+
}
448+
376449
/**
377450
* Fetches the minimum required Gradle version from the AGP release notes page. This is used as a
378451
* fallback when the AGP version is not yet listed in the compatibility table.
@@ -401,12 +474,11 @@ class GenerateMatrix : CliktCommand() {
401474
for (row in table.select("tr")) {
402475
val cells = row.select("td").map { it.text() }
403476
if (cells.size >= 2 && cells[0].trim().equals("Gradle", ignoreCase = true)) {
404-
return try {
405-
Version.parse(cells[1], strict = false)
406-
} catch (e: Throwable) {
407-
echo("Warning: Could not parse Gradle version '${cells[1]}' from release notes")
408-
null
409-
}
477+
return parseVersionOrNull(cells[1])
478+
?: run {
479+
echo("Warning: Could not parse Gradle version '${cells[1]}' from release notes")
480+
null
481+
}
410482
}
411483
}
412484
}

0 commit comments

Comments
 (0)