Single-binary CMP compatibility testing
Date: 2026-06-30 Status: Approved (design), pending implementation
Problem
compose2pdf publishes one artifact. Consumers pull that single jar alongside their own Compose Multiplatform (CMP) version. The compatibility CI must answer one question: does the one shipped binary run on each CMP version we claim to support?
The current compatibility.yml does not answer that. For each matrix cell it rewrites gradle/libs.versions.toml — overriding both the Kotlin and CMP versions — and recompiles the whole library against that combo (:compose2pdf:build). That is a source-compatibility test of a binary we never ship (Kotlin 2.3.21 against CMP 1.9.3, etc.). It tells us nothing about whether the artifact we actually publish — bytecode from one Kotlin compiler against one CMP API — runs on a different CMP runtime.
Two secondary problems:
- The pinned build base (
compose-multiplatform = 1.10.3) is legacy. Latest stable is 1.11.1; 1.12.0 is in beta. We should build against the currently released CMP. compose-versions.jsonis stale (1.9.3, 1.10.3, 1.11.0-beta03).
Key constraint: Skiko is decoupled from the CMP version
Skiko’s version has no relationship to the CMP version string:
| CMP | requires skiko-awt |
|---|---|
| 1.9.3 | 0.9.22.2 |
| 1.11.1 | 0.144.6 |
So a naive “force the test runtime’s org.jetbrains.compose.* to version X” would leave Skiko resolved by conflict resolution (highest wins) — mismatched with the target Compose runtime, producing failures for the wrong reason. compose2pdf renders through Skiko, so this matters.
Implication: let the Compose Gradle plugin do the version wiring. Applying the plugin at version X pulls the correct Compose + Skiko + stdlib for X — exactly what a real downstream project gets.
Design
Principle: build the one artifact against the currently-released CMP, then verify it runs on each supported runtime by resolving it the way a downstream project would.
1. Build base (what ships)
Bump gradle/libs.versions.toml:
compose-multiplatform:1.10.3→1.11.1(latest stable)kotlin:2.3.20→2.4.0(latest stable; the matrix already exercised1.11.1+2.4.0at the library layer, so it is validated for the base. The base bump also runs:fidelity-test:testlocally to confirm 2.4.0 is fine for the fidelity module; fall back to2.3.21only if that surfaces a regression.)1.11.1(minor 11 < 12) keeps selecting the cmpLegacy scene driver, so the published jar targets the ≤1.11 internal scene API.
Main CI already builds+tests this pinned combo, covering “base runs on base.”
2. Consumer harness (new) — compat-consumer/
An independent Gradle build (its own settings.gradle.kts, deliberately not included in the root build — that isolation is what lets it apply a different Compose plugin version without disturbing the library build).
settings.gradle.kts:pluginManagementresolves the Kotlin, Compose, and Compose-compiler plugin versions from-PcomposeVersion/-PkotlinVersionGradle properties.build.gradle.kts: depends oncom.chrisjenx:compose2pdf:<version>resolved frommavenLocal()(the jar built against 1.11.1). The version is passed as-Pcompose2pdfVersion(sourced from the rootgradle.properties, currently1.1.4-SNAPSHOT).- A smoke check as an
applicationmain()(no test-framework wiring): callrenderToPdf { Text(...) }, assert the returned bytes are non-empty and begin with the%PDF-magic header (check(...)→ non-zero exit on failure). This exercises the published binary being called from code compiled by the target version’s Compose compiler against the target runtime — the real downstream scenario, including Skiko rendering.
Why a force is still needed. compose2pdf’s POM declares Compose 1.11.1 (its build base) as a transitive dependency. A consumer that merely applied an older Compose plugin would have that transitive 1.11.1 win conflict resolution — the smoke check would silently run on 1.11.1, not the target. The consumer therefore forces the whole org.jetbrains.compose* group to the target version via resolutionStrategy.eachDependency. Skiko cascades correctly with no per-row bookkeeping: once a Compose module is forced to version X, Gradle resolves that module’s POM, which declares X’s Skiko — the base version’s Skiko edge leaves the graph. Forcing an exact target also models the strongest form of the guarantee: “does the one binary run on exactly this Compose version.” A skikoVersion property is the documented fallback if any version fails to cascade.
Dependency-direction note: because compose2pdf itself requires Compose 1.11.1, a real downstream on an older version only reaches that runtime by force-pinning Compose below our floor; the realistic, higher-value cell is the forward one (1.12.0-beta01), which — see §3 — is expected to fail against a cmpLegacy jar.
3. Rewritten compatibility.yml
Matrix stays {os: [ubuntu-latest, macos-latest]} × {version} (Skiko is native, so both OSes matter). Per cell:
./gradlew :compose2pdf:publishToMavenLocal— builds the one artifact against pinned 1.11.1.- Resolve the published version from
gradle.properties. - Run the consumer against the cell’s target:
./gradlew -p compat-consumer run -PcomposeVersion=<v> -PkotlinVersion=<k> -Pcompose2pdfVersion=<lib>(using the root Gradle wrapper; distribution is independent of the target build’ssettings.gradle.kts).
Pre-release cells are non-blocking: continue-on-error: $ — a version string containing - (e.g. 1.12.0-beta01) is a pre-release and is informational, so the cmpLegacy→cmpNext boundary does not paint the badge red. Stable cells gate the workflow.
Keep xvfb-run on Linux. Delete the perl “Override versions” step.
4. compose-versions.json + auto-update
The matrix version set is already the current support window — {1.10.3, 1.11.1, 1.12.0-beta01}, all at Kotlin 2.4.0 (previous stable / current stable / upcoming beta). No JSON edit is required; the per-row Kotlin now configures the consumer build for that version, and 2.4.0 currently works for every row (validated in §2’s local run). Per-row Kotlin pinning (older minors → older Kotlin) is deferred — introduce it only if a row’s smoke check fails on 2.4.0.
update-compose-versions.yml change — also bump the build base: after rewriting compose-versions.json, the weekly job sets gradle/libs.versions.toml’s compose-multiplatform to the highest stable detected version and kotlin to the latest stable Kotlin, then runs render-compat-tables.py so the PR carries the regenerated docs. The PR-gate condition widens to fire when either compose-versions.json or gradle/libs.versions.toml changed. Keeps “ship against currently released” automatic; the human reviews/merges each bump.
What each layer proves
| Layer | Proves |
|---|---|
| Main CI (pinned 1.11.1) | The shipped binary builds and its own tests pass. |
| Consumer @ 1.10.3 | Shipped binary runs on the previous stable runtime (back-compat). |
| Consumer @ 1.11.1 | Consumer harness itself is sound; base-on-base via real resolution. |
| Consumer @ 1.12.0-beta01 | Forward-compat early warning against the next release. |
A version that fails the consumer smoke test is one we cannot support with the current build base — the signal to drop it from the window (or change the base).
Non-goals / YAGNI
- No fidelity/raster comparison in the consumer — a
%PDF-+ non-empty assertion is enough to prove the binary loads and renders against the target runtime. - No new module inside the root build; the consumer stays a separate build.
- No
compose-versions.jsonchange — the matrix set is already correct. - No per-row Kotlin pinning yet — deferred until a row fails on 2.4.0.
Files touched
gradle/libs.versions.toml— bump base CMP (1.11.1) + Kotlin (2.4.0).docs/compatibility.md,README.md— regenerated byrender-compat-tables.py..github/workflows/compatibility.yml— consumer-based flow; drop recompile..github/workflows/update-compose-versions.yml— also bump the build base + regen docs.compat-consumer/settings.gradle.kts— new; version-parameterized plugins + mavenLocal.compat-consumer/build.gradle.kts— new; mavenLocal dep +resolutionStrategyforce +application.compat-consumer/gradle.properties— new; JVM args + default property values.compat-consumer/src/main/kotlin/com/chrisjenx/compat/Smoke.kt— new;main()%PDF-check..gitignore— ensurecompat-consumer/build/is ignored (if not already covered).