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: # scheduled-baseline changes only the googleRelease baseline profile and READMEs, which # no PR job builds; the merge queue still runs everything. if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'scheduled-baseline' ) runs-on: ubuntu-26.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. config/ holds the # ProGuard rules desktopApp's release jars read and the license texts it bundles. - 'build-logic/**' - 'config/**' - '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/**' - 'store-screenshots/**' - 'schema-strings/**' # 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 scripts/check-changes-filter.py - name: Verify the root module list matches settings.gradle.kts run: python3 scripts/check-module-list.py - name: Verify every module with tests is wired into a CI test shard run: python3 scripts/check-test-shards.py # 1c. REPO CHECKS: actionlint, shellcheck, the script self-tests, and the store-listing, # AppStream and generated-file checks. Store listings are mirrored from Crowdin, so # this job intentionally runs on the translation-sync PRs too (no # scheduled-updates 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 Workflows, Scripts & Metadata if: github.repository == 'meshtastic/Meshtastic-Android' runs-on: ubuntu-26.04-arm timeout-minutes: 5 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # actionlint plus its shellcheck pass over every run: block. The image bundles # shellcheck and reads .github/actionlint.yaml from the mounted workspace. - name: Lint GitHub workflows (actionlint) uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 with: args: -color - name: Lint repo shell scripts (shellcheck) # -x so sourced libraries are followed into rather than reported as SC1091, and # find rather than a glob: scripts/*.sh missed scripts/lib/, scripts/docs/ and # scripts/verify-flatpak/ entirely. run: find scripts -name '*.sh' -print0 | xargs -0 shellcheck -x # rb-check runs only in the merge queue and needs two release builds, so Step 6's # classification would otherwise ship unexercised. It lives here rather than in # lint-check because it needs no toolchain, and because lint-check sits behind the # `android` path filter — which does not list scripts/, so a PR touching only the # scanner would have skipped its own test. - name: Self-test verify-rb Step 6 run: ./scripts/verify-rb-selftest.sh - name: Self-test verify-abi-parity run: ./scripts/verify-abi-parity-selftest.sh # Locale codes, lengths and HTML for all three stores that read the tree. supply # uploads every locale directory it finds and neither it nor the Crowdin CLI # validates the names, so an unmapped code is only rejected by the Play API, partway # through an upload; F-Droid and IzzyOnDroid read the tree straight from git. - name: Validate store listing metadata run: python3 scripts/check-store-metadata.py # default.txt is rendered from metainfo.xml; a PR that edits one without the # other ships a listing that no longer says what the release did. - name: Check Play what's-new matches AppStream run: python3 scripts/sync-play-changelog.py --check # 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 # Flathub wants screenshot links from a tag or a commit, never a branch. Ours # are the release assets the internal cut attaches, so they name the version. if grep '' "$METAINFO" | grep -qvF "/releases/download/v$VERSION/"; then echo "::error file=$METAINFO::Every URL must point at the v$VERSION release assets (releases/download/v$VERSION/...). Update them alongside the VERSION_NAME_BASE bump." exit 1 fi # Flathub runs this exact command (flatpak-builder-lint checks/metainfo.py) and turns any # non-zero exit into appstream-failed-validation at publish time. Nothing here ran it, so a # malformed reached Flathub before CI ever saw it. Catches a whitespace-only or # empty-

description; a truly empty and a release with no # description at all both still validate, so this is hygiene, not a prose gate. - name: Validate AppStream metainfo run: | sudo apt-get update -qq sudo apt-get install -y -qq appstream appstreamcli validate --no-net \ desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml # 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. It is the required # check, so it runs on a hosted Ubuntu label that shares the org's pool with the build jobs, # not on ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build. check-workflow-status: name: Check Workflow Status runs-on: ubuntu-26.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::Workflow, script or metadata checks failed" exit 1 fi # skipped means neither the android nor the desktop filter matched if [[ "${{ 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."