---
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: `<module>/stability/<module>.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<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 — `@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: <composable> <what changed> — justified by <reason>"
```

### 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 `<module>/stability/<module>.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<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 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.
