Files
Meshtastic-Android/.github/workflows/reusable-check.yml
T
2026-05-28 00:33:35 +00:00

599 lines
22 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Reusable Android Check
on:
workflow_call:
inputs:
run_lint:
type: boolean
default: true
run_unit_tests:
type: boolean
default: true
run_coverage:
type: boolean
default: true
run_desktop_flatpak_src:
type: boolean
default: false
upload_artifacts:
type: boolean
default: true
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
CODECOV_TOKEN:
required: false
DATADOG_APPLICATION_ID:
required: false
DATADOG_CLIENT_TOKEN:
required: false
GOOGLE_MAPS_API_KEY:
required: false
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
# Fallback VERSION_CODE for jobs that don't consume the setup output.
VERSION_CODE: ${{ github.run_number }}
jobs:
# ── Fast Setup (version code + cache config) ────────────────────────
# Lightweight job that unblocks lint, tests, and builds in parallel.
setup:
runs-on: ubuntu-24.04-arm
permissions:
contents: read
timeout-minutes: 5
outputs:
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
version_code: ${{ steps.version_code.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: 'blob:none'
submodules: false
- name: Determine cache read-only setting
id: cache_config
shell: bash
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]] || [[ "${{ github.event_name }}" == "merge_group" ]] || [[ "${{ github.ref }}" == gh-readonly-queue/* ]]; then
echo "cache_read_only=false" >> "$GITHUB_OUTPUT"
else
echo "cache_read_only=true" >> "$GITHUB_OUTPUT"
fi
- name: Calculate version code from git commit count
id: version_code
shell: bash
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2 || echo 0)
VERSION_CODE=$((COMMIT_COUNT + OFFSET))
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
# ── Lint & Static Analysis ──────────────────────────────────────────
lint-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 30
needs: setup
env:
VERSION_CODE: ${{ needs.setup.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: 'blob:none'
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.setup.outputs.cache_read_only }}
- name: Lint, Analysis & KMP Smoke Compile
if: inputs.run_lint == true
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue
- name: KMP Smoke Compile (lint skipped)
if: inputs.run_lint == false
run: ./gradlew kmpSmokeCompile -Pci=true --continue
# ── Screenshot Test Validation ──────────────────────────────────────
screenshot-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 20
needs: setup
if: inputs.run_lint == true
env:
VERSION_CODE: ${{ needs.setup.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.setup.outputs.cache_read_only }}
- name: Screenshot Test Validation
run: ./gradlew :screenshot-tests:validateDebugScreenshotTest -Pci=true
- name: Upload screenshot diff report
if: failure()
uses: actions/upload-artifact@v7
with:
name: screenshot-diff-report
path: screenshot-tests/build/reports/screenshotTest/
retention-days: 14
if-no-files-found: warn
# ── Reproducible Build Verification ─────────────────────────────────
# Only runs on main push and merge-queue (not PRs) to keep PR feedback fast.
rb-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 30
if: inputs.run_lint == true && (github.event_name == 'push' || github.event_name == 'merge_group')
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'true'
- name: Verify Reproducible Build (fdroid)
env:
VERSION_CODE: ${{ github.run_number }}
run: |
# Comprehensive RB verification for F-Droid/IzzyOnDroid.
# Based on: https://izzyondroid.org/docs/reproducibleBuilds/DebugFailedRBs/
# Catches regressions that have historically broken reproducibility:
# 1. aboutlibraries.json non-determinism (network fetching)
# 2. Datadog buildId leaking into fdroid APK
# 3. Google/Firebase/GMS/MLKit classes in fdroid APK
# 4. DEPENDENCY_INFO_BLOCK in signing block
# 5. Native library stripping (NDK version mismatch)
# 6. aboutlibraries "generated" timestamp in res/M7.json
# 7. baseline.prof determinism (flaky builds)
# See: https://github.com/meshtastic/Meshtastic-Android/issues/3231
echo "── Step 1: Verify aboutlibraries.json determinism ──"
rm -f androidApp/src/main/resources/aboutlibraries.json
./gradlew :androidApp:exportLibraryDefinitions -Pci=true --no-configuration-cache
cp androidApp/src/main/resources/aboutlibraries.json /tmp/aboutlibraries-run1.json
rm -f androidApp/src/main/resources/aboutlibraries.json
./gradlew :androidApp:exportLibraryDefinitions -Pci=true --no-configuration-cache --rerun-tasks
cp androidApp/src/main/resources/aboutlibraries.json /tmp/aboutlibraries-run2.json
if ! diff -q /tmp/aboutlibraries-run1.json /tmp/aboutlibraries-run2.json; then
echo "::error::aboutlibraries.json is NOT deterministic across runs!"
diff /tmp/aboutlibraries-run1.json /tmp/aboutlibraries-run2.json | head -20
exit 1
fi
echo "✅ aboutlibraries.json is deterministic"
echo "── Step 2: Build fdroid release APK ──"
./gradlew :androidApp:assembleFdroidRelease -Pci=true -Pmeshtastic.disableAbiSplits=true --no-configuration-cache
APK=$(find androidApp/build/outputs/apk/fdroid/release -name "*.apk" | head -1)
if [ -z "$APK" ]; then
echo "::error::No fdroid release APK found"
exit 1
fi
echo "Checking APK: $APK"
echo "── Step 3: Check for datadog.buildId ──"
if unzip -l "$APK" | grep -q "datadog.buildId"; then
echo "::error::fdroid APK contains assets/datadog.buildId — breaks RB!"
exit 1
fi
echo "✅ No datadog.buildId in fdroid APK"
echo "── Step 4: Check for proprietary libraries (dex scan) ──"
TMPDIR=$(mktemp -d)
unzip -q "$APK" -d "$TMPDIR"
OFFENDERS=""
for pattern in "com/google/firebase" "com/google/android/gms" "com/crashlytics" "com/google/mlkit" "com/google/android/datatransport" "androidx/privacysandbox/ads"; do
for dex in "$TMPDIR"/classes*.dex; do
if [ -f "$dex" ] && strings "$dex" | grep -q "L${pattern}/"; then
OFFENDERS="${OFFENDERS}\n - $pattern"
break
fi
done
done
if [ -n "$OFFENDERS" ]; then
echo -e "::error::fdroid APK contains proprietary libraries:${OFFENDERS}"
rm -rf "$TMPDIR"
exit 1
fi
echo "✅ No proprietary libraries in fdroid APK"
echo "── Step 5: Check for DEPENDENCY_INFO_BLOCK (signing block blob) ──"
# Parse the APK Signing Block structure to find the dependency info pair.
# Naive byte scans produce false positives in large APKs.
if python3 << 'PYEOF'
import struct, sys
with open("$APK", "rb") as f:
data = f.read()
magic = b"APK Sig Block 42"
idx = data.rfind(magic)
if idx < 0:
sys.exit(0)
block_size = struct.unpack_from("<Q", data, idx - 8)[0]
block_start = idx + 16 - 8 - block_size
pos = int(block_start)
end = idx - 8
while pos + 12 <= end:
pair_size = struct.unpack_from("<Q", data, pos)[0]
pair_id = struct.unpack_from("<I", data, pos + 8)[0]
if pair_id == 0x504b4453:
print(f"DEPENDENCY_INFO_BLOCK found (id=0x{pair_id:08x})")
sys.exit(1)
pos += 8 + int(pair_size)
sys.exit(0)
PYEOF
then
echo "::error::fdroid APK contains DEPENDENCY_INFO_BLOCK — remove with dependenciesInfo { includeInApk = false }"
rm -rf "$TMPDIR"
exit 1
fi
echo "✅ No DEPENDENCY_INFO_BLOCK in signing block"
echo "── Step 6: Check native libraries have debug symbols (not stripped) ──"
STRIPPED_LIBS=""
for so in $(find "$TMPDIR" -name "*.so" 2>/dev/null); do
# If .symtab section is missing, the library was stripped
if ! readelf -S "$so" 2>/dev/null | grep -q "\.symtab"; then
# Libraries without symtab are stripped — this is only a problem
# if keepDebugSymbols is not working as expected
LIB_NAME=$(basename "$so")
STRIPPED_LIBS="${STRIPPED_LIBS} ${LIB_NAME}"
fi
done
# Note: Some third-party .so files arrive pre-stripped, which is OK.
# We only warn here; a hard failure would be too aggressive.
if [ -n "$STRIPPED_LIBS" ]; then
echo "::warning::Some native libraries appear stripped (may cause NDK-version-dependent RB failures):${STRIPPED_LIBS}"
else
echo "✅ Native libraries retain debug symbols"
fi
echo "── Step 7: Check aboutlibraries 'generated' timestamp not in APK ──"
# The M7.json (or aboutlibraries.json in Java resources) should NOT contain
# a "generated" field, which introduces a build-time timestamp.
ABOUT_JSON=""
if [ -f "$TMPDIR/aboutlibraries.json" ]; then
ABOUT_JSON="$TMPDIR/aboutlibraries.json"
else
# May be in res/ as M7.json or similar
ABOUT_JSON=$(find "$TMPDIR/res" -name "*.json" -exec grep -l "aboutLibraries" {} \; 2>/dev/null | head -1)
fi
if [ -n "$ABOUT_JSON" ] && grep -q '"generated"' "$ABOUT_JSON"; then
echo "::error::aboutlibraries contains 'generated' timestamp field — add excludeFields = listOf(\"generated\") to build config"
rm -rf "$TMPDIR"
exit 1
fi
echo "✅ No 'generated' timestamp in aboutlibraries data"
echo "── Step 8: Verify build from clean tree (version-control-info) ──"
if [ -f "$TMPDIR/META-INF/version-control-info.textproto" ]; then
if grep -q "modified: true" "$TMPDIR/META-INF/version-control-info.textproto"; then
echo "::warning::APK built from dirty tree (version-control-info shows modified:true). Release builds must use a clean tree."
else
echo "✅ Built from clean tree"
fi
else
echo "️ No version-control-info.textproto (AGP may not embed it for debug-signed builds)"
fi
rm -rf "$TMPDIR"
echo ""
echo "🎉 All RB checks passed"
# ── Sharded Unit Tests ──────────────────────────────────────────────
# Tests are split into 3 shards that run in parallel:
# shard-core: core:* KMP module tests (allTests)
# shard-feature: feature:* KMP module tests (allTests)
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp, core:barcode, etc.)
test-shards:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 45
needs: setup
if: inputs.run_unit_tests == true
env:
VERSION_CODE: ${{ needs.setup.outputs.version_code }}
strategy:
fail-fast: false
matrix:
shard:
- name: shard-core
tasks: >-
:core:ble:allTests
:core:common:allTests
:core:data:allTests
:core:database:allTests
:core:domain:allTests
:core:model:allTests
:core:navigation:allTests
:core:network:allTests
:core:prefs:allTests
:core:repository:allTests
:core:service:allTests
:core:takserver:allTests
:core:testing:allTests
:core:ui:allTests
kover: >-
:core:ble:koverXmlReport
:core:common:koverXmlReport
:core:data:koverXmlReport
:core:database:koverXmlReport
:core:domain:koverXmlReport
:core:model:koverXmlReport
:core:navigation:koverXmlReport
:core:network:koverXmlReport
:core:prefs:koverXmlReport
:core:repository:koverXmlReport
:core:service:koverXmlReport
:core:takserver:koverXmlReport
:core:testing:koverXmlReport
:core:ui:koverXmlReport
- name: shard-feature
tasks: >-
:feature:connections:allTests
:feature:firmware:allTests
:feature:intro:allTests
:feature:map:allTests
:feature:messaging:allTests
:feature:node:allTests
:feature:settings:allTests
kover: >-
:feature:connections:koverXmlReport
:feature:firmware:koverXmlReport
:feature:intro:koverXmlReport
:feature:map:koverXmlReport
:feature:messaging:koverXmlReport
:feature:node:koverXmlReport
:feature:settings:koverXmlReport
- name: shard-app
tasks: >-
:androidApp:testFdroidDebugUnitTest
:androidApp:testGoogleDebugUnitTest
:desktopApp:test
:core:barcode:testFdroidDebugUnitTest
:core:barcode:testGoogleDebugUnitTest
kover: >-
:androidApp:koverXmlReportFdroidDebug
:androidApp:koverXmlReportGoogleDebug
:core:barcode:koverXmlReportFdroidDebug
:core:barcode:koverXmlReportGoogleDebug
:desktopApp:koverXmlReport
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.setup.outputs.cache_read_only }}
- name: Run Tests & Coverage (${{ matrix.shard.name }})
run: |
kover_tasks=""
if [[ "${{ inputs.run_coverage }}" == "true" ]]; then
kover_tasks="${{ matrix.shard.kover }}"
fi
./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
report_type: test_results
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
files: "**/build/reports/kover/report*.xml"
- name: Upload shard reports
if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: reports-${{ matrix.shard.name }}
path: |
**/build/reports
**/build/test-results
retention-days: 7
# ── Android Build ────────────────────────────────────────────────────
android-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 60
needs: setup
env:
VERSION_CODE: ${{ needs.setup.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.setup.outputs.cache_read_only }}
- name: Build Android APKs
run: ./gradlew androidApp:assembleFdroidDebug androidApp:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue
- name: Upload debug artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: app-debug-apks
path: androidApp/build/outputs/apk/*/debug/*.apk
retention-days: 7
- name: Report App Size
if: always()
run: |
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find androidApp/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
# ── Desktop Build ───────────────────────────────────────────────────
build-desktop:
name: Build Desktop Debug (${{ matrix.os }})
if: inputs.run_desktop_builds == true
runs-on: ${{ matrix.os }}
permissions:
contents: read
timeout-minutes: 60
needs: setup
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
env:
VERSION_CODE: ${{ needs.setup.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.setup.outputs.cache_read_only }}
- name: Build Desktop
run: ./gradlew :desktopApp:createDistributable -Pci=true
- name: Upload Desktop artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: desktop-app-${{ runner.os }}-${{ runner.arch }}
path: desktopApp/build/compose/binaries/main/app/
retention-days: 7
# ── Flatpak Sources ───────────────────────────────────────────────────
build-flatpak-src:
name: Generate Flatpak Sources (${{ matrix.os }})
if: inputs.run_desktop_flatpak_src == true
runs-on: ${{ matrix.os }}
permissions:
contents: read
timeout-minutes: 60
needs: setup
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm]
env:
VERSION_CODE: ${{ needs.setup.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: true
# Isolated Gradle user home — see explanation in release.yml.
# packageUberJarForCurrentOS is what the in-flatpak build invokes; it resolves the FULL
# runtime classpath. :assemble alone would miss runtime-only deps (skiko, ktor-cio, …).
- name: Generate Flatpak Sources
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
run: cp build/flatpak-sources.json flatpak-sources.json
- run: ls -lah flatpak-sources.json
- name: Upload Flatpak Sources
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: flatpak-sources-${{ runner.arch }}
path: flatpak-sources.json
retention-days: 7