name: MotorSignal Android CI on: push: branches: [ master ] pull_request: branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ] workflow_dispatch: permissions: contents: read concurrency: group: android-build-${{ github.ref }} cancel-in-progress: true env: FLUTTER_VERSION: '3.44.6' JAVA_VERSION: '17' ANDROID_COMPILE_SDK: '36' ANDROID_BUILD_TOOLS: '36.0.0' GRADLE_VERSION: '9.1.0' jobs: detect: name: Detect Android changes runs-on: ubuntu-latest permissions: contents: read outputs: android_changed: ${{ steps.detector.outputs.android_changed }} app_changed: ${{ steps.detector.outputs.app_changed }} steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Node uses: actions/setup-node@v6 with: node-version: '22' - name: Run detector tests run: node --test .github/scripts/detect-android-changes.test.cjs - name: Detect changes id: detector env: GITHUB_EVENT_NAME: ${{ github.event_name }} BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: node .github/scripts/detect-android-changes.cjs analyze: name: Analyze runs-on: ubuntu-latest needs: detect if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true' permissions: contents: read steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 1 - name: Set up Flutter CI uses: ./.github/actions/flutter-ci-setup with: build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }} - name: Generate Drift sources run: dart run build_runner build --delete-conflicting-outputs - name: Analyze run: flutter analyze --no-fatal-infos test: name: Tests (shard ${{ matrix.shard }}/4) runs-on: ubuntu-latest needs: detect if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true' permissions: contents: read strategy: fail-fast: false matrix: shard: [0, 1, 2, 3] steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 1 - name: Set up Flutter CI uses: ./.github/actions/flutter-ci-setup with: build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }} - name: Generate Drift sources run: dart run build_runner build --delete-conflicting-outputs - name: Run test shard run: flutter test --total-shards 4 --shard-index ${{ matrix.shard }} debug-apk: name: Debug APK build runs-on: ubuntu-latest needs: detect if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true' permissions: contents: write steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 1 - name: Set up Flutter CI uses: ./.github/actions/flutter-ci-setup with: setup-java: 'true' build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }} - name: Free runner disk space shell: bash run: | set -euo pipefail sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /opt/ghc /usr/local/.ghcup /usr/local/lib/android/sdk/emulator /usr/local/lib/android/sdk/system-images sudo apt-get clean || true sudo rm -rf /var/lib/apt/lists/* docker system prune -af --volumes 2>/dev/null || true df -h / AVAILABLE_KB="$(df --output=avail / | tail -n 1 | tr -d ' ')" test "$AVAILABLE_KB" -ge "$((5 * 1024 * 1024))" - name: Create Flutter local.properties shell: bash run: | set -euo pipefail test -n "${FLUTTER_ROOT:-}" && test -d "$FLUTTER_ROOT" printf 'flutter.sdk=%s\n' "$FLUTTER_ROOT" > android/local.properties - name: Provision Android SDK shell: bash run: | set -euo pipefail SDKROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -type f -name sdkmanager -print -quit 2>/dev/null || true)" test -x "$SDKMANAGER" yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true MISSING_COMPONENTS=() if [ ! -d "$SDKROOT/platform-tools" ]; then MISSING_COMPONENTS+=("platform-tools") fi if [ ! -d "$SDKROOT/platforms/android-${ANDROID_COMPILE_SDK}" ]; then MISSING_COMPONENTS+=("platforms;android-${ANDROID_COMPILE_SDK}") fi if [ ! -d "$SDKROOT/build-tools/${ANDROID_BUILD_TOOLS}" ]; then MISSING_COMPONENTS+=("build-tools;${ANDROID_BUILD_TOOLS}") fi if [ ${#MISSING_COMPONENTS[@]} -gt 0 ]; then echo "Installing missing Android SDK components: ${MISSING_COMPONENTS[*]}" "$SDKMANAGER" "${MISSING_COMPONENTS[@]}" yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true else echo "Required Android SDK components already present." fi echo "ANDROID_SDK_ROOT=$SDKROOT" >> "$GITHUB_ENV" - name: Provision Gradle wrapper shell: bash run: | set -euo pipefail if [ ! -f android/gradlew ]; then (cd android && gradle wrapper --gradle-version "$GRADLE_VERSION" --distribution-type all); fi chmod +x android/gradlew ./android/gradlew --version - name: Flutter and Android environment run: | flutter --version flutter doctor -v java -version - name: Generate Drift sources run: dart run build_runner build --delete-conflicting-outputs - name: Commit regenerated Drift sources if: github.event_name == 'push' && github.actor != 'github-actions[bot]' shell: bash run: | set -euo pipefail if git diff --quiet -- lib/database/database.g.dart; then echo 'database.g.dart is already current.' exit 0 fi git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add lib/database/database.g.dart git commit -m 'Regenerate Drift database source' git push origin HEAD:${GITHUB_REF_NAME} - name: Build debug APK shell: bash run: flutter build apk --debug - name: Verify debug APK shell: bash run: | set -euo pipefail if [ -f "build/app/outputs/flutter-apk/app-production-debug.apk" ] && [ ! -f "build/app/outputs/flutter-apk/app-debug.apk" ]; then cp "build/app/outputs/flutter-apk/app-production-debug.apk" "build/app/outputs/flutter-apk/app-debug.apk" fi APK="build/app/outputs/flutter-apk/app-debug.apk" test -s "$APK" file "$APK" sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256 if [ -n "${ANDROID_SDK_ROOT:-}" ]; then AAPT="$(find "$ANDROID_SDK_ROOT/build-tools" -type f -name aapt -print -quit 2>/dev/null || true)" if [ -x "$AAPT" ]; then "$AAPT" dump badging "$APK" | grep "package: name='app.motorsignal.motorcycle'" echo "Package identity verified: app.motorsignal.motorcycle" fi fi - name: Upload debug APK if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v5 with: name: motorsignal-debug-apk path: | build/app/outputs/flutter-apk/app-debug.apk build/app/outputs/flutter-apk/app-debug.apk.sha256 if-no-files-found: error retention-days: 3 analyze-and-build: name: Analyze, test and debug build runs-on: ubuntu-latest needs: [detect, analyze, test, debug-apk] if: always() && (github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true') permissions: contents: read steps: - name: Require all Android CI jobs to pass env: ANALYZE_RESULT: ${{ needs.analyze.result }} TEST_RESULT: ${{ needs.test.result }} DEBUG_APK_RESULT: ${{ needs.debug-apk.result }} shell: bash run: | set -euo pipefail test "$ANALYZE_RESULT" = success test "$TEST_RESULT" = success test "$DEBUG_APK_RESULT" = success release: name: Build signed release APK and AAB runs-on: ubuntu-latest needs: [detect, analyze-and-build] if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.native_build_changed == 'true')) permissions: contents: read steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 1 - name: Set up Java uses: actions/setup-java@v5 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} cache: gradle - name: Set up Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true - name: Free runner disk space shell: bash run: | set -euo pipefail sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /opt/ghc /usr/local/.ghcup /usr/local/lib/android/sdk/emulator /usr/local/lib/android/sdk/system-images sudo apt-get clean || true sudo rm -rf /var/lib/apt/lists/* docker system prune -af --volumes 2>/dev/null || true - name: Create Flutter local.properties shell: bash run: | set -euo pipefail test -n "${FLUTTER_ROOT:-}" && test -d "$FLUTTER_ROOT" printf 'flutter.sdk=%s\n' "$FLUTTER_ROOT" > android/local.properties - name: Provision Android SDK shell: bash run: | set -euo pipefail SDKROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -type f -name sdkmanager -print -quit 2>/dev/null || true)" test -x "$SDKMANAGER" yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true MISSING_COMPONENTS=() if [ ! -d "$SDKROOT/platform-tools" ]; then MISSING_COMPONENTS+=("platform-tools") fi if [ ! -d "$SDKROOT/platforms/android-${ANDROID_COMPILE_SDK}" ]; then MISSING_COMPONENTS+=("platforms;android-${ANDROID_COMPILE_SDK}") fi if [ ! -d "$SDKROOT/build-tools/${ANDROID_BUILD_TOOLS}" ]; then MISSING_COMPONENTS+=("build-tools;${ANDROID_BUILD_TOOLS}") fi if [ ${#MISSING_COMPONENTS[@]} -gt 0 ]; then echo "Installing missing Android SDK components: ${MISSING_COMPONENTS[*]}" "$SDKMANAGER" "${MISSING_COMPONENTS[@]}" yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true else echo "Required Android SDK components already present." fi echo "ANDROID_SDK_ROOT=$SDKROOT" >> "$GITHUB_ENV" - name: Provision Gradle wrapper shell: bash run: | set -euo pipefail if [ ! -f android/gradlew ]; then (cd android && gradle wrapper --gradle-version "$GRADLE_VERSION" --distribution-type all); fi chmod +x android/gradlew ./android/gradlew --version - name: Get dependencies run: flutter pub get - name: Generate Drift sources run: dart run build_runner build --delete-conflicting-outputs - name: Restore release keystore env: KEYSTORE_BASE64: ${{ secrets.MOTORSIGNAL_KEYSTORE_BASE64 }} shell: bash run: | set -euo pipefail test -n "$KEYSTORE_BASE64" || { echo "Missing MOTORSIGNAL_KEYSTORE_BASE64 repository secret"; exit 1; } printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/app/motorsignal-upload-keystore.jks chmod 600 android/app/motorsignal-upload-keystore.jks test -s android/app/motorsignal-upload-keystore.jks - name: Create signing properties env: STORE_PASSWORD: ${{ secrets.MOTORSIGNAL_STORE_PASSWORD }} KEY_PASSWORD: ${{ secrets.MOTORSIGNAL_KEY_PASSWORD }} KEY_ALIAS: ${{ secrets.MOTORSIGNAL_KEY_ALIAS }} shell: bash run: | set -euo pipefail test -n "$STORE_PASSWORD" || { echo "Missing MOTORSIGNAL_STORE_PASSWORD repository secret"; exit 1; } test -n "$KEY_PASSWORD" || { echo "Missing MOTORSIGNAL_KEY_PASSWORD repository secret"; exit 1; } test -n "$KEY_ALIAS" || { echo "Missing MOTORSIGNAL_KEY_ALIAS repository secret"; exit 1; } cat > android/key.properties </dev/null - name: Build release APK run: flutter build apk --release - name: Build release App Bundle run: flutter build appbundle --release - name: Verify release artifacts and signatures env: STORE_PASSWORD: ${{ secrets.MOTORSIGNAL_STORE_PASSWORD }} shell: bash run: | set -euo pipefail if [ -f "build/app/outputs/flutter-apk/app-production-release.apk" ] && [ ! -f "build/app/outputs/flutter-apk/app-release.apk" ]; then cp "build/app/outputs/flutter-apk/app-production-release.apk" "build/app/outputs/flutter-apk/app-release.apk" fi if [ -f "build/app/outputs/bundle/productionRelease/app-production-release.aab" ] && [ ! -f "build/app/outputs/bundle/release/app-release.aab" ]; then mkdir -p "build/app/outputs/bundle/release" cp "build/app/outputs/bundle/productionRelease/app-production-release.aab" "build/app/outputs/bundle/release/app-release.aab" fi APK="build/app/outputs/flutter-apk/app-release.apk" AAB="build/app/outputs/bundle/release/app-release.aab" test -s "$APK" test -s "$AAB" file "$APK" "$AAB" "$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -n 1)/apksigner" verify --verbose "$APK" sha256sum "$APK" "$AAB" | tee build/release-artifacts.sha256 - name: Upload release APK uses: actions/upload-artifact@v5 with: name: motorsignal-release-apk path: | build/app/outputs/flutter-apk/app-release.apk build/release-artifacts.sha256 if-no-files-found: error retention-days: 7 - name: Upload release App Bundle uses: actions/upload-artifact@v5 with: name: motorsignal-release-aab path: | build/app/outputs/bundle/release/app-release.aab build/release-artifacts.sha256 if-no-files-found: error retention-days: 7 - name: Remove signing credentials if: always() shell: bash run: | rm -f android/key.properties android/app/motorsignal-upload-keystore.jks