Root cause: intermittently the launched activity window has has-window-focus=false for the WHOLE instrumented run, so Espresso's RootViewPicker (onView().check(), Intents.intended(), pressBack(), focus-dependent clipboard) times out after 10s and fails EVERY focus-dependent test at once while the ~280 pure-Compose semantics tests (which don't need window focus) pass. A failing E2E (35) leg's logcat (PR #470, run 28985259521) shows has-window-focus=true ZERO times across the whole session and both the first attempt and the once-retry fail identically -- a persistent environmental state, not a per-test transient. The prior mitigation, a single fire-and-forget `adb shell input keyevent 82` (MENU) right after boot, is too weak: MENU no longer dismisses the modern (API 30+) keyguard and, delivered before SystemUI/keyguard comes up, is simply dropped -- so the insecure keyguard / non-interactive display persists and no app window ever takes focus. Fix: a single shared helper, .github/scripts/emulator_focus_gate.py, invoked identically by BOTH E2E jobs (the e2e API 29-36 matrix AND e2e-preview API 37) and by the local preflight runners (local_instrumented.py / api37_e2e.py), so it cannot drift. It wakes the display (KEYCODE_WAKEUP), dismisses + disables the keyguard (wm dismiss-keyguard, locksettings set-disabled true), keeps the screen on (svc power stayon true + max screen_off_timeout), zeroes the animation scales, then polls dumpsys power/window until the device is interactive AND a real window holds input focus (mCurrentFocus non-null) -- re-nudging each iteration -- before the suite runs. Applied uniformly, this also gives e2e-preview the animation-disable the matrix already had. The gate is soft (bounded wait, then proceeds with a ::warning:: and the final device state) and non-fatal (`|| true`), preserving #454's guarantee that the unlock never aborts the boot; it leaves #454's manual boot, #460's path-filter and #464's wedge-capture untouched. The pure readiness parser is unit-tested by test_emulator_focus_gate.py (run by the traffic-control-tests job). Determinism is validated by this PR's own matrix run. Closes #468
GitHub Actions workflows
This directory holds the repo's workflows:
ci.yml— the pull-request gate: build, unit tests, static analysis (ktlint / detekt), and the E2E/instrumented-test matrix, aggregated into oneCI passedcheck that branch protection requires. It also runs thetraffic-controljob described below.autoupdate.yml— rebases every open PR ontomainwhenevermainadvances, so the "branches up to date" branch rule never needs a manual update.release.yml— turns a pushed version tag into signed, published release artifacts; seedocs/release.md.
The rest of this README is about traffic-control — the job (in the Checks tab it
shows up as "Traffic control (runner priority)") that decides whose CI gets to run
first when several PRs are queued at once.
Why this job exists
GitHub Actions has no concept of "run this PR's checks before that one" — every PR's workflow run joins the same pool of runners and is served roughly first-come, first-served. That's fine most of the time, but with several PRs open at once it means an urgent one-line hotfix queues up as an equal to a routine refactor, and can end up stuck waiting behind CI runs for changes that aren't in any hurry.
traffic-control addresses that by reading a priority label on the current PR,
comparing it against every other open PR, and then either freeing up a runner by
cancelling a lower-priority PR's run (preemption), or briefly waiting before this
PR's own heavy jobs start so a higher-priority PR's jobs get a head start
(hold-back). It runs first in every PR's CI: every other job in ci.yml
(debug-build, unit-tests, static-analysis, e2e, e2e-preview) declares
needs: traffic-control, so it always goes first —
PR's CI run starts
│
▼
traffic-control
│ 1. compute this PR's effective priority (see table below)
│ 2. PASS 1 — preemption: cancel strictly-lower-priority OTHER PRs' active
│ runs, but only if we're P0, or the target PR is `broken`
│ 3. PASS 2 — hold-back: if we're not P0, wait (up to 180s) while any
│ strictly-higher-priority OTHER PR still has an active run, then
│ proceed regardless
▼
debug-build · unit-tests · static-analysis · e2e · e2e-preview
Effective priority
Priority comes from a label on the PR:
| Label | Effective priority | Meaning |
|---|---|---|
P0 |
0 (highest) | Emergency only — production is broken, or an emergency security fix. |
P1 – P9 |
1 – 9 | Higher number = lower priority. |
(no P label) |
5 (default) | Normal priority — most PRs. |
broken |
10 (lowest) | A stuck/failing PR, deprioritised below even P9. Overrides any P0–P9 label also present. |
Apply at most one P0–P9 label; if more than one is somehow present, the numerically
lowest (most urgent) one wins. The broken label is meant to be applied by a maintainer
to a PR whose CI is stuck or failing, as a "let everyone else go first while this gets
fixed" signal — not something a PR author sets on their own work. Removing it restores
whatever P0–P9 priority (or the P5 default) the PR would otherwise have.
Preemption vs. holding back
P0 preempts everyone lower
If this PR is P0, it's treated as an emergency: the job immediately cancels the
in-progress or queued CI runs of every other open PR at a strictly lower priority
(that is, anything that isn't also P0), freeing up their runners right away. A PR
that gets cancelled this way isn't harmed long-term — it simply reruns on its next push,
or the next time autoupdate.yml rebases it onto main. Because nothing outranks an
emergency, a P0 PR also never does the hold-back wait described below.
A broken PR can be preempted by anyone
A PR labelled broken can't merge while it's broken, so its CI run occupying a runner
is wasted capacity. Any PR that isn't itself broken — in other words, any PR with a
real P0–P9 priority — outranks it and may cancel its active run to reclaim the
runner, not just a P0 PR. broken is also the only priority level that yields to
everything: since it sits below every other level, it always waits for other PRs'
runs rather than the other way around.
P1–P9 yield, but never cancel
Every other level (P1–P9, including the P5 default) is cooperative rather than
aggressive: it never cancels a run that's already going, no matter how much lower that
run's priority is. Instead, before letting its own heavy jobs start, it checks whether
any strictly higher-priority PR currently has an active or queued CI run. If so, it
waits — polling every 15 seconds and re-checking the full list of open PRs each time, so
a newly opened higher-priority PR is picked up mid-wait too — giving that PR's jobs a
chance to reach the runner queue first. The wait is capped at 180 seconds
(comfortably inside the job's 6-minute hard timeout); once the budget runs out, this PR
proceeds regardless. A PR should never be able to block itself indefinitely.
Safety invariants
Whatever the priority math says, a few things are hard-coded to never happen:
- Never touches
main/ push-triggered runs. The job only acts onpull_requestevents, and every run it's even allowed to consider cancelling is filtered down toevent == pull_requestwithheadBranch != main. - Never cancels this PR's own run. The current PR is excluded from the "other PRs" list up front by PR number, and the currently-executing run ID is skipped too, just in case.
- Never cancels an equal-or-higher-priority run. Only strictly-lower-priority PRs (a numerically larger, i.e. worse, priority) are ever candidates for cancellation.
Honest limitation
This is a best-effort head start, not a real priority queue. GitHub Actions has no API for "give this run's jobs priority over that run's jobs" — runners are handed out roughly FIFO no matter what this job does. Hold-back approximates priority by making lower-priority PRs wait a little before their jobs even enter that FIFO queue, but under sustained contention (many PRs queuing at once) the bounded wait can run out before a higher-priority PR's jobs have actually made it through the runner pool. The waiting job itself is cheap and short-lived, which is exactly why the wait is capped rather than open-ended — occasionally under-prioritizing is preferable to a job that ties up a runner indefinitely just to wait.
Not a merge gate
traffic-control is an optimizer, not a check your PR needs to pass. It's deliberately
left out of ci-passed's needs: list, every GitHub API call it makes is guarded
against failure, the script always exits 0, and the step itself runs with
continue-on-error: true. A hiccup here — a transient API error, a missing permission,
a fork PR without write access — can never fail or block your PR.
That said, the heavy jobs still order themselves after it via needs: traffic-control,
so if this job were ever skipped or failed outright, GitHub would mark those jobs
skipped — and ci-passed treats a required job coming back skipped as a gate
failure. So the worst case is fail-safe: it blocks the merge rather than letting an
untested PR through.
It also needs very little to run: no checkout step (it only calls the gh CLI), and
just two permissions (actions: write to cancel runs, pull-requests: read to read
labels). Values that come from outside the repo — labels, branch names — are only ever
read through gh's JSON output into shell variables, never interpolated as shell code.
Where this is heading
#342 is rewriting this logic as a tested Python module
(.github/scripts/traffic_control.py), with a couple of small behavior refinements:
draft PRs will also sink to the bottom (like broken), and PRs at the exact same
priority level get an explicit order (whichever run is already in flight finishes
first; among the rest, whoever has been waiting longest goes next). This README
describes the shell-script version currently in ci.yml — see the comment block above
the traffic-control job there for the byte-for-byte spec — and will be updated once
#342 lands.