Skip to content

fix(payment): pin promo-code checkout presentment to USD so the $269 minimum passes (#23299) #27962

fix(payment): pin promo-code checkout presentment to USD so the $269 minimum passes (#23299)

fix(payment): pin promo-code checkout presentment to USD so the $269 minimum passes (#23299) #27962

name: Mobile App Checks
on:
# Nightly calls run these same jobs against one immutable main snapshot.
workflow_call:
inputs:
nightly_main_sha:
description: Main SHA pinned by the unfiltered nightly caller
type: string
required: false
default: ''
push:
branches: main
# Stacked PRs (V6 on V5, #14358) must run this workflow. `branches: main`
# made GitHub skip the entire workflow — the iOS job never existed, which
# reads as "green" and proves nothing.
pull_request:
workflow_dispatch:
schedule:
- cron: '41 4 * * *'
permissions:
contents: read
issues: read
concurrency:
# PRs cancel their own older runs. Main pushes key on the exact SHA with
# cancel-in-progress false, matching desktop-swift-ci: mobile release
# admission (verify_mobile_release_admission.py) requires a first-attempt
# push-event Mobile Release Eligibility success on the exact release SHA,
# and a pending push run replaced by a newer event leaves a cancelled check
# that no rerun can re-mint (attempt 2 does not count). The daily cron and
# manual dispatches keep per-run groups so they can never evict a push run.
group: mobile-app-checks-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || (github.event_name == 'push' && github.sha) || format('run-{0}', github.run_id) }}-${{ inputs.nightly_main_sha != '' && format('nightly-{0}', github.run_id) || 'normal' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Abort a stalled git HTTP transfer instead of hanging. Hosted-runner fetches
# from github.com intermittently stop receiving data for 7-10 minutes before
# the connection resets (Backend Hermetic E2E run 39435458398 spent 498 s in
# "Checkout code" and hit its 10-minute cap). With these set, git aborts any
# transfer that stays under 1 KB/s for 60 s, and actions/checkout retries the
# fetch (its built-in 3 attempts); a healthy depth-1 fetch takes ~20 s.
# Applies to every git network call in the workflow, including script steps.
GIT_HTTP_LOW_SPEED_LIMIT: "1000"
GIT_HTTP_LOW_SPEED_TIME: "60"
jobs:
changes:
name: Detect Changes
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
diff_base: ${{ steps.changes.outputs.diff_base }}
full_ci: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.tier.outputs.full_ci) }}
has_app_codegen: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_codegen) }}
has_app_compile_smoke: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_compile_smoke) }}
has_app_android_pr: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_android_pr) }}
has_app_ios_compile: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_ios_compile) }}
has_app_journeys: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_journeys) }}
has_app_journeys_pr: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_journeys_pr) }}
has_app_dart: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_dart) }}
has_app_l10n: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_app_l10n) }}
has_flutter_generated: ${{ inputs.nightly_main_sha != '' && 'true' || (steps.changes.outputs.has_flutter_generated) }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
# Full history is required: detect-changes diffs against the merge
# base. blob:none keeps the commit graph but skips file contents,
# which is what made the unfiltered clone of this ~1.25GB repo take
# 7-13 minutes.
fetch-depth: 0
filter: blob:none
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Resolve current CI tier
id: tier
uses: ./.github/actions/ci-tier
- name: Detect changed paths
id: changes
uses: ./.github/actions/detect-changes
generated-files:
name: Generated Files
runs-on: ubuntu-latest
timeout-minutes: 20
needs: changes
if: needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_flutter_generated == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
fetch-depth: 1
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
# Match repo-checks.yml so dependency resolution cannot drift when
# the stable channel advances beyond the repository's lockfile.
flutter-version: 3.44.5
cache: true
# flutter-action already caches the SDK and ~/.pub-cache. build_runner's
# incremental state is not covered by it and is what makes codegen slow.
- name: Cache build_runner state
uses: actions/cache@v6
with:
path: app/.dart_tool/build
key: ${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}-
${{ runner.os }}-flutter-buildrunner-
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
- name: Check Flutter generated code
if: needs.changes.outputs.has_app_codegen == 'true'
working-directory: app
run: |
dart run build_runner build --delete-conflicting-outputs
git diff --exit-code -- lib || {
echo "Generated Dart files are stale. Run: cd app && dart run build_runner build --delete-conflicting-outputs"
exit 1
}
if [ -n "$(git ls-files --others --exclude-standard -- lib)" ]; then
echo "Generated Dart files are stale; untracked generated files were produced:"
git ls-files --others --exclude-standard -- lib
exit 1
fi
- name: Check Flutter l10n
if: needs.changes.outputs.has_app_l10n == 'true'
working-directory: app
run: |
flutter gen-l10n
git diff --exit-code -- lib/l10n || {
echo "Generated localization files are stale. Run: cd app && flutter gen-l10n"
exit 1
}
if [ -n "$(git ls-files --others --exclude-standard -- lib/l10n)" ]; then
echo "Generated localization files are stale; untracked generated files were produced:"
git ls-files --others --exclude-standard -- lib/l10n
exit 1
fi
analyze-and-test:
name: Dart Analyze & Tests
runs-on: ubuntu-latest
needs: changes
if: github.event_name == 'schedule' || needs.changes.outputs.has_app_dart == 'true'
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- name: Cache build_runner state
uses: actions/cache@v6
with:
path: app/.dart_tool/build
key: ${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}-
${{ runner.os }}-flutter-buildrunner-
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
# Also satisfies test.sh's generated-file prerequisites, so it skips its
# own pub get and build_runner run.
- name: Prepare Flutter dev compile inputs
working-directory: app
run: |
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
{
echo "API_BASE_URL="
echo "USE_WEB_AUTH=true"
echo "USE_AUTH_CUSTOM_TOKEN=true"
echo "STAGING_API_URL="
} > .dev.env
: > .env
- name: Generate Flutter code
working-directory: app
run: dart run build_runner build --delete-conflicting-outputs
- name: Run analyzer ratchet
working-directory: app
run: bash scripts/analyze_ratchet.sh
- name: Run Flutter tests
id: flutter_tests
working-directory: app
run: |
set -o pipefail
bash test.sh \
--reporter expanded \
--file-reporter "json:$GITHUB_WORKSPACE/flutter-test-results.json" \
2>&1 | tee "$GITHUB_WORKSPACE/flutter-test-output.log"
- name: Summarize failed Flutter tests
if: ${{ failure() && steps.flutter_tests.outcome == 'failure' }}
env:
FLUTTER_TEST_RESULTS: ${{ github.workspace }}/flutter-test-results.json
run: |
python3 - <<'PY'
import json
import os
from collections import defaultdict
from pathlib import Path
report = Path(os.environ['FLUTTER_TEST_RESULTS'])
summary = Path(os.environ['GITHUB_STEP_SUMMARY'])
tests = {}
errors = defaultdict(list)
failures = []
if report.exists():
for raw_line in report.read_text().splitlines():
try:
event = json.loads(raw_line)
except json.JSONDecodeError:
continue
event_type = event.get('type')
if event_type == 'testStart':
test = event.get('test', {})
tests[str(test.get('id'))] = test
elif event_type == 'error':
details = str(event.get('error', 'Unknown error'))
if event.get('stackTrace'):
details = f"{details}\n{event['stackTrace']}"
errors[str(event.get('testID'))].append(details)
elif event_type == 'testDone' and event.get('result') in {'failure', 'error'}:
failures.append(event)
with summary.open('a') as output:
output.write('## Flutter test diagnostics\n\n')
if not failures:
output.write(
'Flutter exited unsuccessfully but emitted no failed-test event. '
'Download the `flutter-test-diagnostics` artifact for the complete JSON stream and console log.\n'
)
for failure in failures:
test_id = str(failure.get('testID'))
test = tests.get(test_id, {})
name = test.get('name', f'Unknown test (id {test_id})')
output.write(f'### {name}\n\n')
details = '\n'.join(errors[test_id]).strip()
if details:
output.write('```text\n')
output.write(details[-6000:].replace('```', '``\\`'))
output.write('\n```\n\n')
PY
- name: Upload Flutter test diagnostics
if: ${{ failure() && steps.flutter_tests.outcome == 'failure' }}
uses: actions/upload-artifact@v7
with:
name: flutter-test-diagnostics
path: |
flutter-test-results.json
flutter-test-output.log
if-no-files-found: warn
journeys-hermetic:
name: Seeded Journeys (hermetic)
runs-on: ubuntu-latest
needs: changes
if: github.event_name == 'schedule' || (github.event_name == 'push' && needs.changes.outputs.has_app_journeys == 'true') || (needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_app_journeys_pr == 'true')
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
fetch-depth: 1
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
# The canonical verify entrypoint resolves the dev-harness python; its
# import probe needs python-dotenv (same dependency the backend venv
# provides locally). Nothing else: the lane is synthetic-fixture,
# loopback-only, and secret-free — safe for untrusted fork PRs.
- name: Install dev-harness python deps
run: python3 -m pip install --quiet --disable-pip-version-check python-dotenv
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
- name: Prepare Flutter dev compile inputs
working-directory: app
run: |
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
{
echo "API_BASE_URL="
echo "USE_WEB_AUTH=true"
echo "USE_AUTH_CUSTOM_TOKEN=true"
echo "STAGING_API_URL="
} > .dev.env
: > .env
- name: Generate Flutter code
working-directory: app
run: dart run build_runner build --delete-conflicting-outputs
# Same command as the local lane (SCA-490): focused fast feedback with
# mechanical discovery, receipt validation, and fail-closed selection.
- name: Run hermetic seeded journeys
id: journeys
run: |
bash scripts/dev-harness/mobile-verify.sh fast --all \
--evidence-dir "$GITHUB_WORKSPACE/journey-evidence" --json
- name: Upload journey evidence
if: ${{ always() && steps.journeys.conclusion != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: journey-evidence
path: journey-evidence
if-no-files-found: warn
android-compile-smoke:
name: Android Compile Smoke
runs-on: ubuntu-latest
needs: changes
if: github.event_name == 'schedule' || (github.event_name == 'push' && needs.changes.outputs.has_app_compile_smoke == 'true') || (needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_app_android_pr == 'true')
timeout-minutes: 25
env:
# Read by app/android/app/build.gradle (ndkVersion) and the NDK install
# step below; test_mobile_ci_infra_guards.py keeps it equal to
# build.gradle's fallback pin.
NDK_VERSION: "28.2.13676358"
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
fetch-depth: 1
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Free runner disk space
# ubuntu-latest ships with ~14GB free; the Gradle build runs out of
# space during stripDebugSymbols. Remove unused preinstalled toolchains
# (keep /usr/local/lib/android — the build needs the Android SDK).
run: |
sudo rm -rf /usr/share/dotnet /usr/local/.ghcup /opt/hostedtoolcache/CodeQL \
/usr/local/share/boost /usr/local/share/chromium /usr/local/lib/node_modules
sudo docker image prune --all --force
df -h /
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- name: Cache build_runner state
uses: actions/cache@v6
with:
path: app/.dart_tool/build
key: ${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}-
${{ runner.os }}-flutter-buildrunner-
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
- name: Prepare Flutter dev compile inputs
working-directory: app
run: |
mkdir -p android/app/src/dev/ android/app/src/prod/
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json
cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json
cp setup/prebuilt/key.properties android/key.properties
{
echo "API_BASE_URL="
echo "USE_WEB_AUTH=true"
echo "USE_AUTH_CUSTOM_TOKEN=true"
echo "STAGING_API_URL="
} > .dev.env
: > .env
- name: Generate Flutter code for compile smoke
working-directory: app
run: dart run build_runner build --delete-conflicting-outputs
- name: Setup Gradle for Flutter compile smoke
uses: gradle/actions/setup-gradle@v6
with:
# Cold Gradle homes took 11-12m in runs 40022514904/40025498700;
# with 11m of runner/Flutter setup, run 40030083284 hit the 25m cap.
# Basic caching keys caches + wrapper by OS/arch and Gradle build
# files, not commit SHA, so backend-only edits reuse the same entry.
# Populate on PRs too: the main-only JVM writer left all three
# attempts cold. GitHub scopes PR writes to that PR's merge ref.
# This provider's keys are separate from the JVM job's enhanced
# cache, and an exact hit skips saving another copy on every commit.
cache-provider: basic
cache-read-only: false
- name: Restore pinned Android NDK
# Same corrupt-download flake as the emulator job hit (runs 39506049904,
# 39626150998): AGP auto-installs a missing NDK inside Gradle configure
# with no retry, and one truncated dl.google.com response fails the job
# before any app code runs. Restore-only: this job is the Mobile App
# Checks wall, so the one-time cache save stays on the emulator job,
# which runs in every event this job does; install_android_ndk.py
# covers a miss with one retry.
uses: actions/cache/restore@v6
with:
path: /usr/local/lib/android/sdk/ndk/${{ env.NDK_VERSION }}
key: ${{ runner.os }}-android-ndk-${{ env.NDK_VERSION }}
- name: Install pinned Android NDK
run: python3 .github/scripts/install_android_ndk.py --ndk-version "$NDK_VERSION"
- name: Build Flutter Android dev debug APK
working-directory: app
env:
# GitHub Actions sets CI=true; android/app/build.gradle reads Codemagic
# keystore env vars in that case even for debug APK builds.
# The debug keystore is the in-repo prebuilt file — no secrets.
CM_KEYSTORE_PATH: ${{ github.workspace }}/app/setup/prebuilt/debug.keystore
CM_KEYSTORE_PASSWORD: android
CM_KEY_ALIAS: androiddebugkey
CM_KEY_PASSWORD: android
# arm64-only since c8f35061fc: extra ABIs OOM'd ubuntu-latest during
# stripDebugSymbols. Does not compile the JNI opus shim for armeabi-v7a or x86_64.
run: flutter build apk --debug --flavor dev --target-platform android-arm64
# --analyze-size is release-mode + single-ABI only and would be a second
# ~10-15 min compile. Debug is not Play download size; this is a coarse
# zip breakdown of the APK this job already built. Never a gate.
- name: Report debug APK size
run: |
python3 .github/scripts/report_debug_apk_size.py \
--apk app/build/app/outputs/flutter-apk/app-dev-debug.apk \
--json "$GITHUB_WORKSPACE/apk-size-report.json" \
--summary "$GITHUB_STEP_SUMMARY"
- name: Upload debug APK size report
uses: actions/upload-artifact@v7
with:
name: apk-size-${{ github.event.pull_request.head.sha || github.sha }}
path: apk-size-report.json
if-no-files-found: error
retention-days: 5
- name: Upload Flutter Android dev debug APK
uses: actions/upload-artifact@v7
with:
name: app-dev-debug-${{ github.event.pull_request.head.sha || github.sha }}
path: app/build/app/outputs/flutter-apk/app-dev-debug.apk
if-no-files-found: error
retention-days: 5
compression-level: 0
android-unit-tests:
name: Android JVM Unit Tests
runs-on: ubuntu-latest
needs: changes
if: github.event_name == 'schedule' || (github.event_name == 'push' && needs.changes.outputs.has_app_compile_smoke == 'true') || (needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_app_android_pr == 'true')
timeout-minutes: 25
env:
# Read by app/android/app/build.gradle (ndkVersion) and the NDK install
# step below; test_mobile_ci_infra_guards.py keeps it equal to
# build.gradle's fallback pin.
NDK_VERSION: "28.2.13676358"
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
fetch-depth: 1
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- name: Cache build_runner state
uses: actions/cache@v6
with:
path: app/.dart_tool/build
key: ${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}-
${{ runner.os }}-flutter-buildrunner-
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
- name: Prepare Flutter dev compile inputs
working-directory: app
run: |
mkdir -p android/app/src/dev/ android/app/src/prod/
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json
cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json
cp setup/prebuilt/key.properties android/key.properties
{
echo "API_BASE_URL="
echo "USE_WEB_AUTH=true"
echo "USE_AUTH_CUSTOM_TOKEN=true"
echo "STAGING_API_URL="
} > .dev.env
: > .env
- name: Generate Flutter code for JVM unit tests
working-directory: app
run: dart run build_runner build --delete-conflicting-outputs
# gradlew, gradle-wrapper.jar, and local.properties are gitignored.
# On main they were created by the APK compile in the same job before
# this Gradle task ran. The parallel job never compiles an APK, so
# generate those files only (`--config-only`).
- name: Generate Android Gradle wrapper
working-directory: app
run: flutter build apk --debug --flavor dev --target-platform android-arm64 --config-only
- name: Setup Gradle for Android JVM unit tests
uses: gradle/actions/setup-gradle@v6
with:
# Sole Gradle User Home writer. Compile-smoke is restore-only so
# this job's ~1m POST save is not on the workflow wall (JVM finishes
# minutes before Android Compile Smoke in the V5 sample).
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
cache-cleanup: on-success
gradle-home-cache-includes: |
caches
notifications
wrapper
- name: Restore pinned Android NDK
# Gradle configure resolves ndkVersion for :app:testDevDebugUnitTest
# too, so without this the job auto-downloads the NDK the same way the
# emulator job did before runs 39506049904/39626150998. Restore-only:
# the emulator job seeds the cache key; install_android_ndk.py covers
# a miss with one retry.
uses: actions/cache/restore@v6
with:
path: /usr/local/lib/android/sdk/ndk/${{ env.NDK_VERSION }}
key: ${{ runner.os }}-android-ndk-${{ env.NDK_VERSION }}
- name: Install pinned Android NDK
run: python3 .github/scripts/install_android_ndk.py --ndk-version "$NDK_VERSION"
- name: Run Android JVM unit tests
working-directory: app/android
env:
CM_KEYSTORE_PATH: ${{ github.workspace }}/app/setup/prebuilt/debug.keystore
CM_KEYSTORE_PASSWORD: android
CM_KEY_ALIAS: androiddebugkey
CM_KEY_PASSWORD: android
# Same target-platform as the APK job so a shared Gradle cache hit
# does not rebuild every ABI. This job does not wait for the APK.
run: ./gradlew :app:testDevDebugUnitTest -Ptarget-platform=android-arm64
android-emulator-checks:
name: Android Emulator Acceptance
runs-on: ubuntu-latest
needs: changes
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'push' && needs.changes.outputs.has_app_compile_smoke == 'true') || (needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_app_android_pr == 'true')
timeout-minutes: 35
env:
# Read by app/android/app/build.gradle (ndkVersion) and the NDK install
# step below; test_mobile_ci_infra_guards.py keeps it equal to
# build.gradle's fallback pin.
NDK_VERSION: "28.2.13676358"
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- uses: gradle/actions/setup-gradle@v6
with:
gradle-version: '8.14.2'
cache-read-only: true
- name: Install acceptance build tools
run: |
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "build-tools;36.0.0" "platforms;android-36"
- name: Cache pinned Android NDK
# AGP auto-installs a missing NDK inside Gradle configure with no
# retry, so one truncated dl.google.com response fails the job before
# any app code runs ("Error on ZipFile unknown archive": runs
# 39506049904, 39469917718, 39626150998 — dead 5 s into a download
# that takes ~22 s when healthy). Cache the extracted NDK so most runs
# make no dl.google.com request at all. Sole writer: the restore-only
# compile-smoke and unit-test jobs keep post-job saves off their wall,
# and this job runs in every event they do.
uses: actions/cache@v6
with:
path: /usr/local/lib/android/sdk/ndk/${{ env.NDK_VERSION }}
key: ${{ runner.os }}-android-ndk-${{ env.NDK_VERSION }}
- name: Install pinned Android NDK
# Cache-miss path: explicit install, corrupt-archive purge, one retry.
run: python3 .github/scripts/install_android_ndk.py --ndk-version "$NDK_VERSION"
- name: Prepare secret-free local app inputs
working-directory: app
run: |
mkdir -p android/app/src/dev/ android/app/src/prod/
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json
cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json
cp setup/prebuilt/key.properties android/key.properties
{
echo 'API_BASE_URL='
echo 'USE_WEB_AUTH=true'
echo 'USE_AUTH_CUSTOM_TOKEN=true'
echo 'STAGING_API_URL='
} > .dev.env
: > .env
flutter pub get
dart run build_runner build --delete-conflicting-outputs
bash scripts/validate_mobile_build_config.sh --flavor dev --profile local_dev
- name: Build minified AOT APK for emulator acceptance
working-directory: app
env:
CM_KEYSTORE_PATH: ${{ github.workspace }}/app/setup/prebuilt/debug.keystore
CM_KEYSTORE_PASSWORD: android
CM_KEY_ALIAS: androiddebugkey
CM_KEY_PASSWORD: android
# One ABI limits R8/native-build memory. Existing arm64 compile remains.
run: flutter build apk --release --flavor dev --target-platform android-x64 --dart-define=OMI_APP_PROFILE=local_dev
- name: Enable Linux KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Exercise actual native lifecycle and packaged startup
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
with:
api-level: 36
target: google_apis
arch: x86_64
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot -memory 2048
# One explicit strict Bash invocation: no later smoke can hide a
# native failure, independent of the action's script parser.
script: >-
bash -euo pipefail -c '
python3 app/scripts/android_fgs_probe.py --serial emulator-5554 --gradle gradle --rounds 5 --output android-acceptance/native &&
python3 app/scripts/android_startup_smoke.py --serial emulator-5554 --apk app/build/app/outputs/flutter-apk/app-dev-release.apk --aapt "$ANDROID_HOME/build-tools/36.0.0/aapt" --rounds 3 --output android-acceptance/startup'
- name: Upload emulator acceptance evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: android-acceptance-${{ github.event.pull_request.head.sha || github.sha }}
path: android-acceptance/
if-no-files-found: error
retention-days: 14
ios-compile-check:
name: iOS Compile Check
runs-on: macos-26
needs: changes
if: needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_app_ios_compile == 'true'
# Local cold Xcode time was 334s on an M-series Mac. Hosted macos-26 still
# pays Flutter SDK, pub get, build_runner, and CocoaPods on top of that.
# Cold p50 is ~33 min, but a slow cold runner measured 39:25 for the real
# steps alone (16:12 manifest checks + 16:27 Xcode build, run 39634604539
# attempt 1); the post-job Post Setup Flutter cache save (59 s on the
# sibling success run 119312386446) then hit the 40-min ceiling and the
# job reported cancelled after every real step passed. 50 keeps the
# wedge-guard above that cold path; budget counts p50 (19), not the cap.
timeout-minutes: 50
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
fetch-depth: 0
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Select and assert pinned Xcode
run: desktop/macos/scripts/run-swift-ci.sh --select-toolchain
# Manifest flow coverage bootstraps the pinned Swift formatter. Share the
# desktop binary cache so a cold source build cannot consume the iOS
# compiler's job budget (16 minutes on PR #21488).
- name: Restore Swift formatter and linter tools
id: ios-swift-tools-cache
uses: actions/cache/restore@v6
with:
path: |
~/.cache/omi-swift-format/*/.build/release/swift-format
~/.cache/omi-swiftlint/*/swiftlint
key: ${{ runner.os }}-desktop-swift-tools-v3-xcode266-${{ hashFiles('desktop/macos/scripts/swift-format-wrapper.sh', 'desktop/macos/scripts/swiftlint-wrapper.sh') }}
restore-keys: |
${{ runner.os }}-desktop-swift-tools-v3-xcode266-
# The macOS-scoped selection includes desktop-beta-admission-firestore-
# contention, whose run.sh needs backend/.venv or uv (the same fallback
# desktop-swift-ci.yml relies on). Nightly 39586610411 failed the iOS
# job at that check's setup with neither present.
- name: Install uv for manifest checks
uses: astral-sh/setup-uv@ecd24dd710f2fb0dca1693a67af11fc4a5c5ec84
with:
# Exact version resolves locally; an unset version means "latest",
# which fetches the astral-sh/versions manifest and has hard-failed
# CI on that network call. Matches desktop-swift-ci.yml and
# backend/Dockerfile UV_VERSION.
version: "0.11.13"
- name: Run macOS-scoped iOS manifest checks
run: |
python3 .github/scripts/run_checks.py --lane ci --platform macos \
${{ inputs.nightly_main_sha != '' && '--changed-files /tmp/nightly-all-files.txt --skip-pr-body-checks --keep-going' || '' }} \
--exclusive-platform --base "${{ needs.changes.outputs.diff_base }}" \
--skip-pr-body-checks
- name: Save Swift tools before iOS compilation
if: steps.ios-swift-tools-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: |
~/.cache/omi-swift-format/*/.build/release/swift-format
~/.cache/omi-swiftlint/*/swiftlint
key: ${{ steps.ios-swift-tools-cache.outputs.cache-primary-key }}
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- name: Cache build_runner state
uses: actions/cache@v6
with:
path: app/.dart_tool/build
key: ${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}-
${{ runner.os }}-flutter-buildrunner-
- name: Cache CocoaPods
uses: actions/cache@v6
with:
path: |
app/ios/Pods
~/Library/Caches/CocoaPods
key: ${{ runner.os }}-cocoapods-${{ hashFiles('app/ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-cocoapods-
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
- name: Prepare Flutter dev compile inputs
working-directory: app
run: |
mkdir -p android/app/src/dev/ android/app/src/prod/ ios/Config/Dev ios/Config/Prod
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json
cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json
cp setup/prebuilt/key.properties android/key.properties
cp setup/prebuilt/GoogleService-Info-Local.plist ios/Config/Dev/GoogleService-Info.plist
cp setup/prebuilt/GoogleService-Info-Local.plist ios/Config/Prod/GoogleService-Info.plist
cp setup/prebuilt/GoogleService-Info-Local.plist ios/Runner/GoogleService-Info.plist
{
echo "API_BASE_URL="
echo "USE_WEB_AUTH=true"
echo "USE_AUTH_CUSTOM_TOKEN=true"
echo "STAGING_API_URL="
} > .dev.env
: > .env
- name: Generate Flutter code for iOS compile
working-directory: app
run: dart run build_runner build --delete-conflicting-outputs
# omiWatchApp is a watchOS companion. `flutter build ios --simulator`
# without `-d` fails after pod install: "A device ID is required to
# build an app with a watchOS companion app." (run 35256673731).
- name: Pick an iPhone simulator
run: |
python3 - <<'PY'
import json
import os
import subprocess
import sys
raw = subprocess.check_output(
["xcrun", "simctl", "list", "devices", "available", "-j"],
text=True,
)
data = json.loads(raw)
picked = None
for runtime, devices in data.get("devices", {}).items():
if "iOS" not in runtime:
continue
for device in devices:
if device.get("isAvailable") and "iPhone" in device.get("name", ""):
picked = device
if picked is None:
print(raw, file=sys.stderr)
raise SystemExit("no available iPhone simulator")
udid = picked["udid"]
print(f"Using simulator {picked['name']} ({udid})")
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as handle:
handle.write(f"IOS_SIMULATOR_UDID={udid}\n")
PY
- name: Build Flutter iOS simulator debug
working-directory: app
# Flavor scheme is `dev` (Debug-dev). omiWatchApp.xcscheme lacks
# customLLDBInitFile; Flutter warns, the compile still proceeds.
run: flutter build ios --simulator --debug --flavor dev --no-codesign -d "$IOS_SIMULATOR_UDID"
- name: Prove stable iOS compiler emits no Siri metadata
working-directory: app
run: |
python3 - <<'PY'
import json
from pathlib import Path
bundle = Path('build/ios/iphonesimulator/Runner.app')
if not bundle.is_dir():
raise SystemExit(f'missing built Runner.app: {bundle}')
actions = bundle / 'Metadata.appintents/extract.actionsdata'
payload = json.loads(actions.read_text()) if actions.is_file() else {}
for kind in ('actions', 'entities'):
if payload.get(kind):
raise SystemExit(f'Xcode 26.6 unexpectedly emitted {kind}: {sorted(payload[kind])}')
print('Xcode 26.6 Runner metadata: no Siri actions or entities')
PY
dart-tests-kiritimati:
# Full far-timezone suite runs on main and nightly; PRs retain the ordinary
# Dart suite. An unrelated fork does not claim timezone coverage.
name: Dart Tests (Pacific/Kiritimati)
runs-on: ubuntu-latest
needs: changes
if: inputs.nightly_main_sha != '' || github.event_name == 'schedule' || (github.event_name == 'push' && needs.changes.outputs.has_app_dart == 'true')
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.nightly_main_sha || github.sha }}
- name: Inventory all nightly paths
if: inputs.nightly_main_sha != ''
shell: bash
run: git ls-files -- . ':!omi/**' ':!omiGlass/**' > /tmp/nightly-all-files.txt
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- name: Cache build_runner state
uses: actions/cache@v6
with:
path: app/.dart_tool/build
key: ${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-buildrunner-${{ hashFiles('app/pubspec.lock') }}-
${{ runner.os }}-flutter-buildrunner-
- name: Resolve Flutter dependencies
working-directory: app
run: flutter pub get
- name: Prepare Flutter dev compile inputs
working-directory: app
run: |
cp lib/firebase_options_local.dart lib/firebase_options_dev.dart
cp lib/firebase_options_local.dart lib/firebase_options_prod.dart
{
echo "API_BASE_URL="
echo "USE_WEB_AUTH=true"
echo "USE_AUTH_CUSTOM_TOKEN=true"
echo "STAGING_API_URL="
} > .dev.env
: > .env
- name: Generate Flutter code
working-directory: app
run: dart run build_runner build --delete-conflicting-outputs
- name: Run Flutter tests under Pacific/Kiritimati
run: TZ=Pacific/Kiritimati bash app/test.sh
- name: Run Flutter tests under Pacific/Pago_Pago
if: always()
run: TZ=Pacific/Pago_Pago bash app/test.sh
# This is the only mobile status that a release-admission caller should
# consume. Lane D must query this exact check name on the exact source SHA,
# then write the normalized evidence expected by
# verify_mobile_release_admission.py:
#
# {"check_name":"Mobile Release Eligibility",
# "workflow_name":"Mobile App Checks",
# "workflow_path":".github/workflows/mobile-app-checks.yml",
# "event":"push", "status":"completed", "conclusion":"success",
# "head_branch":"main", "head_sha":"<source_sha>",
# "repository":"BasedHardware/omi"}
#
# Selected Dart coverage must succeed, including on cheap-tier forks. Other
# conditional jobs may be skipped for inapplicable or deferred heavy coverage.
# Changes itself must succeed; failures and cancellations remain ineligible.
mobile-release-eligibility:
name: Mobile Release Eligibility
runs-on: ubuntu-latest
needs:
- changes
- generated-files
- analyze-and-test
- journeys-hermetic
- android-compile-smoke
- android-unit-tests
- android-emulator-checks
- ios-compile-check
- dart-tests-kiritimati
if: ${{ always() }}
timeout-minutes: 10
steps:
- name: Aggregate conditional mobile checks
env:
CHANGES_RESULT: ${{ needs.changes.result }}
DART_EXPECTED: ${{ github.event_name == 'schedule' || needs.changes.outputs.has_app_dart == 'true' }}
FULL_CI: ${{ needs.changes.outputs.full_ci }}
HAS_GENERATED: ${{ needs.changes.outputs.has_flutter_generated }}
HAS_DART: ${{ needs.changes.outputs.has_app_dart }}
HAS_JOURNEYS: ${{ needs.changes.outputs.has_app_journeys_pr }}
HAS_ANDROID: ${{ needs.changes.outputs.has_app_android_pr }}
HAS_IOS: ${{ needs.changes.outputs.has_app_ios_compile }}
GENERATED_FILES_RESULT: ${{ needs.generated-files.result }}
ANALYZE_AND_TEST_RESULT: ${{ needs.analyze-and-test.result }}
JOURNEYS_RESULT: ${{ needs.journeys-hermetic.result }}
ANDROID_COMPILE_RESULT: ${{ needs.android-compile-smoke.result }}
ANDROID_UNIT_RESULT: ${{ needs.android-unit-tests.result }}
ANDROID_EMULATOR_RESULT: ${{ needs.android-emulator-checks.result }}
EMULATOR_EXPECTED: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'push' && needs.changes.outputs.has_app_compile_smoke == 'true') || (needs.changes.outputs.full_ci == 'true' && needs.changes.outputs.has_app_android_pr == 'true') }}
IOS_COMPILE_RESULT: ${{ needs.ios-compile-check.result }}
DART_TIMEZONE_RESULT: ${{ needs.dart-tests-kiritimati.result }}
run: |
set -euo pipefail
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Detect Changes did not complete successfully: $CHANGES_RESULT" >&2
exit 1
fi
if [[ ( "${HAS_DART:-false}" == true || "${DART_EXPECTED:-false}" == true ) && "$ANALYZE_AND_TEST_RESULT" != success ]]; then
echo "Required Dart Analyze & Tests did not pass: $ANALYZE_AND_TEST_RESULT" >&2
exit 1
fi
if [[ "$FULL_CI" != "true" ]]; then
if [[ "$HAS_GENERATED" == true || "$HAS_JOURNEYS" == true || "$HAS_ANDROID" == true || "$HAS_IOS" == true ]]; then
notice='fork PR — a maintainer adds label ci:full, then Re-run all jobs'
echo "::notice title=Heavy CI deferred::$notice"
echo "Heavy CI deferred: $notice" >> "$GITHUB_STEP_SUMMARY"
for spec in \
"$HAS_GENERATED:$GENERATED_FILES_RESULT" \
"$HAS_DART:$DART_TIMEZONE_RESULT" \
"$HAS_JOURNEYS:$JOURNEYS_RESULT" \
"$HAS_ANDROID:$ANDROID_COMPILE_RESULT" \
"$HAS_ANDROID:$ANDROID_UNIT_RESULT" \
"$HAS_ANDROID:$ANDROID_EMULATOR_RESULT" \
"$HAS_IOS:$IOS_COMPILE_RESULT"; do
required="${spec%%:*}"
result="${spec#*:}"
if [[ "$required" == true && "$result" != skipped ]]; then
echo "A deferred mobile check must be skipped, got: $result" >&2
exit 1
fi
done
exit 0
fi
if [[ "${HAS_DART:-false}" == true || "${DART_EXPECTED:-false}" == true ]]; then
echo 'Mobile Release Eligibility: Dart Analyze & Tests passed; no deferred heavy checks selected' | tee -a "$GITHUB_STEP_SUMMARY"
else
echo 'Mobile Release Eligibility: no mobile heavy checks selected' | tee -a "$GITHUB_STEP_SUMMARY"
fi
exit 0
fi
if [[ "$EMULATOR_EXPECTED" == true && "$ANDROID_EMULATOR_RESULT" != success ]]; then
echo "Required Android Emulator Acceptance did not pass: $ANDROID_EMULATOR_RESULT" >&2
exit 1
fi
for result in \
"$GENERATED_FILES_RESULT" \
"$ANALYZE_AND_TEST_RESULT" \
"$JOURNEYS_RESULT" \
"$ANDROID_COMPILE_RESULT" \
"$ANDROID_UNIT_RESULT" \
"$ANDROID_EMULATOR_RESULT" \
"$IOS_COMPILE_RESULT" \
"$DART_TIMEZONE_RESULT"; do
case "$result" in
success|skipped) ;;
*)
echo "A mobile check failed, was cancelled, or is otherwise ineligible: $result" >&2
exit 1
;;
esac
done
echo "Mobile Release Eligibility: success"
post-merge-failure-issues:
name: Post-merge mobile failure issues
needs: [changes, analyze-and-test, journeys-hermetic, android-compile-smoke, android-unit-tests, android-emulator-checks, dart-tests-kiritimati]
if: ${{ always() && inputs.nightly_main_sha == '' && (github.event_name == 'push' || github.event_name == 'schedule') }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Reconcile one issue per mobile suite
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
DART: ${{ needs.analyze-and-test.result }}
JOURNEYS: ${{ needs.journeys-hermetic.result }}
ANDROID_COMPILE: ${{ needs.android-compile-smoke.result }}
ANDROID_JVM: ${{ needs.android-unit-tests.result }}
ANDROID_EMULATOR: ${{ needs.android-emulator-checks.result }}
TIMEZONES: ${{ needs.dart-tests-kiritimati.result }}
EVENT: ${{ github.event_name }}
DART_EXPECTED: ${{ needs.changes.outputs.has_app_dart }}
JOURNEYS_EXPECTED: ${{ needs.changes.outputs.has_app_journeys }}
ANDROID_EXPECTED: ${{ needs.changes.outputs.has_app_compile_smoke }}
TIMEZONES_EXPECTED: ${{ needs.changes.outputs.has_app_dart }}
run: |
set -euo pipefail
failed=0
for spec in "Dart Analyze & Tests:$DART:$DART_EXPECTED" "Seeded Journeys:$JOURNEYS:$JOURNEYS_EXPECTED" "Android Compile Smoke:$ANDROID_COMPILE:$ANDROID_EXPECTED" "Android JVM Unit Tests:$ANDROID_JVM:$ANDROID_EXPECTED" "Android Emulator Acceptance:$ANDROID_EMULATOR:$ANDROID_EXPECTED" "Dart Far Timezones:$TIMEZONES:$TIMEZONES_EXPECTED"; do
suite="${spec%%:*}"
rest="${spec#*:}"
result="${rest%%:*}"
expected="${rest#*:}"
[[ "$EVENT" == push && "$expected" != true && "$result" == skipped ]] && continue
title="CI post-merge failure: $suite"
issue_number="$(gh api --method GET "repos/$REPO/issues?state=open&per_page=100" --paginate --slurp | jq -r --arg title "$title" '[.[][] | select(.title == $title and .pull_request == null)][0].number // empty')"
if [[ "$result" == success ]]; then
if [[ -n "$issue_number" ]]; then
gh issue close "$issue_number" --repo "$REPO" --reason completed
fi
continue
fi
failed=1
printf 'The %s suite was %s on a main or nightly run.\n\nEvidence: %s\n' "$suite" "$result" "$RUN_URL" > /tmp/ci-suite-issue.md
if [[ -n "$issue_number" ]]; then
gh issue edit "$issue_number" --repo "$REPO" --body-file /tmp/ci-suite-issue.md
else
gh issue create --repo "$REPO" --title "$title" --body-file /tmp/ci-suite-issue.md
fi
done
exit "$failed"