--- name: validating-compose-stability description: >- Use this skill to gate CI on Jetpack Compose stability — catch when a composable becomes unskippable/unrestartable or a parameter goes stable → unstable, before it ships and tanks recomposition. Covers the Compose Stability Analyzer Gradle plugin's `stabilityDump` (write a `.stability` baseline) and `stabilityCheck` (compare current compilation against it, fail on regression) tasks, the `composeStabilityAnalyzer { stabilityValidation { … } }` DSL (`failOnStabilityChange`, `ignoreNonRegressiveChanges`, `ignored*` lists, `allowMissingBaseline`, `stabilityConfigurationFiles`), the `@IgnoreStabilityReport` annotation, committing `app/stability/app.stability` as a team baseline, the deliberate baseline-update workflow, and GitHub Actions wiring (`needs: build`, since the analysis reads compiled output). Use when the user mentions "stabilityCheck", "stabilityDump", "compose stability analyzer", "stability baseline", "@IgnoreStabilityReport", "composeStabilityAnalyzer", or "fail the build on stability changes". license: Apache-2.0. See LICENSE for complete terms. metadata: author: Jaewoong Eum (skydoves) keywords: - jetpack-compose - compose-stability - recomposition - stability-analyzer - stabilityCheck - stabilityDump - stability-baseline - gradle-plugin - ci-cd - github-actions --- # 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 stabilityCheck` to 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 (`.stability` files) so changes show up in PR diffs. - A recomposition regression shipped because nothing flagged a `List` parameter or a non-`@Immutable` data 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.conf` for external types, deferred state reads, `derivedStateOf`) — that is Compose performance authoring; see the `compose-performance-skills` repo's stability skills. This skill only validates and gates. - The user wants to find *which* recompositions are firing at runtime (Layout Inspector recomposition counts, `Recomposer` metrics, 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.md` and `../../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 a `needs: build` (or running `:module:compileDebugKotlin` first) — running the check before compilation produces wrong results or fails. - A baseline committed to version control: `/stability/.stability` (generated by `stabilityDump`). ## Workflow - [ ] **1. Configure the validation block.** In each Compose module's `build.gradle.kts`: ```kotlin 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: ```bash ./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.** `stabilityCheck` compares 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 run `stabilityDump` if the change was intentional. ```yaml # .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 `List` parameter, an external type that genuinely can't be stable), update the baseline as a documented commit — never silently: ```bash ./gradlew :app:compileDebugKotlin ./gradlew :app:stabilityDump git add app/stability/app.stability git commit -m "Update stability baseline: PokemonList now takes List — 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 — `@Preview` functions, internal scaffolding — with `@IgnoreStabilityReport`; they drop out of both the dump and the check. For whole packages/classes/modules, use `ignoredPackages`/`ignoredClasses`/`ignoredProjects`. ```kotlin @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: ```kotlin composeStabilityAnalyzer { stabilityValidation { failOnStabilityChange.set(System.getenv("CI") == "true") } } ``` Most CI platforms set `CI=true`. ## Patterns ### Pattern: running `stabilityCheck` before compilation ```yaml # 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. ``` ```yaml # 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" ```bash # 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. ``` ```bash # 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: — justified by " ``` ### Pattern: `@Preview` functions polluting the baseline ```kotlin // 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. ``` ```kotlin // 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 `.stability` baseline files to version control — they are the shared contract; an uncommitted baseline gates nothing. - **MUST** run `stabilityCheck` only after Kotlin compilation (CI: `needs:` a build job, or compile in the same Gradle invocation). The analyzer consumes compiled output. - **MUST** treat a `stabilityDump` baseline update as a deliberate, reviewed commit with a justification in the message — never as a way to clear a failing check. - **MUST** exclude `@Preview` and other non-API composables from the contract via `@IgnoreStabilityReport` (or `ignoredClasses`/`ignoredPackages`) so the baseline diffs only on real changes. - **MUST NOT** set `failOnStabilityChange = false` (or `allowMissingBaseline = 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 the `compose-performance-skills` repo. - **PREFERRED:** `ignoreNonRegressiveChanges = true` if 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 stabilityDump` produces `/stability/.stability` and it is committed (`git status` clean after a no-op dump). - [ ] `./gradlew stabilityCheck` passes on the committed baseline and **fails** when a composable is deliberately made unstable (e.g. add a `List` parameter to a tracked composable) with a message naming it and a `~` entry. - [ ] The CI workflow's stability job has `needs: build` (or compiles before `stabilityCheck`). - [ ] `@Preview` / scaffolding composables carry `@IgnoreStabilityReport` (or match an `ignoredClasses` pattern) and do not appear in the `.stability` file. - [ ] `failOnStabilityChange` is `true` (or `CI`-gated to true) and `allowMissingBaseline` is `false` once the baseline exists. ## References - skydoves.github.io/compose-stability-analyzer/gradle-plugin/stability-validation/ — `stabilityDump` / `stabilityCheck` tasks, the `composeStabilityAnalyzer { stabilityValidation { … } }` DSL and every option, `.stability` baseline format, `~`/`+`/`-` change types, `@IgnoreStabilityReport`, multi-module behavior. - skydoves.github.io/compose-stability-analyzer/gradle-plugin/ci-cd/ — GitHub Actions wiring, the mandatory `needs: build` dependency, 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 compiler `stability_configuration_path` file) — the authoring side this gate protects. - Sibling skill: `../../preview/capturing-preview-screenshots-in-ci/SKILL.md` — another Compose CI gate (device-rendered preview catalog); same `needs: build`-style ordering concerns.