diff --git a/.github/workflows/kotlin-baseline.yml b/.github/workflows/kotlin-baseline.yml new file mode 100644 index 0000000..3f87079 --- /dev/null +++ b/.github/workflows/kotlin-baseline.yml @@ -0,0 +1,44 @@ +# Fails when the SDK we would publish needs a newer Kotlin than the one we support. A build +# that merely compiles says nothing about this; see scripts/check-kotlin-baseline.py. +name: Kotlin Baseline + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + kotlin-baseline: + name: Published SDK stays within the Kotlin baseline + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + java-version: "21" + distribution: "temurin" + cache: gradle + + # The same two outputs publishing is built from, without signing or uploading anything. + - name: Build the POM and the AAR + run: ./gradlew :android:generatePomFileForMavenPublication :android:bundleReleaseAar --no-configuration-cache + + - name: Check them against the Kotlin baseline + run: python3 scripts/check-kotlin-baseline.py android/build/publications/maven/pom-default.xml android/build/outputs/aar/android-release.aar diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 12ea3b5..bb012e2 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,6 +2,7 @@ import com.vanniktech.maven.publish.AndroidSingleVariantLibrary import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.SourcesJar import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion // The Dokka Gradle plugin puts Dokka's engine, and with it Jackson, on this build script's // own classpath, which the project-level resolutionStrategy below cannot reach. Importing the @@ -148,11 +149,20 @@ android { } } -// `kotlinOptions { jvmTarget = "11" }` became a script compilation error with Kotlin 2.4, so the -// build could not even configure. This is the replacement the error points at. +// The compiler here is newer than the Kotlin our users run (AGP 9 needs it, and the root build +// raises it past a security advisory). A compiler stamps its own version into every class it +// emits, and an app's compiler rejects anything more than one minor version above itself. So the +// output is held at Kotlin 2.1: language and API level, and the stdlib the POM declares. +// scripts/check-kotlin-baseline.py fails CI if any of this drifts. +// +// `kotlinOptions { jvmTarget = "11" }` became a script compilation error with Kotlin 2.4, hence +// `compilerOptions`. kotlin { + coreLibrariesVersion = "2.1.0" compilerOptions { jvmTarget.set(JvmTarget.JVM_11) + languageVersion.set(KotlinVersion.KOTLIN_2_1) + apiVersion.set(KotlinVersion.KOTLIN_2_1) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5d1d31..db13c84 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] agp = "9.4.1" +# The compiler and build plugin only. What the SDK publishes is held at Kotlin 2.1 in +# android/build.gradle.kts. kotlin = "2.4.20" # core-ktx 1.17+ needs a newer compileSdk / AGP than 8.9.2 supports; lift with the AGP 9 migration. coreKtx = "1.16.0" @@ -13,7 +15,9 @@ material = "1.14.0" androidx-annotation = "1.11.0" -kotlinx-serialization-json = "1.11.0" +# Held at 1.8.x: later releases are built on newer Kotlin than our users run, and their compilers +# cannot read them. scripts/check-kotlin-baseline.py enforces this. +kotlinx-serialization-json = "1.8.0" retrofit = "3.0.0" # okhttp 5.x pulls in okhttp-android, which needs compileSdk 37; lift with the AGP 9 migration. diff --git a/scripts/check-kotlin-baseline.py b/scripts/check-kotlin-baseline.py new file mode 100644 index 0000000..c252748 --- /dev/null +++ b/scripts/check-kotlin-baseline.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Fail when the SDK we publish needs a newer Kotlin than the baseline we support. + +An app's Kotlin compiler reads the metadata stamped into our classes and into the Kotlin +libraries listed in our POM. It refuses anything more than one minor version newer than itself, +so an SDK built with Kotlin 2.4 stops every app on Kotlin 2.2 or older from compiling. Nothing +in a normal build shows that, which is how a routine Dependabot bump nearly shipped it. + +Usage: check-kotlin-baseline.py + +To raise the baseline on purpose, change the constants below and say so in the release notes. +""" +import re +import struct +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +# Highest Kotlin metadata version our classes may carry (Kotlin 2.1). +BASELINE_METADATA = (2, 1, 0) + +# Highest major.minor of each Kotlin library the POM may declare. Pinning a library here is a +# claim that its metadata is no newer than the baseline; check that before raising a ceiling. +POM_CEILINGS = { + "org.jetbrains.kotlin:kotlin-stdlib": (2, 1), + "org.jetbrains.kotlinx:kotlinx-serialization-json": (1, 8), +} + + +def parse_version(text): + return tuple(int(part) for part in re.findall(r"\d+", text)[:3]) + + +def check_pom(pom_path): + problems = [] + namespace = {"m": "http://maven.apache.org/POM/4.0.0"} + for dep in ET.parse(pom_path).getroot().findall(".//m:dependency", namespace): + group = dep.findtext("m:groupId", namespaces=namespace) + artifact = dep.findtext("m:artifactId", namespaces=namespace) + version = dep.findtext("m:version", namespaces=namespace) or "" + key = f"{group}:{artifact}" + if not group.startswith("org.jetbrains.kotlin"): + continue + ceiling = POM_CEILINGS.get(key) + if ceiling is None: + problems.append(f"POM declares {key}:{version}, which has no ceiling in POM_CEILINGS") + elif parse_version(version)[:2] > ceiling: + problems.append( + f"POM declares {key}:{version}, above the {'.'.join(map(str, ceiling))}.x ceiling" + ) + return problems + + +def module_metadata_versions(classes_jar): + """The metadata version in each META-INF/*.kotlin_module header: a count, then the ints.""" + found = {} + with zipfile.ZipFile(classes_jar) as jar: + for name in jar.namelist(): + if name.startswith("META-INF/") and name.endswith(".kotlin_module"): + data = jar.read(name) + (count,) = struct.unpack(">i", data[:4]) + found[name] = struct.unpack(f">{count}i", data[4 : 4 + 4 * count]) + return found + + +def class_metadata_versions(classes_jar): + """The kotlin.Metadata `mv` of every class, read with one javap run over the whole jar.""" + with zipfile.ZipFile(classes_jar) as jar: + names = [n[:-6].replace("/", ".") for n in jar.namelist() if n.endswith(".class")] + out = subprocess.run( + ["javap", "-v", "-p", "-cp", str(classes_jar), *names], + capture_output=True, text=True, check=True, + ).stdout + found, current = {}, None + for line in out.splitlines(): + header = re.match(r"Classfile .*!/(.+)\.class", line) + if header: + current = header.group(1) + mv = re.match(r"\s+mv=\[(\d+),(\d+),(\d+)\]", line) + if mv and current: + found[current] = tuple(int(x) for x in mv.groups()) + return found + + +def check_aar(aar_path): + with tempfile.TemporaryDirectory() as tmp: + with zipfile.ZipFile(aar_path) as aar: + classes_jar = aar.extract("classes.jar", tmp) + modules = module_metadata_versions(classes_jar) + classes = class_metadata_versions(classes_jar) + if not modules or not classes: + return ["found no Kotlin metadata in the AAR, so the check cannot vouch for it"] + problems = [ + f"{name} is stamped {'.'.join(map(str, v))}" + for name, v in modules.items() + if v > BASELINE_METADATA + ] + too_new = sorted(name for name, v in classes.items() if v > BASELINE_METADATA) + if too_new: + problems.append( + f"{len(too_new)} of {len(classes)} classes are stamped above " + f"{'.'.join(map(str, BASELINE_METADATA))}, e.g. {', '.join(too_new[:3])}" + ) + return problems + + +def main(): + pom_path, aar_path = (Path(arg) for arg in sys.argv[1:3]) + problems = check_pom(pom_path) + check_aar(aar_path) + baseline = ".".join(map(str, BASELINE_METADATA[:2])) + if problems: + print(f"FAIL: the published SDK would not compile in an app on Kotlin {baseline}:") + for problem in problems: + print(f" - {problem}") + sys.exit(1) + print(f"OK: the published SDK stays within the Kotlin {baseline} baseline.") + + +if __name__ == "__main__": + main()