Files
LibreMail/.github/workflows/ci.yml
T
JMR-devandClaude Opus 4.8 2e0eaef4e9 feat(ci): no-regression JVM coverage gate scoped to the testable surface
Scope :app:jacocoTestReport's denominator to the JVM-testable surface and
add a :app:jacocoTestCoverageVerification no-regression gate that shares the
same classDirectories/executionData/sourceDirectories, wired into both the
`check` lifecycle task and CI's unit-test job (part of the `CI passed` gate).

Excluded from the denominator (structurally unreachable from a JVM unit
test): Compose screen/component render code, Android framework entry points
(*Activity/*Service/Application/*BackupAgent), Hilt DI (**/di/**), and the
src/debug cold-open probe. Kept in scope: ViewModels, repositories, mappers,
DAOs, utils, richtext, mail, reporting logic, and the six WorkManager Workers.

Corrects PR #292, which excluded **/*Worker*: SyncWorker, BackfillWorker,
PruneWorker, SendWorker, ReportPurgeWorker and ReportUploadWorker are all
directly unit-tested, so they stay counted in both numerator and denominator
(only their Hilt wiring, WorkManagerModule, is excluded, via **/di/**).

Baseline: 80.21% line (4838/6032). Floor: 0.79 (~1.2% headroom) so ordinary
noise doesn't red-flag it while a real drop fails. Manual ratchet for now:
bump the floor up in the same PR when coverage rises materially.

Closes #251
Closes #292

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:27:17 -05:00

652 lines
32 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: GPL-3.0-or-later
name: CI
on:
pull_request:
branches: [main]
# A new push to a PR cancels any in-flight run for that PR.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# SDK packages this project builds against (compileSdk 37 / build-tools 37.0.0).
# Quote the package ids when passed to sdkmanager — the ';' is a shell separator.
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
# ── Priority-based runner orchestration ──────────────────────────────────────
# Runs FIRST (the heavy jobs below all `needs: traffic-control`). It reads THIS
# PR's P0–P9 label (and the special `broken` label) to order runner access.
# Effective priority: `broken` => 10 (BOTTOM, below P9), overriding any P0–P9;
# else the lowest-numbered P0–P9 label present (P0 = highest); else default P5.
#
# • P0 = EMERGENCY ONLY (app broken in production / emergency security update).
# P0 PREEMPTS: it cancels the in-progress / queued CI runs of ALL strictly-
# LOWER-priority OTHER open PRs to grab their runners immediately. A preempted
# PR simply re-runs on its next push / autoupdate rebase.
#
# • `broken` = STUCK/FAILING PR — a MANUALLY-applied signal (maintainer / repo
# owner only) meaning "deprioritise to the bottom so others aren't blocked
# behind it while it's being fixed." Its effective priority is 10, so it NEVER
# preempts (even if it's also labelled P0 — `broken` wins; a stuck PR can't be
# an emergency merge) and ALWAYS yields: every other PR, even lower P-levels,
# advances ahead of it. And because a broken PR's run is wasted (it can't
# merge), ANY higher-priority PR — not just P0 — MAY cancel its in-progress run
# to reclaim the runner. Removing the label restores its normal P-priority.
# (Example: a P3 PR with failing CI was making lower-priority PRs wait behind
# it; marking it `broken` lets them proceed — and reclaim its runner.)
#
# • P1–P9 = YIELD WITHOUT BUMPING. They NEVER cancel a NON-broken lower-priority
# run that is already going — a higher-priority PR does not evict it, it just
# takes the next free slot. Mechanism: a bounded hold-back. This job polls and
# defers (up to HOLD_BACK_BUDGET_SECONDS, kept well under timeout-minutes) while
# any strictly-higher-priority OTHER open PR still has an active/queued CI run,
# so that PR's heavy jobs reach the runner queue ahead of this PR's. When the
# budget elapses it proceeds anyway (a PR never blocks itself).
#
# Net preemption rule — a strictly-lower-priority OTHER PR's active run is cancelled
# iff (THIS PR is P0) OR (that PR is `broken`); otherwise it is left to run and we
# yield. So: P0 preempts ALL lower runs; ANY PR preempts lower `broken` runs; P1–P9
# never preempt a non-broken run.
#
# Hard safety rules, all enforced in the script below:
# • never cancels a run on main / a push event (filters --event pull_request);
# • never cancels THIS PR's own run (skips self by PR number + run id);
# • never cancels an equal-or-higher-priority PR (only strictly-lower, prio > self);
# • P1–P9 cancel NO non-broken run — they only wait (bounded), then proceed.
#
# Honest limitation: GitHub Actions has no native priority queue and assigns
# runners roughly FIFO, so the hold-back is a BEST-EFFORT head-start, not a hard
# guarantee — under sustained contention the bounded wait can expire before a
# higher-priority PR drains. The waiting job also occupies a (cheap, short-lived)
# runner meanwhile, which is exactly why the wait is kept bounded.
#
# It is deliberately NOT a merge-gate check: it is absent from `ci-passed`'s
# needs, every API call is guarded, the script always exits 0, and the step is
# `continue-on-error` — so a hiccup (API error, missing permission, fork PR)
# can never fail or block CI. The heavy jobs only *order* after it via `needs`;
# if it were ever skipped/failed they'd be skipped, which `ci-passed` now treats
# as a gate failure (fail-safe: blocks merge, never spuriously passes).
traffic-control:
name: Traffic control (runner priority)
runs-on: ubuntu-latest
timeout-minutes: 6 # hard backstop; the P1–P9 hold-back budget below stays well under this
permissions:
actions: write # cancel lower-priority runs (P0 emergencies + broken targets)
pull-requests: read # read PR P0–P9 labels
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SELF_PR: ${{ github.event.pull_request.number }}
# P1–P9 bounded hold-back knobs. BUDGET must stay comfortably below
# timeout-minutes so the poll loop always exits 0 before the hard job timeout
# fires — a timed-out job would skip the heavy jobs and fail `ci-passed`.
HOLD_BACK_BUDGET_SECONDS: "180"
HOLD_BACK_POLL_SECONDS: "15"
steps:
# No checkout: this job only calls the gh CLI (auto-configured from GH_TOKEN /
# GH_REPO), so it needs neither the repo contents nor the default contents:read.
- name: Apply runner priority (P0/broken preempt; P1–P9 hold back)
continue-on-error: true # belt-and-suspenders: never let this fail the run
run: |
# GitHub invokes run steps with `bash -eo pipefail`. Disable errexit so a
# single failed API call can't abort the step; we guard every call and
# always exit 0. Attacker-influenced values (branch names, labels) are only
# ever read via env / gh JSON into shell vars — never interpolated as code.
set +e
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ] || [ -z "${SELF_PR:-}" ]; then
echo "Not a pull_request event (or no PR number) — nothing to do."
exit 0
fi
# Effective priority of a labels JSON array read on stdin: a `broken` label
# => 10 (bottom, below P9), overriding any P0–P9; else the highest-priority
# (lowest-numbered) P0–P9 label present; else 5.
prio_of() {
jq -r 'if any(.[]; .name == "broken") then 10
else ([ .[] | .name | select(test("^P[0-9]$")) | ltrimstr("P") | tonumber ]
| if length == 0 then 5 else min end) end' 2>/dev/null
}
# Snapshot of every open PR (number, head branch, labels) to stdout.
list_open_prs() {
gh pr list --state open --limit 300 --json number,headRefName,labels
}
# Active (non-completed) CI run ids on head branch $1 — PR events only, never
# main. Shared by the preemption pass (ids to cancel) and the hold-back
# (presence => keep waiting).
active_run_ids_for_head() {
gh run list --workflow ci.yml --branch "$1" --event pull_request \
--limit 100 --json databaseId,status,headBranch,event 2>/dev/null \
| jq -r '.[]
| select(.event == "pull_request")
| select(.headBranch != "main")
| select(.status != "completed")
| .databaseId' 2>/dev/null
}
# One initial snapshot, used to read THIS PR's own priority.
if ! list_open_prs > open_prs.json 2>err.txt; then
echo "::warning::Could not list open PRs — skipping. $(cat err.txt 2>/dev/null)"
exit 0
fi
self_labels=$(jq -c --argjson pr "$SELF_PR" \
'([ .[] | select(.number == $pr) | .labels ] | .[0]) // []' open_prs.json 2>/dev/null)
self_prio=$(printf '%s' "${self_labels:-[]}" | prio_of)
case "$self_prio" in ''|*[!0-9]*) self_prio=5 ;; esac
if [ "$self_prio" -ge 10 ]; then prio_label="broken (below P9, bottom)"; else prio_label="P$self_prio"; fi
echo "This PR #$SELF_PR effective priority: $prio_label (P0 = highest/emergency, P9 = lowest, 'broken' = bottom)."
# ── PASS 1: PREEMPTION — cancel a strictly-lower OTHER PR's active runs ──
# A strictly-lower-priority (prio > self) OTHER PR's active CI run is
# cancelled iff keeping it running is wasteful, i.e. EITHER:
# • THIS PR is P0 (emergency — reclaim every lower runner now), OR
# • that OTHER PR is `broken` (its run can't merge, so ANY higher-priority
# PR — not just P0 — may reclaim its runner).
# Otherwise (we're P1–P9 and the target isn't broken) we DON'T cancel; we
# only yield to genuinely-higher-priority PRs in PASS 2.
# Emit "number<TAB>head<TAB>prio<TAB>broken(0|1)" for every OTHER open PR
# (broken => effective prio 10, the bottom, overriding any P0–P9 label).
jq -r --argjson self "$SELF_PR" '
.[] | select(.number != $self)
| (any(.labels[]; .name == "broken")) as $b
| [ .number, .headRefName,
(if $b then 10
else ([ .labels[] | .name | select(test("^P[0-9]$")) | ltrimstr("P") | tonumber ]
| if length == 0 then 5 else min end) end),
(if $b then 1 else 0 end) ]
| @tsv' open_prs.json 2>/dev/null > others.tsv
cancelled_total=0
while IFS=$'\t' read -r num head prio isbroken; do
[ -n "${num:-}" ] || continue
case "$prio" in ''|*[!0-9]*) prio=5 ;; esac
[ "$isbroken" = "1" ] || isbroken=0
# Never touch an equal-or-higher-priority PR — only strictly lower.
if [ "$prio" -le "$self_prio" ]; then
echo "· PR #$num (P$prio): equal-or-higher priority — left untouched."
continue
fi
# Strictly lower, but only a P0 self OR a broken target is preemptible.
if [ "$self_prio" -ne 0 ] && [ "$isbroken" != "1" ]; then
echo "· PR #$num (P$prio): strictly lower, not broken, and we're not P0 — left to run (we don't cancel it)."
continue
fi
if [ "$self_prio" -eq 0 ]; then reason="P0 emergency"; else reason="target is 'broken'"; fi
tag="P$prio"; [ "$isbroken" = "1" ] && tag="broken"
echo "· PR #$num ($tag, head '$head'): preemptible ($reason) — checking for active CI runs."
run_ids=$(active_run_ids_for_head "$head")
if [ -z "$run_ids" ]; then
echo " no active CI runs."
continue
fi
while IFS= read -r run_id; do
[ -n "$run_id" ] || continue
[ "$run_id" = "${GITHUB_RUN_ID:-}" ] && continue # never cancel our own run
if gh run cancel "$run_id" 2>err.txt; then
echo " cancelled run $run_id (freed its runner)."
cancelled_total=$((cancelled_total + 1))
else
echo "::warning::could not cancel run $run_id — likely already finished. $(cat err.txt 2>/dev/null)"
fi
done <<< "$run_ids"
done < others.tsv
echo "Preemption pass complete — cancelled $cancelled_total run(s)."
# ── PASS 2: BOUNDED HOLD-BACK — yield to strictly-higher, cancel NOTHING ──
# P0 is top priority: nothing outranks an emergency, so it never yields.
if [ "$self_prio" -eq 0 ]; then
echo "P0 emergency — not yielding; proceeding immediately."
exit 0
fi
# Defer this PR's heavy jobs (which `needs: traffic-control`) while any
# strictly-higher-priority OTHER open PR still has an active/queued CI run,
# so those heavy jobs reach the runner queue first. Cancel NOTHING here.
# Bounded by budget; on expiry proceed regardless (never block ourselves,
# never hit the hard job timeout). Fail-open: any API hiccup => stop waiting.
case "$HOLD_BACK_BUDGET_SECONDS" in ''|*[!0-9]*) HOLD_BACK_BUDGET_SECONDS=180 ;; esac
case "$HOLD_BACK_POLL_SECONDS" in ''|*[!0-9]*) HOLD_BACK_POLL_SECONDS=15 ;; esac
deadline=$(( $(date +%s) + HOLD_BACK_BUDGET_SECONDS ))
echo "$prio_label — holding back up to ${HOLD_BACK_BUDGET_SECONDS}s for strictly-higher-priority PRs (no cancellation)."
while :; do
remaining=$(( deadline - $(date +%s) ))
if [ "$remaining" -le 0 ]; then
echo "Hold-back budget elapsed — proceeding; higher-priority PRs got their head start."
break
fi
# Refresh so newly opened higher-priority PRs are seen mid-wait.
if ! list_open_prs > open_prs.json 2>err.txt; then
echo "::warning::Could not refresh open PRs — proceeding. $(cat err.txt 2>/dev/null)"
break
fi
# Strictly-higher-priority OTHER PRs (broken => 10, so a broken PR is never
# higher than a non-broken one): "number<TAB>head<TAB>prio".
jq -r --argjson self "$SELF_PR" --argjson me "$self_prio" '
.[] | select(.number != $self)
| { n: .number, h: .headRefName,
p: (if any(.labels[]; .name == "broken") then 10
else ([ .labels[] | .name | select(test("^P[0-9]$")) | ltrimstr("P") | tonumber ]
| if length == 0 then 5 else min end) end) }
| select(.p < $me)
| [ .n, .h, .p ] | @tsv' open_prs.json 2>/dev/null > higher.tsv
if [ ! -s higher.tsv ]; then
echo "No strictly-higher-priority open PRs — proceeding."
break
fi
blockers=""
while IFS=$'\t' read -r num head prio; do
[ -n "${num:-}" ] || continue
if [ -n "$(active_run_ids_for_head "$head")" ]; then
blockers="$blockers #$num(P$prio)"
fi
done < higher.tsv
if [ -z "$blockers" ]; then
echo "No strictly-higher-priority PR has active CI runs — proceeding."
break
fi
sleep_s="$HOLD_BACK_POLL_SECONDS"
[ "$remaining" -lt "$sleep_s" ] && sleep_s="$remaining"
echo "Yielding to strictly-higher-priority PR(s) with active CI:${blockers} — re-checking in ${sleep_s}s (${remaining}s budget left)."
[ "$sleep_s" -gt 0 ] && sleep "$sleep_s"
done
echo "Hold-back complete — this PR's heavy jobs may now start."
exit 0
debug-build:
name: Debug build
needs: traffic-control # order after runner-priority orchestration (P0/broken preempt; P1–P9 hold-back)
# x86_64: Linux-arm64 runners can't set up this SDK — android-actions/setup-android's sdkmanager
# fails (exit 1) on the android-37.0 preview platform, and the emulator package has no arm64-Linux
# build. Build/unit-test results are host-arch-independent anyway (R8/AGP/JVM); real arm64
# device-ABI coverage would need arm64 emulators, which require macOS hosts.
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Assemble debug APK
run: ./gradlew assembleDebug --stacktrace
- name: Upload debug APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
unit-tests:
name: Unit tests
needs: traffic-control # order after runner-priority orchestration (P0/broken preempt; P1–P9 hold-back)
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace
- name: Upload unit test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unit-test-report
path: app/build/reports/tests/testDebugUnitTest/
if-no-files-found: warn
# JaCoCo XML + HTML coverage for the JVM unit tests (issue #192), scoped to the JVM-testable
# surface (issues #290/#292). The report is generated and uploaded first, then a no-regression
# gate (issue #251) fails the job if overall LINE coverage drops below the floor pinned in
# app/build.gradle.kts. Kept in this unit-test job so it is part of the `CI passed` gate.
- name: Generate JaCoCo coverage report
if: ${{ !cancelled() }}
run: ./gradlew :app:jacocoTestReport --stacktrace
- name: Upload coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-coverage-report
path: app/build/reports/jacoco/jacocoTestReport/
if-no-files-found: warn
# No-regression coverage gate (issue #251): fails CI if scoped LINE coverage regresses below the
# floor pinned in app/build.gradle.kts. Runs after the upload so the HTML/XML report is always
# archived for triage even when this step goes red.
- name: Verify JaCoCo coverage (no-regression floor)
if: ${{ !cancelled() }}
run: ./gradlew :app:jacocoTestCoverageVerification --stacktrace
static-analysis:
name: Static analysis
needs: traffic-control # order after runner-priority orchestration (P0/broken preempt; P1–P9 hold-back)
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
# AGP configuration needs the SDK even for ktlint/detekt (they run on the :app module).
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# --continue so a ktlint failure still lets detekt report (and vice versa).
- name: Run ktlint and detekt
run: ./gradlew :app:ktlintCheck :app:detekt --continue --stacktrace
- name: Upload analysis reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: static-analysis-reports
path: |
app/build/reports/ktlint/
app/build/reports/detekt/
if-no-files-found: warn
e2e:
name: E2E
needs: traffic-control # order after runner-priority orchestration (P0/broken preempt; P1–P9 hold-back)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Every Android API level across the rolling ~7-year support window: minSdk (29 / Android 10,
# 2019) through the latest stable. Each level boots its own emulator and runs the full
# instrumented + Compose UI (E2E) suite; all of them fan in to the "CI passed" gate. When a
# new Android ships, add it and drop the oldest level that has aged out of ~7 years. API 37
# (preview) is NOT in this matrix because emulator-runner can't provision its nonstandard
# android-37.0 / google_apis_ps16k image (it would wedge the gate) — it's covered separately
# by the custom-provisioned `e2e-preview` job below. Keep in sync with
# testOptions.managedDevices in app/build.gradle.kts.
api-level: [29, 30, 31, 32, 33, 34, 35, 36]
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# The hardware-accelerated emulator needs KVM, which is gated behind a udev rule.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cache AVD snapshot
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api-level }}-google_apis-x86_64
# On a cache miss, cold-boot the emulator once so its snapshot can be cached,
# making subsequent runs start from a warm snapshot.
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
# reactivecircus/android-emulator-runner runs an un-guarded, fatal `adb shell input keyevent 82`
# after boot. On snapshot resume that can race system_server (sys.boot_completed=1 before the
# `input` binder service is republished), aborting the job before Gradle runs with
# "No service published for: input" — an ~2%, API-29-only infra flake, not a test failure. Make
# the step non-fatal and retry once: two independent boots drop the race to ~0.04%. The definitive
# fix (adopt the e2e-preview job's manual-boot + `keyevent 82 || true`) is tracked separately.
- name: Run E2E tests
id: e2e
continue-on-error: true
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace
- name: Run E2E tests (retry after emulator boot race)
if: steps.e2e.outcome == 'failure'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace
- name: Upload E2E test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-report-api${{ matrix.api-level }}
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
# API 37 (Android 17, preview) E2E. Its only system image is the nonstandard
# android-37.0 / google_apis_ps16k (16 KB page size), which reactivecircus/android-emulator-runner
# can't provision (it builds android-37 / google_apis, neither of which exists), so this job
# CUSTOM-PROVISIONS the emulator with sdkmanager/avdmanager/emulator directly. It is REQUIRED:
# part of the "CI passed" gate's needs (the preview emulator has proven stable in practice), so a
# genuine failure blocks merges. When a stable, emulator-runner-friendly API 37 image ships, fold
# 37 into the main `e2e` matrix and delete this job.
e2e-preview:
name: E2E (API 37 preview)
needs: traffic-control # order after runner-priority orchestration (P0/broken preempt; P1–P9 hold-back)
runs-on: ubuntu-latest
timeout-minutes: 35
env:
API37_IMAGE: "system-images;android-37.0;google_apis_ps16k;x86_64"
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# The hardware-accelerated emulator needs KVM, which is gated behind a udev rule.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# Cache the ~1 GB preview system image so only the first run pays the download.
- name: Cache API 37 system image
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
# GitHub-hosted ubuntu runners install the SDK at /usr/local/lib/android/sdk; caching the
# image dir (with its package metadata) lets sdkmanager treat it as installed and skip the
# re-download on a cache hit.
path: /usr/local/lib/android/sdk/system-images/android-37.0
key: sysimg-android-37.0-google_apis_ps16k-x86_64
- name: Install SDK packages + preview system image
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" "platform-tools" "emulator" "$API37_IMAGE"
- name: Create API 37 AVD
run: |
# avdmanager and the emulator disagree on the default AVD dir when ANDROID_SDK_HOME is set
# on the runner (avdmanager writes $ANDROID_SDK_HOME/.android/avd; the emulator looks in
# $ANDROID_SDK_HOME/avd), which made the boot step report "Unknown AVD name [api37]". Pin
# ANDROID_AVD_HOME so both agree, and carry it to the boot step via $GITHUB_ENV.
export ANDROID_AVD_HOME="$HOME/.android/avd"
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV"
mkdir -p "$ANDROID_AVD_HOME"
echo "no" | avdmanager create avd -n api37 -k "$API37_IMAGE" -d pixel_2 --force
echo "AVDs visible to the emulator:"; "$ANDROID_SDK_ROOT/emulator/emulator" -list-avds
- name: Boot emulator and run E2E
run: |
set -euo pipefail
EMU_LOG="${RUNNER_TEMP:-/tmp}/emulator.log"
boot_emulator() {
echo "::group::Start API 37 emulator (attempt $1)"
# Capture the emulator's own output — without this a boot failure is invisible.
"$ANDROID_SDK_ROOT/emulator/emulator" -avd api37 \
-no-window -no-audio -no-boot-anim -no-snapshot -accel on \
-gpu swiftshader_indirect -camera-back none -camera-front none > "$EMU_LOG" 2>&1 &
# ONE bounded wait covering both device registration and full boot, so a stuck emulator
# fails fast instead of hanging the whole job until the 35-min cap (the original bug).
if timeout 300 adb wait-for-device shell \
'while [ "$(getprop sys.boot_completed | tr -d "\r")" != "1" ]; do sleep 2; done'; then
echo "::endgroup::"; return 0
fi
echo "::endgroup::"
echo "::warning::API 37 emulator did not boot within 300s (attempt $1)"
adb devices || true
echo "--- emulator.log (tail) ---"; tail -120 "$EMU_LOG" || true
adb emu kill 2>/dev/null || true
sleep 5
return 1
}
booted=0
for attempt in 1 2; do boot_emulator "$attempt" && { booted=1; break; }; done
[ "$booted" = "1" ] || { echo "::error::API 37 preview emulator failed to boot after 2 attempts"; exit 1; }
adb shell input keyevent 82 || true
./gradlew connectedDebugAndroidTest --stacktrace
- name: Dump emulator log on failure
if: failure()
run: |
echo "--- emulator.log ---"; tail -200 "${RUNNER_TEMP:-/tmp}/emulator.log" 2>/dev/null || echo "(none)"
echo "--- logcat ---"; adb logcat -d 2>/dev/null | tail -120 || echo "(device unavailable)"
- name: Shut down emulator
if: always()
run: adb emu kill || true
- name: Upload E2E (API 37) report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-report-api37-preview
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
# Single aggregating gate so branch protection can require ALL CI jobs with one stable status
# check. It depends on every job — including each api-level of the E2E matrix — so adding/removing
# a matrix level needs no change to branch protection (the per-"(api-level)" check names would
# otherwise have to be re-listed each time).
ci-passed:
name: CI passed
if: always()
# `traffic-control` is intentionally NOT listed here — it is a best-effort
# optimizer, not a merge requirement. But because the heavy jobs `needs:` it,
# a (should-never-happen) traffic-control failure would mark them 'skipped';
# treating 'skipped' as a gate failure below keeps that fail-safe (blocks the
# merge rather than letting it through untested).
needs: [static-analysis, debug-build, unit-tests, e2e, e2e-preview]
runs-on: ubuntu-latest
steps:
- name: Verify every required job succeeded
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
run: |
echo "Required CI jobs did not all succeed:"
echo " static-analysis: ${{ needs.static-analysis.result }}"
echo " debug-build: ${{ needs.debug-build.result }}"
echo " unit-tests: ${{ needs.unit-tests.result }}"
echo " e2e: ${{ needs.e2e.result }}"
echo " e2e-preview: ${{ needs.e2e-preview.result }}"
exit 1