enforcing-stability-in-ci — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited enforcing-stability-in-ci (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Stability is a property that silently regresses. A new var in a shared data class can disable skipping across dozens of screens, and nothing in the build output complains. Treat stability like binary compatibility: commit a baseline, diff it on every PR, fail the build when the diff goes the wrong way. This skill sets that gate up using the skydoves/compose-stability-analyzer Gradle plugin and points to j-roskopf/ComposeGuard for non-Android multiplatform projects.
stabilityCheck, stabilityDump, baseline drift, ComposeGuard, or "fail the build on stability regression".../diagnosing-compose-stability/SKILL.md first to make sure the current state is acceptable; baselining a broken state just locks the brokenness in.../understanding-stability-inference/SKILL.md.org.jetbrains.kotlin.plugin.compose). Strong skipping is default on../gradlew assemble or equivalent.../diagnosing-compose-stability/SKILL.md first)..stability baseline must be checked in).Add the plugin to the version catalog and apply it to every module that ships UI composables.
# gradle/libs.versions.toml
[versions]
compose-stability-analyzer = "0.7.3"
[plugins]
compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "compose-stability-analyzer" }// app/build.gradle.kts (and every UI feature module)
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.stability.analyzer)
}The plugin must be applied after the Compose compiler plugin so it can read the same compiler output.
// app/build.gradle.kts
composeStabilityAnalyzer {
enabled.set(true)
stabilityValidation {
enabled.set(true)
outputDir.set(layout.projectDirectory.dir("stability"))
failOnStabilityChange.set(true)
ignoredPackages.set(
listOf(
"com.example.preview", // @Preview-only composables
"com.example.benchmark", // microbenchmark fixtures
)
)
ignoredClasses.set(
listOf(
"com.example.testing.FakeRepository",
)
)
stabilityConfigurationFiles.add(
rootProject.layout.projectDirectory.file("stability_config.conf")
)
}
}Key settings:
outputDir — where .stability baseline files are written. Pick a path inside the module so it is naturally co-located with the source. Do not write to build/; that directory is gitignored and the baseline must be committed.failOnStabilityChange — set to true for CI. Locally, developers may toggle this to false for iteration speed, but the committed value must be true.ignoredPackages / ignoredClasses — exclude @Preview composables, benchmark fixtures, and test doubles that intentionally violate stability rules.stabilityConfigurationFiles — point at the same stability_config.conf the Compose compiler uses, so the plugin's verdict matches the compiler's../gradlew :app:stabilityDump
# or, for a specific variant:
./gradlew :app:debugStabilityDumpThe plugin writes one .stability file per module per variant under outputDir. Commit them.
git add app/stability/
git commit -m "chore(stability): seed baseline"A single .stability entry looks like this:
@Composable
public fun com.example.CounterDisplay(count: com.example.MainViewModel): kotlin.Unit
skippable: false
restartable: true
params:
- count: RUNTIME (requires runtime check)Each entry records the composable signature, skippable / restartable flags, and the per-parameter stability classification. The diff format is line-stable across runs, so PR reviewers see a meaningful change set.
stabilityCheck into CIAdd a Gradle task invocation to the same job that compiles. Earlier signal beats a separate stage.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Assemble and check stability
run: ./gradlew assemble :app:stabilityCheck
- name: Upload stability diff on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: stability-diff
path: app/build/reports/stability/stabilityCheck reads the committed baseline, regenerates the current snapshot in build/, diffs them, and fails the task if any composable became less skippable or any class became less stable. The PR check turns red and the developer sees exactly which composable regressed and why.
When stabilityCheck fails, the task output names the regressed composables and the new unstable parameter or class. The developer has two valid responses:
var → val, List → ImmutableList, Flow parameter → collectAsStateWithLifecycle upstream). Re-run :stabilityDump locally to confirm the baseline is unchanged, then push.:stabilityDump to update the baseline, commit it, and add a note in the PR description explaining why the regression is intentional. Code review enforces the justification — the diff is plain text and reviewable.Treat baseline updates the same way the team treats binary-compatibility baseline updates: visible in code review, requires explanation.
ignoredPackages and ignoredClasses exist for legitimate exemptions: previews, benchmarks, test doubles, generated code. Do not use them to silence regressions in production code. Every ignore entry needs a comment explaining why; reviewers should reject ignore additions that lack a rationale.
// RIGHT
ignoredPackages.set(
listOf(
"com.example.preview", // @Preview composables intentionally use mutable state for tooling
"com.example.benchmark" // microbenchmark fixtures need fresh allocations per iteration
)
)// WRONG
ignoredPackages.set(listOf("com.example.feature.cart"))
// WRONG because: silencing an entire production package hides real regressions in cart.
// The team will rediscover the lost skipping as field jank weeks later.If the project is Compose Multiplatform (desktop, iOS via KMP) or otherwise outside the Android Gradle plugin's reach, use j-roskopf/ComposeGuard. It implements the same idea (commit a baseline, diff in CI, fail on regression) and exposes <variant>ComposeCompilerGenerate and <variant>ComposeCompilerCheck tasks. Wire it the same way as stabilityDump / stabilityCheck above. The skydoves plugin is the primary recommendation for Android-only projects because it integrates with @TraceRecomposition for runtime correlation.
# RIGHT
app/stability/
app-debug.stability <-- committed
app-release.stability <-- committed
# CI runs:
./gradlew :app:stabilityCheck # diffs current vs committed baseline; fails on regression# WRONG
app/build/stability/ <-- in build/, gitignored, never committed
# WRONG because: every CI run starts from a fresh build/, the baseline does not exist,
# stabilityCheck either no-ops or always passes. The gate is theater.// RIGHT — committed default
composeStabilityAnalyzer {
stabilityValidation {
failOnStabilityChange.set(true)
}
}// WRONG — committed default
composeStabilityAnalyzer {
stabilityValidation {
failOnStabilityChange.set(false)
}
}
// WRONG because: turns the gate into an informational warning. Developers learn to ignore
// the warning. Stability regresses anyway. Set true and let CI block the merge.Local override is fine via a Gradle property the developer can pass:
failOnStabilityChange.set(
providers.gradleProperty("composeStabilityStrict").orNull?.toBoolean() ?: true
)# Local fast iteration — opt out explicitly per invocation
./gradlew :app:stabilityCheck -PcomposeStabilityStrict=false# RIGHT — earlier signal, single workflow run
- run: ./gradlew assemble :app:stabilityCheck# WRONG — separate jobs split signal
jobs:
assemble:
steps: [./gradlew assemble]
stability:
needs: assemble
steps: [./gradlew :app:stabilityCheck]# WRONG because: the stability job re-resolves dependencies and re-runs Compose compilation
# from scratch, doubling CI time and delaying the failure signal. Same job, single Gradle daemon.@TraceRecomposition at runtimeThe plugin proves a class is classified stable. It does not prove the running app actually recomposes less. Pair the CI gate with runtime tracing (see ../../measurement/tracing-recompositions-at-runtime/SKILL.md) so the team has both signals: compile-time classification stability and runtime recomposition counts. A green CI gate with rising recomposition counts means a runtime-only invalidation source (e.g. a misplaced mutableStateOf read) that classification cannot catch.
.stability baseline file(s) to version control. A baseline that lives in build/ is not a baseline.failOnStabilityChange.set(true) in the committed Gradle config so CI blocks merges on regression. Local opt-out via a Gradle property is acceptable; committing false is not.ignoredPackages / ignoredClasses without a one-line comment explaining why. Unjustified ignores are how stability silently rots back in.../diagnosing-compose-stability/SKILL.md and ../stabilizing-compose-types/SKILL.md first; baseline only what the team is willing to defend.:app. A regression in a feature module is invisible to a :app-only check until the feature is wired into the main graph.:stabilityCheck in the same CI job as compile, behind assemble. Earlier signal, one Gradle daemon, half the wall-clock time.stabilityConfigurationFiles at the same stability_config.conf the Compose compiler reads, so the plugin and the compiler agree on classifications.Stability is a contract, not a magic spell — and the CI gate is the contract enforcer. Skippability is a diagnostic, not a KPI: the goal is "no regressions from the baseline the team agreed on", not "100% skippable".
composeStabilityAnalyzer plugin block present in every UI module's build.gradle.kts.app/stability/ (or the configured outputDir) is committed to the repo and contains at least one .stability file per shipping variant.failOnStabilityChange.set(true) is the committed default../gradlew :app:stabilityCheck (or the equivalent module path) on every pull request.val to var in a shared data class on a throwaway branch, push, and confirm the PR check turns red with the regressed composable named in the failure output. Revert the test change.ignoredPackages and ignoredClasses has a comment explaining why it is exempt../gradlew :app:stabilityDump locally, the only changes (if any) are in the .stability files and are reviewable as plain text.For runtime correlation of CI-green stability with actual recomposition counts, see ../../measurement/tracing-recompositions-at-runtime/SKILL.md. For the conceptual classification model that determines what stabilityCheck is checking, see ../understanding-stability-inference/SKILL.md. For the upstream type fixes that resolve regressions surfaced by the gate, see ../stabilizing-compose-types/SKILL.md.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.