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 }} desktop: ${{ steps.filter.outputs.desktop }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # 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/**' # Desktop packaging only runs post-merge on main, so windows-latest never saw a # PR — that is how #6777's Gradle 9.7.1 bump went green here and reddened main # (CMP's MSI/WiX path reads Project.layout on the root project from ':desktopApp', # and only Windows trips it). Narrow gate rather than always-on: the 4-OS matrix is # ~40 runner-minutes, and macOS bills at 10x. # libs.versions.toml is in deliberately, whole-file: #6904 was a one-line CMP bump # in the catalog, and CMP's packaging is exactly what breaks. Same over-run trade # verify-flatpak already accepts (#6911) — a filter naming today's keys goes stale # silently the moment the build reads another one. desktop: - 'desktopApp/**' - 'scripts/build-appimage.sh' # Shared build machinery that shapes the packaged output. - 'build-logic/**' - 'gradle/wrapper/**' - 'gradle/libs.versions.toml' # Root inputs the packaging tasks actually read: config.properties supplies the # version metadata baked into the installers (ProjectExtensions.kt, VersionInfo.kt), # and the rest change plugin resolution, configuration, or how gradlew is invoked. - 'build.gradle.kts' - 'settings.gradle.kts' - 'gradle.properties' - 'config.properties' - 'gradlew' - 'gradlew.bat' - '.github/workflows/reusable-check.yml' - '.github/actions/gradle-setup/**' 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 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Workflow linting (actionlint + its shellcheck integration over every run: block). # Version-pinned; the runner image ships shellcheck. ~1s over the whole tree. - name: Lint GitHub workflows (actionlint) run: | bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 /tmp /tmp/actionlint -color - name: Lint repo shell scripts (shellcheck) run: shellcheck scripts/*.sh - 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 # The flatpak offline manifest is generated on x86_64 but consumed by an arm64 builder, so the # root build.gradle.kts has to force-resolve every artifact the two arches resolve differently. # Since flatpak-sources 0.2.0 those resolve transitively, so the natives look after themselves — # but a *root* does not: a per-architecture runtime desktopApp resolves that nothing declares has # nothing to expand from, and the miss surfaces as `Could not find ` eleven minutes into the # arm64 build (#6901). Offline, so it costs seconds. It lives here rather than in # verify-flatpak.yml because that workflow's path filter excludes gradle/libs.versions.toml — a # dependency bump, the very thing that causes this drift, would never have run it. - name: Check flatpak platform dependencies cover every per-arch runtime run: python3 scripts/verify-flatpak/check-platform-deps.py # 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 # Coverage stays off for PRs to keep feedback fast (< 10 mins); mainline coverage comes # from main-check. Desktop *compilation* is covered on every PR by :desktopApp:test in the # shard-app shard, so the desktop matrix here is about packaging — the jpackage/WiX path # that compilation never reaches — and only runs when the desktop filter matches. validate-and-build: needs: check-changes # `desktop` as well as `android`: scripts/build-appimage.sh is in the desktop filter but # deliberately not the android one, so gating on android alone would skip this whole # workflow for an AppImage-only change and the desktop matrix would never run. if: needs.check-changes.outputs.android == 'true' || needs.check-changes.outputs.desktop == 'true' uses: ./.github/workflows/reusable-check.yml permissions: contents: read pull-requests: write # Gradle job summary as a PR comment on failure with: run_lint: true run_screenshot_tests: ${{ needs.check-changes.outputs.screenshots == 'true' }} run_unit_tests: true run_coverage: false # Installers (dmg/msi+exe/deb+rpm+AppImage) upload on every run that builds them — # upload_artifacts is already true here — so reviewers can install a PR's desktop build # instead of waiting for the post-merge snapshot. run_desktop_builds: ${{ needs.check-changes.outputs.desktop == 'true' }} upload_artifacts: true secrets: inherit # 3. WORKFLOW STATUS: Ensures required checks are satisfied # Pure gate job: no checkout, no toolchain, just reads `needs` results. ubuntu-slim is a # container-backed single-CPU runner that starts faster than a VM (15-min job cap, x64 only). check-workflow-status: name: Check Workflow Status runs-on: ubuntu-slim 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."