Versioning policy

September 12, 2026 · View on GitHub

Operational policy for evolving the contracts described in API_STABILITY.md. This document is normative for everyone landing changes that touch a public surface.

1. Versioning schemes

Different surfaces use different schemes — pick the right one for the contract:

SurfaceSchemeCarrier
Gradle plugin (ee.schimke.composeai.preview)SemverMaven Central coordinate version
CLI (compose-preview)SemverGitHub release tag, compose-preview --version
VS Code extensionSemverVSIX manifest
MCP serverSemverTied to CLI release
Annotation library (preview-annotations)SemverMaven Central coordinate
Daemon JSON-RPC protocolIntegerprotocolVersion in initialize
previews.jsonIntegerschemaVersion field
Per-data-product payloadIntegerschemaVersion per DataProductCapability
HistoryEntry sidecar JSONIntegerschemaVersion field
bundle.json in a packed preview bundleIntegerschemaVersion field (BUNDLE_SCHEMA_VERSION)
daemon-launch.json from daemon-launch-builderIntegerschemaVersion field
GH composite actionsSemver via tag/SHAConsumer uses: ref

Plugin / CLI / extension share one release-please-driven semver chain (see RELEASING.md). Protocol and schema integers are independent.

Two of those carriers don't exist yet. previews.json and the HistoryEntry sidecar are both listed above as carrying a schemaVersion field, and neither actually writes one. The per-data-product and bundle schemas do. See § 10.

1.1 Consumer-side pinning

The table above is what we version. What a consumer pins is one value: the project version pincomposePreview.version in gradle.properties (or the catalog's [versions] composePreviewCli), read by the CLI, the VS Code extension and the install / apply composite actions alike, so a project can't render against three different releases depending on which door it was entered through. Full reference: VERSION_PIN.md. Set it with compose-preview pin --cli.

2. Version bumps for the published artifacts

Ordinary semver, with the major effectively frozen:

Commit typeBumpExample
fix:patch2.8.0 → 2.8.1
feat:minor2.8.0 → 2.9.0
chore:, docs:, ci:, refactor:, test:no release
a new majorbasically neveronly a from-scratch rewrite

This is release-please's default strategy — there is deliberately no versioning key in the config. The ! that would otherwise cut a major is rejected by CI (the No breaking-change marker job in pr-title.yml), so the only route to one is a deliberate release-as. Mechanism and rationale: RELEASING.md → Versioning after 2.0.0.

A major no longer signals a breaking change; it signals a rewrite. Breaking changes ship in minors. What the major used to promise now has to be read out of §§ 3–5 and the changelog: § 3 defines what counts as breaking, § 5 governs the deprecation cycle, and the changelog is where a consumer actually sees it. That is a narrowing of what the number claims, not of the policy behind it — § 10 already recorded that most of the enforcement these documents describe is not implemented, and freezing the major stops the number implying a guarantee that was never mechanically kept.

One consequence, recorded rather than discovered later. versionsIncompatible (cli/src/main/kotlin/ee/schimke/composeai/cli/Version.kt) compares major versions to decide whether a pin/CLI skew is a warning or a note, on the documented grounds that "a major release changes the render/daemon wire format". With the major frozen that predicate is false across all of 2.x, so every skew reports as a note. It stays correct across the 1.x → 2.x boundary, which is why it is unchanged, but it stops being a signal after that.

3. What counts as breaking

By surface:

  • Plugin DSL — removing or renaming a Property<T>, retyping it, removing a nested extension, removing an enum value, changing a convention(...) default in a way that flips semantics for an existing user, raising the supported AGP/Kotlin/Compose floor outside the matrix-slide rule (§ 6).
  • CLI — removing or renaming a flag or subcommand, changing exit codes, changing --json output shape (other than additive fields), changing default behavior of an existing flag.
  • MCP — removing a tool, renaming a tool, narrowing an input schema, changing the meaning of an existing input or output field.
  • Annotation library — removing a parameter, renaming a parameter, retyping a parameter, reordering an annotation array literal default.
  • Daemon protocol — anything outside the additive list in API_STABILITY.md § 2.1.
  • previews.json / HistoryEntry schemas — same as the protocol: removed/renamed fields, semantic changes.
  • bundle.json / daemon-launch.json — same again, and these two matter more than their absence from API_STABILITY.md § 1 suggests: a packed bundle outlives the CLI that wrote it, and daemon-launch.json is read by VS Code and by non-Gradle producers. Bump the format's schemaVersion and keep readers tolerant of the older value.
  • GH actions — removing an input, changing a default that would change observed behavior for a workflow that didn't override it, renaming a default branch convention.

Anything else is additive.

3.1 The data-product payload classes are a JSON contract, not a JVM ABI

The published data-*-core artifacts carry Kotlin data classes — LayoutInspectorVectorPath, FigmaSvgVectorPath and their neighbours — that serialize the daemon's data products. What is promised about them is the JSON: additive fields with documented defaults (§ 4.3), unknown fields ignored on both sides (§ 4.2), and the per-product schemaVersion bumped so a consumer can tell the shapes apart (§ 4.1). A reader holding an older artifact decodes a newer payload, which is the property these types exist to have.

Their JVM ABI is not a second contract on top of that, and adding a field is not a breaking change. Kotlin compiles a data class's constructor and copy with a descriptor per parameter, so an additive property necessarily replaces both — the 9-argument constructor becomes a 10-argument one. There is no way to append a field and keep them: a hand-written secondary constructor restores the all-arguments call path but not the synthetic $default a Kotlin caller uses when it omits a parameter, so the artifact would read as binary compatible while an old consumer still failed to link. A partial restoration that looks total is worse than a recorded change.

What is guaranteed instead is that the change is deliberate and visible: every one of these modules runs explicitApi() plus abiValidation() with a committed dump under api/, and wires checkKotlinAbi into check (see § 9), so a surface change is a reviewable diff in the dump rather than a downstream surprise. Consumers pinning one of these artifacts should move it with the CLI they resolve — they are one release train, not independently versioned libraries.

This does not extend to :rc-player-*, which are ordinary libraries an external consumer compiles against on its own cadence. There the ABI is the contract.

4. The wire-format rules

These apply to surfaces 1, 2, 3, and the history sidecar.

4.1 Enum discipline

Every enum that crosses a process boundary should be decoded tolerantly:

  • An unknown string maps to a per-enum UNKNOWN value (Kotlin) or a falsy sentinel (TypeScript).
  • Code branching on the enum has an explicit else / default arm.
  • The fixture corpus has at least one fixture per enum that exercises a synthetic future value to prove tolerance.

Adding a new enum value is additive under this rule. Without tolerant decode, every new enum value is a silent break — the rule is what would make the additive promise real.

Not true of the existing wire enums. Most @Serializable enums in Messages.kt are plain enums with no UNKNOWN member and no custom serializer — FileKind and ChangeType are two of many. ignoreUnknownKeys covers unknown fields, not unknown enum values, so a peer sending a newly added value today makes decoding throw. Treat this section as the rule for enums you add or touch, not as a description of the current corpus. Retrofitting the rest is tracked work, not a shipped guarantee.

4.2 Unknown fields

Both sides ignore unknown JSON fields. Kotlin uses Json { ignoreUnknownKeys = true } (already configured); TypeScript decoders use a structural cast and only key off documented fields.

4.3 Optional vs required

New fields are always optional, with a documented default. The default must preserve old-behavior semantics. Promoting an optional field to required is a breaking change.

4.4 Capabilities, not version checks

Clients gate features on ServerCapabilities entries, not on daemonVersion semver. New features always add a capability flag, even when they look "obviously additive".

4.5 protocolVersion bumps

Bumping protocolVersion requires:

  • A coordinated daemon + every-client release. This is the only mechanism that works today — see below.
  • A migration note in docs/daemon/PROTOCOL.md.
  • Updated fixture corpus.

Serving the previous version for one minor cycle, so clients can ship on their own schedule, is what this list should require. It is left out because the daemon cannot do it: there is no way to satisfy it, and a requirement no one can meet is worse than an acknowledged gap. Restore it here in the same change that implements range negotiation.

Range negotiation — the daemon advertising a {min, max} and serving both — is not in 1.0.0 and is deferred to a later 1.x (§ 10). initialize carries a single protocolVersion: Int today, and JsonRpcServer fails the handshake on any mismatch. Until it lands, the VS Code extension's current..current-1 support window does not exist in any form: the extension sends one hard-coded version and the daemon rejects anything else, which is why a coordinated release is the only way a bump works and why the previous-version requirement is held back rather than listed.

5. Deprecation policy

Applies to plugin DSL, CLI flags, MCP tools, and any other named public surface.

  1. Mark deprecated in the same release that introduces the replacement.
    • Kotlin: @Deprecated(level = WARNING, replaceWith = ...).
    • CLI: warning to stderr on use; mention in --help with strikethrough text.
    • MCP: prefix tool description with [DEPRECATED] and document the replacement.
    • GH action input: warning step that prints to the job log.
  2. Keep functional for at least two minor releases (≥ 6 months elapsed, whichever is longer).
  3. Escalate to ERROR in a subsequent minor (Kotlin: level = ERROR; CLI: warning becomes louder).
  4. Remove only at a major.

Exceptions are permitted only for security fixes, documented in CHANGELOG.

6. Toolchain compatibility (AGP × Kotlin × Compose × Robolectric)

The plugin declares a supported matrix in RENDERER_COMPATIBILITY.md. The matrix slides on its own cadence:

  • Adding a newer corner is additive (any minor).
  • Dropping a corner older than 18 months is permitted at any minor with one release of warning. That warning has to go in the CHANGELOG and release notes: apply() never sees an AGP, Kotlin, or Compose version, so it cannot print the "AGP X.Y is deprecated; will be unsupported in compose-preview Z.0" message this rule used to promise, and a consumer gets no in-build notice at all.
  • Dropping multiple majors at once requires a plugin major.

What apply() actually gates today is the Gradle version (GradleVersionCheck.kt); there is no AGP / Kotlin / Compose comparison at configuration time, so an out-of-matrix consumer gets whatever failure the toolchain produces rather than a named supported range. compose-preview doctor is not a substitute: it prints the resolved AGP and Kotlin versions as an informational check and flags known dependency mismatches (CompatRules.kt is given dependency maps and the Gradle version, not the AGP/Kotlin versions), so it evaluates no matrix predicate at all.

The CI integration suite renders against several toolchain points, but not as explicit current / current-1 / next-RC cells — and only one cell runs per PR, with the full matrix reserved for the nightly cron (§ 9).

7. Branch conventions (GH actions)

The default branches are part of the public contract:

  • compose-preview/main, compose-preview/pr
  • compose-preview/resources/main, compose-preview/resources/pr
  • compose-preview/a11y/main, compose-preview/a11y/pr

These names are frozen forever. PR comments published by the comment action embed permanent commit URLs on these branches; renaming them retroactively breaks every linked image in every closed PR.

Adding new branch conventions for new pipelines is fine. Renaming an existing one is not.

8. Release coordination

Single release train governed by release-please. The plugin, CLI, MCP, extension, and annotations all bump together at minor and major; patches are independent per artifact.

Daemon protocol versions and per-data-product schema versions are decoupled — a release may bump protocolVersion from 1 to 2 without bumping the artifact major (the artifact major bumps for consumer-visible breakage; the protocol bump is invisible to plugin/CLI consumers as long as the daemon supports the previous version).

8.1 The preview server is on its own train

compose-preview serve and browse do not contain a server; they launch one. Since #5177 the server body lives in yschimke/compose-preview-server and releases on its own cadence — it went to 2.0.0 when it left, while this repository was still on 1.x, so the two version lines mean nothing to each other. A CLI version does not name a server version and never will.

What names one is the newest release, resolved at run time. There is no pin in gradle/libs.versions.toml any more, and no coordinate either.

ServerDistributionProvision asks https://api.github.com/repos/yschimke/compose-preview-server/releases/latest for the newest tag on the first serve that finds no server, fetches that release's compose-preview-server-<version>.tar.gz, and caches it under <cache>/composeai/preview-server/<version>/ — keyed on the server's version, so a CLI upgrade does not orphan an unchanged server. compose-preview mcp serve takes compose-preview-mcp-<version>.tar.gz from the same release, so one resolution still governs that pair: the two archives ride one release and there is no cadence on which they would skew usefully.

Why there is no pin. There used to be two — composeai-preview-server-dist for the release an installed CLI downloads, composeai-preview-serve for the published jar this repository compiled against — and both are gone, for different reasons.

The library pin went because compose-preview-server stopped publishing to Maven Central. Its artifacts are the .tar.gz distributions on each GitHub release; there is no jar to compile against. :cli's wire-drift tests drive the distribution instead, through ServeDistributionHarness.

The distribution pin went because it was buying less than it cost. The argument for it was that resolving at run time lets a server this CLI has never been built against arrive without a pull request. That is true, and it is now accepted: serve is a launcher over a process boundary, so what the two halves must agree on is the wire rather than a linkage, and a pin only ever delayed a skew rather than preventing one — an installed CLI kept whatever release it was built against until someone upgraded the CLI, which is its own kind of stale. What replaces the pin as a check is the wire-drift suite, which drives the distribution it would actually fetch.

doctor reports what it found and what it would fetch, without asking the network: it reads the cache, because "cheap and works offline" is doctor's contract and "which release is newest" is a network question.

Skew is bounded rather than prevented, and the escape hatches are unchanged: an operator may point at any server with COMPOSE_PREVIEW_SERVER or --server-binary (that choice always wins over the fetched copy), and COMPOSE_PREVIEW_SERVER_VERSION names a release instead of the newest — which is how a machine or a CI job pins itself. doctor is where you see which one is in effect.

Offline never resolves and never fetches: an air-gapped machine gets the newest copy it already has, or the installation hint. It never gets a hung download, and it never gets an API call either.

9. Compatibility testing

Three layers were designed; this is what each one actually does today.

LayerIntended gateReality
Fixture corpusdocs/daemon/protocol-fixtures/Adding a wire message without a fixture fails CIKotlin round-trips the fixtures it covers. TypeScript does not round-tripdaemonProtocol.test.ts does JSON.parse(...) as T and asserts selected properties on 16 of the 28 fixtures, so a renamed field nobody asserts passes. And the inventory (MessagesTest.fixtureInventoryMatchesExpected) is a hand-maintained set: a new message adds no entry and lands green. History, interactive, stream, XR, recording and extension methods are already uncovered
Kotlin BCV — named for :gradle-plugin and :preview-annotationsChanging a public API without updating the golden file fails CINot wired on those modules. No .api golden files, no ABI check in any workflow for them. Note they are also the wrong target: the DSL consumers compile against is :gradle-plugin-config, the artifact that gets conflict-resolved between a pinned config plugin and a CLI-injected runtime. Two sets are gated, by different routes: the four :rc-player-* library modules run explicitApi() plus the Kotlin Gradle plugin's built-in abiValidation(), with committed JVM and klib dumps under rc-player/*/api/ and checkKotlinAbi named explicitly in the rc-player-tests job — see API_STABILITY.md § 5.1. Sixteen more do the same and wire checkKotlinAbi into their own check, so the ordinary build runs it without CI naming the task: :agent-grant-protocol, :preview-data-api, :bundle-coordinates, :bundle-format, :common-image-crop, :common-io, :daemon:client, :daemon:core, :render-session-api, :render-session-subprocess, and the six data-*-core payload modules. For those payload modules, read the gate as § 3.1 describes it — a record of a deliberate change, not a compatibility promise
Toolchain integration matrix.github/workflows/integration.ymlBumping a matrix corner without a green run fails CITwo different things. On a PR: one cell (wear-os-samples (ComposeStarter)); agp8-min is skipped, and a diff touching only safe paths skips the matrix entirely with the required legs re-emitted green. On the nightly cron: the full matrix, so AGP-floor drift surfaces within a day rather than on the PR. There is no push-to-main lane — the drift these cells catch is upstream, correlated with time rather than with our commits. Either way the cells are external repositories and fixtures, not pinned current / current-1 / next-RC

The fixture corpus and the integration suite are real tests that catch real regressions. What none of the three currently provides is the exhaustive coverage the rules above assume.

10. What is and is not enforced

Read §§ 2–9 as the intended policy, not as a description of shipped machinery. 1.0.0 is a version number: it ended the 0.x convention where a minor could break anything, and it says we intend to treat the surfaces in API_STABILITY.md as contracts. It did not turn on the enforcement these documents describe. An earlier revision of this section claimed those mechanisms were live; they are not, and the difference matters to anyone deciding how much to depend on a surface.

What holds today rests on review and convention:

  • We do not knowingly break a listed surface without a major.
  • The deprecation cycle in § 5 is followed when a surface is retired.
  • The fixture corpus and the integration suite catch real regressions within their coverage (§ 9).

What is described but not implemented:

MechanismDescribed inState
Kotlin BCV on :gradle-plugin / :preview-annotations§ 9No .api golden files, no CI check — a breaking API change ships silently. The :rc-player-* modules are gated (see § 9); these are not
Tolerant enum decode across the wire§ 4.1Most Messages.kt enums have no UNKNOWN; a new value from a peer throws
apply()-time AGP × Kotlin × Compose gate§ 6Only the Gradle version is checked; doctor reports skew but does not gate
Exhaustive fixture coverage§ 9Inventory is a hand-maintained set; a new message lands green with no fixture
schemaVersion on previews.json and the HistoryEntry sidecar§ 1Field absent from both — a reader cannot tell a supported document from a future one
protocolVersion: {min, max} range negotiation§ 4.5, API_STABILITY.md § 2.1Single Int; the daemon rejects any value but its own
VS Code ↔ daemon N..N-1 windowAPI_STABILITY.md § 3Not possible while the above holds — the extension sends one hard-coded version and a mismatch fails the handshake
compose-preview <cmd> --json-schemaAPI_STABILITY.md § 2.7Not implemented; capability detection is --version plus --help
// API: stable / // API: incubating source tagsAPI_STABILITY.md § 5Not applied
@Stable / @Incubating DSL tiersAPI_STABILITY.md § 2.4Not implemented — no opt-in tier exists

Most are additive and can land in any 1.x. Until one does, do not cite it as a guarantee — in a PR description, in docs, or to a consumer.

Range negotiation is the exception: it cannot be done additively in the shape described. protocolVersion is typed as a number on both sides (InitializeParams.protocolVersion: Int in Messages.kt, protocolVersion: number in the TypeScript interface and PROTOCOL.md § 3), so sending {min, max} in that field makes either peer fail to decode the handshake before any negotiation could happen — the one message where failing closed is guaranteed. It needs either a coordinated protocolVersion bump, or a staged rollout that adds the range as a new optional field an existing peer ignores while the numeric field keeps working, then retires the numeric one a cycle later. Chicken-and-egg worth noting: the staged path is the only one that doesn't require the coordinated release that range negotiation exists to avoid.

The 1.0 readiness punch list was issue #798; it covered feature completeness, not the enforcement above.