feat(wear): controller visual redesign — fixed palette, §4 layout, numeric editor, gates G1–G6 #497
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: v3 Mockup Appearance Gate | |
| # `documentation/mockups/pass2d.html` is the appearance contract the eight screens of the v3 arc are | |
| # built from, and nothing else in CI reads HTML. `documentation/mockups/shell_gate.py` is the | |
| # instrument that does — nine checks, two of them driven through a real browser — and until this | |
| # workflow it ran by hand, which is the standing B9 names: the contract was verified whenever | |
| # somebody remembered to verify it. | |
| # | |
| # It runs as its own workflow rather than as a step in `android_build_unified.yml` for two reasons. | |
| # It is seconds long and needs no emulator, no JDK, no keystore and no secret of any kind, so a step | |
| # there would sit behind `assembleDebug` and the rest of a 60-minute job for no gain. And that | |
| # workflow is `workflow_call`-ed by `android_deploy_prod.yml`, which would quietly put a mockup | |
| # appearance question on the release path, where it can block a production deploy. | |
| # | |
| # There is deliberately NO skip input. The house pattern for one is `skip_ui_tests` / | |
| # `skip_buid_checks` in `android_deploy_prod.yml` (`type: boolean`, `default: false`, a description | |
| # naming the single legitimate use), and it is the right shape for a 60-minute emulator suite being | |
| # retried. It is the wrong shape here: this gate's whole claim is that it ran, and a switch that | |
| # turns it into a green no-op is the failure it exists to refuse. Same reasoning as the script's own | |
| # "a missing browser is a FAILURE, never a skip". | |
| on: | |
| # PRs into `master` are excluded, and that is not a convenience. | |
| # | |
| # `pr_guard.yml` reds any PR to `master` whose head is not `release/release-v.X.Y.Z` — a check, so | |
| # it reports rather than prevents, but the convention it polices means a PR to `master` is a | |
| # roll-up of commits already reviewed on `dev`, never the PR that changes the mockup. The second | |
| # reason stands alone regardless: `master` does not contain `documentation/mockups/` at all (it sits at | |
| # v1.48.0, 282 commits behind `dev`), so there is no baseline blob to read. Measured, not assumed: | |
| # | |
| # $ shell_gate.py --base $(git rev-parse origin/master) | |
| # shell_gate: git show b315fb90…:documentation/mockups/pass2d.html failed: | |
| # fatal: path 'documentation/mockups/pass2d.html' exists on disk, but not in 'b315fb90…' | |
| # EXIT=1 | |
| # | |
| # An unfiltered trigger therefore reds every release PR with an instrument failure dressed as a | |
| # verdict. Note this is a BRANCH filter, not a paths filter — the gate always runs for the PRs it | |
| # covers. A paths filter is the wrong tool here even though it looks like the obvious one: check 9 | |
| # reads `AppColors.kt` as well as the mockup, and the drift it was written for came from the | |
| # palette moving in Kotlin while the drawing stayed still, so a filter on | |
| # `documentation/mockups/**` would have missed the very thing that motivated the check. | |
| # | |
| # `edited` is in the type list, and it is the one entry that is not boilerplate. The default set is | |
| # `opened, synchronize, reopened`; retargeting a PR's base fires `edited` (`changes.base.ref`) and | |
| # NOT `synchronize`, because the head SHA does not change. For every other workflow here that is | |
| # harmless — `android_build_unified.yml` also has a bare `pull_request:` and builds HEAD, which the | |
| # retarget did not touch. This job is the only one in the repo whose measurement takes the base | |
| # branch as an INPUT (`github.base_ref` → `git merge-base` → `--base`), so without `edited` a green | |
| # earned against one baseline survives a retarget onto another and that stale green is what merges. | |
| # It fails OPEN, which is the direction this arc's own spec says a reader cannot recover from. | |
| # Verified rather than assumed, on public PRs whose head SHA never moved: after a base retarget the | |
| # only workflow that fired was the one subscribing to `edited`. | |
| # | |
| # The cost is a re-run on every title/body edit. This job is seconds; a stale green is not. | |
| pull_request: | |
| types: [ opened, synchronize, reopened, edited ] | |
| branches-ignore: [ master ] | |
| workflow_dispatch: | |
| workflow_call: | |
| inputs: | |
| ref: | |
| type: string | |
| required: true | |
| description: "Git ref to check out and gate" | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| mockup-gate: | |
| name: Mockup Appearance Gate | |
| runs-on: ubuntu-latest | |
| # Not the house's 60. This job is seconds; the only thing in it that can hang is the headless | |
| # browser, and the script already caps its own probe at 90s (`PROBE_TIMEOUT_S`), so anything | |
| # past a few minutes is a wedged runner rather than slow work. | |
| timeout-minutes: 10 | |
| defaults: | |
| run: | |
| # `shell: bash` is `bash -eo pipefail`, where the platform default is `bash -e` alone. The | |
| # steps below read git through pipelines; without pipefail a failing `git log` mid-pipe | |
| # yields an empty token list and a gate that runs in the wrong mode without saying so. | |
| shell: bash | |
| steps: | |
| - name: Checkout branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| # MANDATORY, and the one setting this job must not inherit from `android_build_unified.yml` | |
| # (which passes only `ref:` and so runs at the default depth of 1). Checks 1 and 3 read a | |
| # baseline blob via `git show <base>:<path>`, and the known negative below reads both | |
| # `f52462c7` and `9139d8c8`. Under a shallow clone every one of those dies as | |
| # `fatal: invalid object name` — exit 1, before a single check runs. | |
| fetch-depth: 0 | |
| # Explicit, not inherited. The script's floor is Python 3.12: it puts a backslash inside an | |
| # f-string replacement field, which is a SyntaxError before PEP 701. The runner image happens | |
| # to ship 3.12 today; pinning it means an image move announces itself here as a setup failure | |
| # rather than as a syntax error in a gate script nobody touched. | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| # Checks 7 and 8 render the file in a real browser, and they are the only instrument in this | |
| # repo that can see the class of escape the gate was built for: six structural checks | |
| # certified the mockup for several rounds while its nav indicator measured zero width. The | |
| # script treats an absent browser as a FAIL, never a skip, so this install is load-bearing. | |
| # | |
| # BEFORE YOU CHANGE THIS STEP, read §27 of documentation/feature-specs/v3-redesign-spec.md — | |
| # "the instrument's packaging changes its answer". The finding is recorded there with its | |
| # measurements and its run ids; what follows is the same thing in short. | |
| # | |
| # IT MUST BE THE DEB, and that is measured rather than preferred. Every UNPACKED build hangs | |
| # under this probe's flag set — it never returns, so the script's own 90s cap fires and the | |
| # run dies as "the browser did not return within 90s". Measured on one runner, each given 45s: | |
| # | |
| # /usr/bin/chromium (image's unpacked snapshot 150.0.7871.0) HANG | |
| # Chrome for Testing 151, Chrome for Testing 150, snapshot 153 (zips) HANG | |
| # /usr/bin/google-chrome (image's deb 150.0.7871.128) OK, 10.7s | |
| # this step's deb (151.0.7922.71) OK, 1.4s | |
| # | |
| # So the discriminator is packaging, not version: the 151 deb works where the 151 zip hangs. | |
| # `chrome-headless-shell` also completes, and is still not an option — it lays the page out | |
| # DIFFERENTLY (pill 113px against the 129px every other build and the script's own comment | |
| # agree on). The gate's assertions are loose enough not to notice, which is exactly why the | |
| # substitution would be wrong: an appearance gate measured through a browser that renders the | |
| # page differently is not measuring the drawing. | |
| # | |
| # `_current_amd64.deb` deliberately floats. Pinning a URL Google eventually deletes trades a | |
| # loud, immediate failure for a silent one later, and layout is identical across 150 and 151. | |
| - name: Install Google Chrome | |
| run: | | |
| curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 \ | |
| -o "$RUNNER_TEMP/google-chrome-stable.deb" \ | |
| https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb | |
| sudo apt-get install -y --no-install-recommends "$RUNNER_TEMP/google-chrome-stable.deb" | |
| /usr/bin/google-chrome --version | |
| # This step is not ceremony, and since the finding above it is not even only about | |
| # provenance — without it the gate TIMES OUT. | |
| # | |
| # `find_browser()` walks CHROME_CANDIDATES in order and takes the FIRST hit: chromium, | |
| # chromium-browser, google-chrome, google-chrome-stable, chrome. The runner image ships | |
| # `/usr/bin/chromium` and `/usr/bin/chromium-browser` as symlinks into an unpacked Chromium | |
| # snapshot — the two candidates the script tries FIRST, and the two that hang. The deb lands | |
| # at `google-chrome`, third. So the shadow is what makes the working browser the one the | |
| # script finds; an install whose result is never used is the exact shape of green this gate | |
| # exists to refuse, and here it does not even fail quietly. | |
| # | |
| # `$GITHUB_PATH` is prepended to PATH for subsequent steps, which beats `/usr/bin` without | |
| # touching a system directory. | |
| - name: Make the installed browser win the script's candidate search | |
| run: | | |
| test -x /usr/bin/google-chrome | |
| mkdir -p "$RUNNER_TEMP/browser-bin" | |
| ln -sfn /usr/bin/google-chrome "$RUNNER_TEMP/browser-bin/chromium" | |
| echo "$RUNNER_TEMP/browser-bin" >> "$GITHUB_PATH" | |
| - name: Assert the render checks have the browser they need | |
| run: | | |
| RESOLVED=$(command -v chromium) | |
| echo "chromium resolves to : $RESOLVED" | |
| echo "which points at : $(readlink -f "$RESOLVED")" | |
| echo "the installed deb is : $(readlink -f /usr/bin/google-chrome)" | |
| chromium --version | |
| if [ "$(readlink -f "$RESOLVED")" != "$(readlink -f /usr/bin/google-chrome)" ]; then | |
| echo "::error::the browser the script will pick is NOT the deb this job installed. The" | |
| echo "::error::shadow step above has stopped working, so the render checks are about to" | |
| echo "::error::run through the image's unpacked Chromium — which hangs, and will burn" | |
| echo "::error::90s per check before failing. Fix the shadow; do not accept the image's" | |
| echo "::error::browser silently." | |
| exit 1 | |
| fi | |
| # BASE is the merge-base against the PR's OWN base branch, and not against `dev`. | |
| # | |
| # The script's header carries a "DO NOT SIMPLIFY THIS BACK — BASE IS A PIN" warning against | |
| # `BASE=dev`. This is not that, and the difference is worth reading before touching this step. | |
| # The hazard there is a baseline that ALREADY CONTAINS the change under test: `f52462c7` put | |
| # the mockup on `dev` by direct commit, so with `BASE=dev` the diff was empty and checks 1 | |
| # and 3 passed by inspecting nothing. A pull request's base branch cannot contain that pull | |
| # request's own commits, so it satisfies the rule by construction — whether or not it happens | |
| # to be `dev`. | |
| # | |
| # For a STACKED PR it is not `dev`, and the difference is measured rather than theoretical. | |
| # Run against `dev` from the branch below this one, check 1 reds on `rust`, `meta` and | |
| # `molten`: three tokens declared and reviewed in the PR underneath, which the PR being gated | |
| # does not own. | |
| # | |
| # Outside a pull request (`workflow_dispatch` / `workflow_call`) there is no base branch, and | |
| # the fallback is `dev`. On a run whose HEAD *is* `dev` that degenerates to the empty diff | |
| # described above; that path is a manual convenience, not the gate. | |
| - name: Resolve the baseline | |
| id: baseline | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| BASE_BRANCH="${BASE_REF:-dev}" | |
| # Observed, not hypothetical: a dispatch of this workflow on a stacked branch resolved | |
| # `merge-base against origin/dev` and red check 1 on the branch BELOW it — a true statement | |
| # about the branch, and not the question the PR asks. The red is correct and reads as a | |
| # broken branch, so the fallback path says out loud which question it answered. | |
| if [ -z "$BASE_REF" ]; then | |
| echo "::notice::Not a pull_request run, so the baseline falls back to dev. On a stacked" | |
| echo "::notice::branch that is NOT the PR's baseline — it spans every commit below you, so" | |
| echo "::notice::an undeclared :root change in a parent branch reds check 1 here and would" | |
| echo "::notice::not on the PR itself. Run it in the PR for the verdict that counts." | |
| fi | |
| git fetch --no-tags --quiet origin "+refs/heads/$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH" | |
| BASE=$(git merge-base "origin/$BASE_BRANCH" HEAD) | |
| { | |
| echo "base=$BASE" | |
| echo "base-branch=$BASE_BRANCH" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "baseline $BASE — merge-base against origin/$BASE_BRANCH" | |
| # A declared `:root` change has to live in git, not in an invocation. | |
| # | |
| # `--allow-root-change` hard-coded into this file would allow every future change silently, | |
| # and a flag CI passes automatically is not a gate. So the declaration is read out of the | |
| # commits in the range instead — an `Allow-root-change: rust, meta, molten` trailer on the | |
| # commit that makes the change. It is reviewable in the diff, permanent in history, and scoped | |
| # to the commit that earned it. The script's own rule is unchanged and still does the work: | |
| # the run fails if the actual diff does not match the declared names exactly, in either | |
| # direction. Once such a PR merges the question disappears, because the merge-base advances | |
| # and strict mode passes again. | |
| # | |
| # `%(trailers:key=…)` and not a grep over `%B`: this repo's commit bodies carry some eighty | |
| # distinct `Key:`-shaped tokens in prose (`Verified:`, `Gates:`, `Known-negative:`, `Remedy:` | |
| # …), and a body regex would collide with all of them. The trailer parser reads only the | |
| # trailer block. The key match is case-insensitive. `separator=%x2C` collapses a commit | |
| # carrying the trailer twice onto one line, so the same split covers both forms. | |
| # | |
| # The `grep -E` filter is the security boundary, not cosmetics. These values are commit-message | |
| # text — attacker-controlled on a fork PR — and the token list is deliberately word-split into | |
| # argv below. Anything that is not a bare token name is dropped, so the worst a hostile | |
| # trailer can do is name a token that did not change, which check 1 rejects: it can make this | |
| # gate red, never green. `|| true` is required because "no trailer in the range" is the normal | |
| # case and a `grep` that matches nothing exits 1, which under `pipefail` would fail the step. | |
| - name: Read the declared :root change from the commit trailers | |
| id: declared | |
| env: | |
| BASE: ${{ steps.baseline.outputs.base }} | |
| run: | | |
| ALLOW=$( | |
| git log --format='%(trailers:key=Allow-root-change,valueonly,separator=%x2C)' "$BASE..HEAD" \ | |
| | tr ',' ' ' \ | |
| | tr -s '[:space:]' '\n' \ | |
| | sed 's/^-*//' \ | |
| | grep -E '^[A-Za-z0-9_-]+$' \ | |
| | sort -u \ | |
| | tr '\n' ' ' || true | |
| ) | |
| echo "allow=$ALLOW" >> "$GITHUB_OUTPUT" | |
| if [ -n "$ALLOW" ]; then | |
| echo "declared in $BASE..HEAD: $ALLOW" | |
| git log --format=' %h %s' --grep '^Allow-root-change:' -i "$BASE..HEAD" | |
| else | |
| echo "no Allow-root-change trailer in $BASE..HEAD — check 1 runs in strict byte-identical mode" | |
| fi | |
| # Every run of this job is a demonstration that the detector fires, rather than a green whose | |
| # red was last seen at authoring time. `--target f52462c7` reproduces a real historical escape: | |
| # at that commit the nav indicator measured zero width while six structural checks certified | |
| # the file. If this ever stops going red the gate has broken, and the PR that broke it is the | |
| # one holding the evidence. | |
| # | |
| # It runs BEFORE the gate itself, on the same reasoning that puts the Paparazzi goldens before | |
| # detekt in `android_build_unified.yml` and `assertGoldenLiveness` after them: prove the | |
| # instrument, then believe it. | |
| # | |
| # Exit 1 is the PASSING condition here, so the assertion is inverted — and it asserts two | |
| # things, not one. Exit 1 alone is also satisfied by checks 6 and 9, which fail at that ref | |
| # for unrelated historical reasons, so an exit-code-only assertion would survive check 7 | |
| # quietly ceasing to discriminate. That has already happened once in this arc: an id-based | |
| # probe selector degraded this same known negative from "the pill measures 0px wide" to "no | |
| # pill element found" — a weaker failure wearing the same red. The second and third assertions | |
| # pin the reason, not just the colour. | |
| - name: Prove the detector fires (the known negative must go red) | |
| run: | | |
| LOG="$RUNNER_TEMP/known-negative.log" | |
| STATUS=0 | |
| python3 documentation/mockups/shell_gate.py --target f52462c7 > "$LOG" 2>&1 || STATUS=$? | |
| cat "$LOG" | |
| if [ "$STATUS" -eq 0 ]; then | |
| echo "::error::the known negative went GREEN (exit 0). --target f52462c7 must reproduce" | |
| echo "::error::the zero-width nav indicator, and it did not. The gate is broken; do not" | |
| echo "::error::read any other green in this job as evidence." | |
| exit 1 | |
| fi | |
| if ! grep -qE '^ \[FAIL\] 7 nav pill renders and tracks$' "$LOG"; then | |
| echo "::error::the known negative went red (exit $STATUS) but check 7 did not fail. Checks" | |
| echo "::error::6 and 9 also fail at that ref, so the exit code alone is not evidence that" | |
| echo "::error::the render probe still discriminates. Find out what check 7 is doing now." | |
| exit 1 | |
| fi | |
| if ! grep -qF 'width=0px→0px' "$LOG"; then | |
| echo "::error::check 7 failed at the known negative, but not with the zero-width" | |
| echo "::error::measurement this gate exists to catch. A weaker failure (for instance" | |
| echo "::error::UNMEASURED, or a missing pill element) wears the same red without proving" | |
| echo "::error::the same thing. See the probe's own note on structural vs id selectors." | |
| exit 1 | |
| fi | |
| echo "known negative reproduced: exit $STATUS, check 7 red at width=0px→0px" | |
| # `-v` prints the evidence table even when green, on the rule AGENTS.md already states for the | |
| # Gradle gates: a green with no evidence is not evidence. `--allow-root-change` is greedy | |
| # (`nargs="+"`) so it must come last on the line. | |
| # | |
| # `!cancelled()` rather than `always()`: if the step above found the detector broken, this job | |
| # is failing either way, but a developer still deserves to see the verdict on their own change | |
| # in the same run instead of chasing it after the instrument is fixed. | |
| - name: Run the mockup appearance gate | |
| if: ${{ !cancelled() }} | |
| env: | |
| BASE: ${{ steps.baseline.outputs.base }} | |
| ALLOW: ${{ steps.declared.outputs.allow }} | |
| run: | | |
| if [ -n "$ALLOW" ]; then | |
| # Unquoted deliberately: the filtered token list must become separate argv entries. | |
| # shellcheck disable=SC2086 | |
| python3 documentation/mockups/shell_gate.py --base "$BASE" -v --allow-root-change $ALLOW | |
| else | |
| python3 documentation/mockups/shell_gate.py --base "$BASE" -v | |
| fi |