name: Android CI/CD env: GRADLE_CACHE_ENCRYPTED_KEY: ${{ secrets.GRADLE_ENCRYPTED_KEY }} on: push: branches: [main, dev] pull_request: branches: [main, dev] workflow_dispatch: permissions: contents: read # Cancel in-progress runs when a new commit lands on the same PR branch. # On main (push events), each commit gets a unique group so rapid merges # don't cancel each other's CI runs. concurrency: group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: true jobs: # ───────────────────────────────────────────────────────────────────── # Job 0: Detect changes — skips heavy Android build/tests for doc-only PRs # ───────────────────────────────────────────────────────────────────── changes: name: Detect Changes runs-on: ubuntu-latest timeout-minutes: 3 outputs: code: ${{ steps.filter.outputs.code }} steps: - name: Checkout uses: actions/checkout@v7 - name: Paths Filter uses: dorny/paths-filter@v4 id: filter with: filters: | code: - 'app/**' - 'gradle/**' - 'build.gradle.kts' - 'settings.gradle.kts' - 'gradle.properties' - 'gradlew' - 'gradlew.bat' - '.github/workflows/android.yml' - '.editorconfig' - 'flake.nix' - 'flake.lock' # ───────────────────────────────────────────────────────────────────── # Job 1: ktlint (code style) — fastest feedback, runs in parallel. # ───────────────────────────────────────────────────────────────────── ktlint: name: ktlint — Code Style needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up JDK 21 uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }} - name: Grant Execute Permission for Gradlew run: chmod +x gradlew - name: Cache ktlint id: cache-ktlint uses: actions/cache@v6 with: path: ~/.local/bin/ktlint key: ktlint-1.8.0 - name: Install ktlint if: steps.cache-ktlint.outputs.cache-hit != 'true' run: | mkdir -p ~/.local/bin curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint chmod +x ktlint mv ktlint ~/.local/bin/ - name: Add local bin to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Run ktlintCheck run: ./gradlew ktlintCheck - name: Run checkColorLiterals (hardcoded Color guard, issue #622) run: ./gradlew checkColorLiterals - name: Upload ktlint reports if: always() uses: actions/upload-artifact@v7 with: name: ktlint-reports path: | app/build/reports/ktlint/ build/reports/ktlint/ if-no-files-found: ignore retention-days: 7 # ───────────────────────────────────────────────────────────────────── # Job 2: Android Lint (static analysis) — runs in parallel with ktlint. # Depends on compileDebugKotlin, so it also catches unresolved refs. # ───────────────────────────────────────────────────────────────────── android-lint: name: Android Lint — Static Analysis needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up JDK 21 uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }} - name: Grant Execute Permission for Gradlew run: chmod +x gradlew - name: Run lintDebug run: ./gradlew lintDebug - name: Upload lint reports if: always() uses: actions/upload-artifact@v7 with: name: lint-reports path: app/build/reports/lint-results-debug.* retention-days: 7 # ───────────────────────────────────────────────────────────────────── # Job 3: Unit tests — runs in parallel with ktlint & android-lint. # ───────────────────────────────────────────────────────────────────── unit-tests: name: Unit & Integration Tests needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up JDK 21 uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }} - name: Grant Execute Permission for Gradlew run: chmod +x gradlew - name: Run test run: ./gradlew test - name: Upload test reports if: always() uses: actions/upload-artifact@v7 with: name: test-reports path: app/build/reports/tests/ retention-days: 7 # ───────────────────────────────────────────────────────────────────── # Job 4: Build Debug APK — depends on all three above passing. # ───────────────────────────────────────────────────────────────────── build: name: Build Debug APK needs: [changes, ktlint, android-lint, unit-tests] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up JDK 21 uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }} - name: Grant Execute Permission for Gradlew run: chmod +x gradlew - name: Build assembleDebug run: ./gradlew assembleDebug - name: Upload Debug APK Artifact if: success() uses: actions/upload-artifact@v7 with: name: hermescontrol-debug-apk path: app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error retention-days: 7 # ───────────────────────────────────────────────────────────────────── # Job 5: Release Compile — verifies release variant compiles. # Catches issues like debugImplementation-scoped dependencies that # main source code depends on (e.g. okhttp3.logging.HttpLoggingInterceptor). # debug-only CI jobs (ktlint, lint, tests) won't catch these. # ───────────────────────────────────────────────────────────────────── release-compile: name: Release Compile Check needs: [changes, ktlint, android-lint, unit-tests] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up JDK 21 uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }} - name: Grant Execute Permission for Gradlew run: chmod +x gradlew - name: Compile release Kotlin run: ./gradlew compileReleaseKotlin # ───────────────────────────────────────────────────────────────────── # Job 6: Instrumented tests — runs on Android emulator. # Required for Compose UI tests (TEST-09, TEST-14) and Navigation3 tests # that need a real Activity lifecycle. Cannot run on JVM. # ───────────────────────────────────────────────────────────────────── instrumented-tests: name: Instrumented Tests needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v7 - name: Set up JDK 21 uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }} - name: Grant Execute Permission for Gradlew run: chmod +x gradlew # Enable KVM hardware acceleration on the Linux runner - name: Enable KVM group permissions 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 # Cache the AVD to avoid downloading/recreating it from scratch every time - name: AVD Cache id: avd-cache uses: actions/cache@v6 with: path: | ~/.android/avd/* ~/.android/adb* key: avd-api34-atd-x86_64 - name: Run connectedDebugAndroidTest uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 target: aosp_atd arch: x86_64 disable-animations: true force-avd-creation: false emulator-options: -no-window -no-audio -no-boot-anim -camera-back none -memory 2048 script: ./gradlew connectedDebugAndroidTest --no-daemon - name: Upload instrumented test reports if: always() uses: actions/upload-artifact@v7 with: name: androidTest-reports path: app/build/reports/androidTests/ if-no-files-found: ignore retention-days: 7 # ───────────────────────────────────────────────────────────────────── # Job 7: PR status summary — aggregates all checks. # ───────────────────────────────────────────────────────────────────── ci-summary: name: CI Summary needs: [changes, ktlint, android-lint, unit-tests, build, release-compile] runs-on: ubuntu-latest timeout-minutes: 2 if: always() permissions: contents: read pull-requests: write steps: - name: Check job results env: GH_TOKEN: ${{ github.token }} KTLINT_RESULT: ${{ needs.ktlint.result }} LINT_RESULT: ${{ needs.android-lint.result }} TEST_RESULT: ${{ needs.unit-tests.result }} BUILD_RESULT: ${{ needs.build.result }} RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }} run: | LABEL="ci-red" FAILED=0 FAILED_JOBS="" for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT"; do name="${entry%%:*}" result="${entry#*:}" echo "::notice::$name = $result" if [[ "$result" != "success" && "$result" != "skipped" ]]; then FAILED=1 FAILED_JOBS+="$name " fi done # Label the PR so red CI is visible at a glance; remove it once green. # Only PR events carry a PR number — pushes to main have nothing to label. if [[ "${{ github.event_name }}" == "pull_request" ]]; then PR=${{ github.event.pull_request.number }} if [[ "$FAILED" -eq 1 ]]; then gh pr edit "$PR" --add-label "$LABEL" \ && echo "::notice::Added \"$LABEL\" to PR #$PR (failed: ${FAILED_JOBS% })" else # Fails if the label isn't present — that's fine, ignore it. gh pr edit "$PR" --remove-label "$LABEL" 2>/dev/null || true fi fi if [[ "$FAILED" -eq 1 ]]; then echo "::error::CI failed (${FAILED_JOBS% }) — check individual job logs" exit 1 fi echo "All CI jobs passed ✅"