Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/kotlin-baseline.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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.
Expand Down
123 changes: 123 additions & 0 deletions scripts/check-kotlin-baseline.py
Original file line number Diff line number Diff line change
@@ -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 <pom.xml> <release.aar>

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()
Loading