feat(wear): controller visual redesign — fixed palette, §4 layout, numeric editor, gates G1–G6 #763
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: Android CI/CD - Unified Build and Tests | |
| on: | |
| push: | |
| branches: [ master ] | |
| pull_request: | |
| workflow_dispatch: | |
| workflow_call: | |
| inputs: | |
| ref: | |
| type: string | |
| required: true | |
| description: "Git ref to check out and build" | |
| permissions: | |
| contents: read | |
| issues: read | |
| checks: write | |
| pull-requests: write | |
| # cancel-in-progress runs for the same workflow in the same branch/PR | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Main build job - runs linting, unit tests, and build | |
| build: | |
| name: Build and Unit Tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Checkout branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| - name: Decrypt keystore | |
| env: | |
| KEYSTORE: ${{ secrets.KEYSTORE }} | |
| KEYSTORE_PASSPHRASE: ${{ secrets.KEYSTORE_PASSPHRASE }} | |
| run: | | |
| echo "$KEYSTORE" > keystore.jks.asc | |
| gpg -d --passphrase "$KEYSTORE_PASSPHRASE" --batch keystore.jks.asc > keystore.jks | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '21' | |
| distribution: 'temurin' | |
| cache: gradle | |
| - name: Configure Keystore | |
| env: | |
| KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} | |
| KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} | |
| KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} | |
| run: | | |
| echo "storeFile=keystore.jks" >> keystore.properties | |
| echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> keystore.properties | |
| echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> keystore.properties | |
| echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> keystore.properties | |
| - name: Create Google Services Config file | |
| env: | |
| GOOGLE_SERVICES_JSON_STORE: ${{ secrets.GOOGLE_SERVICES_JSON_STORE }} | |
| run: | | |
| echo "$GOOGLE_SERVICES_JSON_STORE" > app/store/google-services.json.b64 | |
| base64 -d -i app/store/google-services.json.b64 > app/store/google-services.json | |
| - name: Create Google Services DEV Config file | |
| env: | |
| GOOGLE_SERVICES_JSON_DEV: ${{ secrets.GOOGLE_SERVICES_JSON_DEV }} | |
| run: | | |
| echo "$GOOGLE_SERVICES_JSON_DEV" > app/dev/google-services.json.b64 | |
| base64 -d -i app/dev/google-services.json.b64 > app/dev/google-services.json | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x gradlew | |
| - name: Use CI-optimized Gradle properties | |
| run: cp .github/properties/gradle-ci.properties gradle.properties | |
| - name: Use CI-optimized Convention Gradle properties | |
| run: cp .github/properties/gradle-convention-ci.properties build-logic/gradle.properties | |
| - name: Restore Gradle build cache | |
| id: gradle-build-cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.gradle/caches/build-cache-* | |
| key: gradle-build-cache-${{ runner.os }}-${{ hashFiles('settings.gradle.kts', '**/build.gradle.kts', 'gradle/libs.versions.toml', 'gradle.properties') }} | |
| restore-keys: | | |
| gradle-build-cache-${{ runner.os }}- | |
| save-always: true | |
| # The KMP convention aliases assembleDebug -> assemble, which puts every KMP module's | |
| # iOS klib compilation into this step's task graph. The build cache cannot mask a | |
| # break: a changed iosMain input misses the cache key and must compile here, and | |
| # failed compilations never populate the cache — so a FROM-CACHE hit is a | |
| # verification of identical previously-green inputs, same as for the Android compile | |
| # tasks in this step. Kotlin/Native downloads its toolchain to ~/.konan on first use | |
| # (~1GB); cache it or every uncached run pays the download. Keyed on the catalog | |
| # because the K/N compiler version moves with the kotlin pin there. | |
| - name: Restore Kotlin/Native toolchain | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.konan | |
| key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} | |
| restore-keys: | | |
| konan-${{ runner.os }}- | |
| - name: Assert shared KMP UI source-set topology | |
| run: python3 .github/scripts/assert_kmp_ui_source_topology.py | |
| - name: Build with Gradle | |
| run: ./gradlew assembleDebug --full-stacktrace | |
| # The `androidTest` variants had NO gate on any PR. `assembleDebug` does not build them, | |
| # and the only workflow that does — `ui_tests.yml` — is `workflow_dispatch`/`workflow_call` | |
| # only. So the instrumented source sets were compiled exactly once per release, inside | |
| # `android_deploy_prod.yml`'s `ui_tests` job, downstream of both `guard` and `build`. | |
| # | |
| # Measured, not assumed: the v1.49.0 deploy (run 31429081872) died on two of them — | |
| # `:core:ui:kit:checkDebugAndroidTestDuplicateClasses` and | |
| # `mergeDebugAndroidTestJavaResource`, from a Paparazzi test-fixture leak that landed | |
| # 2026-07-26 and sat unseen for a month because the last standalone `ui_tests` run was | |
| # 2026-05-03. Both are build-graph failures: this step alone reproduces them, and it needs | |
| # no emulator, so the cheap half of the instrumented gate now runs on every PR. | |
| # | |
| # This assembles the test APKs; it does not run them. Executing them still needs a device | |
| # and stays in `ui_tests.yml` — a test that goes red on an assertion (as the chart test in | |
| # that same run did) is out of this step's reach by construction. | |
| - name: Assemble instrumented tests | |
| run: ./gradlew assembleDebugAndroidTest --full-stacktrace | |
| # GUARD: nothing else builds this variant. `assembleDebug` builds no release variant and | |
| # `github_release_apk.yml` assembles only `:app:store`, so removing this step returns | |
| # `:app:wear`'s release classpath to having no gate at all — and a `debugImplementation`-only | |
| # dependency is invisible to every other task here. | |
| # | |
| # Only the shipping flavor is built. `:app:wear` has no flavor-specific source directory, so | |
| # devRelease compiles the same sources and would add a second R8 run for no new coverage. | |
| # | |
| # `--rerun-tasks --no-build-cache`, unlike the steps above it. This job restores the Gradle | |
| # build cache and `gradle-ci.properties` sets `org.gradle.caching=true`, so without the flags | |
| # this task can report FROM-CACHE and never assemble anything — and AGENTS.md § "Gate | |
| # discipline" is explicit that FROM-CACHE proves nothing about execution. The `assembleDebug` | |
| # step above keeps its cache hits because stripping them costs ~35 minutes across the whole | |
| # repository; that trade-off does not exist for one Wear module, so the gate is bought | |
| # outright here rather than argued for from cache keys. | |
| - name: Assemble Wear release variant | |
| run: ./gradlew :app:wear:assembleStoreRelease --rerun-tasks --no-build-cache --full-stacktrace | |
| - name: Assert MVI source-set topology | |
| run: python3 .github/scripts/assert_mvi_source_topology.py | |
| # The un-suppressible half of the Wear transport privacy gate. Detekt owns the fast half | |
| # (ForbiddenImport + WearDataLayerApiRule, both in the pre-commit hook), but detekt honours | |
| # `@Suppress` by rule id and by rule-set id, and a rule cannot report its own suppression. | |
| # This step is a plain source scan for exactly that reason, and it also covers the | |
| # reflective route no AST visitor can see. It is Kotlin-only, and rejects any tracked `.java` | |
| # file outright rather than carrying a Java canonicaliser this repository has no input for. | |
| # `--self-test` first: a gate never shown to fire is not a gate, and this one has no findings | |
| # to prove itself with on a clean tree. | |
| - name: Assert Wear transport privacy gate | |
| run: | | |
| python3 .github/scripts/assert_wear_transport_gate.py --self-test | |
| python3 .github/scripts/assert_wear_transport_gate.py | |
| # Visual gate for the v3 redesign. Deliberately placed BEFORE detekt. | |
| # | |
| # detekt cannot currently corrupt this: LintConventionPlugin sets | |
| # `autoCorrect = false` precisely so the gate never rewrites the tree it verifies. | |
| # Running the goldens first means that guarantee does not have to hold — the visual | |
| # gate reads the checked-out tree before any other step could have touched it, | |
| # whatever a future edit to that setting does. | |
| # | |
| # `verifyPaparazziDebug` is finalized by each golden module's `assertGoldenLiveness` | |
| # (gradle/golden-gate.gradle.kts, applied by all 13 golden-holding modules), which | |
| # fails the build if the golden tests did not actually execute. A Paparazzi task that | |
| # discovers no tests still exits 0, so "green" alone does not mean the gate ran. | |
| # | |
| # This job restores the build cache and `gradle-ci.properties` sets `org.gradle.caching=true`, | |
| # so the gate's test task was eligible for FROM-CACHE — which restores the JUnit XML that | |
| # `assertGoldenLiveness` reads, making the liveness claim answer with a previous build's | |
| # evidence. Closed in `gradle/golden-gate.gradle.kts`, where the task is marked | |
| # non-cacheable and never up-to-date in Paparazzi mode, rather than with `--no-build-cache` | |
| # here: the flag would also strip cache hits from every upstream compile task in this graph, | |
| # and would leave local runs of the gate just as replayable as before. | |
| - name: Verify screenshot goldens | |
| run: ./gradlew verifyPaparazziDebug --full-stacktrace | |
| - name: Upload screenshot diffs | |
| uses: actions/upload-artifact@v4 | |
| if: failure() | |
| with: | |
| name: paparazzi-failure-diffs | |
| # alpha05 made this directory variant-aware (…/failures/<variant>/). Each failure | |
| # writes an expected|delta|actual triptych — a red visual gate with no visible diff | |
| # is barely better than no gate. | |
| path: | | |
| **/build/paparazzi/failures/** | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| # The custom detekt rules had NO CI coverage before this step. `:lint-rules` is a plain | |
| # JVM module, so its tests live under `test` — and the pipeline's only test invocation | |
| # was `testDebugUnitTest`, a task that does not exist for it. Every rule guarding the | |
| # MVI/Metro/typography invariants was therefore unverified on every PR. Must run before | |
| # `detekt`, since detekt is what consumes the jar these tests cover. | |
| - name: Run custom detekt rule tests | |
| run: ./gradlew :lint-rules:test --full-stacktrace | |
| - name: Run detekt | |
| run: ./gradlew detekt --full-stacktrace | |
| # A real email address and a real name shipped as fixture data and RENDERED INTO COMMITTED | |
| # PNGs, through the reviews that landed the settings rebuild. Individually obvious, | |
| # collectively invisible — the same class the token-parity seam is checked for, and checked | |
| # the same way: named exceptions with citations, never a loosened pattern. `-v` because a | |
| # gate that passes silently reports nothing (shell_gate.py's own lesson). | |
| - name: Check for personal data in tracked files | |
| run: python3 documentation/personal_data_gate.py -v | |
| - name: Run Android Lint | |
| run: ./gradlew lintDebug --no-configuration-cache --full-stacktrace | |
| - name: Run forced MVI Android-host tests | |
| id: mvi_host_tests | |
| continue-on-error: true | |
| run: > | |
| ./gradlew :core:ui:mvi:testAndroidHostTest | |
| --rerun-tasks --no-build-cache --no-configuration-cache | |
| --full-stacktrace --console=plain | |
| - name: Assert exact MVI Android-host identities | |
| if: ${{ !cancelled() && steps.mvi_host_tests.outcome != 'skipped' }} | |
| run: python3 .github/scripts/assert_mvi_host_identities.py | |
| - name: Propagate forced MVI Android-host Gradle failure | |
| if: ${{ !cancelled() && steps.mvi_host_tests.outcome == 'failure' }} | |
| run: exit 1 | |
| - name: Run Unit Tests | |
| run: ./gradlew testDebugUnitTest --full-stacktrace | |
| - name: Publish Unit Test Results | |
| uses: EnricoMi/publish-unit-test-result-action@v2 | |
| if: always() | |
| with: | |
| files: | | |
| **/build/test-results/test*.xml | |
| **/build/test-results/**/*.xml | |
| check_name: Unit Test Results | |
| comment_title: Unit Test Results | |
| commit: ${{ github.event.pull_request.head.sha || github.sha }} | |
| report_individual_runs: true | |
| deduplicate_classes_by_file_name: false | |
| compare_to_earlier_commit: true | |
| pull_request_build: commit | |
| check_run_annotations: all tests, skipped tests | |
| - name: Detailed Unit Test Report | |
| uses: mikepenz/action-junit-report@v4 | |
| if: always() | |
| with: | |
| report_paths: | | |
| **/build/test-results/test*.xml | |
| **/build/test-results/**/*.xml | |
| check_name: Detailed Unit Test Report | |
| detailed_summary: true | |
| include_passed: true | |
| fail_on_failure: false | |
| require_tests: true | |
| annotate_only: false | |
| job_summary: true | |
| - name: Upload detekt reports | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: detekt-reports | |
| path: | | |
| **/build/reports/detekt/ | |
| detekt.yml | |
| retention-days: 30 | |
| - name: Upload lint reports | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: lint-reports | |
| path: | | |
| **/build/reports/lint-results-*.html | |
| **/build/reports/lint-results-*.xml | |
| lint.xml | |
| retention-days: 30 | |
| - name: Annotate PR with lint results | |
| if: github.event_name == 'pull_request' && always() | |
| uses: yutailang0119/action-android-lint@v4 | |
| with: | |
| report-path: "**/build/reports/lint-results-*.xml" | |
| # The Phase-7 native gate behind the stable required context `KMP iOS kit smoke`: one forced | |
| # Gradle invocation executes the kit, navigation, MVI, start-mode, plan-editor and image-viewer | |
| # Phase-7 Native tests on the iOS simulator, then a checked-in script requires every exact | |
| # per-module XML identity. | |
| # Narrow on purpose: no Xcode app, no Apple signing, no XCFramework, no App Store Connect | |
| # secrets. See | |
| # documentation/feature-specs/kmp-phase-7-1-ui-kit.md §9 and kmp-phase-7-2-navigation.md §9. | |
| kmp-ios-kit-smoke: | |
| name: KMP iOS kit smoke | |
| runs-on: macos-26 | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Checkout branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| - name: Select Xcode 26.6 and assert the simulator runtime | |
| run: | | |
| sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer | |
| xcodebuild -version | |
| xcodebuild -version | grep -q "Xcode 26.6" | |
| # The Kotlin/Native test task boots an iOS simulator; fail here, not mid-Gradle. | |
| xcrun simctl list runtimes | tee /dev/stderr | grep -q "iOS" | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '21' | |
| distribution: 'temurin' | |
| cache: gradle | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x gradlew | |
| - name: Use CI-optimized Gradle properties | |
| run: cp .github/properties/gradle-ci.properties gradle.properties | |
| - name: Use CI-optimized Convention Gradle properties | |
| run: cp .github/properties/gradle-convention-ci.properties build-logic/gradle.properties | |
| # Same rationale as the Linux job's ~/.konan cache; macOS downloads its own K/N toolchain. | |
| - name: Restore Kotlin/Native toolchain | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.konan | |
| key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} | |
| restore-keys: | | |
| konan-${{ runner.os }}- | |
| # Repository configuration reads a signing keystore at configuration time. This job holds | |
| # no production secret by design: an ephemeral throwaway JKS satisfies configuration and | |
| # signs nothing that leaves the runner. GUARD: the convention resolves storeFile against | |
| # the repository root even for absolute paths (java.io.File(parent, "/abs") concatenates), | |
| # so the keystore must sit in the workspace and be referenced relatively. | |
| - name: Configure ephemeral signing material | |
| run: | | |
| keytool -genkeypair -alias ci-smoke -keyalg RSA -keysize 2048 -validity 1 \ | |
| -storepass ci-smoke-pass -keypass ci-smoke-pass \ | |
| -dname "CN=kmp-ios-kit-smoke" -keystore "$RUNNER_TEMP/ci-smoke.jks" | |
| cp "$RUNNER_TEMP/ci-smoke.jks" ci-smoke.jks | |
| { | |
| echo "storeFile=ci-smoke.jks" | |
| echo "keyAlias=ci-smoke" | |
| echo "storePassword=ci-smoke-pass" | |
| echo "keyPassword=ci-smoke-pass" | |
| } > keystore.properties | |
| # --continue so a kit failure cannot mask whether navigation ran (and vice versa): the | |
| # identity script needs both modules' XML to say which tuple broke. | |
| - name: Run the native kit navigation MVI start-mode plan-editor and feature tests | |
| id: native_tests | |
| run: > | |
| ./gradlew | |
| :core:ui:kit:iosSimulatorArm64Test | |
| :core:ui:navigation:iosSimulatorArm64Test | |
| :core:ui:mvi:iosSimulatorArm64Test | |
| :core:ui:start-mode:iosSimulatorArm64Test | |
| :core:ui:plan-editor:iosSimulatorArm64Test | |
| :feature:image-viewer:iosSimulatorArm64Test | |
| :feature:plan-editor:iosSimulatorArm64Test | |
| --rerun-tasks --no-build-cache --no-configuration-cache | |
| --continue --full-stacktrace --console=plain | |
| # An exit code is not evidence: the checked-in script structurally parses each module's | |
| # JUnit XML and independently requires one exact (classname, name) tuple per module — | |
| # a repo-wide total or substring match would accept two kit tests and zero navigation | |
| # tests, or a classname from one case paired with a method name from another. Counts are | |
| # validated PER <testsuite> before any module total is formed, so two malformed suites | |
| # cannot cancel out into a consistent-looking aggregate. It allows additional PASSING | |
| # cases, so a module can grow a second native test without edits here. | |
| # | |
| # The condition runs it whenever the Gradle step actually STARTED, which covers three | |
| # outcomes: a red TEST run leaves XML and the script names the module and tuple that | |
| # broke, which Gradle's exit code cannot; a compile or simulator-boot failure leaves no | |
| # XML and the script says the results are missing, which is the honest answer; and a | |
| # setup failure (checkout, Xcode selection, JDK, signing material) skips the Gradle step, | |
| # so this step stays skipped too rather than masking that failure behind a | |
| # missing-results error. Cancellation also skips it. | |
| - name: Assert exact native test identities | |
| if: ${{ !cancelled() && steps.native_tests.outcome != 'skipped' }} | |
| run: python3 .github/scripts/assert_kmp_ios_smoke.py | |
| - name: Upload native test results | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: kmp-ios-kit-smoke-results | |
| path: | | |
| core/ui/kit/build/test-results/iosSimulatorArm64Test/ | |
| core/ui/navigation/build/test-results/iosSimulatorArm64Test/ | |
| core/ui/mvi/build/test-results/iosSimulatorArm64Test/ | |
| core/ui/start-mode/build/test-results/iosSimulatorArm64Test/ | |
| core/ui/plan-editor/build/test-results/iosSimulatorArm64Test/ | |
| feature/image-viewer/build/test-results/iosSimulatorArm64Test/ | |
| feature/plan-editor/build/test-results/iosSimulatorArm64Test/ | |
| retention-days: 30 |