|
| 1 | +# 17 — CI optimization & Nix-caching evaluation |
| 2 | + |
| 3 | +Analysis and changes to `.github/workflows/ci.yml` + `release.yml`. Versions, |
| 4 | +AGP, and Gradle are pinned (plans/01) and were NOT touched. |
| 5 | + |
| 6 | +## Bottleneck assessment (current CI, before this change) |
| 7 | + |
| 8 | +Single `build` job on `ubuntu-latest` runs, in series: |
| 9 | +`:irc:test :app:testDebugUnitTest` → `:app:lintDebug` → `:app:assembleDebug`. |
| 10 | + |
| 11 | +Wall-clock drivers, roughly in order: |
| 12 | + |
| 13 | +1. **Gradle dependency resolution / download** on a cold cache (Compose BOM, |
| 14 | + Hilt, Room, KSP, AGP, Kotlin, Robolectric jars). This is the biggest cold |
| 15 | + cost and is exactly what `gradle/actions/setup-gradle@v4` caches (Gradle |
| 16 | + user home: downloaded deps + wrapper dist + build cache). |
| 17 | +2. **KSP + Kotlin/Compose compilation** of `:app` (Hilt + Room processors). |
| 18 | + CPU-bound; incremental across runs only via the Gradle build cache. |
| 19 | +3. **Lint** (`:app:lintDebug`) — the intermittent |
| 20 | + `NoClassDefFoundError` in `ModifierDeclarationDetector` lives here; it is a |
| 21 | + Gradle-worker classloader race, not a real lint failure. |
| 22 | +4. **Tests** (Robolectric pulls its runtime; `:irc` is trivially fast). |
| 23 | +5. **Android SDK**: effectively free — `ubuntu-latest` ships a preinstalled SDK, |
| 24 | + so there is no SDK download at all. |
| 25 | + |
| 26 | +Already cached: Gradle user home + Gradle build cache (via setup-gradle); |
| 27 | +configuration-cache and build-cache are enabled in `gradle.properties`. |
| 28 | +Not cached / not free: cold compilation output when the build cache misses (new |
| 29 | +runners, cache eviction). The Android SDK does not need caching because it is |
| 30 | +preinstalled. |
| 31 | + |
| 32 | +## Nix-caching evaluation (the explicit ask) |
| 33 | + |
| 34 | +Question: would moving CI to `nix develop` + a Nix binary cache be faster? |
| 35 | + |
| 36 | +**Verdict: No. Keep `setup-java` + preinstalled SDK + Gradle cache.** |
| 37 | + |
| 38 | +Reasoning: |
| 39 | + |
| 40 | +- The flake's `androidenv.composeAndroidPackages` closure (SDK platform 35, |
| 41 | + build-tools 35.0.0, platform-tools, JDK 17) is large. On a cold run CI must |
| 42 | + *realize* that whole closure before Gradle even starts. The current path pays |
| 43 | + **zero** for the SDK because `ubuntu-latest` preinstalls it. Nix trades a free |
| 44 | + toolchain for a downloaded/cached one — strictly worse on the first run and, |
| 45 | + at best, a wash later. |
| 46 | +- Binary-cache options and why none win here: |
| 47 | + - `DeterminateSystems/magic-nix-cache-action` — the hosted Magic Nix Cache |
| 48 | + service was **shut down (Feb 2025)**; not a viable dependency. |
| 49 | + - `cachix/cachix-action` — works, but needs an external Cachix account, an |
| 50 | + auth token secret, and a push step. Operational weight for a single-dev |
| 51 | + Android app with no Nix-built artifacts to share. Not worth it. |
| 52 | + - `nix-community/cache-nix-action` (Nix store in GitHub Actions cache) — the |
| 53 | + only self-contained option, but it stores the *entire* Android SDK closure |
| 54 | + in the **same 10 GB/repo GitHub cache budget** that the far-more-valuable |
| 55 | + Gradle cache already uses. They compete and evict each other, making both |
| 56 | + caches less reliable. Net negative for cache hit-rate. |
| 57 | +- The recent flakiness (a hang and the lint classloader race) is **not** a |
| 58 | + toolchain-provisioning problem, so Nix would not fix it. |
| 59 | + |
| 60 | +Hybrid considered and rejected: using Nix only for the pure-JVM `:irc` job. |
| 61 | +Even there, realizing the JDK closure costs more than `setup-java`, which is |
| 62 | +already fast and cached by GitHub. No upside. |
| 63 | + |
| 64 | +Bottom line: Nix stays the canonical **local** dev env (flake.nix); CI stays on |
| 65 | +`setup-java` + preinstalled SDK + `setup-gradle`, consistent with plans/08. |
| 66 | + |
| 67 | +## Changes applied |
| 68 | + |
| 69 | +`ci.yml`: |
| 70 | + |
| 71 | +- **Split into two parallel jobs**: `irc` (pure-JVM `:irc:test`) and `app` |
| 72 | + (`:app:testDebugUnitTest` → lint → `:app:assembleDebug` + artifact). The fast |
| 73 | + irc feedback no longer waits behind, or is blocked by, a slow/hanging app |
| 74 | + build. Each job gets its own setup-gradle cache automatically. |
| 75 | +- **`timeout-minutes`** on both jobs (irc 15, app 25) so a hang fails fast |
| 76 | + instead of burning the ~6h default and wedging the concurrency group. This |
| 77 | + directly addresses the ~15 min hang that had to be cancelled manually. |
| 78 | +- **Lint hardened against the classloader race**: `:app:lintDebug` now runs |
| 79 | + `--no-daemon -Dorg.gradle.workers.max=1` (serializes the lint worker, killing |
| 80 | + the `ModifierDeclarationDetector` `NoClassDefFoundError` race) wrapped in a |
| 81 | + bounded 2-attempt retry as a belt-and-suspenders guard. Only the lint step |
| 82 | + retries; tests and assemble do not. |
| 83 | +- Gradle caching left to setup-gradle defaults (read/write on `main`, read-only |
| 84 | + on PRs — no `cache-disabled`). Configuration-cache still enabled via |
| 85 | + `gradle.properties`; `--no-daemon` is compatible with it (cache is serialized |
| 86 | + to disk). |
| 87 | + |
| 88 | +`release.yml`: |
| 89 | + |
| 90 | +- **`timeout-minutes: 30`** added. Signing path untouched — keystore decode and |
| 91 | + all `MOTD_*` env stay exactly as before; still builds the signed release APK |
| 92 | + on `v*` tags. |
| 93 | + |
| 94 | +Functional equivalence preserved: push/PR still runs `:irc:test` + app unit |
| 95 | +tests + lint + `assembleDebug` and uploads the debug APK; tags still build the |
| 96 | +signed APK. |
| 97 | + |
| 98 | +## What to measure after pushing |
| 99 | + |
| 100 | +- Wall-clock of the `irc` vs `app` jobs separately (confirm irc finishes early |
| 101 | + and the split actually parallelizes). |
| 102 | +- Whether lint still ever throws `NoClassDefFoundError`; if attempt 1 keeps |
| 103 | + failing and attempt 2 saves it, the `--no-daemon`/single-worker fix is |
| 104 | + insufficient and lint should stay permanently single-worker (already is). |
| 105 | +- Whether `--no-daemon` on lint measurably lengthens the app job (extra JVM |
| 106 | + startup + config-cache reload). If it costs more than the flake was worth, |
| 107 | + drop `--no-daemon` and keep only `-Dorg.gradle.workers.max=1` + retry. |
| 108 | +- Gradle cache hit-rate on PRs (setup-gradle logs a cache report) to confirm the |
| 109 | + two jobs aren't thrashing the 10 GB budget. |
0 commit comments