# Verification matrix — every push/PR and reusable workflow call. # # Layers (each fails the run independently): # flutter_verify analyze + unit/widget tests + project-lib LCOV floor # linux_build `flutter build linux --release` with the packaging deps # android_ipc_smoke API 35 emulator: Flutter MethodChannel → Chaquopy → Python # python_verify pytest + 75% coverage ratchet + ruff correctness gate # docs_links relative Markdown links resolve # secrets_scan gitleaks (no committed credentials) # workflow_lint parse every workflow and require triggers/jobs # # Coverage exclusions are intentionally narrow: `.coveragerc` omits only # typing-only blocks and optional Python script entrypoints; the Dart checker # measures unique executable lines under `lib/` and ignores SDK/test files. # Flutter's floor is ratcheted at 30% (higher than the previous 24% baseline). # Dart formatting remains a separate cleanup because of repository-wide legacy # churn; Ruff style rules outside correctness remain intentionally ungated. # The Ruff gate covers the whole correctness rule set, unused imports (F401) # included. name: Verification on: # T4-5: every branch + every PR, not just main. Feature branches must # prove green before merge, not after. push: pull_request: workflow_call: # Least privilege, stated rather than inherited (#120). Absent this block the # job token carries the repository default, which is a settings-page value no # file in the repository describes. Every job here reads code and nothing else. permissions: contents: read # One run per ref: a new push supersedes the previous run on that branch. concurrency: group: verify-${{ github.ref }} cancel-in-progress: true jobs: flutter_verify: name: Flutter Analyze & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: stable - name: Install dependencies run: flutter pub get - name: Run static analysis run: flutter analyze - name: Run widget & unit tests with coverage run: flutter test --coverage - name: Enforce Flutter project-lib coverage floor # --max-unloaded 0 plus the allow-list: one file, `engine_service.dart`, # is a pure interface with no executable lines, so LCOV emits no # record for it. Everything else must be instrumented, which is what # stops a deleted import from quietly raising the percentage. run: | python3 tool/check_flutter_coverage.py coverage/lcov.info \ --fail-under 30 \ --max-unloaded 0 \ --allow-unloaded core/engine/engine_service.dart - name: Upload coverage artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: flutter-coverage path: coverage/lcov.info # The Linux desktop build is the one artefact no other gate touched: analyze # and `flutter test` never run CMake, so a plugin whose `pkg_check_modules` # needs a package the build host lacks (libsecret-1 >= 0.18.4 for # flutter_secure_storage) passed every check and then lost the entire nightly # — Publish was skipped because the Linux legs failed in two seconds. # Install the same dependency list the release job installs (build.yml) and # build for real. linux_build: name: Linux desktop build runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: stable cache: true - name: Install Linux build dependencies run: | sudo apt-get update -y sudo apt-get install -y ninja-build libgtk-3-dev liblzma-dev \ libsecret-1-dev libjsoncpp-dev pkg-config - name: Install Flutter dependencies run: flutter pub get - name: Build Linux release bundle run: | flutter build linux --release \ --dart-define=GRABLYTIC_FLUTTER_VERSION="$(flutter --version | head -n 1)" \ --dart-define=GRABLYTIC_GIT_SHA="$(git rev-parse --short HEAD)" test -x build/linux/*/release/bundle/grablytic android_ipc_smoke: name: Android API 35 — Flutter → Chaquopy → Python IPC runs-on: ubuntu-latest env: # The bridge smoke needs a compileable APK but does not exercise bundled # package extraction; release/package CI remains responsible for assets. ORG_GRADLE_PROJECT_skipNativePackages: "true" steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: "17" - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: stable cache: true - name: Install Flutter dependencies run: flutter pub get # SEC-002/003/004/005: the tier-1 feed URL policy, the Atom parser, the # file-share path guard and the boot-receiver action allowlist are all # pure Kotlin, so their tests are JVM unit tests — no emulator, no # device. `--config-only` runs no Gradle task; it writes the plugin # registrant and the Gradle wrapper (both git-ignored) that # `testDebugUnitTest` needs. The release `--config-only` in the lint step # below rewrites the registrant for the release variant. - name: Kotlin unit tests (feed URL, Atom parser, path guard, boot actions) run: | flutter build apk --debug --config-only cd android ./gradlew :app:testDebugUnitTest --console=plain # Hosted runners may expose /dev/kvm only to the kvm group. Without access, # the emulator silently falls back to slow software CPU emulation. - name: Enable KVM for the Android emulator run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm sudo chmod 0666 /dev/kvm test -r /dev/kvm && test -w /dev/kvm # Lint on the release variant. `lint { abortOnError = true }` in # android/app/build.gradle.kts used to be disabled, so nothing # checked a release build before it shipped. # # `flutter pub get` writes the plugin registrant with the dev-only # plugins (integration_test) that the release variant does not # link, so the release Java compile fails before lint even starts. # A release-mode `flutter build --config-only` rewrites the # registrant without them and injects the Gradle wrapper into # android/ (it is git-ignored), without running a single Gradle # task, so this stays a lint-only step. # # `lintVitalRelease` is the task AGP runs on every release build # with `checkReleaseBuilds = true` — but AGP SKIPS it for the app # module (only plugin and library modules are analysed by it), so # it cannot be the gate. The full `lintRelease` analysis is the # gate: it reports every lint finding in the app sources. # # Lint itself crashes while analysing the Kotlin DSL build scripts # ("Cannot find a KaModule for the VirtualFile"). That is a bug in # the lint tool, not a finding in this project. The verdict is # therefore taken from the lint REPORT, not from the log text: a # real finding and the tool's own crash can land in the same run, # and a log-text match would hide the finding. The job fails for # every Error/Fatal finding whose id is not `LintError`. - name: Android lint (release variant) run: | flutter build apk --release --config-only cd android set +e ./gradlew :app:lintRelease --console=plain 2>&1 | tee "$RUNNER_TEMP/lint-release.log" lint_status=${PIPESTATUS[0]} set -e report=app/build/reports/lint-results-release.xml if [ "$lint_status" -eq 0 ]; then echo "Android lint passed." exit 0 fi # The lint task failed. Decide from the REPORT: a real finding # and the lint tool's own crash can land in the same run, and # a log-text match would hide the finding. if [ -f "$report" ]; then real_findings=$(python3 - "$report" <<'PY' import sys import xml.etree.ElementTree as ET try: root = ET.parse(sys.argv[1]).getroot() except Exception: print("unknown") raise SystemExit(0) issues = root.findall("issue") real = [ i for i in issues if (i.get("severity") or "").lower() in ("error", "fatal") and (i.get("id") or "") != "LintError" ] print(len(real)) PY ) if [ "$real_findings" = "0" ]; then echo "::warning::Android lint aborted inside the lint tool (LintError only). No finding in this project was reported - see $report" exit 0 fi echo "::error::Android lint reported $real_findings error/fatal finding(s) in this project - see $report" exit 1 fi if grep -q "Cannot find a KaModule for the VirtualFile" "$RUNNER_TEMP/lint-release.log"; then echo "::warning::Android lint aborted inside the lint tool while analysing the Kotlin DSL build scripts (Cannot find a KaModule for the VirtualFile). That is a lint bug, not a finding in this project, so the release lint could not produce a report." exit 0 fi echo "::error::Android lint failed with status $lint_status and produced no report." exit "$lint_status" - name: Run end-to-end Android integration test uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: 35 target: google_apis arch: x86_64 profile: pixel_6 emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect disable-animations: true disable-linux-hw-accel: false # The host driver runs this device test without the unused DDS service. script: >- flutter drive --no-dds --driver=test_driver/integration_test.dart --target=integration_test/android_engine_ipc_test.dart -d emulator-5554 python_verify: name: Python Engine Tests (3.11 / 3.12) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install uv run: pip install uv - name: Install dependencies & engine run: uv pip install --system yt-dlp pytest pytest-cov -e engine/ - name: Run engine tests with coverage ratchet run: pytest engine/tests/ -q --cov=engine/grablytic_engine --cov-report=term --cov-fail-under=75 # astral-sh/ruff-action, pinned to immutable commit SHA (upstream # carries no `v4` moving tag — `.../ruff-action@v4` fails to resolve). # Gate: the full correctness rule set (F), including F401 # (unused imports). F401 was disabled behind an "import audit # backlog" that has since been worked off — the engine and its # tests are clean, so the exclusion is gone. The ruff VERSION pin # is load-bearing (new releases add rules); the ACTION pin is # supply-chain hygiene. # # `src` takes whitespace-separated paths, so the gates in tool/ are # covered too (#118): they run in CI on every push, and a broken gate # fails open, which is the one place a lint miss costs the most. # E9 (syntax errors) and W605 (invalid escape sequence) are in the # set because every rule under F is name- and scope-level; W605 is # what an unescaped `\d` in a docstring trips (#116). - name: Ruff correctness gate uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: version: "0.16.7" src: "engine/ tool/" args: "check --select F,E9,W605 --output-format=github" # Finding #61 of the teardown review: the count of deliberately # broad handlers kept rising while the audit record called the work # complete. Deleting them is not the fix (most are load-bearing in # logging and storage paths), so this gate holds the two counts at # their current values and fails only when one grows. Reduce them # with `python3 tool/check_broad_catches.py --update`; each raise # needs its own "why", and the reason retires when the number moves. - name: Broad-catch ratchet run: python3 tool/check_broad_catches.py # Finding #105 of the sixth review pass: the app version lived in six # literals that disagreed. It is stamped from pubspec.yaml now, and # this gate fails when a new literal appears in lib/. - name: App version literals run: python3 tool/check_version_literals.py # The gates above are code, so they get tests. Without this step the # suite in tool/tests/ only ran when somebody remembered to run it. - name: Tooling tests run: pytest tool/tests -q docs_links: name: Docs Link Check runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Check relative Markdown links resolve run: python3 tool/check_doc_links.py secrets_scan: name: Secret Scan runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} workflow_lint: name: Workflow YAML Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Install YAML parser run: python -m pip install PyYAML - name: Parse every workflow as YAML + check required keys run: | python3 - <<'EOF' import sys import yaml from pathlib import Path failed = False for path in sorted(Path(".github/workflows").glob("*.yml")): try: doc = yaml.safe_load(path.read_text()) except yaml.YAMLError as e: print(f"::error file={path}::YAML parse error: {e}") failed = True continue if not isinstance(doc, dict): print(f"::error file={path}::workflow document must be a mapping") failed = True continue # NOTE: YAML 1.1 parses the `on:` key as boolean True. on = doc.get("on", doc.get(True)) if on is None or "jobs" not in doc: print(f"::error file={path}::missing top-level 'on'/'jobs' keys") failed = True continue print(f"OK: {path} ({len(doc['jobs'])} jobs)") sys.exit(1 if failed else 0) EOF