name: Pull Request CI on: pull_request: branches: [ main, "release/**" ] permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: # 1. CHANGE DETECTION: Prevents unnecessary builds. Also verifies the path # filter below stays aligned with the module roots in settings.gradle.kts # (folded into this job rather than run standalone: runner-pool slots, not # compute, are the scarce resource during queue bursts). check-changes: if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' ) runs-on: ubuntu-24.04-arm timeout-minutes: 10 outputs: android: ${{ steps.filter.outputs.android }} screenshots: ${{ steps.filter.outputs.screenshots }} steps: - uses: actions/checkout@v7.0.1 - uses: dorny/paths-filter@v4 id: filter with: token: '' filters: | # Anything in screenshot-tests' dependency closure (feature/* and # core/* transitively) or the build machinery that shapes rendering. # androidApp-, desktopApp- and docs-only changes skip screenshots on # PRs; the merge queue always runs them as the final gate. screenshots: - 'screenshot-tests/**' - 'core/**' - 'feature/**' - 'build-logic/**' - 'config/**' - 'gradle/**' - 'build.gradle.kts' - 'settings.gradle.kts' - 'gradle.properties' - 'config.properties' - 'compose_compiler_config.conf' - '.github/workflows/**' - '.github/actions/**' android: # CI/workflow implementation - '.github/workflows/**' - '.github/actions/**' # Product modules validated by reusable-check - 'androidApp/**' - 'baselineprofile/**' - 'desktopApp/**' - 'core/**' - 'feature/**' - 'screenshot-tests/**' - 'docs-screenshots/**' # Shared build infrastructure - 'build-logic/**' - 'config/**' - 'gradle/**' # Root build entrypoints/config that can alter task graph or outputs - 'build.gradle.kts' - 'config.properties' - 'compose_compiler_config.conf' - 'gradle.properties' - 'gradlew' - 'gradlew.bat' - 'settings.gradle.kts' - 'test.gradle.kts' - name: Verify module roots are represented in check-changes filter run: | python3 - <<'PY' import re from pathlib import Path settings = Path('settings.gradle.kts').read_text() workflow = Path('.github/workflows/pull-request.yml').read_text() module_roots = { module.split(':')[0] for module in re.findall(r'":([^"]+)"', settings) } allowed_extra_roots = {'baselineprofile'} expected_roots = module_roots | allowed_extra_roots filter_paths = { path.split('/')[0] for path in re.findall(r"-\s*'([^']+/\*\*)'", workflow) } # Filter roots that are intentionally not Gradle module roots # (CI/workflow implementation + shared build infrastructure). allowed_infra_roots = {'.github', 'build-logic', 'config', 'gradle'} missing = sorted(expected_roots - filter_paths) unexpected = sorted(filter_paths - expected_roots - allowed_infra_roots) if missing or unexpected: print('check-changes filter drift detected:') if missing: print(' Missing roots:', ', '.join(missing)) if unexpected: print(' Unexpected roots:', ', '.join(unexpected)) raise SystemExit(1) print('check-changes filter is aligned with settings.gradle module roots.') PY # Second drift guard: the shard task lists in reusable-check.yml are # hand-maintained and have silently dropped modules before (discovery, # docs, wifi-provision, car, datastore, konsist had tests that never ran # in CI). Every module in settings.gradle.kts must appear in the shard # matrix or be explicitly exempted — and an exempt module that gains test # sources fails the guard until it is wired into a shard. - name: Verify every module with tests is wired into a CI test shard run: | python3 - <<'PY' import re from pathlib import Path settings = Path('settings.gradle.kts').read_text() check = Path('.github/workflows/reusable-check.yml').read_text() modules = set(re.findall(r'"(:[^"]+)"', settings)) # Modules whose tests run in a dedicated job or only on-device -- # exempt unconditionally. covered_elsewhere = { ':screenshot-tests', # dedicated screenshot-check job ':docs-screenshots', # doc-screenshot generation (screenshot tooling) ':baselineprofile', # benchmark module, instrumented-only } # Modules with no unit-test sources yet. One of these gaining test # sources fails the guard: move it into a shard in reusable-check.yml # and remove it from this list. no_tests_yet = { ':core:di', ':core:nfc', ':core:resources', } shards = check.split('# ── Sharded Unit Tests')[1].split('# ── Android Build')[0] def has_test_sources(module): root = Path(module.lstrip(':').replace(':', '/')) return any( f.suffix == '.kt' for d in root.glob('src/*') if 'test' in d.name.lower() for f in d.rglob('*.kt') ) problems = [] for m in sorted(modules): if m in covered_elsewhere: continue if m in no_tests_yet: if has_test_sources(m): problems.append(f'{m} is exempt as test-less but has test sources -- wire it into a shard') # Require an actual test task (allTests / test / testUnitTest), # not just any reference -- a lone kover entry must not satisfy this. elif not re.search(rf'{re.escape(m)}:(allTests|test)', shards): problems.append(f'{m} has no test task in any reusable-check.yml test shard') if problems: print('CI shard coverage drift detected:') for p in problems: print(' -', p) raise SystemExit(1) exempt = covered_elsewhere | no_tests_yet print(f'{len(modules) - len(modules & exempt)} modules verified against the shard matrix.') PY # 1c. STORE METADATA: Enforce store-listing length limits (e.g. the F-Droid / # Play 80-char short_description). These files are mirrored from Crowdin, so # this guard intentionally runs on the translation-sync PRs too (no # scheduled-updates / l10n_main skip) -- that is where overlength translations # land. It is a standalone lightweight job, decoupled from the Gradle build so # a one-line translation fix never triggers a full assemble/test cycle. check-metadata: name: Check Store Metadata if: github.repository == 'meshtastic/Meshtastic-Android' runs-on: ubuntu-24.04-arm timeout-minutes: 5 permissions: contents: read steps: - uses: actions/checkout@v7.0.1 - name: Validate store listing metadata lengths run: python3 scripts/check-metadata-length.py # Compose Multiplatform does not strip Android-style \" / \' escapes, so a # backslash written before a quote renders literally in the UI (PR #6357). # Guards the English source strings; locale mirrors are cleaned upstream by # the Crowdin post-export processor. - name: Check for escaped quotes in base string resources run: python3 scripts/check-string-escapes.py # The deep-link tables (README + developer guide) and both Obtainium import # files are generated from CHANNELS x FLAVORS in obtainium/generate-links.py. # Offline and deterministic, so drift can only come from a commit: this fails # the PR that hand-edits a generated file or changes the script without # regenerating. The network half (are the APK filters still matching real # release assets?) is the --refresh probe in scheduled-updates.yml. - name: Check Obtainium generated links are current run: python3 obtainium/generate-links.py --check # Flathub/AppStream needs a entry for the version being shipped; a stale # block degrades (or fails) the Flathub listing. Fails the PR that bumps # VERSION_NAME_BASE until the matching entry is added. - name: Require AppStream release entry for current version run: | VERSION=$(grep '^VERSION_NAME_BASE=' config.properties | cut -d'=' -f2) METAINFO=desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml if ! grep -q "version=\"$VERSION\"" "$METAINFO"; then echo "::error file=$METAINFO::Missing entry. Add it alongside the VERSION_NAME_BASE bump." exit 1 fi # 2. VALIDATION & BUILD: Delegate to reusable-check.yml # We disable coverage and desktop builds for PRs to keep feedback fast # (< 10 mins). Desktop compilation is already covered by the :desktopApp:test # task in the shard-app test shard. validate-and-build: needs: check-changes if: needs.check-changes.outputs.android == 'true' uses: ./.github/workflows/reusable-check.yml with: run_lint: true run_screenshot_tests: ${{ needs.check-changes.outputs.screenshots == 'true' }} run_unit_tests: true run_coverage: false upload_artifacts: true secrets: inherit # 3. WORKFLOW STATUS: Ensures required checks are satisfied check-workflow-status: name: Check Workflow Status runs-on: ubuntu-24.04-arm timeout-minutes: 5 permissions: {} needs: [check-changes, check-metadata, validate-and-build] if: always() steps: - name: Check Workflow Status run: | # skipped is fine (bot branches); failure also covers the filter-drift step if [[ "${{ needs.check-changes.result }}" == "failure" || "${{ needs.check-changes.result }}" == "cancelled" ]]; then echo "::error::Change detection or filter drift check failed" exit 1 fi if [[ "${{ needs.check-metadata.result }}" == "failure" || "${{ needs.check-metadata.result }}" == "cancelled" ]]; then echo "::error::Store metadata length check failed" exit 1 fi # If changes were detected but build failed, fail the status check if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled") ]]; then echo "::error::Android Check failed" exit 1 fi # If no changes were detected, this still succeeds to satisfy required status check echo "Workflow status satisfied."