End the merge cascade and give the traffic-controller ownership of CI *triggering*.
- autoupdate.yml updates PR branches with the built-in GITHUB_TOKEN instead of a PAT,
so an update push no longer auto-retriggers CI (GitHub's anti-recursion rule) — the
cascade (every merge re-runs every PR, cancel-in-progress thrashing them) is gone.
- New scheduler ci-trigger.yml -> traffic_control.py --mode trigger (re-)triggers CI
for the highest-priority PR(s) whose head SHA has absent/stale checks, a few at a
time (inflight cap), in the existing P0-P9 / broken-draft priority order — a
poor-man's merge queue reusing the priority core. It runs after autoupdate finishes
(workflow_run, race-free) plus a cron backstop plus manual dispatch.
- Triggering uses workflow_dispatch, which is EXEMPT from anti-recursion, so the
built-in GITHUB_TOKEN (actions: write) starts the run — NO PAT / secret change needed.
- ci.yml gains a workflow_dispatch trigger (pr/head_sha/reason inputs) and a per-PR
concurrency group unifying pull_request and dispatch runs; its on: pull_request path
is kept so brand-new PRs, human pushes, and fork PRs always get CI (fail-open).
Pure select_triggers / classify_sha_runs decision core added to traffic_control.py with
24 new unit tests (priority order, oldest-first fairness, inflight cap, fork skip, P0
bypass+preempt, head-SHA needy classification, and a liveness/anti-starvation simulation).
Closes#349
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a fast traffic-control-tests job (ubuntu, actions/checkout +
actions/setup-python, no emulator/Gradle) that runs the 37 pure-stdlib
unit tests for .github/scripts/traffic_control.py on every PR, and
wires it into ci-passed's needs so a regression blocks merge instead
of only being caught locally.
Closes#346
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore (and extend to drafts) the old bash's broken-reclaim behaviour that the
initial Python refactor had dropped. runs_to_cancel now cancels an OTHER PR's
active/queued runs when EITHER:
(a) THIS PR is P0 and that PR is strictly-lower (reclaim every lower runner); OR
(b) that PR is broken/draft (effective priority 10) and THIS PR is strictly-higher
(effective priority < 10) — a wasted run any ready PR may reclaim.
P1-P9 still never bump a *normal* (non-broken/draft) lower run; a broken/draft PR
(P10) preempts nothing (nothing is strictly-lower than the bottom, and the
equal-or-higher invariant means a P10 never cancels another P10). Self / main-push /
equal-or-higher invariants unchanged.
Updates the module docstring + ci.yml comments (the "only P0 preempts" wording
becomes: P0 preempts everything strictly-lower; additionally, any strictly-higher PR
preempts a broken/draft run) and the job step/permission/needs comments. Adds unit
tests: P3 reclaims a broken P10 run and a draft P10 run; P3 does not bump a normal P5
run; a P10 self preempts nothing; plus an end-to-end P5-reclaims-draft-then-waits
scenario. 37 unit tests pass; ci.yml parses clean; --dry-run shows a P3 cancelling a
draft (and broken) run while still yielding to a higher P1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The priority-based runner orchestration ("traffic-control") lived as a large
inline-bash step in ci.yml — a two-pass preemption + hold-back script that was
effectively untestable in YAML. Move it into .github/scripts/traffic_control.py,
structured as a pure decision CORE + a thin gh-I/O SHELL:
* Pure functions (no network/clock/subprocess), unit-testable in isolation:
- effective_priority(pr): lowest-numbered P0-P9, default P5; broken OR draft => 10.
- runs_to_cancel(this_pr, all_prs, self_run_id): PASS 1 — run ids to cancel,
empty unless THIS PR is P0; only strictly-lower running/queued runs; never self
(by number or run id), never equal-or-higher.
- wait_blockers(this_pr, all_prs): PASS 2 — yield to any strictly-higher PR with
an active/queued run, and to same-level peers ordered ahead (running-first,
then oldest createdAt). Empty => proceed.
* Shell (run_live): gathers the snapshot via gh, applies cancels, runs the bounded
hold-back poll loop; always exits 0. --dry-run feeds the core a snapshot JSON and
prints decisions with zero network.
* No jq/bash dependency (cross-platform, per the repo's Python-stdlib convention).
ci.yml's traffic-control job now checks out the repo and runs the module. Job
permissions gain `contents: read` (for checkout) alongside the existing
`actions: write` / `pull-requests: read`; env and downstream `needs:` wiring
unchanged; step stays `continue-on-error`.
33 stdlib unittest cases cover priority resolution, P0-only preemption, the
self/main/equal-or-higher invariants, and the same-level running-first/oldest
ordering.
Behaviour is preserved except the ticket's refinements: (1) drafts now count as
P10 (bottom); (2) an explicit same-level running-first-then-oldest tiebreaker; and
(3) per the ticket's order-of-operations, ONLY P0 preempts — the old bash also let
any higher-priority PR cancel a `broken` target's run, which no longer happens
(a broken/draft run is only cancelled by a P0, via the same strictly-lower rule).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API 37 preview E2E boot has flaked twice (#285, #333) with only
"did not boot within 300s" and no root-cause signal. Add rich boot
diagnostics by default, kept in parity between CI and the local
hand-provisioning script (api37_e2e.py):
- Launch the emulator with `-verbose -debug init,avd_config,kernel`
(diagnostics only; no boot-affecting flag changed), still redirecting
to $EMU_LOG.
- Stream `adb logcat -v time` to a file from the moment the device
registers (via `adb wait-for-device logcat`, backgrounded).
- On a boot timeout, dump accel-check, /dev/kvm presence, GPU mode,
free mem/disk, the AVD config.ini and the emulator.log tail; CI writes
these to a boot-diagnostics file, the local script prints them.
- CI uploads emulator.log + logcat.txt + boot-diagnostics.txt as an
artifact with `if: always()` so they survive a timeout/cancel, and
prints a concise summary (accel/KVM status + last 50 lines of
emulator.log) to the step log.
The existing 2-attempt boot retry + boot-completed wait loop are
unchanged; the diagnostics are additive.
Closes#334
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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#251Closes#292
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only P0 preempts in-progress runs (emergency reservation). P1-P9 no longer
cancel lower-priority runs; instead traffic-control holds back (bounded poll,
kept under timeout-minutes) while strictly-higher-priority PRs still have
active/queued CI runs, so their heavy jobs reach the runner queue first.
New `broken` label forces effective priority below P9 (sentinel 10): a broken
PR never preempts (even if also labelled P0 -- broken wins) and always yields,
and because its run is wasted, ANY higher-priority PR (not just P0) may cancel
its in-progress run to reclaim the runner. Net rule: a strictly-lower run is
cancelled iff (self is P0) OR (target is broken); otherwise yield.
All existing safety preserved: never main/push runs, never our own run, never
an equal-or-higher-priority PR; PR-controlled strings via env/jq only;
continue-on-error + set +e + always exit 0; traffic-control stays a
non-required best-effort job and ci-passed is unchanged.
Validated with actionlint and a mocked-gh + fake-clock logic harness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a lightweight `traffic-control` job that runs first (the heavy
build/E2E jobs `needs:` it) and preempts contended runners by PR
priority. It reads the triggering PR's P0–P9 label (P0 = highest,
P9 = lowest; default P5 when unlabeled) and cancels the in-progress /
queued CI runs of strictly-lower-priority OTHER open PRs, freeing their
runners for the higher-priority PR.
Safety: never cancels main/push runs, the PR's own run, or an
equal-or-higher-priority PR — only strictly-lower-priority OTHER open
PRs' active CI runs. The job is best-effort (every gh call guarded,
always exits 0, step is continue-on-error) and is NOT part of the
`CI passed` merge gate. `ci-passed` now also treats a `skipped` heavy
job as a gate failure, so a (should-never-happen) traffic-control
failure blocks the merge fail-safe rather than passing it untested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the crash-interrupted #192 WIP (app/build.gradle.kts already had a
jacocoTestReport task and toolVersion pin recovered onto build-192-jacoco):
- Move the JaCoCo tool version into gradle/libs.versions.toml instead of a
hardcoded string in app/build.gradle.kts, matching how every other plugin
version in this repo is sourced.
- Fix the generated-code exclusion list against the real compileDebugKotlin
output (verified by inspecting the compiled class tree): Room's
KSP-generated `_Impl` DAOs/database and the Compose compiler's per-file
ComposableSingletons holders are the only generated code that actually
lands in classDirectories, since Hilt/Dagger's generated Java and AGP's
BuildConfig/R/Manifest are compiled by a separate javac task this report
never reads. Drop the blanket `**/*$$*` exclude the WIP had — it was
silently discarding ~200 real classes' worth of coverage on Kotlin's own
`$$inlined$` synthetic classes (e.g. Flow.map { ... } transforms in the
repositories), which is hand-written logic, not generated boilerplate.
- Add Hilt_*/Dagger* prefix patterns so the (currently inert,
belt-and-suspenders) Hilt exclusions are actually correct if the
classDirectories scope ever changes.
- Add a minimal CI step to the existing unit-tests job that runs
jacocoTestReport and uploads the XML+HTML report as a build artifact.
No coverage threshold gate yet (a jacocoTestCoverageVerification rule is
a natural follow-up once there's a baseline).
- Document the new :app:jacocoTestReport task in CLAUDE.md.
Verified on JDK 21: fast gate (assembleDebug, testDebugUnitTest,
compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt) plus
jacocoTestReport all pass, from both a warm and a `clean` build. The
report shows real signal (30% instruction / 38% line coverage) with no
generated classes leaking in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The matrix E2E (29) job intermittently fails (~2%, API-29 only) in
reactivecircus/android-emulator-runner's un-guarded, fatal post-boot
`adb shell input keyevent 82`: on snapshot resume sys.boot_completed=1 is
restored before system_server republishes the `input` binder service, so
the job aborts before Gradle runs with "No service published for: input"
(fast-fail ~1m43s). Proven on run 28667366203.
Make the "Run E2E tests" step non-fatal (id + continue-on-error) and add a
guarded second attempt (if steps.e2e.outcome == 'failure'). Two independent
boots drop the race to ~0.04%; a genuine failure on both attempts still
fails the job (outcome, not conclusion). Definitive manual-boot fix: #218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a `static-analysis` job (JDK 21 + Android SDK) that runs
`:app:ktlintCheck :app:detekt` and uploads the reports, and add it to the
`ci-passed` aggregating gate so lint regressions block merges like the
other checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom-provisioned API 37 preview emulator has been stable, so fold its E2E
job into the aggregating "CI passed" gate's needs. Because branch protection
requires only that single check, no settings change is needed.
Drop the now-inaccurate "non-blocking" wording from the job name and comments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The preview emulator failed every boot with "Unknown AVD name [api37]" and
exited immediately (no device -> the bounded wait timed out). avdmanager had
created the AVD under $ANDROID_SDK_HOME/.android/avd while the emulator searched
$ANDROID_SDK_HOME/avd and $HOME/.android/avd. Pin ANDROID_AVD_HOME to one path
both tools use, carry it to the boot step via $GITHUB_ENV, and list AVDs after
creation to verify.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom-provisioned preview job hung in 'Boot emulator and run E2E' until
the 35-min cap: adb wait-for-device had no timeout and the emulator's own
output was never captured, so a failed boot was both invisible and unbounded.
Bound the wait with a single 300s timeout covering device registration + full
boot, retry once, capture the emulator log, and dump it (plus logcat) on
failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Linux-arm64 runners can't set up the SDK here: 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. These are pure build/JVM-unit jobs whose
results are host-arch-independent, so x86_64 loses no device coverage (real
arm64 ABI coverage would require arm64 emulators, i.e. macOS hosts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folder view: a left navigation drawer lists each account's IMAP folders;
tapping one browses and caches that folder's mail. IMAP UIDs are unique only
within a folder, so message identity, the fetch/read/flag/delete paths, sync,
and the Room cache all became folder-aware (id = "accountId:folder:uid"; new
`folder` column; schema v7->v8). Standard folders (Inbox/Sent/Drafts/Spam/Trash/
Archive) surface with friendly names + icons via RFC 6154 SPECIAL-USE attributes
with a case-insensitive name fallback; the multi-account drawer adds an account
switcher and a unified "All Inboxes". INBOX stays the only auto-synced,
IDLE-watched, notifying folder; other folders sync on demand.
Lower minSdk 33 -> 29 for a rolling ~7-year Android support window; guard the
API-33 POST_NOTIFICATIONS runtime request accordingly.
Tests and CI:
- Bump espresso-core 3.6.1 -> 3.7.0 so Compose UI tests run on API 37
(3.6.1's InputManagerEventInjectionStrategy reflects a removed hidden method).
- New coverage across layers: FolderRoleTest, ImapClientTest folder cases,
MailboxViewModelTest, MailRepositoryImplTest folder routing, a FolderDrawer
Compose UI test, and LibreMailDatabaseTest folder DAO/reconcile tests.
- Gradle Managed Devices + a CI E2E matrix over every API 29-36; a single
"CI passed" gate job fans in all jobs and is required by branch protection.
- Non-blocking, custom-provisioned API 37 (preview) E2E job with image caching.
- Build + unit-test jobs run on arm64 (ubuntu-24.04-arm); emulators stay x86_64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GitHub Actions (every action pinned to its commit SHA, version in a comment):
- ci.yml, on pull_request to main, runs three jobs: a debug build, the
unit tests, and the instrumented suite on a headless emulator.
- release.yml, on workflow_dispatch, builds the release APK, archives the
source as zip + tar.gz, and publishes a GitHub release.
Compose UI tests (app/src/androidTest), driving the real screens with
fake-backed view models so they need no network, database, or Hilt graph:
- ManualSetupScreen: submit-button validation, advanced-options toggle,
and add-account success/failure.
- ComposeScreen: send-button enablement and the send -> close flow.
- LibreMailBottomBar: tab rendering and selection callback.
Mark gradlew executable (100755) so it runs on the Linux CI runners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>