A backward/forward compatibility gate for kotlinx-serialization @Serializable models — like buf breaking, but for JSON.
📖 Full documentation → chrisjenx.github.io/serialkompat · quick start · rules · CI setup · API
You delete a field. Payloads in queues, caches, and old app versions still carry it:
@Serializable
data class OrderEvent(
val id: String,
val amountCents: Long,
- val note: String? = null,
)$ ./gradlew serialkompatCheck
serialkompat: 1 active finding(s) (1 breaking, 0 warning), 0 acknowledged
BREAK PROPERTY_REMOVED com.example.wire.OrderEvent (backward)
field 'note' was removed from com.example.wire.OrderEvent
fix: Removing a field drops its data for tolerant readers; keep it (or bridge a rename with @JsonNames) until nothing uses it; else bump major.kotlinx-serialization-json has no safety net for schema evolution. Rename a property, drop a default, or make a field non-null, and every old client still sending the old shape breaks. Persisted JSON can become undecodable. The usual defence is hand-written round-trip tests.
serialkompat turns wire compatibility into a CI gate. It reads the JSON schema from your compiled @Serializable models, diffs it against a baseline, and fails the build on incompatible changes. The rules are grounded in how kotlinx-serialization actually behaves.
@Serializable ─▶ Extractor ─▶ Snapshot ─▶ Differ ─▶ Classifier ─▶ Report
types (runtime (canonical (deltas) (rules + (findings
descriptor model) severity) + exit code)
walk, JVM)
- Extraction walks the compiled
SerialDescriptorgraph, so it sees exactly what goes on the wire: the real JSON keys (after@SerialNameandnamingStrategy), optionality, nullability, enums, and polymorphism resolved through yourSerializersModule. - The baseline is the "old" schema you compare against. It is extracted live from a git ref, such as your target branch, so there is no baseline file to maintain. For long-lived persisted data there is also an append-only schema history (
serialkompatRecord/serialkompatCheckHistory). - Classification is direction-aware and config-aware. Backward compatible means new code can read old data. Forward compatible means old code can read new data.
FULLchecks both. It also reads your actualJson { }settings, because whether a change is safe depends onignoreUnknownKeys,namingStrategy,encodeDefaults, and friends. - Every rule is verified against real kotlinx-serialization by a round-trip oracle test: serialize with the old model, decode with the new one, and assert the classifier predicted what actually happened.
The plugin is on the Gradle Plugin Portal, so no extra repository setup is needed. Apply it to the module that holds your @Serializable wire or persisted models:
// build.gradle.kts
import com.chrisjenx.serialkompat.core.CompatibilityDirection
plugins {
kotlin("jvm")
kotlin("plugin.serialization")
id("com.chrisjenx.serialkompat") version "0.2.0"
}
serialkompat {
// Root @Serializable types whose JSON wire contract must stay compatible.
types.set(listOf("com.example.wire.OrderEvent", "com.example.wire.Payment"))
// Optional: read your real Json { } config (naming strategy, discriminator, …).
jsonInstance.set("com.example.wire.WireJson.instance")
baselineRef.set("origin/main") // optional; unset auto-detects the default branch
direction.set(CompatibilityDirection.FULL) // BACKWARD / FORWARD / FULL
failOnBreaking.set(true)
}The two tasks you'll use most:
serialkompatExtractwrites the current schema tobuild/serialkompat/current.snapshot.serialkompatCheckextracts the baseline frombaselineRefin a temporary git worktree, diffs it against the current schema, and fails on any unacknowledged breaking change. It is wired intocheck, so it runs on every./gradlew build.
If you set neither types nor a discovery mode, the plugin does nothing.
The report also renders as JSON (build/serialkompat/report.json), SARIF, and GitHub annotations; see report formats. For a 5-minute walkthrough, read the quick start. For the CLI and every option, see setup.
The composite action runs the gate, posts a sticky PR comment with the findings, and (on pull requests) adds annotations to the workflow run. The Gradle task itself stays CI-agnostic: it writes a JSON report and sets the exit code.
# .github/workflows/serialkompat.yml
on: pull_request
jobs:
serialkompat:
runs-on: ubuntu-latest
permissions:
pull-requests: write # needed to post the sticky comment
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 } # the baseline is extracted from git history
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 17 }
- uses: chrisjenx/serialkompat@v0
with:
ref: origin/${{ github.base_ref }}@v0 is a floating tag that tracks the latest stable 0.x release. See CI setup for the action's inputs and for other CI systems.
Severity depends on the direction and on your reader config. A few examples, with default Json { } settings:
| Change | Backward (new reads old) | Forward (old reads new) |
|---|---|---|
| Add optional field | ✅ safe | ❌ break, unless the old reader has ignoreUnknownKeys |
| Add required field | ❌ break | ❌ break, unless the old reader has ignoreUnknownKeys |
Rename key (no @JsonNames) |
❌ break | ❌ break |
| Make field nullable | ✅ safe | ❌ break: old readers reject null (explicitNulls = false) |
| Enum: add value | ✅ safe | ❌ break: old readers reject the new value |
| Enum: remove value | ❌ break | ✅ safe |
See the rules reference for the full rule matrix and how your config changes each verdict. The deep dive explains extraction, classification, and the git-ref baseline.
| Module | What |
|---|---|
serialkompat-core |
Pure-Kotlin Snapshot model, differ, classifier, rule set, report. No I/O. |
serialkompat-extractor |
Runtime SerialDescriptor → Snapshot extraction (JVM). |
serialkompat-gradle |
The Gradle plugin (serialkompatCheck). |
serialkompat-cli |
Standalone serialkompat diff <baseline> <current> for non-Gradle / cross-repo use. |
serialkompat-annotations |
@SerialkompatIgnore / @SerialkompatChecked discovery markers (Kotlin Multiplatform). |
./gradlew build # compile, test, format check (spotless), API check (BCV)
./gradlew spotlessApply # auto-format
./gradlew apiDump # update public-API baselines after an intended API change
./gradlew koverHtmlReportRequires JDK 17+. Uses the Gradle wrapper (Gradle 9.8.0), Kotlin 2.4.20, and kotlinx-serialization 1.11.0.
serialkompat-core, -extractor, -gradle (with its plugin marker), and -annotations publish to Maven Central through the vanniktech maven-publish plugin. The Gradle plugin also publishes to the Gradle Plugin Portal through com.gradle.plugin-publish (final and prerelease versions; the Portal rejects SNAPSHOTs). Credentials live only in repository secrets:
| Repo secret | Maps to |
|---|---|
MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD |
ORG_GRADLE_PROJECT_mavenCentralUsername / …mavenCentralPassword |
SIGNING_KEY_ID / SIGNING_KEY / SIGNING_KEY_PASSWORD |
ORG_GRADLE_PROJECT_signingInMemoryKeyId / …signingInMemoryKey / …signingInMemoryKeyPassword |
GRADLE_PUBLISH_KEY / GRADLE_PUBLISH_SECRET |
the env vars of the same name (locally: gradle.publish.key / gradle.publish.secret in ~/.gradle/gradle.properties) |
To check the Portal metadata without publishing: ./gradlew :serialkompat-gradle:publishPlugins --validate-only -Pversion=X.Y.Z.
Release. First, in a PR, rename ## [Unreleased] in CHANGELOG.md to ## [X.Y.Z] - YYYY-MM-DD and add a fresh empty ## [Unreleased] above it. Then dispatch the Release workflow from main with the version: X.Y.Z, or X.Y.Z-suffix for a prerelease. It runs these jobs in order:
- Validate. Fails before anything ships if the run isn't on
main, any of the seven secrets is missing, the version is malformed, tagvX.Y.Zalready exists, or (for a final release)CHANGELOG.mdhas no## [X.Y.Z]section. - Test.
./gradlew buildon JDK 17 and 21, on macOS so the KMP klibs are complete. - Publish.
publishAndReleaseToMavenCentral, then:serialkompat-gradle:publishPluginsto the Plugin Portal in a separate job. A Portal failure doesn't stop tagging; re-run that job once the cause is fixed. - Tag and release. Tags
vX.Y.Zon the tested commit and creates the GitHub release (notes: that version's CHANGELOG section, then GitHub's generated PR list), marked as a prerelease if the version has a suffix. Stable releases also move the floating major tag that Action users pin (v0for 0.x,v1for 1.x). - Bump. Opens a PR moving
gradle.propertiesto the next-SNAPSHOT(the next patch after a final release, or the same version after a prerelease:1.0.0-rc1→1.0.0-SNAPSHOT), becausemainis branch-protected. This needs Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests". Without it the release still completes, and the job warns you to bump the version by hand.
Tagging, the GitHub release, and the bump PR all use the workflow's own GITHUB_TOKEN.
Snapshot. Pushes to main that touch module sources or build files publish a -SNAPSHOT. The Snapshot Publish workflow refuses to run if gradle.properties holds a non-SNAPSHOT version.
Locally, ./gradlew publishToMavenLocal publishes to ~/.m2. Signing uses your signing.* Gradle properties.
action.yml already carries the Marketplace metadata (name, description, branding). Listing it is a one-time manual step after the first stable release, because GitHub can't automate the Marketplace toggle:
- Cut a stable release with the Release workflow. This creates the
vX.Y.Zrelease and moves the major tag. - On that release's page (Releases → Edit), tick "Publish this Action to the GitHub Marketplace" and accept the Marketplace Developer Agreement (repo owner, first time only).
- Pick the primary and secondary categories, then save.
Listing is for discoverability only. uses: chrisjenx/serialkompat@v0 works as soon as the v0 tag exists.
Contributions welcome — this project is built test-first. See CONTRIBUTING.md and our Code of Conduct. Good starting points are issues labelled good first issue.
Apache License 2.0 © 2026 Chris Jenkins and serialkompat contributors.