Increase test coverage #275
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 | |
| on: | |
| push: | |
| branches: ["main"] | |
| tags: ["*"] | |
| pull_request: | |
| workflow_dispatch: | |
| concurrency: | |
| group: android-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: write | |
| env: | |
| CI_SHA: ${{ github.event.pull_request.head.sha || github.sha }} | |
| jobs: | |
| notices: | |
| name: Notices | Generation + drift | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Generate notices | |
| run: npm run notices:generate | |
| - name: Verify notice drift | |
| run: npm run notices:check | |
| - name: Build packaged app assets | |
| run: npm run build | |
| - name: Verify packaged notice drift | |
| run: bash scripts/package-third-party-notice.sh --check | |
| - name: Ensure compliance artifacts are committed | |
| run: | | |
| set -euo pipefail | |
| git diff --exit-code -- \ | |
| THIRD_PARTY_NOTICES.md | |
| - name: Upload notice artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: third-party-notices | |
| path: | | |
| THIRD_PARTY_NOTICES.md | |
| dist/THIRD_PARTY_NOTICES.md | |
| if-no-files-found: warn | |
| web-unit: | |
| name: Web | Unit tests (coverage) | |
| runs-on: ubuntu-latest | |
| env: | |
| HVSC_UPDATE_84_CACHE: ${{ github.workspace }}/.cache/hvsc | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Cache HVSC Update 84 archive | |
| uses: actions/cache@v4 | |
| with: | |
| path: ${{ github.workspace }}/.cache/hvsc | |
| key: hvsc-update-84-${{ runner.os }} | |
| - name: Run unit tests with coverage | |
| run: | | |
| start=$(date +%s) | |
| npm run test:coverage | |
| end=$(date +%s) | |
| echo "- unit tests: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Upload unit coverage | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-unit | |
| path: coverage/lcov.info | |
| web-screenshots: | |
| name: Web | Screenshots | |
| runs-on: ubuntu-latest | |
| needs: web-build-coverage | |
| env: | |
| HVSC_UPDATE_84_CACHE: ${{ github.workspace }}/.cache/hvsc | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Cache HVSC Update 84 archive | |
| uses: actions/cache@v4 | |
| with: | |
| path: ${{ github.workspace }}/.cache/hvsc | |
| key: hvsc-update-84-${{ runner.os }} | |
| - name: Fix broken apt repos | |
| run: | | |
| sudo rm -f /etc/apt/sources.list.d/azure-cli.sources /etc/apt/sources.list.d/azure-cli.list || true | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json', 'bun.lockb') }} | |
| - name: Install Playwright browsers | |
| run: | | |
| start=$(date +%s) | |
| npx playwright install --with-deps | |
| end=$(date +%s) | |
| echo "- playwright install: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Download coverage build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: web-dist-coverage | |
| path: dist | |
| - name: Ensure build output exists | |
| run: | | |
| if [[ ! -f dist/index.html ]]; then | |
| echo "dist/index.html missing; rebuilding" | |
| npm run build | |
| fi | |
| - name: Run screenshot tests | |
| env: | |
| PLAYWRIGHT_SKIP_BUILD: "1" | |
| VITE_GIT_SHA: "ci" | |
| VITE_BUILD_TIME: "1970-01-01T00:00:00Z" | |
| SOURCE_DATE_EPOCH: "0" | |
| run: | | |
| start=$(date +%s) | |
| npm run screenshots | |
| end=$(date +%s) | |
| echo "- screenshots: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Upload screenshot evidence | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-evidence-screenshots | |
| path: test-results/evidence/** | |
| web-build-coverage: | |
| name: Web | Build (coverage) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Build web (coverage probes) | |
| run: | | |
| start=$(date +%s) | |
| VITE_COVERAGE=true VITE_ENABLE_TEST_PROBES=1 npm run build | |
| end=$(date +%s) | |
| echo "- build (coverage): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Upload coverage build output | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: web-dist-coverage | |
| path: dist | |
| web-e2e: | |
| name: Web | E2E (sharded) | |
| runs-on: ubuntu-latest | |
| container: | |
| image: mcr.microsoft.com/playwright:v1.57.0-noble | |
| defaults: | |
| run: | |
| shell: bash | |
| needs: web-build-coverage | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] | |
| shardTotal: [12] | |
| env: | |
| VITE_COVERAGE: "true" | |
| VITE_ENABLE_TEST_PROBES: "1" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Download coverage build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: web-dist-coverage | |
| path: dist | |
| - name: Ensure coverage build exists | |
| run: | | |
| if [[ ! -f dist/index.html ]]; then | |
| echo "dist/index.html missing; rebuilding with coverage instrumentation" | |
| VITE_COVERAGE=true VITE_ENABLE_TEST_PROBES=1 npm run build | |
| fi | |
| if ! grep -qr "__coverage__" dist/assets 2>/dev/null; then | |
| echo "coverage instrumentation missing; rebuilding with coverage instrumentation" | |
| VITE_COVERAGE=true VITE_ENABLE_TEST_PROBES=1 npm run build | |
| fi | |
| - name: Prepare coverage output | |
| run: | | |
| mkdir -p .nyc_output | |
| - name: Select Playwright shard files | |
| run: | | |
| node scripts/get-playwright-shard-files.mjs \ | |
| --shard=${{ matrix.shard }} \ | |
| --total=${{ matrix.shardTotal }} \ | |
| > shard-files.txt | |
| echo "Shard files:" >> $GITHUB_STEP_SUMMARY | |
| sed 's/^/- /' shard-files.txt >> $GITHUB_STEP_SUMMARY | |
| - name: Run Playwright e2e tests (shard ${{ matrix.shard }}/${{ matrix.shardTotal }}) | |
| env: | |
| PLAYWRIGHT_SKIP_BUILD: "1" | |
| VITE_GIT_SHA: "ci" | |
| VITE_BUILD_TIME: "1970-01-01T00:00:00Z" | |
| SOURCE_DATE_EPOCH: "0" | |
| run: | | |
| start=$(date +%s) | |
| mapfile -t SHARD_FILES < shard-files.txt | |
| npm run test:e2e -- "${SHARD_FILES[@]}" | |
| end=$(date +%s) | |
| echo "- e2e shard ${{ matrix.shard }}/${{ matrix.shardTotal }}: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Verify nyc output | |
| if: always() | |
| run: | | |
| if ! find .nyc_output -type f -name "*.json" | grep -q .; then | |
| echo "No nyc coverage output found in shard ${{ matrix.shard }}/${{ matrix.shardTotal }}" | |
| exit 1 | |
| fi | |
| - name: Upload e2e coverage shard | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: e2e-nyc-output-${{ matrix.shard }} | |
| path: .nyc_output/** | |
| if-no-files-found: ignore | |
| include-hidden-files: true | |
| - name: Upload e2e evidence shard | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-evidence-e2e-${{ matrix.shard }} | |
| path: test-results/evidence/** | |
| web-coverage-merge: | |
| name: Web | Coverage + evidence merge | |
| runs-on: ubuntu-latest | |
| needs: [web-unit, web-screenshots, web-e2e] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies (npm) | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Download unit coverage | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: coverage-unit | |
| path: coverage | |
| - name: Download e2e nyc outputs | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: e2e-nyc-output-* | |
| path: artifacts/nyc | |
| merge-multiple: true | |
| - name: Download Playwright evidence | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: playwright-evidence-* | |
| path: test-results/evidence | |
| merge-multiple: true | |
| - name: Collect nyc coverage | |
| run: | | |
| mkdir -p .nyc_output | |
| mkdir -p artifacts/nyc | |
| if find artifacts/nyc -type f -name "*.json" | grep -q .; then | |
| find artifacts/nyc -type f -name "*.json" -exec cp -f {} .nyc_output/ \; | |
| fi | |
| - name: Validate Playwright evidence | |
| run: | | |
| start=$(date +%s) | |
| npm run validate:evidence | |
| end=$(date +%s) | |
| echo "- evidence validation: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Merge coverage reports | |
| run: | | |
| start=$(date +%s) | |
| echo "==> Generating E2E coverage report..." | |
| npx nyc report \ | |
| --temp-dir .nyc_output \ | |
| --report-dir coverage/e2e \ | |
| --reporter=lcov \ | |
| --reporter=text-summary | |
| echo "==> Merging unit + E2E LCOV for Codecov..." | |
| npx lcov-result-merger \ | |
| "coverage/{lcov.info,e2e/lcov.info}" \ | |
| coverage/lcov-merged.info | |
| end=$(date +%s) | |
| echo "- coverage merge: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: List web coverage artifacts | |
| run: | | |
| echo "Coverage files:"; | |
| find coverage -maxdepth 3 -type f \( -name "*.info" -o -name "*.json" -o -name "*.html" \) | sort | |
| - name: Verify coverage artifacts | |
| env: | |
| EXPECT_WEB_COVERAGE: "1" | |
| run: node scripts/verify-coverage-artifacts.mjs | |
| - name: Enforce coverage threshold | |
| env: | |
| COVERAGE_MIN: "90" | |
| COVERAGE_FILE: "coverage/lcov-merged.info" | |
| run: node scripts/check-coverage-threshold.mjs | |
| - name: Enforce coverage paths | |
| env: | |
| MIN_SRC_FILES: "50" | |
| MIN_SRC_LINES: "2000" | |
| run: node scripts/check-lcov-paths.mjs | |
| - name: Upload web coverage to Codecov | |
| uses: codecov/codecov-action@v5 | |
| env: | |
| CODECOV_SHA: ${{ env.CI_SHA }} | |
| CC_SHA: ${{ env.CI_SHA }} | |
| with: | |
| files: ./coverage/lcov-merged.info | |
| disable_search: true | |
| flags: unittests,e2etests | |
| name: c64-commander-web-coverage | |
| use_pypi: true | |
| fail_ci_if_error: true | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| - name: Upload Playwright evidence | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-evidence | |
| path: test-results/evidence/** | |
| android-tests: | |
| name: Android | Tests + Coverage | |
| runs-on: ubuntu-latest | |
| env: | |
| GRADLE_MAX_WORKERS: "6" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Resolve app version | |
| run: | | |
| if [[ "${GITHUB_REF_TYPE}" == "tag" && -n "${GITHUB_REF_NAME}" ]]; then | |
| APP_VERSION_RAW="${GITHUB_REF_NAME}" | |
| else | |
| APP_VERSION_RAW="$(git describe --tags --exact-match 2>/dev/null || true)" | |
| if [[ -z "$APP_VERSION_RAW" ]]; then | |
| APP_VERSION_RAW="$(node -p "require('./package.json').version" 2>/dev/null || true)" | |
| fi | |
| fi | |
| APP_VERSION="${APP_VERSION_RAW#v}" | |
| VERSION_CODE=$((1600 + GITHUB_RUN_NUMBER)) | |
| echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV | |
| echo "VERSION_NAME=$APP_VERSION" >> $GITHUB_ENV | |
| echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 17 | |
| cache: gradle | |
| - name: Setup Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Export Android SDK env | |
| run: | | |
| if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then | |
| echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> $GITHUB_ENV | |
| elif [[ -n "${ANDROID_HOME:-}" ]]; then | |
| echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV | |
| fi | |
| - name: Cache Android SDK system images | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ${{ env.ANDROID_SDK_ROOT }}/system-images | |
| ${{ env.ANDROID_SDK_ROOT }}/emulator | |
| ${{ env.ANDROID_SDK_ROOT }}/platform-tools | |
| ${{ env.ANDROID_SDK_ROOT }}/platforms/android-${{ env.ANDROID_API_LEVEL }} | |
| ~/.android/avd | |
| ~/.android/adb* | |
| key: android-sdk-${{ runner.os }}-api${{ env.ANDROID_API_LEVEL }}-${{ env.ANDROID_SYSTEM_IMAGE }} | |
| - name: Decode keystore | |
| env: | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then | |
| mkdir -p android/keystore | |
| echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.keystore | |
| else | |
| echo "ANDROID_KEYSTORE_BASE64 is not set. Skipping keystore setup." | |
| fi | |
| - name: Export signing env | |
| env: | |
| KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} | |
| KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then | |
| echo "KEYSTORE_STORE_PASSWORD=$KEYSTORE_STORE_PASSWORD" >> $GITHUB_ENV | |
| echo "KEYSTORE_KEY_PASSWORD=$KEYSTORE_KEY_PASSWORD" >> $GITHUB_ENV | |
| echo "KEYSTORE_KEY_ALIAS=c64commander" >> $GITHUB_ENV | |
| echo "KEYSTORE_STORE_FILE=${{ github.workspace }}/android/keystore/release.keystore" >> $GITHUB_ENV | |
| echo "HAS_KEYSTORE=true" >> $GITHUB_ENV | |
| else | |
| echo "Signing secrets not set. Skipping signing env export." | |
| echo "HAS_KEYSTORE=false" >> $GITHUB_ENV | |
| fi | |
| - name: Accept licenses | |
| run: yes | sdkmanager --licenses | |
| - name: Install SDK components | |
| run: | | |
| sdkmanager \ | |
| "platform-tools" \ | |
| "platforms;android-34" \ | |
| "build-tools;34.0.0" | |
| - name: Build web + sync | |
| run: | | |
| start=$(date +%s) | |
| npm run cap:build | |
| end=$(date +%s) | |
| echo "- cap build: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Verify Android assemble path (dry-run) | |
| run: | | |
| set -e | |
| cd android | |
| ./gradlew -m :app:assembleDebug | |
| - name: Inspect SevenZ/XZ dependency graph | |
| run: | | |
| set -e | |
| cd android | |
| ./gradlew :app:dependencies --configuration debugRuntimeClasspath | |
| ./gradlew :app:dependencyInsight --dependency xz --configuration debugRuntimeClasspath | |
| - name: Build debug + minifiedDebug APKs | |
| run: | | |
| set -e | |
| cd android | |
| ./gradlew :app:assembleDebug :app:assembleMinifiedDebug | |
| - name: Verify SevenZ/XZ runtime classes in APK DEX | |
| run: | | |
| set -euo pipefail | |
| DEBUG_APK=$(ls android/app/build/outputs/apk/debug/*.apk | head -n 1) | |
| MINIFIED_DEBUG_APK=$(ls android/app/build/outputs/apk/minifiedDebug/*.apk | head -n 1) | |
| bash scripts/verify-android-apk-lzma2.sh "$DEBUG_APK" "$MINIFIED_DEBUG_APK" | |
| - name: Run Android tests with coverage | |
| run: | | |
| start=$(date +%s) | |
| cd android | |
| ./gradlew testDebugUnitTest jacocoTestReport | |
| end=$(date +%s) | |
| echo "- android tests: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: List Android coverage artifacts | |
| if: always() | |
| run: | | |
| echo "Android coverage files:"; | |
| find android/app/build/reports -type f -name "jacocoTestReport.xml" | sort | |
| - name: Normalize Android coverage report path | |
| if: always() | |
| id: normalize_android_coverage | |
| run: | | |
| set -e | |
| report=$(find android/app/build/reports -type f -name "*.xml" | grep -i jacoco | head -n 1) | |
| if [ -z "$report" ]; then | |
| echo "No JaCoCo XML report found under android/app/build/reports." >&2 | |
| exit 1 | |
| fi | |
| target="android/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" | |
| mkdir -p "$(dirname "$target")" | |
| if [ "$report" != "$target" ]; then | |
| cp "$report" "$target" | |
| fi | |
| echo "report=$target" >> $GITHUB_OUTPUT | |
| - name: Verify coverage artifacts | |
| if: always() | |
| env: | |
| EXPECT_ANDROID_COVERAGE: "1" | |
| run: node scripts/verify-coverage-artifacts.mjs | |
| - name: Upload Android coverage to Codecov | |
| if: always() | |
| uses: codecov/codecov-action@v5 | |
| env: | |
| CODECOV_SHA: ${{ env.CI_SHA }} | |
| CC_SHA: ${{ env.CI_SHA }} | |
| with: | |
| files: ${{ steps.normalize_android_coverage.outputs.report }} | |
| disable_search: true | |
| flags: android | |
| name: c64-commander-android-coverage | |
| use_pypi: true | |
| fail_ci_if_error: true | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| android-maestro: | |
| name: Android | Maestro gating | |
| runs-on: ubuntu-latest | |
| env: | |
| GRADLE_MAX_WORKERS: "6" | |
| ANDROID_API_LEVEL: "34" | |
| ANDROID_SYSTEM_IMAGE: "system-images;android-34;google_apis;x86_64" | |
| ANDROID_DEVICE_PROFILE: "pixel_6" | |
| ANDROID_AVD_NAME: "c64-ci-constrained-3gb" | |
| ANDROID_AVD_RAM_MB: "3072" | |
| ANDROID_AVD_HEAP_MB: "512" | |
| ANDROID_AVD_CPU_CORES: "2" | |
| ANDROID_AVD_CPU_FREQ_MHZ: "2000" | |
| ANDROID_AVD_LOW_RAM: "no" | |
| EMULATOR_HEADLESS: "1" | |
| BOOT_TIMEOUT_SECS: "180" | |
| MAESTRO_TIMEOUT_SECS: "1800" | |
| MAESTRO_CLI_NO_ANALYTICS: "1" | |
| ANDROID_AVD_HOME: /home/runner/.android/avd | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 17 | |
| cache: gradle | |
| - name: Setup Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Cache Maestro CLI | |
| id: maestro-cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.maestro | |
| key: maestro-cli-${{ runner.os }}-v1 | |
| - name: Export Android SDK env | |
| run: | | |
| if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then | |
| echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> $GITHUB_ENV | |
| elif [[ -n "${ANDROID_HOME:-}" ]]; then | |
| echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV | |
| fi | |
| - name: Enable 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: Start emulator (background) | |
| run: | | |
| set -ex | |
| export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" | |
| export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$PATH" | |
| echo "=== Restarting adb daemon ===" | |
| adb kill-server || true | |
| adb start-server | |
| # Ensure AVD directory exists | |
| mkdir -p "$ANDROID_AVD_HOME" | |
| echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" | |
| echo "=== Installing Android SDK components ===" | |
| sdkmanager "platform-tools" "emulator" "platforms;android-${ANDROID_API_LEVEL}" "${ANDROID_SYSTEM_IMAGE}" | |
| echo "=== Listing existing AVDs ===" | |
| avdmanager list avd || true | |
| echo "=== Creating AVD if needed ===" | |
| if ! avdmanager list avd | grep -q "Name: ${ANDROID_AVD_NAME}"; then | |
| echo "Creating AVD: ${ANDROID_AVD_NAME}" | |
| echo "no" | avdmanager create avd -n "${ANDROID_AVD_NAME}" -k "${ANDROID_SYSTEM_IMAGE}" -d "${ANDROID_DEVICE_PROFILE}" --force | |
| echo "AVD creation completed" | |
| else | |
| echo "AVD ${ANDROID_AVD_NAME} already exists" | |
| fi | |
| echo "=== Verifying AVD exists ===" | |
| avdmanager list avd | |
| ls -la "$ANDROID_AVD_HOME" || true | |
| if ! avdmanager list avd | grep -q "Name: ${ANDROID_AVD_NAME}"; then | |
| echo "ERROR: AVD ${ANDROID_AVD_NAME} was not created!" | |
| exit 1 | |
| fi | |
| AVD_CONFIG="$ANDROID_AVD_HOME/${ANDROID_AVD_NAME}.avd/config.ini" | |
| if [[ -f "$AVD_CONFIG" ]]; then | |
| sed -i "s/^hw.ramSize=.*/hw.ramSize=${ANDROID_AVD_RAM_MB}/" "$AVD_CONFIG" || true | |
| sed -i "s/^vm.heapSize=.*/vm.heapSize=${ANDROID_AVD_HEAP_MB}/" "$AVD_CONFIG" || true | |
| sed -i "s/^hw.cpu.ncore=.*/hw.cpu.ncore=${ANDROID_AVD_CPU_CORES}/" "$AVD_CONFIG" || true | |
| sed -i "s/^hw.cpu.speed=.*/hw.cpu.speed=${ANDROID_AVD_CPU_FREQ_MHZ}/" "$AVD_CONFIG" || true | |
| sed -i "s/^hw.device.lowram=.*/hw.device.lowram=${ANDROID_AVD_LOW_RAM}/" "$AVD_CONFIG" || true | |
| grep -q '^hw.ramSize=' "$AVD_CONFIG" || echo "hw.ramSize=${ANDROID_AVD_RAM_MB}" >> "$AVD_CONFIG" | |
| grep -q '^vm.heapSize=' "$AVD_CONFIG" || echo "vm.heapSize=${ANDROID_AVD_HEAP_MB}" >> "$AVD_CONFIG" | |
| grep -q '^hw.cpu.ncore=' "$AVD_CONFIG" || echo "hw.cpu.ncore=${ANDROID_AVD_CPU_CORES}" >> "$AVD_CONFIG" | |
| grep -q '^hw.cpu.speed=' "$AVD_CONFIG" || echo "hw.cpu.speed=${ANDROID_AVD_CPU_FREQ_MHZ}" >> "$AVD_CONFIG" | |
| grep -q '^hw.device.lowram=' "$AVD_CONFIG" || echo "hw.device.lowram=${ANDROID_AVD_LOW_RAM}" >> "$AVD_CONFIG" | |
| echo "=== AVD config readback ===" | |
| grep -E '^(hw\.ramSize|vm\.heapSize|hw\.cpu\.ncore|hw\.cpu\.speed|hw\.device\.lowram)=' "$AVD_CONFIG" | sort | |
| fi | |
| echo "=== Starting emulator in background ===" | |
| mkdir -p test-results/maestro | |
| nohup "$ANDROID_SDK_ROOT/emulator/emulator" -avd "${ANDROID_AVD_NAME}" -no-window -no-audio -no-metrics -no-boot-anim -no-snapshot -no-snapshot-load -no-snapshot-save -gpu swiftshader_indirect -netdelay none -netspeed full -memory "${ANDROID_AVD_RAM_MB}" -cores "${ANDROID_AVD_CPU_CORES}" > test-results/maestro/emulator.log 2>&1 & | |
| EMULATOR_PID=$! | |
| echo $EMULATOR_PID > test-results/maestro/emulator.pid | |
| echo "Emulator started with PID: $EMULATOR_PID" | |
| # Give emulator a moment to start and check if it's still running | |
| sleep 3 | |
| if ! kill -0 $EMULATOR_PID 2>/dev/null; then | |
| echo "ERROR: Emulator process died immediately" | |
| cat test-results/maestro/emulator.log || true | |
| exit 1 | |
| fi | |
| echo "Emulator process is running" | |
| - name: Wait for emulator boot | |
| run: | | |
| set -e | |
| export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" | |
| export PATH="$ANDROID_SDK_ROOT/platform-tools:$PATH" | |
| adb start-server | |
| boot_start=$(date +%s) | |
| echo "Waiting for emulator to appear in adb devices..." | |
| timeout 240 bash -c 'until adb devices | grep -q "emulator-"; do sleep 2; done' || { | |
| echo "Emulator did not appear on first attempt; restarting adb server and retrying" | |
| adb kill-server || true | |
| adb start-server | |
| timeout 180 bash -c 'until adb devices | grep -q "emulator-"; do sleep 2; done' || { | |
| echo "Emulator did not appear" | |
| adb devices | |
| cat test-results/maestro/emulator.log || true | |
| exit 1 | |
| } | |
| } | |
| SERIAL=$(adb devices | awk '/emulator-/ {print $1; exit}') | |
| echo "Emulator serial: $SERIAL" | |
| timeout 240 adb -s "$SERIAL" wait-for-device || { echo "Device wait-for-device failed"; adb devices -l; exit 1; } | |
| echo "Waiting for boot completion..." | |
| timeout 420 bash -c "until adb -s $SERIAL shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '1'; do sleep 3; done" || { echo "Boot did not complete"; adb -s $SERIAL shell getprop sys.boot_completed || true; cat test-results/maestro/emulator.log || true; exit 1; } | |
| echo "Waiting for boot animation to stop..." | |
| timeout 120 bash -c "until adb -s $SERIAL shell getprop init.svc.bootanim 2>/dev/null | tr -d '\r' | grep -q 'stopped'; do sleep 2; done" || true | |
| CPU_COUNT=$(adb -s "$SERIAL" shell "grep -c '^processor' /proc/cpuinfo" | tr -d '\r' || true) | |
| MEM_TOTAL_KB=$(adb -s "$SERIAL" shell "awk '/MemTotal/ {print \$2; exit}' /proc/meminfo" | tr -d '\r' || true) | |
| echo "runtime cpu_count=$CPU_COUNT" | |
| echo "runtime mem_total_kb=$MEM_TOTAL_KB" | |
| if [[ ! "$CPU_COUNT" =~ ^[0-9]+$ || "$CPU_COUNT" -lt 2 ]]; then | |
| echo "Emulator CPU core count is below expected profile (>=2)" | |
| exit 1 | |
| fi | |
| if [[ -z "$MEM_TOTAL_KB" || "$MEM_TOTAL_KB" -lt 2500000 ]]; then | |
| echo "Emulator memory is below expected profile (~3GB)" | |
| exit 1 | |
| fi | |
| echo "Assuming 2GHz/core profile as host-backed emulator CPU characteristic" | |
| boot_end=$(date +%s) | |
| echo "- emulator boot: $((boot_end-boot_start))s" >> $GITHUB_STEP_SUMMARY | |
| echo "- emulator profile: RAM=${ANDROID_AVD_RAM_MB}MB CORES=${ANDROID_AVD_CPU_CORES} CPU_FREQ=assumed-2GHz" >> $GITHUB_STEP_SUMMARY | |
| echo "emulator_boot_seconds=$((boot_end-boot_start))" >> test-results/maestro/timings.txt | |
| echo "Emulator is ready" | |
| adb devices -l | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Install Maestro CLI | |
| if: steps.maestro-cache.outputs.cache-hit != 'true' | |
| run: | | |
| set -euo pipefail | |
| MAESTRO_VERSION="cli-2.2.0" | |
| MAESTRO_SHA256="6de501d2e8adf2d60f4b6b3174dc4b5e393f2f2617245d350a659627dccb0922" | |
| tmpdir="$(mktemp -d)" | |
| trap 'rm -rf "$tmpdir"' EXIT | |
| curl -fsSL "https://github.com/mobile-dev-inc/Maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o "$tmpdir/maestro.zip" | |
| echo "${MAESTRO_SHA256} $tmpdir/maestro.zip" | sha256sum -c - | |
| mkdir -p "$HOME/.maestro" | |
| mkdir -p "$HOME/.maestro/bin" | |
| unzip -q "$tmpdir/maestro.zip" -d "$HOME/.maestro" | |
| chmod +x "$HOME/.maestro/maestro/bin/maestro" | |
| ln -sf "$HOME/.maestro/maestro/bin/maestro" "$HOME/.maestro/bin/maestro" | |
| "$HOME/.maestro/bin/maestro" --version | |
| echo "$HOME/.maestro/bin" >> $GITHUB_PATH | |
| - name: Export Maestro CLI path | |
| run: | | |
| echo "$HOME/.maestro/bin" >> $GITHUB_PATH | |
| - name: Build web + sync | |
| run: | | |
| start=$(date +%s) | |
| VITE_ENABLE_TEST_PROBES=1 npm run cap:build | |
| end=$(date +%s) | |
| echo "- cap build: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Build APK (debug) | |
| run: | | |
| start=$(date +%s) | |
| VITE_ENABLE_TEST_PROBES=1 npm run android:apk | |
| end=$(date +%s) | |
| echo "- apk debug: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Start Android telemetry monitor | |
| run: | | |
| set -euo pipefail | |
| mkdir -p ci-artifacts/telemetry/android | |
| ( | |
| status=0 | |
| code_file="ci-artifacts/telemetry/android/monitor.exitcode" | |
| trap 'echo "${status:-1}" > "$code_file"' EXIT | |
| TELEMETRY_INTERVAL_SEC=1 \ | |
| TELEMETRY_OUTPUT_DIR=ci-artifacts/telemetry/android \ | |
| ANDROID_PACKAGE_NAME=uk.gleissner.c64commander \ | |
| TELEMETRY_DEVICE_NAME="${ANDROID_AVD_NAME}" \ | |
| ci/telemetry/android/monitor_android.sh & | |
| monitor_child_pid=$! | |
| echo "$monitor_child_pid" > ci-artifacts/telemetry/android/monitor.child.pid | |
| wait "$monitor_child_pid" || status=$? | |
| ) > ci-artifacts/telemetry/android/monitor-run.log 2>&1 & | |
| echo $! > ci-artifacts/telemetry/android/monitor.pid | |
| sleep 2 | |
| if ! kill -0 "$(cat ci-artifacts/telemetry/android/monitor.pid)" 2>/dev/null; then | |
| echo "Android telemetry monitor failed to start" | |
| cat ci-artifacts/telemetry/android/monitor-run.log || true | |
| exit 1 | |
| fi | |
| - name: Install APK and prime telemetry | |
| run: | | |
| set -euo pipefail | |
| export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" | |
| export PATH="$ANDROID_SDK_ROOT/platform-tools:$PATH" | |
| package_name="uk.gleissner.c64commander" | |
| SERIAL=$(adb devices | awk '/emulator-/ {print $1; exit}') | |
| if [[ -z "$SERIAL" ]]; then | |
| echo "::error::No emulator serial found" | |
| adb devices -l || true | |
| exit 1 | |
| fi | |
| echo "Emulator serial: $SERIAL" | |
| # Install APK so the app can be launched for telemetry priming. | |
| # run-maestro-gating.sh will reinstall with -r (harmless). | |
| debug_apk_dir="android/app/build/outputs/apk/debug" | |
| apk_path="$debug_apk_dir/app-debug.apk" | |
| if [[ ! -f "$apk_path" ]]; then | |
| apk_path="$(find "$debug_apk_dir" -maxdepth 1 -type f -name '*-debug.apk' ! -name '*androidTest*.apk' | sort | head -n 1 || true)" | |
| fi | |
| if [[ -z "$apk_path" || ! -f "$apk_path" ]]; then | |
| echo "::error::APK not found under $debug_apk_dir" | |
| ls -la "$debug_apk_dir" || true | |
| exit 1 | |
| fi | |
| echo "Using APK: $apk_path" | |
| adb -s "$SERIAL" install -r -t -d "$apk_path" || { | |
| echo "APK install failed; trying uninstall + reinstall" | |
| adb -s "$SERIAL" uninstall "$package_name" 2>/dev/null || true | |
| adb -s "$SERIAL" install -t -d "$apk_path" | |
| } | |
| echo "APK installed, verifying package..." | |
| timeout 15 bash -c "until adb -s '$SERIAL' shell pm list packages '$package_name' | tr -d '\r' | grep -q '$package_name'; do sleep 1; done" || { | |
| echo "::error::Package not discoverable after install" | |
| exit 1 | |
| } | |
| # Launch app to prime the telemetry monitor with initial samples. | |
| # Uses -W (wait) for synchronous launch confirmation. | |
| echo "Launching app for telemetry priming..." | |
| adb -s "$SERIAL" shell am start -W -n "${package_name}/.MainActivity" 2>&1 || true | |
| # Wait for telemetry monitor to capture at least one sample row. | |
| csv="ci-artifacts/telemetry/android/metrics.csv" | |
| preflight_timeout="${TELEMETRY_PREFLIGHT_TIMEOUT_SEC:-30}" | |
| if timeout "$preflight_timeout" bash -c "until [[ -f '$csv' ]] && [[ \$(wc -l < '$csv' 2>/dev/null || echo 0) -gt 1 ]]; do sleep 1; done"; then | |
| echo "Telemetry priming OK: $(wc -l < "$csv") lines in metrics.csv" | |
| else | |
| echo "::warning::Telemetry priming: monitor did not capture samples within ${preflight_timeout}s" | |
| echo "=== monitor diagnostics ===" | |
| ls -la ci-artifacts/telemetry/android/ || true | |
| tail -n 30 ci-artifacts/telemetry/android/monitor-run.log || true | |
| tail -n 20 ci-artifacts/telemetry/android/events.log || true | |
| adb -s "$SERIAL" shell "pidof $package_name" || echo "(pidof returned empty)" | |
| adb -s "$SERIAL" shell "ps -A | grep -i c64" || echo "(ps grep returned empty)" | |
| # Non-fatal: Maestro will relaunch the app and monitor should capture then. | |
| fi | |
| - name: Run Maestro gating flows | |
| env: | |
| CI: "true" | |
| CI_RUN_LOWRAM_FLOW: "false" | |
| MAESTRO_LOG_LEVEL: "debug" | |
| MAESTRO_CLI_LOG_LEVEL: "debug" | |
| run: | | |
| set -euo pipefail | |
| export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" | |
| export PATH="$ANDROID_SDK_ROOT/platform-tools:$HOME/.maestro/bin:$PATH" | |
| start=$(date +%s) | |
| # Verify emulator is still available | |
| adb devices -l | |
| maestro_status=0 | |
| bash scripts/run-maestro-gating.sh --skip-emulator-start --skip-build || maestro_status=$? | |
| if [[ "$maestro_status" -ne 0 ]]; then | |
| echo "=== Emulator log ===" >&2 | |
| cat test-results/maestro/emulator.log || true | |
| echo "=== adb devices ===" >&2 | |
| adb devices -l || true | |
| exit "$maestro_status" | |
| fi | |
| end=$(date +%s) | |
| echo "- maestro gating: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Assert memory class logging | |
| if: success() | |
| run: | | |
| set -euo pipefail | |
| export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" | |
| export PATH="$ANDROID_SDK_ROOT/platform-tools:$PATH" | |
| SERIAL=$(adb devices | awk '/emulator-/ {print $1; exit}') | |
| if [[ -z "$SERIAL" ]]; then | |
| echo "::warning::No emulator serial found for memory class assertion; skipping check" | |
| exit 0 | |
| fi | |
| adb -s "$SERIAL" logcat -c || true | |
| adb -s "$SERIAL" shell am start -n uk.gleissner.c64commander/.MainActivity >/dev/null 2>&1 || true | |
| if ! timeout 45 bash -c 'until adb -s "$0" logcat -d | grep -q "Android memory class detected:"; do sleep 2; done' "$SERIAL"; then | |
| echo "::warning::MainActivity memory class log not found after launch+retry" | |
| adb -s "$SERIAL" logcat -d | tail -n 200 || true | |
| fi | |
| - name: Stop Android telemetry monitor | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| code_file="ci-artifacts/telemetry/android/monitor.exitcode" | |
| child_pid="" | |
| if [[ -f ci-artifacts/telemetry/android/monitor.child.pid ]]; then | |
| child_pid="$(cat ci-artifacts/telemetry/android/monitor.child.pid)" | |
| fi | |
| if [[ -f ci-artifacts/telemetry/android/monitor.pid ]]; then | |
| pid="$(cat ci-artifacts/telemetry/android/monitor.pid)" | |
| if [[ -n "$child_pid" ]]; then | |
| kill -TERM "$child_pid" 2>/dev/null || true | |
| else | |
| kill -TERM "$pid" 2>/dev/null || true | |
| fi | |
| for _ in $(seq 1 20); do | |
| if [[ -f ci-artifacts/telemetry/android/monitor.exitcode ]]; then | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if [[ -n "$child_pid" ]] && kill -0 "$child_pid" 2>/dev/null; then | |
| kill -KILL "$child_pid" 2>/dev/null || true | |
| fi | |
| for _ in $(seq 1 10); do | |
| if [[ -f "$code_file" ]]; then | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if kill -0 "$pid" 2>/dev/null; then | |
| kill -KILL "$pid" 2>/dev/null || true | |
| fi | |
| for _ in $(seq 1 5); do | |
| if [[ -f "$code_file" ]]; then | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if [[ ! -f "$code_file" ]]; then | |
| echo "1" > "$code_file" | |
| echo "telemetry(android): synthesized monitor.exitcode=1 because wrapper exited before writing status" >&2 | |
| fi | |
| fi | |
| cat ci-artifacts/telemetry/android/monitor-run.log || true | |
| - name: Diagnose Android telemetry inputs | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| telemetry_dir="ci-artifacts/telemetry/android" | |
| echo "=== telemetry dir tree ===" | |
| ls -la "$telemetry_dir" || true | |
| csv="$telemetry_dir/metrics.csv" | |
| if [[ -f "$csv" ]]; then | |
| echo "=== metrics.csv lines ===" | |
| wc -l "$csv" || true | |
| echo "=== metrics.csv head ===" | |
| head -n 5 "$csv" || true | |
| echo "=== metrics.csv tail ===" | |
| tail -n 5 "$csv" || true | |
| fi | |
| echo "=== monitor-run.log tail ===" | |
| tail -n 80 "$telemetry_dir/monitor-run.log" || true | |
| echo "=== events.log tail ===" | |
| tail -n 80 "$telemetry_dir/events.log" || true | |
| - name: Summarize telemetry | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| TELEMETRY_SUMMARY_DIR=ci-artifacts/telemetry \ | |
| TELEMETRY_INPUT_CSVS=ci-artifacts/telemetry/android/metrics.csv \ | |
| python3 ci/telemetry/summarize_metrics.py | |
| - name: Render telemetry charts | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| TELEMETRY_SUMMARY_DIR=ci-artifacts/telemetry python3 ci/telemetry/render_charts.py | |
| - name: Upload Maestro evidence | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: maestro-evidence | |
| path: | | |
| test-results/maestro/** | |
| test-results/evidence/maestro/** | |
| ci-artifacts/telemetry/android/** | |
| ci-artifacts/telemetry/summary.json | |
| ci-artifacts/telemetry/summary.md | |
| ci-artifacts/telemetry/charts/** | |
| - name: Enforce Android telemetry gates | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| expected_header="timestamp,platform,device,process_name,pid,cpu_percent,rss_kb,threads,pss_kb,dalvik_pss_kb,native_pss_kb,total_pss_kb" | |
| debug_tail_lines="${TELEMETRY_GATE_DEBUG_TAIL_LINES:-80}" | |
| csv="ci-artifacts/telemetry/android/metrics.csv" | |
| if [[ ! -f "$csv" ]]; then | |
| echo "telemetry gate failed: missing $csv" | |
| exit 1 | |
| fi | |
| header=$(head -n 1 "$csv") | |
| if [[ "$header" != "$expected_header" ]]; then | |
| echo "telemetry gate failed: unexpected CSV header" | |
| echo "expected: $expected_header" | |
| echo "actual: $header" | |
| exit 1 | |
| fi | |
| line_count=$(wc -l < "$csv") | |
| data_rows=$((line_count - 1)) | |
| if [[ "$data_rows" -lt 2 ]]; then | |
| echo "telemetry gate failed: expected multiple data rows, found ${data_rows}" | |
| echo "telemetry gate debug: csv=${csv} line_count=${line_count} data_rows=${data_rows}" | |
| tail -n "$debug_tail_lines" ci-artifacts/telemetry/android/monitor-run.log || true | |
| tail -n "$debug_tail_lines" ci-artifacts/telemetry/android/events.log || true | |
| if [[ -f ci-artifacts/telemetry/android/metadata.json ]]; then | |
| echo "telemetry gate debug: metadata.json" | |
| cat ci-artifacts/telemetry/android/metadata.json || true | |
| fi | |
| exit 1 | |
| fi | |
| code_file="ci-artifacts/telemetry/android/monitor.exitcode" | |
| if [[ ! -f "$code_file" ]]; then | |
| echo "telemetry gate failed: monitor exit code missing" | |
| exit 1 | |
| fi | |
| code=$(cat "$code_file") | |
| if [[ "$code" == "3" ]]; then | |
| if [[ "${GITHUB_REF_TYPE}" == "tag" || "${GITHUB_REF_NAME}" == release/* ]]; then | |
| echo "telemetry gate failed: main process disappearance/restart detected on release flow; see ci-artifacts/telemetry/android/events.log" | |
| exit 1 | |
| fi | |
| echo "telemetry gate warning: main process disappearance/restart detected (non-release flow); see ci-artifacts/telemetry/android/events.log" | |
| exit 0 | |
| fi | |
| if [[ "$code" == "137" ]]; then | |
| echo "telemetry gate warning: monitor exited with code 137 (likely infra resource kill); see ci-artifacts/telemetry/android/monitor-run.log" | |
| exit 0 | |
| fi | |
| if [[ "$code" != "0" ]]; then | |
| echo "telemetry gate failed: monitor exited with code $code" | |
| exit 1 | |
| fi | |
| android-packaging: | |
| name: Android | Packaging | |
| runs-on: ubuntu-latest | |
| needs: [android-maestro] | |
| env: | |
| GRADLE_MAX_WORKERS: "6" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Resolve app version | |
| run: | | |
| if [[ "${GITHUB_REF_TYPE}" == "tag" && -n "${GITHUB_REF_NAME}" ]]; then | |
| APP_VERSION_RAW="${GITHUB_REF_NAME}" | |
| else | |
| APP_VERSION_RAW="$(git describe --tags --exact-match 2>/dev/null || true)" | |
| if [[ -z "$APP_VERSION_RAW" ]]; then | |
| APP_VERSION_RAW="$(node -p "require('./package.json').version" 2>/dev/null || true)" | |
| fi | |
| fi | |
| APP_VERSION="${APP_VERSION_RAW#v}" | |
| VERSION_CODE=$((1600 + GITHUB_RUN_NUMBER)) | |
| echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV | |
| echo "VERSION_NAME=$APP_VERSION" >> $GITHUB_ENV | |
| echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV | |
| - name: Install dependencies | |
| run: | | |
| start=$(date +%s) | |
| npm ci | |
| end=$(date +%s) | |
| echo "- deps install (npm): $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 17 | |
| cache: gradle | |
| - name: Setup Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Export Android SDK env | |
| run: | | |
| if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then | |
| echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> $GITHUB_ENV | |
| elif [[ -n "${ANDROID_HOME:-}" ]]; then | |
| echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV | |
| fi | |
| - name: Decode keystore | |
| env: | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then | |
| mkdir -p android/keystore | |
| echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.keystore | |
| else | |
| echo "ANDROID_KEYSTORE_BASE64 is not set. Skipping keystore setup." | |
| fi | |
| - name: Export signing env | |
| env: | |
| KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} | |
| KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then | |
| echo "KEYSTORE_STORE_PASSWORD=$KEYSTORE_STORE_PASSWORD" >> $GITHUB_ENV | |
| echo "KEYSTORE_KEY_PASSWORD=$KEYSTORE_KEY_PASSWORD" >> $GITHUB_ENV | |
| echo "KEYSTORE_KEY_ALIAS=c64commander" >> $GITHUB_ENV | |
| echo "KEYSTORE_STORE_FILE=${{ github.workspace }}/android/keystore/release.keystore" >> $GITHUB_ENV | |
| echo "HAS_KEYSTORE=true" >> $GITHUB_ENV | |
| else | |
| echo "Signing secrets not set. Skipping signing env export." | |
| echo "HAS_KEYSTORE=false" >> $GITHUB_ENV | |
| fi | |
| - name: Accept licenses | |
| run: yes | sdkmanager --licenses | |
| - name: Install SDK components | |
| run: | | |
| sdkmanager \ | |
| "platform-tools" \ | |
| "platforms;android-34" \ | |
| "build-tools;34.0.0" | |
| - name: Build web + sync | |
| run: | | |
| start=$(date +%s) | |
| npm run cap:build | |
| end=$(date +%s) | |
| echo "- cap build: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Build APK (debug) | |
| run: | | |
| start=$(date +%s) | |
| npm run android:apk | |
| end=$(date +%s) | |
| echo "- apk debug: $((end-start))s" >> $GITHUB_STEP_SUMMARY | |
| - name: Build APK (release) | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| run: cd android && ./gradlew assembleRelease | |
| - name: Build App Bundle (release) | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| run: cd android && ./gradlew bundleRelease | |
| - name: Rename release APK for publishing | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| run: | | |
| mv android/app/build/outputs/apk/release/c64commander-${{ env.APP_VERSION }}.apk \ | |
| android/app/build/outputs/apk/release/c64commander-${{ env.APP_VERSION }}-android.apk | |
| - name: Upload APK artifact (release) | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: c64commander-release-apk | |
| path: | | |
| android/app/build/outputs/apk/release/c64commander-${{ env.APP_VERSION }}-android.apk | |
| - name: Rename AAB for publishing | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| run: | | |
| mv android/app/build/outputs/bundle/release/app-release.aab \ | |
| android/app/build/outputs/bundle/release/c64commander-${{ env.APP_VERSION }}-android-play.aab | |
| - name: Upload AAB artifact (release) | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: c64commander-release-aab | |
| path: android/app/build/outputs/bundle/release/c64commander-${{ env.APP_VERSION }}-android-play.aab | |
| release-artifacts: | |
| name: Release | Attach APK/AAB | |
| runs-on: ubuntu-latest | |
| needs: [web-coverage-merge, android-tests, android-packaging] | |
| permissions: | |
| contents: write | |
| env: | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.CI_SHA }} | |
| - name: Resolve app version | |
| run: | | |
| if [[ "${GITHUB_REF_TYPE}" == "tag" && -n "${GITHUB_REF_NAME}" ]]; then | |
| APP_VERSION_RAW="${GITHUB_REF_NAME}" | |
| else | |
| APP_VERSION_RAW="$(git describe --tags --exact-match 2>/dev/null || true)" | |
| if [[ -z "$APP_VERSION_RAW" ]]; then | |
| APP_VERSION_RAW="$(node -p "require('./package.json').version" 2>/dev/null || true)" | |
| fi | |
| fi | |
| APP_VERSION="${APP_VERSION_RAW#v}" | |
| echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV | |
| - name: Resolve signing availability | |
| run: | | |
| if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then | |
| echo "HAS_KEYSTORE=true" >> $GITHUB_ENV | |
| else | |
| echo "HAS_KEYSTORE=false" >> $GITHUB_ENV | |
| fi | |
| - name: Download release APK artifact | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: c64commander-release-apk | |
| path: artifacts/release-apk | |
| - name: Download release AAB artifact | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: c64commander-release-aab | |
| path: artifacts/release-aab | |
| - name: Validate release artifact filenames | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| run: | | |
| set -euo pipefail | |
| test -f "artifacts/release-apk/c64commander-${APP_VERSION}-android.apk" | |
| test -f "artifacts/release-aab/c64commander-${APP_VERSION}-android-play.aab" | |
| ! find artifacts -type f | grep -Ei '(debug|unsigned|altstore|app-release\.aab)' | |
| - name: Ensure GitHub release exists | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| TAG="${GITHUB_REF_NAME}" | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| echo "Release already exists for $TAG" | |
| else | |
| if [[ "$TAG" == *-rc* ]]; then | |
| gh release create "$TAG" --target "$GITHUB_SHA" --verify-tag --prerelease --generate-notes | |
| else | |
| gh release create "$TAG" --target "$GITHUB_SHA" --verify-tag --generate-notes | |
| fi | |
| fi | |
| - name: Upload release artifacts to GitHub release | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| gh release upload "${GITHUB_REF_NAME}" \ | |
| "artifacts/release-apk/c64commander-${APP_VERSION}-android.apk" \ | |
| "artifacts/release-aab/c64commander-${APP_VERSION}-android-play.aab" \ | |
| --clobber | |
| - name: Verify Android release assets are attached | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| assets_json="$(gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name')" | |
| echo "Release assets:" | |
| echo "$assets_json" | |
| grep -Fx "c64commander-${APP_VERSION}-android.apk" <<<"$assets_json" >/dev/null | |
| grep -Fx "c64commander-${APP_VERSION}-android-play.aab" <<<"$assets_json" >/dev/null | |
| - name: Upload AAB to Google Play (internal) | |
| if: startsWith(github.ref, 'refs/tags/') && env.HAS_KEYSTORE == 'true' | |
| uses: r0adkll/upload-google-play@v1 | |
| with: | |
| serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} | |
| packageName: uk.gleissner.c64commander | |
| releaseFiles: artifacts/release-aab/c64commander-${{ env.APP_VERSION }}-android-play.aab | |
| track: internal | |
| # TODO Change to 'completed' once all requirements met and app submitted once manually | |
| status: draft |