Skip to content

feat(wear): controller visual redesign — fixed palette, §4 layout, numeric editor, gates G1–G6 #763

feat(wear): controller visual redesign — fixed palette, §4 layout, numeric editor, gates G1–G6

feat(wear): controller visual redesign — fixed palette, §4 layout, numeric editor, gates G1–G6 #763

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