validating-compose-stability
About validating-compose-stability
A development skill for teams using Jetpack Compose that adds CI validation for composable and parameter stability through committed baselines and Gradle checks. It focuses on detecting regressions, not teaching how to fix unstable code.
Key features
- 01Generate human-readable .stability baselines
- 02Check stability changes with Gradle tasks
- 03Fail CI on stability regressions
- 04Exclude packages, classes, and projects
- 05Support warning-only local checks
Use cases
- 01Flag a composable that becomes unskippable
- 02Review stability changes in pull requests
- 03Run stability validation after Kotlin compilation
- 04Keep previews out of tracked baselines
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill validating-compose-stabilityAfter installing, type/validating-compose-stabilityin Claude Code to confirm it loaded.
Details
From the project READMEValidating 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.