ci: fix the required gates and trim merge-queue and main-check cost (#7388)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-26 23:22:17 +00:00
1 parent a8df0f0ac5
commit ae89b83464
20 files changed
+330 -316

No files matched your search

+10 -8
View File
@@ -8,14 +8,14 @@
# - base strings.xml -> run scripts/sort-strings.py (keeps the file sorted
# and regenerates .skills/compose-ui/strings-index.txt;
# AGENTS.md mandates this but no CI job enforces it)
# - fastlane/metadata/** -> run scripts/check-metadata-length.py and BLOCK on
# overlength store listings (the pull-request.yml
# - fastlane/metadata/** -> run scripts/check-store-metadata.py and BLOCK on
# store-rule violations (the pull-request.yml
# check-metadata job is blocking; F-Droid #4262)
# - settings.gradle.kts -> remind about the pull-request.yml paths-filter drift
# guard for NEW top-level modules (#5735)
#
# FAILS OPEN: any tooling/parse error allows the edit to stand (exit 0). Notes are
# surfaced to Claude via PostToolUse additionalContext; only the metadata length
# surfaced to Claude via PostToolUse additionalContext; only the store metadata
# check blocks (exit 2), because that one is a hard CI gate.
input=$(cat)
@@ -51,11 +51,13 @@ $out"
;;
*fastlane/metadata/android/*)
out=$( (cd "$repo_root" && python3 scripts/check-metadata-length.py) 2>&1 )
if [ $? -ne 0 ]; then
out=$( (cd "$repo_root" && python3 scripts/check-store-metadata.py) 2>&1 )
rc=$?
# 1 is a store-rule violation; any other failure is tooling and fails open.
if [ "$rc" -eq 1 ]; then
{
printf '%s\n' "Store-listing metadata exceeds a length limit (scripts/check-metadata-length.py)."
printf '%s\n' "Fix this before it lands — the pull-request.yml check-metadata job is blocking (F-Droid #4262; limits count Unicode code points, not bytes). Details:"
printf '%s\n' "Store-listing metadata breaks a store rule (scripts/check-store-metadata.py)."
printf '%s\n' "Fix this before it lands: the pull-request.yml check-metadata job is blocking (F-Droid #4262; limits count Unicode code points, not bytes). Details:"
printf '%s\n' "$out"
} >&2
exit 2
@@ -64,7 +66,7 @@ $out"
;;
*settings.gradle.kts)
emit_context "You edited settings.gradle.kts. If you added a NEW TOP-LEVEL module directory, add its '<root>/**' line to the 'android:' paths-filter in .github/workflows/pull-request.yml (case-sensitive) or the verify-check-changes-filter drift guard will fail the PR (bit us on #5735). New sub-modules under an already-listed root (core/**, feature/**, etc.) are already covered — no change needed."
emit_context "You edited settings.gradle.kts. If you added a NEW TOP-LEVEL module directory, add its '<root>/**' line to the 'android:' paths-filter in .github/workflows/pull-request.yml (case-sensitive) or scripts/check-changes-filter.py will fail the PR, and add the module to ALL_MODULES_FULL in RootConventionPlugin.kt or scripts/check-module-list.py will. New sub-modules under an already-listed root (core/**, feature/**, etc.) need no filter change."
;;
*/src/commonMain/*.kt|*/src/commonTest/*.kt)
+1 -1
View File
@@ -2,7 +2,7 @@ name: Bug Report
description: File a bug report.
title: "[Bug]: "
labels: [bug]
projects: [meshtastic/Meshtastic-Android]
projects: [meshtastic/26]
body:
- type: markdown
attributes:
+1 -1
View File
@@ -2,7 +2,7 @@ name: Feature Request
description: File a request for new feature or functionality.
title: "[Feature Request]: "
labels: [enhancement]
projects: [meshtastic/Meshtastic-Android]
projects: [meshtastic/30]
body:
- type: checkboxes
id: checklist
@@ -2,7 +2,7 @@ name: Internal testing - Bug Report
description: File a bug report.
title: "[Bug]: "
labels: [bug, ch_testing]
projects: [meshtastic/Meshtastic-Android]
projects: [meshtastic/26]
body:
- type: markdown
attributes:
+1 -1
View File
@@ -12,7 +12,7 @@
org.gradle.daemon=false
# ── Memory ────────────────────────────────────────────────────────────
# Public-repo ubuntu-24.04 runners have 16 GB RAM. Keep Gradle + Kotlin daemon
# Public-repo hosted Ubuntu x64 runners have 16 GB RAM. Keep Gradle + Kotlin daemon
# within budget (4g Gradle + 6g Kotlin daemon, leaving room for lint and K/N).
# Only kotlin.daemon.jvmargs is read from a properties file; kotlin.daemon.jvm.options
# is a system property. Unset, the daemon inherits org.gradle.jvmargs' 4g and OOMs.
@@ -9,11 +9,14 @@ excludeAgent: "code-review"
- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values.
- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`.
- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise.
- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`.
- Lightweight jobs (status gates, labelers, triage, run-cancellers, changelog/release
- Runner labels are named by tier here; the workflows carry the versions.
- Desktop build matrix: `macos-latest`, `windows-latest`, and Ubuntu x64 and arm64; `build-desktop`
in `reusable-check.yml` lists the labels.
- Lightweight jobs off the required-check path (labelers, run-cancellers, changelog/release
cleanup): use `ubuntu-slim`. It is container-backed and starts in seconds, but it is
single-CPU, unprivileged, x64-only, and its 15-minute job cap is a hard platform limit — so it
single-CPU, unprivileged, x64-only, and its 15-minute job cap is a hard platform limit. It
fits API/script work (`gh`, `jq`, `git`, stdlib `python3`, `github-script`) and nothing that
needs `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone.
- Lightweight jobs that break any of those constraints: use `ubuntu-24.04-arm` runners.
- Gradle-heavy jobs: use `ubuntu-24.04` runners.
- Lightweight jobs that break any of those constraints, and the required `Check Workflow Status`
gates, which must not queue on slim's separate pool: use the pinned Ubuntu LTS arm label.
- Gradle-heavy jobs: use the pinned Ubuntu LTS x64 label that `reusable-check.yml` uses.
@@ -1,41 +0,0 @@
name: Submit Dependency Graph
# PR runs can only generate-and-upload (fork tokens lack contents: write). This submits what
# they saved, from the base repo's trusted context. Never checks out PR code — the snapshot
# artifact is the only input.
on:
workflow_run:
workflows: ['Pull Request CI']
types: [completed]
permissions:
actions: read
contents: write
# head_branch alone would collide across forks that share a branch name (e.g. two "patch-1"s).
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
submit-dependency-graph:
# failed runs may have partial graphs; skip
if: github.repository == 'meshtastic/Meshtastic-Android' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
steps:
# skipped android-check (docs-only/bot PRs) uploads nothing — don't fail red on that
- name: Check the run saved a dependency graph
id: probe
env:
GH_TOKEN: ${{ github.token }}
run: |
count=$(gh api --paginate "repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts?per_page=100" \
--jq '[.artifacts[] | select(.name | startswith("dependency-graph"))] | length' | paste -sd+ | bc)
echo "count=$count" >> "$GITHUB_OUTPUT"
- name: Download and submit dependency graph
if: steps.probe.outputs.count != '0'
uses: gradle/actions/dependency-submission@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
with:
dependency-graph: download-and-submit
+4 -1
View File
@@ -6,13 +6,16 @@ on:
paths-ignore:
- '**/*.md'
- 'docs/**'
- 'fastlane/**'
- 'obtainium/**'
permissions:
contents: read
# Not cancelled: one run plus one pending per ref, so every run that starts finishes.
concurrency:
group: main-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
# Every commit on main arrives via the merge queue, which already ran lint,
+27 -31
View File
@@ -8,28 +8,34 @@ permissions:
contents: read
# Note: github.ref is unique per merge-group entry (gh-readonly-queue/main/pr-N-<sha>),
# so this group never dedupes across re-queues of the same PR — the cancel-superseded
# job below handles that explicitly.
# so this group never dedupes across re-queues of the same PR. check-changes cancels
# those runs itself.
concurrency:
group: build-mq-${{ github.ref }}
cancel-in-progress: true
jobs:
# When a PR is re-queued (an entry ahead of it failed or was removed), GitHub creates a
# new merge group but does NOT cancel the workflow runs of the destroyed one. Those stale
# runs sit queued/running and starve the runner pool. Cancel any older merge-queue run
# for the same PR — only the newest merge group per PR is ever valid.
# No checkout, no toolchain — just gh api + jq. Runs on the same label as check-changes:
# ubuntu-slim is a separate, smaller pool, and a gate job waiting on it holds the whole queue.
cancel-superseded:
name: Cancel Superseded Queue Runs
# Docs-only queue entries (changelog updates, markdown fixes, store listings, Obtainium
# configs) cannot affect the build; skip the heavy pipeline for them. Anything outside
# docs/, fastlane/, obtainium/ and *.md runs full CI. Mirrors the paths-ignore list in
# main-check.yml.
# No checkout, no toolchain: gh api only.
check-changes:
name: Check Changes
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
permissions:
actions: write
contents: read
outputs:
android: ${{ steps.filter.outputs.android }}
steps:
# A re-queued PR gets a new merge group, but GitHub leaves the destroyed group's runs
# queued or running, starving the runner pool. Only the newest group per PR is valid.
# Best effort: a failed cancel must not fail the required check.
- name: Cancel older merge-queue runs for the same PR
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -50,28 +56,18 @@ jobs:
gh api -X POST "repos/${{ github.repository }}/actions/runs/${run_id}/force-cancel" || true
done
# Docs-only queue entries (changelog updates, markdown fixes) cannot affect the build;
# skip the heavy pipeline for them. Anything outside docs/ and *.md runs full CI.
# Mirrors the paths-ignore list in main-check.yml.
check-changes:
name: Check Changes
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
outputs:
android: ${{ steps.filter.outputs.android }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- name: Diff merge group against its base
id: filter
env:
GH_TOKEN: ${{ github.token }}
run: |
git fetch --depth=1 origin "${{ github.event.merge_group.base_sha }}"
changed=$(git diff --name-only "${{ github.event.merge_group.base_sha }}" "${{ github.event.merge_group.head_sha }}")
echo "Changed files:"
changed=$(gh api "repos/${{ github.repository }}/compare/${{ github.event.merge_group.base_sha }}...${{ github.event.merge_group.head_sha }}" \
--jq '.files[].filename')
count=$(grep -c . <<<"$changed" || true)
echo "Changed files ($count):"
echo "$changed"
if echo "$changed" | grep -qvE '^docs/|\.md$|^$'; then
# The compare API lists at most 300 files, so a list that long may be truncated.
if [ "$count" -ge 300 ] || echo "$changed" | grep -qvE '^docs/|^fastlane/|^obtainium/|\.md$|^$'; then
echo "android=true" >> "$GITHUB_OUTPUT"
else
echo "android=false" >> "$GITHUB_OUTPUT"
@@ -100,8 +96,8 @@ jobs:
secrets: inherit
# Pure gate job: no checkout, no toolchain, just reads `needs` results. It is the required
# check, so it runs on the label the rest of the workflow already gets slots on, not on
# ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
# 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
@@ -118,7 +114,7 @@ jobs:
echo "::error::Change detection failed"
exit 1
fi
if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled") ]]; then
if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
echo "::error::Android Check failed"
exit 1
fi
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
const author = context.payload.pull_request.user.login;
const headRef = context.payload.pull_request.head.ref;
const skipAuthors = ['renovate[bot]', 'github-actions[bot]', 'dependabot[bot]'];
const skipRefs = ['scheduled-updates', 'l10n_main'];
const skipRefs = ['scheduled-updates'];
if (!skipAuthors.includes(author) && !skipRefs.includes(headRef)) {
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor', 'desktop', 'chore', 'ci', 'build', 'testing', 'documentation'];
const effectiveLabels = new Set([
+21 -182
View File
@@ -17,7 +17,9 @@ jobs:
# (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' )
# 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:
@@ -104,183 +106,20 @@ jobs:
- '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.
run: python3 scripts/check-changes-filter.py
- 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',
':schema-strings',
':screenshot-tests',
':store-screenshots',
}
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.
run: python3 scripts/check-module-list.py
- name: Verify every module with tests is wired into a CI test shard
run: |
python3 - <<'PY'
import re
from pathlib import Path
run: python3 scripts/check-test-shards.py
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
':store-screenshots', # store-listing screenshots from the real app, 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
# 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 Store Metadata
name: Check Workflows, Scripts & Metadata
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
@@ -288,12 +127,12 @@ jobs:
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.
# 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)
run: |
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 /tmp
/tmp/actionlint -color
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
@@ -401,8 +240,8 @@ jobs:
# 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 the label the rest of the workflow already gets slots on, not on
# ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
# 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
@@ -420,12 +259,12 @@ jobs:
fi
if [[ "${{ needs.check-metadata.result }}" == "failure" || "${{ needs.check-metadata.result }}" == "cancelled" ]]; then
echo "::error::Store metadata length check failed"
echo "::error::Workflow, script or metadata checks 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
# 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
+10 -9
View File
@@ -58,9 +58,9 @@ jobs:
# jobs could even enter the queue. Its two outputs are now sourced without a
# job: cache writability is the pure expression above, and the versionCode
# comes from one of two places:
# - Jobs whose artifacts ship (android-check, build-desktop,
# build-flatpak-src) check out full — blob-less — history so the build's
# GitVersionValueSource derives the real commit-count versionCode.
# - Jobs whose artifacts ship (android-check, build-desktop) check out full,
# blob-less history so the build's GitVersionValueSource derives the real
# commit-count versionCode.
# - Validation-only jobs (lint, screenshot, test shards) pin VERSION_CODE
# to a constant instead: their outputs are never shipped, and the real
# value changes on every commit, which poisons the versionCode-dependent
@@ -104,7 +104,7 @@ jobs:
# upstream; VERSION_CODE is still pinned above so entries would reuse.
cache_configuration_cache: 'false'
- name: Lint, Analysis & KMP Smoke Compile
- name: Spotless, Detekt & Android Lint
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug -Pci=true --continue
# ── Screenshot Test Validation ──────────────────────────────────────
@@ -208,7 +208,7 @@ jobs:
permissions:
contents: read
pull-requests: write
timeout-minutes: 45
timeout-minutes: 35
if: inputs.run_unit_tests == true
env:
VERSION_CODE: 30000000 # pinned for cache stability -- see comment above
@@ -456,12 +456,13 @@ jobs:
retention-days: 7
# ── Android Build ────────────────────────────────────────────────────
# Also generates the dependency graph — assembling both flavors resolves the widest set.
# On main it also submits the dependency graph, since assembling both flavors resolves
# the widest set.
android-check:
runs-on: ubuntu-26.04
# No permissions block on purpose: inherits the caller's. main grants contents: write
# (graph submit); PRs grant read (upload only).
timeout-minutes: 60
# for the graph submit.
timeout-minutes: 30
if: inputs.run_android_build == true
steps:
@@ -603,7 +604,7 @@ jobs:
permissions:
contents: read
pull-requests: write
timeout-minutes: 60
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
+24 -22
View File
@@ -98,53 +98,55 @@ Use this whenever driving the app from a fresh install/uninstall (screenshot tes
## 4) CI Pipeline Architecture
CI is defined in `.github/workflows/reusable-check.yml` and structured as parallel job groups:
CI is defined in `.github/workflows/reusable-check.yml` as parallel job groups. No job `needs:` another, so every one queues for a runner as soon as the run starts:
1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs.
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`). Shard membership is a load-balancing detail, not a taxonomy — heavy modules are moved between shards to even out wall time, so read the matrix rather than inferring it:
- `shard-core`: `allTests` for the remaining `core:*` KMP modules.
1. **`lint-check`** runs `spotlessCheck`, `detekt` and Android lint for both flavors of `:androidApp` and `:core:barcode` in a single Gradle invocation (avoids 3x cold-start overhead). It checks out full history (`fetch-depth: 0`) because spotless ratchets against `origin/main`, and it has no outputs.
2. **`test-shards`** is a 3-shard matrix that runs unit tests in parallel. Shard membership is a load-balancing detail, not a taxonomy: heavy modules are moved between shards to even out wall time, so read the matrix rather than inferring it:
- `shard-core`: `allTests` for the remaining `core:*` KMP modules, plus `kmpSmokeCompile`.
- `shard-feature`: `allTests` for `feature:*` KMP modules **plus `:core:service`**.
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`androidApp`, `desktopApp`, `core:barcode`).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) running `:desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars` (depends on `lint-check`). It packages the debug build type — real installers the snapshot release can ship — and pulls in `proguardReleaseJars` only so a jmods-less packaging JDK fails here rather than at release time. On Linux it then wraps jpackage's `app-image` directory into a real AppImage via `scripts/build-appimage.sh`.
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`androidApp`, `desktopApp`, `core:barcode`, `feature:widget`, `schema-strings`) **plus `:core:database` and `:core:network`**.
Every shard uploads its test results to Codecov. Kover XML coverage is generated and uploaded only when `run_coverage` is true, which only `main-check.yml` passes. Codecov flags follow the module group (`core`, `feature`, `app`, `desktop`), not the shard.
The validation-only jobs (`lint-check`, `screenshot-check`, `test-shards`) pin `VERSION_CODE` to one constant so the versionCode-dependent tasks keep the same cache keys on every commit; `screenshot-check` and `test-shards` also clone shallow (`fetch-depth: 1`). `android-check` and `build-desktop` check out full blob-less history so the build derives the real versionCode.
3. **`android-check`** builds the fdroid and google debug APKs and checks their native-library ABI parity (`scripts/verify-abi-parity.sh`). The merge queue skips it. On `main` it also generates and submits the dependency graph; no other ref submits one.
4. **`build-desktop`** is a multi-OS matrix (macOS, Windows, and Linux x64 and arm64; the job's `matrix.os` carries the labels) running `:desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars`. It packages the debug build type, real installers the snapshot release can ship, and pulls in `proguardReleaseJars` only so a jmods-less packaging JDK fails here rather than at release time. On Linux it then wraps jpackage's `app-image` directory into a real AppImage via `scripts/build-appimage.sh`.
5. **`screenshot-check`** — Runs `:screenshot-tests:validateDebugScreenshotTest` (the visual-regression gate) and uploads a diff report. Note: `:docs-screenshots` is intentionally NOT validated here (generate-only).
6. **`rb-check`** — Reproducible-build verification (`scripts/verify-rb.sh`). Runs **only** in the merge queue.
7. **`verify-flatpak`** — Lives in its own workflow (`.github/workflows/verify-flatpak.yml`), **not** in `reusable-check.yml`, and is not called by it. Generates the Flatpak offline-build sources (`captureFlatpakSources`) and then builds the flatpak fully offline, on an `ubuntu-24.04` + `ubuntu-24.04-arm` matrix. Since #6919 the sources are generated inside each arch's own offline build rather than committed. It is **not a required check** and never runs in the merge queue, so its triggers are scoped accordingly: a PR runs it only when it touches the flatpak tooling itself (`scripts/verify-flatpak/**`, the workflow), while the wider dependency surface it captures (`gradle/libs.versions.toml`, `desktopApp/**`, `gradle/wrapper/**`, the root build scripts) is verified on push to `main` plus a nightly cron.
7. **`verify-flatpak`** lives in its own workflow (`.github/workflows/verify-flatpak.yml`), **not** in `reusable-check.yml`, and is not called by it. Generates the Flatpak offline-build sources (`captureFlatpakSources`) and then builds the flatpak fully offline, on an x86_64 + aarch64 matrix of hosted Ubuntu runners. Since #6919 the sources are generated inside each arch's own offline build rather than committed. It is **not a required check** and never runs in the merge queue, so its triggers are scoped accordingly: a PR runs it only when it touches the flatpak tooling itself (`scripts/verify-flatpak/**`, the workflow), while the wider dependency surface it captures (`gradle/libs.versions.toml`, `desktopApp/**`, `gradle/wrapper/**`, the root build scripts) is verified on push to `main` plus a nightly cron.
8. **`protobufs-bump`** — Its own workflow (`.github/workflows/protobufs-bump.yml`), on any PR that changes the `meshtastic-protobufs` line of `gradle/libs.versions.toml`. Comment only: it previews `:schema-strings:sync` at the new pin and leaves one sticky comment from `scripts/protobufs-bump-summary.py` with the upstream compare, the merged protobufs PRs, the `.proto` delta and the settings strings that will change. Nothing is pushed to the branch. The regeneration itself is a step of `scheduled-updates.yml`: `values/schema_strings.xml` records the pin it was built from, the hourly run compares that with the catalog and runs Gradle only when they differ, and the regenerated English goes up to Crowdin in the same run and rides the scheduled PR with the translations. `RepositorySyncTest` checks the file against the registry only while the recorded pin matches the catalog, so the hour between a bump merging and the scheduled PR is not red.
### Runner Strategy (Four Tiers)
- **`ubuntu-slim`** — The cheapest tier, and where lightweight jobs belong since #6674/#6677: status gates, labelers, triage, run-cancellers, stale, changelog and release cleanup. Container-backed and starts in seconds, but **single-CPU, unprivileged, x64-only, with a hard 15-minute job cap** — so it fits `gh`/`jq`/`git`/stdlib-`python3`/`github-script` work and nothing needing `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone.
- **`ubuntu-24.04-arm`** — Lightweight jobs that break any of those `ubuntu-slim` constraints (release metadata, `main-check`, promotion, dependency-graph submission). Shorter queue times than x64.
- **`ubuntu-24.04`** — Gradle-heavy jobs. Every single-runner job in `reusable-check.yml` pins it (`lint-check`, `screenshot-check`, `rb-check`, `test-shards`, `android-check`), as do release builds, Dokka and docs publishing. The `build-desktop` matrix job spans several runners instead, as does `verify-flatpak` in its own workflow. Pin for reproducibility.
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job, `verify-flatpak`, and release packaging.
The tiers are named here and the workflows carry the label versions.
- **`ubuntu-slim`** is the cheapest tier, for API and script jobs off the required-check path: the labeler, the PR-close run canceller, changelog, the Play listing upload and release cleanup. Container-backed and starts in seconds, but **single-CPU, unprivileged, x64-only, with a hard 15-minute job cap**, so it fits `gh`/`jq`/`git`/stdlib-`python3`/`github-script` work and nothing needing `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone. It is a separate, smaller pool, so the required `Check Workflow Status` gates stay off it: a gate queued there holds up a finished build.
- **The pinned Ubuntu LTS arm label** runs the lightweight jobs that break any of those `ubuntu-slim` constraints or sit on the required-check path: PR and merge-queue change detection, `check-metadata`, the status gates, release metadata, the snapshot publish and promotion. Shorter queue times than x64.
- **The pinned Ubuntu LTS x64 label** runs the Gradle-heavy jobs. Every single-runner job in `reusable-check.yml` pins it (`lint-check`, `screenshot-check`, `rb-check`, `test-shards`, `android-check`), as do release builds, Dokka and docs publishing. The `build-desktop` matrix job spans several runners instead, as does `verify-flatpak` in its own workflow. Pin for reproducibility.
- **Desktop runners:** a multi-OS matrix (macOS, Windows, and Ubuntu x64 and arm64) for the `build-desktop` job, `verify-flatpak`, and release packaging. Each matrix lists its own labels, which can trail the pinned LTS labels above.
`.github/instructions/ci-workflows.instructions.md` restates the picking rule for anyone editing a workflow file.
### CI Gradle Properties
`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides:
`gradle.properties` is tuned for local dev (8g heap, 6g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides:
- `org.gradle.daemon=false` (single-use runners)
- `kotlin.incremental=false` (fresh checkouts)
- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon
- `-Xmx4g` Gradle heap, `-Xmx6g` Kotlin daemon
- VFS watching disabled, workers capped at 4
- `org.gradle.isolated-projects=true` for better parallelism
- Disables unused Android build features (`resvalues`, `shaders`)
### CI Conventions
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that depends on `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` for every KMP module in the hand-maintained `ALL_MODULES_FULL` list, plus `compileAndroidDeviceTest` for `:core:database` and `:core:model`. `scripts/check-module-list.py` fails the PR when that list drifts from `settings.gradle.kts`. CI runs it in `shard-core`.
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level.
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{os}-{arch}-{hash of gradle/libs.versions.toml}`, restoring from the `robolectric-{os}-{arch}-` prefix, so a catalog change that bumps Robolectric rolls the key without a hand edit.
- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing.
- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
- **Test retry:** Develocity plugin's native retry (`develocity.testRetry` on each Test task), configured in `ProjectExtensions.kt` (maxRetries=2, maxFailures=10). Screenshot tests opt out (maxRetries=0). The standalone `org.gradle.test-retry` plugin was removed.
- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
- **Explicit Gradle task paths:** Prefer `androidApp:lintFdroidDebug` over shorthand `lintDebug` in CI.
- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`).
- **Merge queue hygiene:** `merge-queue.yml` cancels superseded runs for the same PR (GitHub does not auto-cancel destroyed merge-group runs) and skips the heavy pipeline for docs-only entries (`docs/**`, `*.md`). `rb-check` runs ONLY in the merge queue. `main-check.yml` passes `run_lint: false` — every main commit is a merge-queue-verified merge commit, so main pushes only rebuild the debug APKs for the snapshot release.
- **Cache writes:** Trusted on `main` only; merge-queue cache scopes are throwaway branches (writes unrecoverable), so the queue reads only, like all other refs.
- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.).
- **Pull request CI:** `.github/workflows/pull-request.yml` runs on PRs into `main` and `release/**`. Its Gradle jobs skip the `scheduled-updates` and `scheduled-baseline` head branches; the merge queue still runs everything for them.
- **Merge queue hygiene:** `merge-queue.yml`'s `check-changes` job first cancels older runs for the same PR, best effort, because GitHub does not auto-cancel destroyed merge-group runs. It then lists the entry's files from the compare API, with no checkout, and skips the heavy pipeline for docs-only entries (`docs/**`, `fastlane/**`, `obtainium/**`, `*.md`), mirrored by `main-check.yml`'s `paths-ignore`. An entry of 300 or more files, the API's listing cap, runs full CI. `rb-check` runs ONLY in the merge queue. `main-check.yml` passes `run_lint: false` because every main commit is a merge-queue-verified merge commit, so main pushes skip lint, `screenshot-check` and `rb-check`, and run the coverage shards, the debug APKs and the desktop packages for the snapshot release. Its concurrency group never cancels a started run: one run executes, one waits, and a newer push replaces only the waiting one.
- **Cache writes:** each cache has its own rule. setup-gradle's Gradle User Home cache is written by `reusable-check.yml` on `main` only (`GRADLE_CACHE_READ_ONLY`), so PRs and the merge queue only read it; outside that workflow, the Google, F-Droid and desktop release builds and `scheduled-baseline.yml` write it and every other caller reads. The Develocity remote build cache is pushed by `push` and `merge_group` builds that have the access key, never by PRs (`MeshtasticDevelocitySettingsPlugin`). The plain `actions/cache` steps in `gradle-setup` (JetBrains JDK, Kotlin/Native, Robolectric) save on a key miss from any ref.
- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.). It runs three drift guards, each runnable locally with `python3`: `scripts/check-changes-filter.py` (every module root in `settings.gradle.kts` has a `<root>/**` line in the `android` filter), `scripts/check-module-list.py` (`RootConventionPlugin`'s `ALL_MODULES_FULL` matches `settings.gradle.kts`) and `scripts/check-test-shards.py` (every module with tests is in a `reusable-check.yml` shard or exempted).
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.
+1 -1
View File
@@ -35,7 +35,7 @@ full refresh also appends a ~95-line managed SPECKIT GOVERNANCE section to
- Package manifest: `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`
(`docs/Gemfile` + `docs/Gemfile.lock` belong to the Jekyll docs site only)
- Task runners: Gradle wrapper (`gradlew`), convention plugins in `build-logic/`
- CI workflows: `.github/workflows/create-or-promote-release.yml`,`.github/workflows/dependency-graph-submit.yml` `.github/workflows/docs-deploy.yml`,`.github/workflows/docs-release.yml` `.github/workflows/main-check.yml`,`.github/workflows/merge-queue.yml` `.github/workflows/msstore-publish.yml`,`.github/workflows/post-release-cleanup.yml` `.github/workflows/pr-closed-cleanup.yml`,`.github/workflows/promote.yml` `.github/workflows/pull-request-target.yml`,`.github/workflows/pull-request.yml` `.github/workflows/release.yml`,`.github/workflows/reusable-check.yml` `.github/workflows/scheduled-baseline.yml`,`.github/workflows/scheduled-updates.yml` `.github/workflows/update-changelog.yml` `.github/workflows/verify-flatpak.yml`,`.github/workflows/winget-publish.yml`
- CI workflows: `.github/workflows/create-or-promote-release.yml` `.github/workflows/docs-deploy.yml`,`.github/workflows/docs-release.yml` `.github/workflows/main-check.yml`,`.github/workflows/merge-queue.yml` `.github/workflows/msstore-publish.yml`,`.github/workflows/post-release-cleanup.yml` `.github/workflows/pr-closed-cleanup.yml`,`.github/workflows/promote.yml` `.github/workflows/pull-request-target.yml`,`.github/workflows/pull-request.yml` `.github/workflows/release.yml`,`.github/workflows/reusable-check.yml` `.github/workflows/scheduled-baseline.yml`,`.github/workflows/scheduled-updates.yml` `.github/workflows/update-changelog.yml` `.github/workflows/verify-flatpak.yml`,`.github/workflows/winget-publish.yml`
- Source paths: `androidApp/`, `desktopApp/`, `core/`, `feature/`, `build-logic/`, `scripts/`
- Test paths: `**/src/commonTest/`, `**/src/jvmTest/`, `**/src/androidHostTest/`,
`screenshot-tests/`, `docs-screenshots/`, `baselineprofile/`, `core/konsist/`
@@ -81,10 +81,8 @@ private val DEVICE_TEST_MODULES = listOf(":core:database", ":core:model")
* Modules that participate in root aggregation (Dokka, Kover) and `kmpSmokeCompile`.
*
* Hand-maintained rather than derived, because `subprojects {}` iteration is incompatible with Isolated Projects.
* The `verify-module-list` guard in `pull-request.yml` fails the build when this drifts from
* `settings.gradle.kts`, so a new module cannot silently fall out of the gate — which is how
* `:feature:discovery`, `:feature:docs` and `:feature:map-maplibre` went unaggregated and uncompiled by
* `kmpSmokeCompile` for several releases.
* `scripts/check-module-list.py`, run by the `check-changes` job in `pull-request.yml`, fails the PR when this drifts
* from `settings.gradle.kts`, so a new module cannot silently fall out of the gate.
*/
private val ALL_MODULES_FULL =
listOf(
@@ -131,11 +129,11 @@ private val ANDROID_ONLY_MODULES = setOf(":androidApp", ":core:barcode", ":featu
* Modules excluded from Dokka aggregation.
*
* These are test harnesses and build-time generators with no API surface a reader would look up: they exist to run
* checks or emit artifacts, not to be called from other modules. Aggregating them only added generation time and
* empty pages to the published `/api/` reference.
* checks or emit artifacts, not to be called from other modules. Aggregating them only added generation time and empty
* pages to the published `/api/` reference.
*
* `:core:testing` is deliberately NOT excluded — it is a shared fixture library that other modules' tests consume,
* so its API docs are useful to contributors writing tests.
* `:core:testing` is deliberately NOT excluded. It is a shared fixture library that other modules' tests consume, so
* its API docs are useful to contributors writing tests.
*/
private val DOKKA_EXCLUDED_MODULES =
setOf(
+2 -2
View File
@@ -2,7 +2,7 @@
title: Testing
parent: Developer Guide
nav_order: 7
last_updated: 2026-09-19
last_updated: 2026-09-26
description: Testing strategy for the Meshtastic KMP project — test categories, screenshot pipeline, baseline profiles, and CI integration.
aliases:
- tests
@@ -163,4 +163,4 @@ Tests run automatically on:
- Push to `main`
- Pre-release validation
Single-runner jobs in `reusable-check.yml` run on `ubuntu-26.04` with JDK 25 and Gradle caching. Two jobs use a matrix: `test-shards` splits into `shard-core`, `shard-feature` and `shard-app`, and `build-desktop` runs across macOS, Windows and Linux, still pinned to `ubuntu-24.04`/`-arm`. Flatpak verification is its own workflow, not a job here. The ARM (`ubuntu-26.04-arm`) and container-backed `ubuntu-slim` runners carry the lightweight utility workflows — see `.skills/testing-ci/SKILL.md` for the four-tier rule, which still quotes the older labels.
Single-runner jobs in `reusable-check.yml` run on the pinned Ubuntu LTS x64 label with JDK 25 and Gradle caching. Two jobs use a matrix: `test-shards` splits into `shard-core`, `shard-feature` and `shard-app`, and `build-desktop` runs across macOS, Windows and Linux x64 and arm64, on the labels its own matrix lists. Flatpak verification is its own workflow, not a job here. The pinned Ubuntu LTS arm label and the container-backed `ubuntu-slim` runners carry the lightweight jobs. `.skills/testing-ci/SKILL.md` has the four-tier rule.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Check that every module root has an entry in pull-request.yml's android filter.
Each top-level directory holding a module in settings.gradle.kts needs a '<root>/**'
line in the android filter, or a PR that touches only that module skips CI. Entries
in the other filters do not count. Exits non-zero on drift.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Filter roots that are intentionally not Gradle module roots
# (CI/workflow implementation + shared build infrastructure).
ALLOWED_INFRA_ROOTS = {'.github', 'build-logic', 'config', 'gradle'}
ALLOWED_EXTRA_ROOTS = {'baselineprofile'}
def android_filter(workflow: str) -> str:
"""Return the entries of the android filter, which is what gates validate-and-build."""
lines = workflow.split('\n')
try:
filters = next(i for i, line in enumerate(lines) if line.strip() == 'filters: |')
start = next(i for i in range(filters + 1, len(lines)) if lines[i].strip() == 'android:')
except StopIteration:
raise SystemExit('check-changes filter drift detected: no android filter in pull-request.yml')
indent = len(lines[start]) - len(lines[start].lstrip())
end = start + 1
while end < len(lines):
line = lines[end]
if line.strip() and len(line) - len(line.lstrip()) <= indent:
break
end += 1
return '\n'.join(lines[start + 1:end])
def main() -> None:
settings = (REPO_ROOT / 'settings.gradle.kts').read_text()
workflow = (REPO_ROOT / '.github/workflows/pull-request.yml').read_text()
module_roots = {
module.split(':')[0]
for module in re.findall(r'":([^"]+)"', settings)
}
expected_roots = module_roots | ALLOWED_EXTRA_ROOTS
# Only a whole-root entry covers a root: 'core/ble/**' leaves the rest of core/ unfiltered.
filter_paths = set(re.findall(r"-\s*'([^'/]+)/\*\*'", android_filter(workflow)))
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.')
if __name__ == '__main__':
main()
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Check ALL_MODULES_FULL in RootConventionPlugin.kt against settings.gradle.kts.
The list is a hand-maintained copy of the settings includes, because iterating
subprojects {} is incompatible with Isolated Projects. A module missing from it is
absent from Dokka aggregation, Kover aggregation and kmpSmokeCompile. Exits non-zero
on drift.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Test harnesses and generators kept out of root aggregation. Exempt in both
# directions: the guard neither forces them in nor lets them be added back.
EXEMPT = {
':baselineprofile',
':core:konsist',
':docs-screenshots',
':schema-strings',
':screenshot-tests',
':store-screenshots',
}
def main() -> None:
settings = (REPO_ROOT / 'settings.gradle.kts').read_text()
plugin = (
REPO_ROOT / '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 )')]))
missing = sorted(modules - listed - EXEMPT)
extra = sorted(listed - modules)
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).')
if __name__ == '__main__':
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Check that every module with tests is wired into a reusable-check.yml test shard.
The shard task lists are hand-maintained. Every module in settings.gradle.kts must
have a test task in the shard matrix or be exempted here, and an exempt test-less
module that gains test sources fails until it is wired into a shard. Exits non-zero
on drift.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# 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
':store-screenshots', # store-listing screenshots from the real app, 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',
}
def has_test_sources(module: str) -> bool:
root = REPO_ROOT / 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')
)
def main() -> None:
settings = (REPO_ROOT / 'settings.gradle.kts').read_text()
check = (REPO_ROOT / '.github/workflows/reusable-check.yml').read_text()
modules = set(re.findall(r'"(:[^"]+)"', settings))
shards = check.split('# ── Sharded Unit Tests')[1].split('# ── Android Build')[0]
# A commented-out task runs nothing, so it must not count as coverage.
shards = '\n'.join(re.sub(r'(^|\s)#.*$', '', line) for line in shards.splitlines())
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.')
if __name__ == '__main__':
main()
+1 -1
View File
@@ -4,7 +4,7 @@
# Step 6 itself only runs in the merge queue and needs two full release builds, so the
# classification it depends on would otherwise ship unexercised: a typo in the allowlist
# silently brings the noise back, and a broken dedup silently restores per-ABI repeats.
# This runs in lint-check on every PR instead.
# This runs in pull-request.yml's check-metadata job on every PR instead.
#
# readelf is stubbed: the fixture writes the literal STRIPPED into a lib to mean "no
# .symtab", anything else means the symbol table survived.