
Enforces wire compatibility for JSON-serializable data models by diffing current schema against a git baseline, honoring JSON reader config, and failing CI with detailed reports and annotations.
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)
SerialDescriptor graph, so it sees exactly what goes on the wire: the real JSON keys (after @SerialName and namingStrategy), optionality, nullability, enums, and polymorphism resolved through your SerializersModule.serialkompatRecord / serialkompatCheckHistory).FULL checks both. It also reads your actual Json { } settings, because whether a change is safe depends on ignoreUnknownKeys, namingStrategy, encodeDefaults, and friends.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:
serialkompatExtract writes the current schema to build/serialkompat/current.snapshot.serialkompatCheck extracts the baseline from baselineRef in a temporary git worktree, diffs it against the current schema, and fails on any unacknowledged breaking change. It is wired into check, 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:
main, any of the seven secrets is missing, the version is malformed, tag vX.Y.Z already exists, or (for a final release) CHANGELOG.md has no ## [X.Y.Z] section../gradlew build on JDK 17 and 21, on macOS so the KMP klibs are complete.publishAndReleaseToMavenCentral, then :serialkompat-gradle:publishPlugins to the Plugin Portal in a separate job. A Portal failure doesn't stop tagging; re-run that job once the cause is fixed.vX.Y.Z on 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 (v0 for 0.x, v1 for 1.x).gradle.properties to 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), because main is 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:
vX.Y.Z release and moves the major tag.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.
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)
SerialDescriptor graph, so it sees exactly what goes on the wire: the real JSON keys (after @SerialName and namingStrategy), optionality, nullability, enums, and polymorphism resolved through your SerializersModule.serialkompatRecord / serialkompatCheckHistory).FULL checks both. It also reads your actual Json { } settings, because whether a change is safe depends on ignoreUnknownKeys, namingStrategy, encodeDefaults, and friends.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:
serialkompatExtract writes the current schema to build/serialkompat/current.snapshot.serialkompatCheck extracts the baseline from baselineRef in a temporary git worktree, diffs it against the current schema, and fails on any unacknowledged breaking change. It is wired into check, 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:
main, any of the seven secrets is missing, the version is malformed, tag vX.Y.Z already exists, or (for a final release) CHANGELOG.md has no ## [X.Y.Z] section../gradlew build on JDK 17 and 21, on macOS so the KMP klibs are complete.publishAndReleaseToMavenCentral, then :serialkompat-gradle:publishPlugins to the Plugin Portal in a separate job. A Portal failure doesn't stop tagging; re-run that job once the cause is fixed.vX.Y.Z on 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 (v0 for 0.x, v1 for 1.x).gradle.properties to 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), because main is 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:
vX.Y.Z release and moves the major tag.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.