ci: migrate Linux workflow to RunsOn self-hosted runners #3739
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: | |
| - master | |
| - 'Stable*' | |
| tags: | |
| - 'v*' | |
| paths-ignore: | |
| - 'docs/**' | |
| pull_request: | |
| merge_group: | |
| workflow_dispatch: | |
| inputs: | |
| build_type: | |
| description: 'Build type' | |
| required: false | |
| default: 'Release' | |
| type: choice | |
| options: | |
| - Release | |
| - Debug | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} | |
| permissions: | |
| contents: read | |
| id-token: write | |
| attestations: write | |
| actions: read | |
| security-events: write | |
| env: | |
| IS_STABLE_BUILD: ${{ (github.ref_type == 'tag' || contains(github.ref, 'Stable')) && 'true' || 'false' }} | |
| jobs: | |
| changes: | |
| if: github.event_name == 'pull_request' | |
| uses: ./.github/workflows/_detect-changes.yml | |
| with: | |
| platform: android | |
| build: | |
| name: Android (${{ matrix.host }}) | |
| needs: changes | |
| if: always() && !cancelled() && (needs.changes.outputs.should_build == 'true' || needs.changes.result == 'skipped') | |
| runs-on: ${{ matrix.runner }} | |
| timeout-minutes: 120 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - host: linux | |
| runner: ubuntu-latest | |
| arch: linux_gcc_64 | |
| qt_host_path: gcc_64 | |
| shell: bash | |
| primary: true | |
| emulator: false | |
| - host: mac | |
| runner: macos-latest | |
| arch: clang_64 | |
| qt_host_path: macos | |
| shell: bash | |
| primary: false | |
| emulator: false | |
| - host: windows | |
| runner: windows-latest | |
| arch: win64_msvc2022_64 | |
| qt_host_path: msvc2022_64 | |
| shell: pwsh | |
| primary: false | |
| emulator: false | |
| - host: linux-emulator | |
| qt_host: linux | |
| runner: ubuntu-latest | |
| arch: linux_gcc_64 | |
| qt_host_path: gcc_64 | |
| shell: bash | |
| primary: false | |
| emulator: true | |
| defaults: | |
| run: | |
| shell: ${{ matrix.shell }} | |
| env: | |
| PACKAGE: QGroundControl | |
| QT_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/deploy/android/android_release.keystore | |
| QT_ANDROID_KEYSTORE_ALIAS: QGCAndroidKeyStore | |
| QT_ANDROID_KEYSTORE_STORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| QT_ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| QT_ANDROID_ABIS: ${{ matrix.emulator && 'x86_64' || matrix.primary && 'arm64-v8a;armeabi-v7a' || 'arm64-v8a' }} | |
| HAS_PLAYSTORE_SECRET: ${{ secrets.GOOGLE_SERVICE_ACCOUNT != '' }} | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@v2 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout repo | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: ${{ github.event_name == 'pull_request' && 1 || 0 }} | |
| - name: Build Setup (Android) | |
| id: setup | |
| uses: ./.github/actions/build-setup | |
| with: | |
| mode: android | |
| qt-host: ${{ matrix.qt_host || matrix.host }} | |
| qt-arch: ${{ matrix.arch }} | |
| abis: ${{ env.QT_ANDROID_ABIS }} | |
| build-type: ${{ inputs.build_type || 'Release' }} | |
| cpm-modules: ${{ runner.temp }}/build/cpm_modules | |
| - name: Install host build dependencies | |
| if: runner.os == 'Linux' | |
| uses: ./.github/actions/install-dependencies | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v6 | |
| with: | |
| cache-read-only: ${{ github.event_name == 'pull_request' }} | |
| - name: Create Debug Keystore | |
| if: ${{ env.QT_ANDROID_KEYSTORE_STORE_PASS == '' || !matrix.primary }} | |
| shell: bash | |
| run: | | |
| keytool -genkey -v \ | |
| -keystore "${RUNNER_TEMP}/debug.keystore" \ | |
| -storepass android -alias androiddebugkey -keypass android \ | |
| -keyalg RSA -keysize 2048 -validity 10000 \ | |
| -dname "CN=Android Debug,O=Android,C=US" | |
| { | |
| echo "QT_ANDROID_KEYSTORE_PATH=${RUNNER_TEMP}/debug.keystore" | |
| echo "QT_ANDROID_KEYSTORE_ALIAS=androiddebugkey" | |
| echo "QT_ANDROID_KEYSTORE_STORE_PASS=android" | |
| echo "QT_ANDROID_KEYSTORE_KEY_PASS=android" | |
| } >> "$GITHUB_ENV" | |
| - name: Configure | |
| uses: ./.github/actions/cmake-configure | |
| with: | |
| build-dir: ${{ runner.temp }}/build | |
| build-type: ${{ inputs.build_type || 'Release' }} | |
| stable: ${{ env.IS_STABLE_BUILD }} | |
| use-qt-cmake: 'false' | |
| extra-args: >- | |
| -DCMAKE_WARN_DEPRECATED=FALSE | |
| -DCMAKE_TOOLCHAIN_FILE=${{ steps.setup.outputs.target_qt_root_dir }}/lib/cmake/Qt6/qt.toolchain.cmake | |
| -DCMAKE_PREFIX_PATH=${{ steps.setup.outputs.target_qt_root_dir }} | |
| -DQT_ANDROID_ABIS=${{ env.QT_ANDROID_ABIS }} | |
| -DQT_HOST_PATH=${{ steps.setup.outputs.host_qt_root_dir }} | |
| -DQT_ANDROID_SIGN_APK=ON | |
| - name: Build | |
| id: build | |
| continue-on-error: ${{ matrix.emulator }} | |
| timeout-minutes: 75 | |
| # Disable Gradle daemon on CI — Windows runners hang for 30+ min after BUILD SUCCESSFUL waiting for daemon shutdown. | |
| env: | |
| GRADLE_OPTS: -Dorg.gradle.daemon=false | |
| uses: ./.github/actions/cmake-build | |
| with: | |
| build-dir: ${{ runner.temp }}/build | |
| build-type: ${{ inputs.build_type || 'Release' }} | |
| parallel-jobs: ${{ matrix.emulator && '2' || '' }} | |
| output-file: qgc-build.log | |
| - name: Retry Build (emulator JSON truncation recovery) | |
| id: build-retry | |
| if: ${{ matrix.emulator && steps.build.outcome == 'failure' }} | |
| shell: bash | |
| env: | |
| BUILD_DIR: ${{ runner.temp }}/build | |
| BUILD_TYPE: ${{ inputs.build_type || 'Release' }} | |
| run: | | |
| set -euo pipefail | |
| log_file="${BUILD_DIR}/qgc-build.log" | |
| settings_json="${BUILD_DIR}/android-QGroundControl-deployment-settings.json" | |
| retry_log="${BUILD_DIR}/qgc-build-retry.log" | |
| if [[ ! -f "${log_file}" ]]; then | |
| echo "::error::Build failed and '${log_file}' is missing." | |
| exit 1 | |
| fi | |
| # Retry only for known intermittent Qt deployment-settings truncation. | |
| if ! grep -Eq "Invalid json file: .*android-QGroundControl-deployment-settings\\.json\\. Reason: unterminated object" "${log_file}"; then | |
| echo "::error::Initial build failed for a non-retriable reason; skipping retry." | |
| exit 1 | |
| fi | |
| echo "::warning::Detected truncated android deployment settings JSON. Retrying build with --parallel 1." | |
| if [[ -f "${settings_json}" ]]; then | |
| python3 -m json.tool "${settings_json}" >/dev/null 2>&1 || true | |
| rm -f "${settings_json}" | |
| fi | |
| cmake --build "${BUILD_DIR}" --target all --config "${BUILD_TYPE}" --parallel 1 2>&1 | tee "${retry_log}" | |
| # `failure()` is required: build's continue-on-error makes outcome alone insufficient. | |
| - name: Annotate emulator build retry failure | |
| if: ${{ failure() && matrix.emulator && steps.build.outcome == 'failure' && steps.build-retry.outcome == 'failure' }} | |
| run: echo "::error::Android emulator build failed (initial + retry). See logs for the truncation root cause." | |
| - name: Upload build log on failure | |
| if: ${{ failure() && steps.build.outcome == 'failure' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: android-${{ matrix.host }}-build-log | |
| path: | | |
| ${{ runner.temp }}/build/qgc-build.log | |
| ${{ runner.temp }}/build/qgc-build-retry.log | |
| ${{ runner.temp }}/build/CMakeFiles/CMakeOutput.log | |
| ${{ runner.temp }}/build/CMakeFiles/CMakeError.log | |
| ${{ runner.temp }}/build/.ninja_log | |
| ${{ runner.temp }}/build/android-QGroundControl-deployment-settings.json | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Android Lint | |
| if: ${{ matrix.host == 'linux' }} | |
| shell: bash | |
| working-directory: ${{ runner.temp }}/build/android-build | |
| run: | | |
| if [[ ! -x "./gradlew" ]]; then | |
| echo "::error::Gradle wrapper not found in ${PWD}" | |
| ls -la | |
| exit 1 | |
| fi | |
| ./gradlew lintRelease --no-daemon | |
| - name: Prepare Artifact | |
| shell: bash | |
| run: | | |
| TEMP_DIR="${RUNNER_TEMP//\\//}" # Convert backslashes for Windows | |
| APK_DIR="${TEMP_DIR}/build/android-build" | |
| if ! ls "${APK_DIR}"/*.apk 1>/dev/null 2>&1; then | |
| echo "::error::No APK files found in ${APK_DIR}" | |
| ls -la "${APK_DIR}" 2>/dev/null || echo "Build directory does not exist" | |
| exit 1 | |
| fi | |
| cp "${APK_DIR}"/*.apk "${TEMP_DIR}/build/${{ env.PACKAGE }}.apk" | |
| - name: Enable KVM | |
| if: ${{ matrix.emulator }} | |
| 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: Restore AVD cache | |
| if: ${{ matrix.emulator }} | |
| id: avd-cache | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: | | |
| ~/.android/avd/* | |
| ~/.android/adb* | |
| key: >- | |
| avd-${{ runner.os }}-${{ runner.arch }}-api${{ steps.setup.outputs.android_platform }} | |
| -x86_64-google_apis-pixel_6-qt${{ steps.setup.outputs.qt_version }} | |
| -ndk${{ steps.setup.outputs.ndk_full_version }}-v2 | |
| - name: Create AVD Snapshot | |
| id: avd-snapshot | |
| if: ${{ matrix.emulator && steps.avd-cache.outputs.cache-hit != 'true' }} | |
| timeout-minutes: 15 | |
| continue-on-error: true | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: ${{ steps.setup.outputs.android_platform }} | |
| system-image-api-level: ${{ steps.setup.outputs.android_platform }} | |
| arch: x86_64 | |
| target: google_apis | |
| profile: pixel_6 | |
| cores: 4 | |
| ram-size: 4096M | |
| heap-size: 1024M | |
| avd-name: qgc-ci | |
| force-avd-creation: false | |
| emulator-boot-timeout: 420 | |
| disable-animations: true | |
| disable-spellchecker: true | |
| emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim | |
| script: echo "Generated AVD snapshot for cache." | |
| - name: Save AVD cache | |
| if: ${{ matrix.emulator && github.event_name != 'pull_request' && steps.avd-cache.outputs.cache-hit != 'true' && steps.avd-snapshot.outcome == 'success' }} | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: | | |
| ~/.android/avd/* | |
| ~/.android/adb* | |
| key: ${{ steps.avd-cache.outputs.cache-primary-key }} | |
| - name: Kill Stale Emulator | |
| if: ${{ matrix.emulator && steps.avd-snapshot.outcome == 'failure' }} | |
| shell: bash | |
| run: | | |
| pkill -9 -f qemu-system || true | |
| adb kill-server 2>/dev/null || true | |
| sleep 5 | |
| - name: Emulator Boot Test | |
| if: ${{ matrix.emulator }} | |
| timeout-minutes: 20 | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: ${{ steps.setup.outputs.android_platform }} | |
| system-image-api-level: ${{ steps.setup.outputs.android_platform }} | |
| arch: x86_64 | |
| target: google_apis | |
| profile: pixel_6 | |
| cores: 4 | |
| ram-size: 4096M | |
| heap-size: 1024M | |
| avd-name: qgc-ci | |
| force-avd-creation: false | |
| emulator-boot-timeout: 420 | |
| disable-animations: true | |
| disable-spellchecker: true | |
| emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot-save | |
| script: | | |
| timeout 20s adb start-server >/dev/null 2>&1 || true | |
| timeout 180s adb wait-for-device || (echo "::error::adb wait-for-device timed out before boot test." && exit 1) | |
| python3 .github/scripts/android_boot_test.py --apk "${{ runner.temp }}/build/${{ env.PACKAGE }}.apk" --package org.mavlink.qgroundcontrol --timeout 120 --stability-window 20 --adb-ready-timeout 180 --install-retries 4 --install-retry-delay 5 --launch-retries 2 --log-output /tmp/qgc_emulator_boot.log | |
| - name: Collect Emulator Diagnostics | |
| if: ${{ failure() && matrix.emulator }} | |
| timeout-minutes: 5 | |
| shell: bash | |
| run: | | |
| OUT_DIR="${RUNNER_TEMP}/emulator-diagnostics" | |
| BUILD_DIR="${RUNNER_TEMP}/build" | |
| mkdir -p "${OUT_DIR}" | |
| cp /tmp/qgc_emulator_boot.log "${OUT_DIR}/qgc_emulator_boot.log" 2>/dev/null || true | |
| cp "${BUILD_DIR}/qgc-build.log" "${OUT_DIR}/qgc-build.log" 2>/dev/null || true | |
| cp "${BUILD_DIR}/qgc-build-retry.log" "${OUT_DIR}/qgc-build-retry.log" 2>/dev/null || true | |
| cp "${BUILD_DIR}/android-QGroundControl-deployment-settings.json" \ | |
| "${OUT_DIR}/android-QGroundControl-deployment-settings.json" 2>/dev/null || true | |
| if [ -f "${OUT_DIR}/android-QGroundControl-deployment-settings.json" ]; then | |
| python3 -m json.tool "${OUT_DIR}/android-QGroundControl-deployment-settings.json" \ | |
| > "${OUT_DIR}/android-QGroundControl-deployment-settings.pretty.json" \ | |
| 2> "${OUT_DIR}/android-QGroundControl-deployment-settings.error.txt" || true | |
| fi | |
| if command -v adb >/dev/null 2>&1; then | |
| timeout 20s adb start-server >/dev/null 2>&1 || true | |
| timeout 20s adb devices > "${OUT_DIR}/adb-devices.txt" 2>&1 || true | |
| if grep -Eq '^emulator-[0-9]+\s+device$' "${OUT_DIR}/adb-devices.txt"; then | |
| timeout 30s adb -e logcat -d > "${OUT_DIR}/adb-logcat-full.txt" 2>/dev/null || true | |
| timeout 30s adb -e shell dumpsys activity > "${OUT_DIR}/dumpsys-activity.txt" 2>/dev/null || true | |
| timeout 30s adb -e shell getprop > "${OUT_DIR}/getprop.txt" 2>/dev/null || true | |
| # Extract GStreamer-specific lines for quick diagnosis | |
| if [ -f "${OUT_DIR}/adb-logcat-full.txt" ]; then | |
| grep -iE 'gst|gstreamer|libgst|GST_PLUGIN|GstVideoReceiver|qml6glsink|qgcvideosinkbin' \ | |
| "${OUT_DIR}/adb-logcat-full.txt" > "${OUT_DIR}/gstreamer-logcat.txt" 2>/dev/null || true | |
| grep -iE 'dlopen.*gst|UnsatisfiedLinkError.*gst|cannot.*load.*gst' \ | |
| "${OUT_DIR}/adb-logcat-full.txt" > "${OUT_DIR}/gstreamer-load-errors.txt" 2>/dev/null || true | |
| fi | |
| else | |
| echo "No online emulator device found; skipping adb diagnostics." > "${OUT_DIR}/adb-skipped.txt" | |
| fi | |
| else | |
| echo "adb not found; skipping adb diagnostics." > "${OUT_DIR}/adb-skipped.txt" | |
| fi | |
| if [ -d "${HOME}/.android/avd" ]; then | |
| find "${HOME}/.android/avd" -maxdepth 3 -type f \( -name "*.log" -o -name "*.ini" \) \ | |
| -exec cp --parents {} "${OUT_DIR}" \; 2>/dev/null || true | |
| fi | |
| ls -la "${OUT_DIR}" || true | |
| - name: Upload Emulator Diagnostics | |
| if: ${{ failure() && matrix.emulator }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: emulator-diagnostics-${{ matrix.host }}-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/emulator-diagnostics | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| - name: Attest and Upload | |
| if: ${{ !matrix.emulator }} | |
| uses: ./.github/actions/attest-and-upload | |
| with: | |
| artifact-name: ${{ env.PACKAGE }}.apk | |
| package-name: ${{ format('{0}-{1}', env.PACKAGE, matrix.host) }} | |
| subject-name: ${{ env.PACKAGE }}-${{ matrix.host }} | |
| aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} | |
| aws-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| aws-distribution-id: ${{ secrets.AWS_DISTRIBUTION_ID }} | |
| upload-aws: ${{ matrix.primary }} | |
| - name: Deploy to Play Store | |
| if: ${{ !matrix.emulator && matrix.primary && github.ref_type == 'tag' && env.HAS_PLAYSTORE_SECRET == 'true' }} | |
| uses: ./.github/actions/playstore | |
| with: | |
| artifact: ${{ runner.temp }}/build/${{ env.PACKAGE }}.apk | |
| service_account_json: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} |