ci(preflight): add API 35 and API 37 emulator E2E to preflight

Extend the local preflight gate from api36 (the sole latest-API GMD
run) to api35 + api36, the top two stable levels in the E2E matrix.
Both Gradle Managed Devices already existed in app/build.gradle.kts
(the api29..36 loop) — confirmed via `:app:tasks --group verification`,
no emulator run needed.

API 37 (preview) was investigated but NOT added as a GMD: its only
published system image is the nonstandard "android-37.0" /
google_apis_ps16k pairing that ci.yml's e2e-preview job installs by
hand via sdkmanager. ManagedVirtualDevice's apiLevel (Int) builds
"android-<N>" and apiPreview (codename) builds "android-<Codename>" —
neither produces "android-37.0", the same gap ci.yml documents as why
reactivecircus/android-emulator-runner can't provision it either.
docs/perf/issue-124-unified-inbox-paging.md independently corroborates
this: its API 37 measurements used a physical Pixel, not an AVD. There
is no api37DebugAndroidTest task to run, so it stays CI-only
(e2e-preview) until a managed-device-compatible image ships; the
comment above testOptions.managedDevices in app/build.gradle.kts now
documents this in detail for the next person who looks.

.claude/skills/preflight/SKILL.md and CLAUDE.md are updated to run
both api35DebugAndroidTest and api36DebugAndroidTest as part of the
required gate, with the API 37 gap called out inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 16:05:12 -05:00
co-authored by Claude Opus 4.8
parent 8779b439f7
commit 1774a33158
3 changed files with 58 additions and 34 deletions
+29 -20
View File
@@ -1,6 +1,6 @@
---
name: preflight
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the latest-API-level emulator E2E before pushing or opening a PR. Mirrors the merge gate and runs the highest-API E2E via its Gradle Managed Device; the full multi-API matrix stays CI-only. Use before treating a change as done.
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the top-of-matrix emulator E2E (currently API 35 + 36) before pushing or opening a PR. Mirrors the merge gate and runs the two highest stable API levels via their Gradle Managed Devices; the rest of the multi-API matrix and the API 37 preview job stay CI-only. Use before treating a change as done.
---
# /preflight
@@ -13,12 +13,12 @@ Run the same fast checks CI enforces on every PR, in order, and report the outco
with a JDK/AGP version mismatch, check `java -version` / `JAVA_HOME` and point it at a 17–21
JDK (e.g. Android Studio's bundled JBR) before retrying.
- PowerShell: invoke the wrapper as `.\gradlew`. Git Bash / the Bash tool: `./gradlew`.
- The final E2E step boots an emulator through a Gradle Managed Device, so the host needs
hardware acceleration (WHPX on Windows, KVM on Linux, HVF on macOS). Gradle downloads the
system image and boots/tears down the AVD itself — no manual emulator setup — but the first
run is slow while the image downloads. If the host has no accelerated emulator and the
managed device cannot boot, report the E2E step as not run rather than treating the gate as
green.
- The final two E2E steps each boot an emulator through their own Gradle Managed Device, so the
host needs hardware acceleration (WHPX on Windows, KVM on Linux, HVF on macOS). Gradle
downloads the system image and boots/tears down each AVD itself — no manual emulator setup —
but the first run per API level is slow while its image downloads. If the host has no
accelerated emulator and a managed device cannot boot, report the E2E step as not run rather
than treating the gate as green.
## Steps
@@ -30,7 +30,8 @@ Run these, stopping at the first failure:
./gradlew :app:compileDebugAndroidTestKotlin
./gradlew :app:lintDebug
./gradlew :app:ktlintCheck :app:detekt
./gradlew :app:api36DebugAndroidTest # latest-API emulator E2E (highest level in the matrix)
./gradlew :app:api35DebugAndroidTest # top-of-matrix emulator E2E (2nd-highest stable level)
./gradlew :app:api36DebugAndroidTest # top-of-matrix emulator E2E (highest stable level)
```
`compileDebugAndroidTestKotlin` compiles the `androidTest` source set — the E2E/instrumented
@@ -44,24 +45,32 @@ merge gate even when the build and lint are green. Add `--continue` to any comma
`:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of
stopping at the first.
`api36DebugAndroidTest` is last because it is the slowest: it runs the full instrumented/E2E
suite on `api36` — the highest API level in the E2E matrix — via its Gradle Managed Device,
which Gradle provisions, boots, and tears down automatically. This one latest-API level is the
E2E that preflight runs locally and it must pass; CI fans the same suite out across the whole
API matrix (plus the API 37 preview job). Keep `api36DebugAndroidTest` in lockstep with the
highest managed device in `app/build.gradle.kts` and the E2E matrix in
`.github/workflows/ci.yml` — when a newer API level is added there, run that one instead.
`api35DebugAndroidTest` and `api36DebugAndroidTest` run last because they are the slowest: each
runs the full instrumented/E2E suite — on `api35`, then `api36`, the top two stable levels in
the E2E matrix — via its own Gradle Managed Device, which Gradle provisions, boots, and tears
down automatically. These two levels are the E2E that preflight runs locally and both must
pass; CI fans the same suite out across the whole API matrix (API 29–36) plus the API 37
preview job. API 37 has no Gradle Managed Device yet — its only published system image is the
nonstandard `android-37.0` / `google_apis_ps16k` pairing, which neither `ManagedVirtualDevice`'s
`apiLevel` (Int) nor `apiPreview` (codename) DSL resolves (see the comment above
`testOptions.managedDevices` in `app/build.gradle.kts`) — so CI's `e2e-preview` job provisions
it by hand instead, and there is no `api37DebugAndroidTest` task to run here. Keep
`api35DebugAndroidTest` / `api36DebugAndroidTest` in lockstep with the top of the managed-device
list in `app/build.gradle.kts` and the E2E matrix in `.github/workflows/ci.yml` — when a newer
API level is added there, run the new top two instead.
## Reporting
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api36 E2E").
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api35+api36 E2E").
- On failure, surface the actual Gradle error and point at the relevant report:
- unit tests → `app/build/reports/tests/testDebugUnitTest/`
- lint → `app/build/reports/lint-results-debug.html`
- ktlint → `app/build/reports/ktlint/` (per source set, e.g. `ktlintTestSourceSetCheck/`)
- detekt → `app/build/reports/detekt/`
- E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g. `.../api36/`)
- Run only the **latest-API** E2E here (`api36DebugAndroidTest`, the highest level in the
matrix); the full multi-API matrix and the API 37 preview job stay CI's job. If the host has
no accelerated emulator and the managed device cannot boot, report that E2E could not run
- E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g. `.../api35/`,
`.../api36/`)
- Run only the **top two stable-API** levels here (`api35DebugAndroidTest` +
`api36DebugAndroidTest`); the rest of the multi-API matrix and the API 37 preview job stay
CI's job — API 37 has no Gradle Managed Device to run locally (see Steps above). If the host
has no accelerated emulator and a managed device cannot boot, report that E2E could not run
rather than treating the gate as green.
+16 -12
View File
@@ -28,15 +28,18 @@ or via Gradle Managed Devices `./gradlew e2eGroupDebugAndroidTest` (whole matrix
**Before treating a change as done**, run the fast CI gate: `assembleDebug` +
`testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` +
`detekt` + the latest-API emulator E2E `api36DebugAndroidTest` (the `/preflight` skill does
all of this). `compileDebugAndroidTestKotlin` compiles the `androidTest` source set that the
static part of the gate skips, catching E2E/instrumented-test compile errors before they
surface only in CI. `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that
`lintDebug` skips, so they catch style violations that would otherwise fail CI's Static
analysis gate. `api36DebugAndroidTest` runs the instrumented/E2E suite on API 36 — the highest
API level in the E2E matrix — via its Gradle Managed Device (Gradle boots and tears down the
emulator automatically). Running that one latest-API level locally is required; the full
multi-API matrix (and the API 37 preview job) stays CI's job.
`detekt` + the top-of-matrix emulator E2E `api35DebugAndroidTest` + `api36DebugAndroidTest`
(the `/preflight` skill does all of this). `compileDebugAndroidTestKotlin` compiles the
`androidTest` source set that the static part of the gate skips, catching E2E/instrumented-test
compile errors before they surface only in CI. `ktlintCheck`/`detekt` cover the
`test`/`androidTest` source sets that `lintDebug` skips, so they catch style violations that
would otherwise fail CI's Static analysis gate. `api35DebugAndroidTest` and
`api36DebugAndroidTest` run the instrumented/E2E suite on the top two stable API levels in the
E2E matrix, each via its own Gradle Managed Device (Gradle boots and tears down each emulator
automatically). Running those two levels locally is required; the rest of the multi-API matrix
(API 29–34) stays CI's job. API 37 (preview) still has no Gradle Managed Device to run locally
— see the comment above `testOptions.managedDevices` in `app/build.gradle.kts` for why — so it
remains exercised only by CI's `e2e-preview` job.
## Build-config gotchas
@@ -70,9 +73,10 @@ pulled in as a real dependency for unit tests because `android.jar`'s version is
A change is not done until it ships with passing **unit tests** and **E2E/instrumented tests**
that exercise the new or changed behaviour. Writing and committing that E2E/instrumented test
is a required part of every task — and the test must actually **run and pass**, not merely
compile: preflight runs the latest-API-level emulator E2E locally (`api36DebugAndroidTest`, the
highest API level in the E2E matrix and its Gradle Managed Device task) and it must be green
before the change is done. CI then runs the full multi-API matrix.
compile: preflight runs the top-of-matrix emulator E2E locally (`api35DebugAndroidTest` +
`api36DebugAndroidTest`, the two highest stable API levels in the E2E matrix and their Gradle
Managed Device tasks) and both must be green before the change is done. CI then runs the full
multi-API matrix plus the API 37 preview job.
## Repo etiquette
+13 -2
View File
@@ -146,8 +146,19 @@ android {
// `./gradlew api29DebugAndroidTest`. Gradle provisions/boots/tears down the emulators and
// downloads the system images on first use. Keep this list in lockstep with the CI matrix in
// .github/workflows/ci.yml; when a new Android ships, add it and drop the oldest level that
// has fallen outside ~7 years. API 37 (preview) is exercised on the dev emulator until a
// stable managed-device image is published, so it is intentionally not listed here.
// has fallen outside ~7 years.
//
// API 37 (preview) is intentionally NOT listed here (re-confirmed 2026-07, see PR that added
// API 35/37 to preflight): its only published system image is the nonstandard
// "android-37.0" / google_apis_ps16k pairing that the e2e-preview job in
// .github/workflows/ci.yml installs directly via sdkmanager. ManagedVirtualDevice only knows
// how to build an "android-<apiLevel:Int>" package id (e.g. `apiLevel = 37` → "android-37")
// or an "android-<apiPreview:codename>" one — neither produces "android-37.0" — so there is
// no DSL path to this image today, the same root cause documented on e2e-preview for why
// reactivecircus/android-emulator-runner can't provision it either. issue #124's perf doc
// (docs/perf/issue-124-unified-inbox-paging.md) independently corroborates this: its API 37
// cross-check used a physical Pixel, not an emulator/AVD. Once a managed-device-compatible
// image is published, add `api37` here (and to the CI matrix) and delete the e2e-preview job.
managedDevices {
localDevices {
listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api ->