mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-13 13:48:00 -04:00
403 lines
19 KiB
YAML
403 lines
19 KiB
YAML
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: ALL_MODULES_FULL in RootConventionPlugin.kt is a hand-maintained
|
|
# copy of settings.gradle.kts (subprojects {} iteration is incompatible with
|
|
# Isolated Projects). It has drifted before: :feature:discovery, :feature:docs
|
|
# and :feature:map-maplibre were never added, so they were silently absent from
|
|
# Dokka aggregation, Kover aggregation and kmpSmokeCompile.
|
|
- name: Verify the root module list matches settings.gradle.kts
|
|
run: |
|
|
python3 - <<'PY'
|
|
import re
|
|
from pathlib import Path
|
|
|
|
settings = Path('settings.gradle.kts').read_text()
|
|
plugin = Path(
|
|
'build-logic/convention/src/main/kotlin/RootConventionPlugin.kt'
|
|
).read_text()
|
|
|
|
include = settings[settings.index('include('):]
|
|
modules = set(re.findall(r'"(:[^"]+)"', include[: include.index('\n)')]))
|
|
|
|
# ALL_MODULES_FULL appears twice (declaration and use), so bound the slice to
|
|
# the declaration's own closing paren rather than searching for the name.
|
|
decl = plugin[plugin.index('ALL_MODULES_FULL ='):]
|
|
listed = set(re.findall(r'"(:[^"]+)"', decl[: decl.index('\n )')]))
|
|
|
|
# Test harnesses and generators, deliberately kept out of root aggregation
|
|
# (PR #6412). Excluded here so the guard does not force them back in.
|
|
exempt = {
|
|
':baselineprofile',
|
|
':core:konsist',
|
|
':docs-screenshots',
|
|
':screenshot-tests',
|
|
}
|
|
|
|
missing = sorted(modules - listed - exempt)
|
|
extra = sorted(listed - modules)
|
|
# The exemption is bidirectional: an exempt module must also stay OUT of the
|
|
# list. Without this, adding one back would pass silently and quietly undo
|
|
# #6412 by pulling a test harness into Dokka, Kover and kmpSmokeCompile.
|
|
readded = sorted(listed & exempt)
|
|
|
|
problems = []
|
|
for m in missing:
|
|
problems.append(
|
|
f'{m} is in settings.gradle.kts but not ALL_MODULES_FULL -- it is absent '
|
|
'from Dokka/Kover aggregation and kmpSmokeCompile'
|
|
)
|
|
for m in extra:
|
|
problems.append(f'{m} is in ALL_MODULES_FULL but no longer in settings.gradle.kts')
|
|
for m in readded:
|
|
problems.append(
|
|
f'{m} is exempt from root aggregation (#6412) but present in '
|
|
'ALL_MODULES_FULL -- remove it, or drop it from the exempt set here'
|
|
)
|
|
|
|
if problems:
|
|
print('Root module list drift detected:')
|
|
for p in problems:
|
|
print(' -', p)
|
|
raise SystemExit(1)
|
|
|
|
print(f'{len(listed)} modules verified against settings.gradle.kts '
|
|
f'({len(exempt)} exempt).')
|
|
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 / test<Variant>UnitTest),
|
|
# 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)
|
|
# -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
|
|
- 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 <jar>` 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 <release> entry for the version being shipped; a stale
|
|
# <releases> 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 <release version=\"$VERSION\"> 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."
|