Validating Compose Stability — A CI Gate On Skippability And Parameter Stability
Compose stability — whether a composable is skippable/restartable and whether its parameters are stable — is invisible until a recomposition profiler catches it in production. This skill makes it a build gate: snapshot the current stability of every composable into a committed .stability baseline, and fail CI when a change makes anything less stable. The tool is the Compose Stability Analyzer Gradle plugin. (Authoring composables for stability — @Immutable/@Stable, the compiler config file, deferred reads — is covered in the compose-performance-skills repo; this skill is purely the validation/CI layer.)
When to use this skill
- The user wants
./gradlew stabilityCheckto fail CI when a composable becomes unskippable or a parameter flips stable → unstable. - The user wants a committed, reviewable baseline of every composable's stability (
.stabilityfiles) so changes show up in PR diffs. - A recomposition regression shipped because nothing flagged a
Listparameter or a non-@Immutabledata class added to a hot composable. - The user mentions
stabilityDump,stabilityCheck,composeStabilityAnalyzer,@IgnoreStabilityReport, or "stability baseline". - The user wants warning-only locally and hard-fail in CI.
When NOT to use this skill
- The user wants to fix an unstable parameter (
@Immutable/@Stable,kotlinx.collections.immutable,stability_config.conffor external types, deferred state reads,derivedStateOf) — that is Compose performance authoring; see thecompose-performance-skillsrepo's stability skills. This skill only validates and gates. - The user wants to find which recompositions are firing at runtime (Layout Inspector recomposition counts,
Recomposermetrics, composition tracing) — different tool, runtime not build time. - The user is doing UI behavioral or screenshot testing — see
../../setup/choosing-test-rule-vs-runtest/SKILL.mdand../../preview/capturing-preview-screenshots-in-ci/SKILL.md.
Prerequisites
- The Compose Stability Analyzer Gradle plugin applied to each Compose module (apply via the plugins block; coordinates and version from the project's catalog / the plugin docs).
- Compose compiler metrics available — the plugin analyzes compiled output, so the module's Kotlin compilation must run before
stabilityCheck/stabilityDump. In CI this means aneeds: build(or running:module:compileDebugKotlinfirst) — running the check before compilation produces wrong results or fails. - A baseline committed to version control:
<module>/stability/<module>.stability(generated bystabilityDump).
Workflow
- 1. Configure the validation block. In each Compose module's
build.gradle.kts:
composeStabilityAnalyzer {
stabilityValidation {
enabled.set(true)
outputDir.set(layout.projectDirectory.dir("stability")) // where the .stability baseline lives
includeTests.set(false)
ignoredPackages.set(listOf("com.example.internal"))
ignoredClasses.set(listOf("PreviewComposables"))
ignoredProjects.set(listOf("benchmarks", "examples"))
failOnStabilityChange.set(true) // build fails on stability changes; false => warning-only
ignoreNonRegressiveChanges.set(false) // true => only regressions count; new-stable / improvements ignored
allowMissingBaseline.set(false) // true => no baseline yet is not a failure (bootstrap only)
stabilityConfigurationFiles.add( // same format as the Compose compiler's stability config
rootProject.layout.projectDirectory.file("stability_config.conf")
)
}
}
Key knobs: failOnStabilityChange (the gate), ignoreNonRegressiveChanges (report only regressions, ignore improvements and newly-added stable composables), stabilityConfigurationFiles (the Compose-compiler-format file declaring external types stable), and the ignored* lists for packages/classes/modules outside the contract.
- 2. Generate the baseline once and commit it. With the module compiled:
./gradlew :app:compileDebugKotlin
./gradlew :app:stabilityDump # writes app/stability/app.stability
git add app/stability/app.stability
git commit -m "Add Compose stability baseline"
The .stability file is human-readable: per composable it lists the fully-qualified signature, skippable/restartable status, and each parameter's stability classification with the reason. Android projects get variant-specific tasks (debugStabilityDump, releaseStabilityCheck, …); multi-module projects get one .stability file per module — ./gradlew stabilityDump runs them all, or target a module.
- 3. Run the check in CI, after compilation.
stabilityCheckcompares the current compilation against the committed baseline and reports three change kinds:~stability regression (a composable/parameter became less stable),+a composable added,-a composable removed. On a regression the build fails with a message naming the affected composables and how their stability changed, and tells you to runstabilityDumpif the change was intentional.
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'zulu' }
- run: ./gradlew :app:compileDebugKotlin
stability_check:
name: Compose Stability Check
runs-on: ubuntu-latest
needs: build # MANDATORY — the analysis reads compiled output
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'zulu' }
- run: ./gradlew stabilityCheck
- 4. Accept an intentional stability change deliberately. When a regression is real and accepted (a justified
Listparameter, an external type that genuinely can't be stable), update the baseline as a documented commit — never silently:
./gradlew :app:compileDebugKotlin
./gradlew :app:stabilityDump
git add app/stability/app.stability
git commit -m "Update stability baseline: PokemonList now takes List<Pokemon> — justified by [reason]"
The diff in the .stability file is the review artifact; the commit message is the audit trail.
- 5. Exclude things that are not part of the contract. Annotate composables that should never be tracked —
@Previewfunctions, internal scaffolding — with@IgnoreStabilityReport; they drop out of both the dump and the check. For whole packages/classes/modules, useignoredPackages/ignoredClasses/ignoredProjects.
@IgnoreStabilityReport
@Preview
@Composable
fun UserCardPreview() { UserCard(user = User("John", 30)) }
- 6. (Optional) strict in CI, warning-only locally. Gate hard on the CI server, stay non-blocking on a developer machine:
composeStabilityAnalyzer {
stabilityValidation { failOnStabilityChange.set(System.getenv("CI") == "true") }
}
Most CI platforms set CI=true.
Patterns
Pattern: running stabilityCheck before compilation
# WRONG
stability_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew stabilityCheck # no compiled output yet
# WRONG because: the analyzer reads the Compose compiler's output. With nothing compiled it
# either fails or reports incorrect results. The check must depend on a build step.
# RIGHT
stability_check:
runs-on: ubuntu-latest
needs: build # or: - run: ./gradlew :app:compileDebugKotlin stabilityCheck
steps:
- uses: actions/checkout@v4
- run: ./gradlew stabilityCheck
Pattern: regenerating the baseline to "make CI green"
# WRONG — CI failed on a stability regression, so:
./gradlew stabilityDump && git commit -am "fix stability check"
# WRONG because: stabilityDump rewrites the baseline to whatever the code currently produces, so
# the check trivially passes — the regression ships, undocumented. The dump is for *accepting* a
# change you understand, with a commit message that says why; it is not a way to silence the gate.
# RIGHT — investigate the ~ entries first; only then, if the change is justified:
./gradlew :app:compileDebugKotlin :app:stabilityDump
git add app/stability/app.stability
git commit -m "Update stability baseline: <composable> <what changed> — justified by <reason>"
Pattern: @Preview functions polluting the baseline
// WRONG — preview functions in the .stability baseline, churning it on every preview edit
@Preview @Composable fun ProfilePreview() { Profile(sampleUser) }
// WRONG because: previews are tooling entry points, not API. Tracking them means the baseline
// diffs every time someone tweaks a preview, drowning real regressions.
// RIGHT — annotate them out (or ignoredClasses = listOf("...Preview...") for a naming convention)
@IgnoreStabilityReport @Preview @Composable fun ProfilePreview() { Profile(sampleUser) }
Mandatory rules
- MUST commit the generated
.stabilitybaseline files to version control — they are the shared contract; an uncommitted baseline gates nothing. - MUST run
stabilityCheckonly after Kotlin compilation (CI:needs:a build job, or compile in the same Gradle invocation). The analyzer consumes compiled output. - MUST treat a
stabilityDumpbaseline update as a deliberate, reviewed commit with a justification in the message — never as a way to clear a failing check. - MUST exclude
@Previewand other non-API composables from the contract via@IgnoreStabilityReport(orignoredClasses/ignoredPackages) so the baseline diffs only on real changes. - MUST NOT set
failOnStabilityChange = false(orallowMissingBaseline = true) permanently — those are for bootstrapping; once a baseline exists, the gate must fail on regressions. - MUST NOT use this skill as the place to fix instability — that is Compose authoring (
@Immutable/@Stable, immutable collections, the compiler stability config); see thecompose-performance-skillsrepo. - PREFERRED:
ignoreNonRegressiveChanges = trueif the team only cares about regressions and finds+/new-stable noise distracting. - PREFERRED: strict in CI, warning-only locally via
failOnStabilityChange.set(System.getenv("CI") == "true").
Verification
-
./gradlew stabilityDumpproduces<module>/stability/<module>.stabilityand it is committed (git statusclean after a no-op dump). -
./gradlew stabilityCheckpasses on the committed baseline and fails when a composable is deliberately made unstable (e.g. add aList<T>parameter to a tracked composable) with a message naming it and a~entry. - The CI workflow's stability job has
needs: build(or compiles beforestabilityCheck). -
@Preview/ scaffolding composables carry@IgnoreStabilityReport(or match anignoredClassespattern) and do not appear in the.stabilityfile. -
failOnStabilityChangeistrue(orCI-gated to true) andallowMissingBaselineisfalseonce the baseline exists.
References
- skydoves.github.io/compose-stability-analyzer/gradle-plugin/stability-validation/ —
stabilityDump/stabilityChecktasks, thecomposeStabilityAnalyzer { stabilityValidation { … } }DSL and every option,.stabilitybaseline format,~/+/-change types,@IgnoreStabilityReport, multi-module behavior. - skydoves.github.io/compose-stability-analyzer/gradle-plugin/ci-cd/ — GitHub Actions wiring, the mandatory
needs: builddependency, build-failure behavior, the baseline-update workflow,failOnStabilityChange.set(System.getenv("CI") == "true"). - github.com/skydoves/compose-stability-analyzer — the plugin source, coordinates, and full configuration reference.
- developer.android.com/develop/ui/compose/performance/stability — Compose stability concepts (skippable/restartable, stable parameters) the baseline is built on.
- developer.android.com/develop/ui/compose/performance/stability/fix — fixing instability (
@Immutable/@Stable, immutable collections, the compilerstability_configuration_pathfile) — the authoring side this gate protects. - Sibling skill:
../../preview/capturing-preview-screenshots-in-ci/SKILL.md— another Compose CI gate (device-rendered preview catalog); sameneeds: build-style ordering concerns.