From 1650d219c25f54443e2dc8e148fe615fa2b80b76 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:45:48 -0500 Subject: [PATCH] ci: enforce store-listing metadata length limits (#5854) Co-authored-by: Claude Opus 4.8 --- .github/workflows/pull-request.yml | 25 +++++++- .github/workflows/scheduled-updates.yml | 9 +++ scripts/check-metadata-length.py | 79 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100755 scripts/check-metadata-length.py diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 52253677e..eb7103828 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -96,6 +96,24 @@ jobs: print('check-changes filter is aligned with settings.gradle module roots.') 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@v6 + - name: Validate store listing metadata lengths + run: python3 scripts/check-metadata-length.py + # 2. VALIDATION & BUILD: Delegate to reusable-check.yml # We disable coverage and desktop builds for PRs to keep feedback fast # (< 10 mins). Desktop compilation is already covered by the :desktopApp:test @@ -117,7 +135,7 @@ jobs: runs-on: ubuntu-24.04-arm timeout-minutes: 5 permissions: {} - needs: [check-changes, verify-check-changes-filter, validate-and-build] + needs: [check-changes, verify-check-changes-filter, check-metadata, validate-and-build] if: always() steps: - name: Check Workflow Status @@ -127,6 +145,11 @@ jobs: 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" diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index 7789b764d..8a36bd1ec 100644 --- a/.github/workflows/scheduled-updates.yml +++ b/.github/workflows/scheduled-updates.yml @@ -101,6 +101,15 @@ jobs: - name: Fix file permissions run: sudo chown -R $USER:$USER . + # Early warning for overlength store-listing translations just pulled from + # Crowdin. Non-blocking on purpose: a hard failure here would abort the + # firmware/hardware/graphs job and stop the PR from being created. The + # hard gate is the check-metadata job in pull-request.yml, which runs + # against the PR this workflow opens. + - name: Check store metadata lengths + continue-on-error: true + run: python3 scripts/check-metadata-length.py + - name: Gradle Setup uses: ./.github/actions/gradle-setup with: diff --git a/scripts/check-metadata-length.py b/scripts/check-metadata-length.py new file mode 100755 index 000000000..7b5dbb236 --- /dev/null +++ b/scripts/check-metadata-length.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Validate Fastlane store-listing metadata against store length limits. + +The ``fastlane/metadata/android`` tree is a mirror of Crowdin: translations are +downloaded on a schedule (see ``.github/workflows/scheduled-updates.yml``). +Crowdin's max-length toggle only blocks *new* submissions; translations entered +before enforcement was enabled are grandfathered in and keep syncing down. This +script is the repo-side guard that catches them regardless of Crowdin state. + +Lengths are measured in Unicode code points (what Google Play and F-Droid / +IzzyOnDroid count), not bytes -- a byte count badly over-reports Cyrillic and +CJK strings. + +Exit status is non-zero if any file exceeds its limit, so it can gate CI. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Repo root = parent of this script's directory (scripts/). +REPO_ROOT = Path(__file__).resolve().parent.parent +METADATA_DIR = REPO_ROOT / "fastlane" / "metadata" / "android" + +# Per-file character limits. Keys are file names under each locale directory. +# 80 is the F-Droid summary / Google Play short-description limit; 30 is the +# Play title limit. Add more entries here to extend coverage. +LIMITS = { + "short_description.txt": 80, + "title.txt": 30, +} + +# Running inside GitHub Actions enables ::error:: annotations on the PR. +IN_GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS") == "true" + + +def char_count(path: Path) -> int: + """Code-point length of a metadata file, ignoring trailing whitespace.""" + return len(path.read_text(encoding="utf-8").rstrip()) + + +def main() -> int: + if not METADATA_DIR.is_dir(): + print(f"error: metadata directory not found: {METADATA_DIR}", file=sys.stderr) + return 2 + + violations: list[tuple[Path, int, int]] = [] + + for file_name, limit in sorted(LIMITS.items()): + for path in sorted(METADATA_DIR.glob(f"*/{file_name}")): + count = char_count(path) + if count > limit: + violations.append((path, count, limit)) + + if not violations: + print("All store-listing metadata is within length limits.") + return 0 + + print("Store-listing metadata exceeds length limits:\n") + for path, count, limit in violations: + rel = path.relative_to(REPO_ROOT) + message = f"{rel} is {count} chars (limit {limit})" + print(f" - {message}") + if IN_GITHUB_ACTIONS: + # Annotate the offending file directly in the PR diff view. + print(f"::error file={rel}::{message}") + + print( + "\nThese files are mirrored from Crowdin. Fix them at the source " + "(shorten or remove the overlength translation so it is re-translated), " + "then re-sync -- editing the mirror here is overwritten on the next sync." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())