Skip to content

Commit f9194f0

Browse files
committed
ci: split irc/app jobs, add timeouts, harden flaky lint
- parallel irc + app jobs so fast :irc feedback isn't blocked by the app build - per-job timeout-minutes (15/25; release 30) so a hang fails fast instead of the ~6h default that wedged the concurrency group - run lint isolated (--no-daemon -Dorg.gradle.workers.max=1) with a bounded retry to dodge the ModifierDeclarationDetector classloader race - evaluated Nix binary caching for CI and rejected it (preinstalled SDK is free; magic-nix-cache is EOL; cache-nix-action thrashes the Gradle cache budget) — rationale in plans/17; signing untouched
1 parent cbde930 commit f9194f0

4 files changed

Lines changed: 175 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,45 @@ concurrency:
99
cancel-in-progress: true
1010

1111
jobs:
12-
build:
12+
# Pure-JVM protocol module: fast, no Android SDK needed. Runs in parallel
13+
# with the app job so a slow/hanging app build never blocks irc feedback.
14+
irc:
1315
runs-on: ubuntu-latest
16+
timeout-minutes: 15
1417
steps:
1518
- uses: actions/checkout@v4
1619
- uses: actions/setup-java@v4
1720
with: { distribution: temurin, java-version: 17 }
1821
- uses: gradle/actions/setup-gradle@v4
19-
- name: Unit tests
20-
run: ./gradlew :irc:test :app:testDebugUnitTest --stacktrace
22+
- name: irc unit tests
23+
run: ./gradlew :irc:test --stacktrace
24+
25+
# Android app: unit tests, lint, and debug APK. Uses the preinstalled
26+
# Android SDK on ubuntu-latest (no SDK download) plus the Gradle cache.
27+
app:
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 25
30+
steps:
31+
- uses: actions/checkout@v4
32+
- uses: actions/setup-java@v4
33+
with: { distribution: temurin, java-version: 17 }
34+
- uses: gradle/actions/setup-gradle@v4
35+
- name: App unit tests
36+
run: ./gradlew :app:testDebugUnitTest --stacktrace
37+
# Lint runs isolated with a single worker and no daemon to avoid the
38+
# flaky Gradle-worker classloader race that surfaces as
39+
# NoClassDefFoundError in ModifierDeclarationDetector. A bounded retry
40+
# (2 attempts) covers the rare case it still races.
2141
- name: Android lint (warnings as errors)
22-
run: ./gradlew :app:lintDebug --stacktrace
42+
run: |
43+
for attempt in 1 2; do
44+
echo "::group::lint attempt $attempt"
45+
./gradlew :app:lintDebug --stacktrace --no-daemon \
46+
-Dorg.gradle.workers.max=1 && { echo "::endgroup::"; exit 0; }
47+
echo "::endgroup::"
48+
echo "lint attempt $attempt failed; retrying" >&2
49+
done
50+
exit 1
2351
- name: Assemble debug APK
2452
run: ./gradlew :app:assembleDebug --stacktrace
2553
- uses: actions/upload-artifact@v4

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ permissions:
99
jobs:
1010
release:
1111
runs-on: ubuntu-latest
12+
timeout-minutes: 30
1213
steps:
1314
- uses: actions/checkout@v4
1415
- uses: actions/setup-java@v4

plans/08-ci-release.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# 08 — CI & release (WP1 authors workflows; runbook for the human)
22

33
CI is the canonical build environment: `ubuntu-latest` with its preinstalled Android SDK.
4-
No Nix in CI (the flake is for local dev only). AGP accepts licenses non-interactively on
5-
hosted runners; no extra license step needed.
4+
No Nix in CI (the flake is for local dev only; the Nix-caching option was evaluated and
5+
rejected — see plans/17). AGP accepts licenses non-interactively on hosted runners; no extra
6+
license step needed.
67

7-
## `.github/workflows/ci.yml`
8+
The CI workflow is split into two parallel jobs (`irc`, `app`), has per-job `timeout-minutes`
9+
so a hang fails fast, and runs lint isolated (`--no-daemon -Dorg.gradle.workers.max=1`) with a
10+
bounded retry to dodge the flaky `ModifierDeclarationDetector` classloader race. Rationale and
11+
the Nix verdict are in plans/17. The current workflow is `.github/workflows/ci.yml`:
812

913
```yaml
1014
name: CI
@@ -18,15 +22,37 @@ concurrency:
1822
cancel-in-progress: true
1923

2024
jobs:
21-
build:
25+
irc:
2226
runs-on: ubuntu-latest
27+
timeout-minutes: 15
2328
steps:
2429
- uses: actions/checkout@v4
2530
- uses: actions/setup-java@v4
2631
with: { distribution: temurin, java-version: 17 }
2732
- uses: gradle/actions/setup-gradle@v4
28-
- name: Unit tests
29-
run: ./gradlew :irc:test :app:testDebugUnitTest --stacktrace
33+
- name: irc unit tests
34+
run: ./gradlew :irc:test --stacktrace
35+
36+
app:
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 25
39+
steps:
40+
- uses: actions/checkout@v4
41+
- uses: actions/setup-java@v4
42+
with: { distribution: temurin, java-version: 17 }
43+
- uses: gradle/actions/setup-gradle@v4
44+
- name: App unit tests
45+
run: ./gradlew :app:testDebugUnitTest --stacktrace
46+
- name: Android lint (warnings as errors)
47+
run: |
48+
for attempt in 1 2; do
49+
echo "::group::lint attempt $attempt"
50+
./gradlew :app:lintDebug --stacktrace --no-daemon \
51+
-Dorg.gradle.workers.max=1 && { echo "::endgroup::"; exit 0; }
52+
echo "::endgroup::"
53+
echo "lint attempt $attempt failed; retrying" >&2
54+
done
55+
exit 1
3056
- name: Assemble debug APK
3157
run: ./gradlew :app:assembleDebug --stacktrace
3258
- uses: actions/upload-artifact@v4
@@ -49,6 +75,7 @@ permissions:
4975
jobs:
5076
release:
5177
runs-on: ubuntu-latest
78+
timeout-minutes: 30
5279
steps:
5380
- uses: actions/checkout@v4
5481
- uses: actions/setup-java@v4

plans/17-ci-optimization.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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

Comments
 (0)