diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml new file mode 100644 index 00000000..b6c851b6 --- /dev/null +++ b/.github/workflows/r8_regression_check.yml @@ -0,0 +1,269 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: R8 Regression Gate + +permissions: + contents: write + issues: write + pull-requests: write + +on: + push: + branches: [ 'main' ] + paths: + - '**/proguard-rules.pro' + - '**/consumer-rules.pro' + - '**/*.pro' + - '**/build.gradle.kts' + - 'gradle/libs.versions.toml' + - 'gradle.properties' + - 'scripts/check_*.py' + pull_request: + branches: [ '*' ] + paths: + - '**/proguard-rules.pro' + - '**/consumer-rules.pro' + - '**/*.pro' + - '**/build.gradle.kts' + - 'gradle/libs.versions.toml' + - 'gradle.properties' + - 'r8-baseline.json' + - 'scripts/check_*.py' + - '.github/workflows/r8_regression_check.yml' + workflow_dispatch: + inputs: + pr_number: + description: 'Pull request number to review (leave empty to check the latest commit)' + required: false + type: string + max_score_drop: + description: 'Max allowed drop in R8 scores (percentage points)' + required: false + default: '2.0' + type: string + max_dex_growth_pct: + description: 'Max allowed uncompressed DEX growth (%)' + required: false + default: '5.0' + type: string + min_lib_shrinking_pct: + description: 'Min required library DEX shrinking percentage (%)' + required: false + default: '90.0' + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + r8-regression-gate: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.repository == 'android/androidify' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork == false) + + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.PAT || github.token }} + fetch-depth: 0 + persist-credentials: ${{ github.event_name == 'push' }} + + - name: Prepare Review Context + id: context + env: + GH_TOKEN: ${{ secrets.PAT || github.token }} + PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if [[ -n "${PR_NUMBER}" ]]; then + if [[ -z "${BASE_REF}" ]]; then + BASE_REF=$(gh pr view "${PR_NUMBER}" --json baseRefName --jq '.baseRefName') + gh pr checkout "${PR_NUMBER}" + git fetch origin "${BASE_REF}" + fi + DIFF_RANGE="origin/${BASE_REF}...HEAD" + echo "mode=pr" >> "${GITHUB_OUTPUT}" + echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" + else + DIFF_RANGE="HEAD~1...HEAD" + echo "mode=commit" >> "${GITHUB_OUTPUT}" + echo "pr_number=" >> "${GITHUB_OUTPUT}" + fi + if [[ -f .gitignore ]]; then + sed -i '/^\.gemini\/$/d; /^\.agents\/$/d' .gitignore + fi + mkdir -p .gemini + git diff --name-only --diff-filter=ACMRT "${DIFF_RANGE}" -- '*.pro' '*.gradle.kts' 'gradle/libs.versions.toml' 'gradle.properties' > .gemini/target_files.txt + git diff -U5 --diff-filter=ACMRT "${DIFF_RANGE}" -- '*.pro' '*.gradle.kts' 'gradle/libs.versions.toml' 'gradle.properties' > .gemini/pr_diff.patch + + - name: Set up JDK 21 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Install Android CLI & R8 Analyzer Skill + run: | + set -euo pipefail + curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh | bash + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + export PATH="${HOME}/.local/bin:${PATH}" + android skills add --agent=gemini --project=. r8-analyzer + + - name: Decode google-services.json + env: + DEBUG_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.DEBUG_GOOGLE_SERVICES_JSON_BASE64 }} + run: | + if [[ -z "${DEBUG_GOOGLE_SERVICES_JSON_BASE64}" ]]; then + cp test-google-services.json app/google-services.json + else + echo "${DEBUG_GOOGLE_SERVICES_JSON_BASE64}" | base64 --decode > app/google-services.json + fi + + - name: Build Release App Bundle (.aab) & Module Libraries (.aar) + run: ./gradlew :app:bundleRelease assembleRelease -x test -x uploadCrashlyticsMappingFileRelease --no-configuration-cache + + - name: Check App Bundle R8 Scores & DEX Size + id: app_r8_gate + env: + ANDROID_CLI_ANALYZE: '1' + MAX_SCORE_DROP: ${{ inputs.max_score_drop || '2.0' }} + MAX_DEX_GROWTH_PCT: ${{ inputs.max_dex_growth_pct || '5.0' }} + run: | + set +e + python3 ./scripts/check_app_r8_score.py \ + --aab app/build/outputs/bundle/release/app-release.aab \ + --baseline r8-baseline.json \ + --max-score-drop "${MAX_SCORE_DROP}" \ + --max-dex-growth-pct "${MAX_DEX_GROWTH_PCT}" \ + --markdown-summary .gemini/r8_summary.md + EXIT_CODE=$? + set -e + if [[ ${EXIT_CODE} -eq 0 ]]; then + echo "passed=true" >> "${GITHUB_OUTPUT}" + else + echo "passed=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Check Module & Library Shrinking Percentage + id: lib_r8_gate + env: + ANDROID_CLI_ANALYZE: '1' + MIN_LIB_SHRINKING_PCT: ${{ inputs.min_lib_shrinking_pct || '90.0' }} + run: | + set +e + python3 ./scripts/check_library_shrinking.py \ + --scan-libraries \ + --min-lib-shrinking-pct "${MIN_LIB_SHRINKING_PCT}" \ + --markdown-summary .gemini/r8_summary.md \ + --append-summary + EXIT_CODE=$? + set -e + if [[ ${EXIT_CODE} -eq 0 ]]; then + echo "passed=true" >> "${GITHUB_OUTPUT}" + else + echo "passed=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Update R8 Baseline on Merge to main + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.app_r8_gate.outputs.passed == 'true' && steps.lib_r8_gate.outputs.passed == 'true' + env: + ANDROID_CLI_ANALYZE: '1' + run: | + python3 ./scripts/check_app_r8_score.py \ + --aab app/build/outputs/bundle/release/app-release.aab \ + --baseline r8-baseline.json \ + --save-baseline + + - name: Commit Updated R8 Baseline + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.app_r8_gate.outputs.passed == 'true' && steps.lib_r8_gate.outputs.passed == 'true' + uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5 + with: + commit_message: "🤖 Update r8-baseline.json after merge [skip ci]" + file_pattern: 'r8-baseline.json' + + - name: Run R8 Configuration Analyzer & Skill Review (on Regression) + if: steps.app_r8_gate.outputs.passed == 'false' || steps.lib_r8_gate.outputs.passed == 'false' + id: gemini_review + uses: google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489 # v0.1.22 + env: + GEMINI_CLI_TRUST_WORKSPACE: 'true' + GITHUB_TOKEN: ${{ secrets.PAT || github.token }} + with: + gemini_api_key: ${{ secrets.GEMINI_API_KEY }} + github_pr_number: ${{ steps.context.outputs.pr_number }} + upload_artifacts: 'true' + settings: | + { + "model": { + "maxSessionTurns": 50 + }, + "context": { + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } + }, + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } + } + prompt: | + An R8 score, library shrinking, or DEX size regression was detected by `android analyze` (see `.gemini/r8_summary.md`). + Use the `r8-analyzer` skill located at `.agents/skills/r8-analyzer/SKILL.md` to review the ProGuard/R8 and build configuration changes in `.gemini/pr_diff.patch` and identify which keep rules or dependency changes caused the regression, along with concrete recommendations to remove or refine them. + + - name: Post Findings on Pull Request + if: steps.context.outputs.pr_number != '' && always() + env: + GH_TOKEN: ${{ secrets.PAT || github.token }} + PR_NUMBER: ${{ steps.context.outputs.pr_number }} + REVIEW_SUMMARY: ${{ steps.gemini_review.outputs.summary }} + run: | + set -euo pipefail + if [[ ! -f .gemini/r8_summary.md ]]; then + exit 0 + fi + COMMENT_FILE="$(mktemp)" + cat .gemini/r8_summary.md > "${COMMENT_FILE}" + if [[ -n "${REVIEW_SUMMARY}" ]]; then + { + echo "" + echo "---" + echo "" + printf '%s\n' "${REVIEW_SUMMARY}" + } >> "${COMMENT_FILE}" + fi + gh pr comment "${PR_NUMBER}" --body-file "${COMMENT_FILE}" + rm -f "${COMMENT_FILE}" + + - name: Upload R8 Configuration Analyzer HTML Report + if: steps.app_r8_gate.outputs.passed == 'false' || steps.lib_r8_gate.outputs.passed == 'false' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: r8-config-analyzer-report + path: app/build/reports/r8/r8-config-analyzer-release.html + if-no-files-found: ignore + + - name: Enforce R8 Regression Gate + if: steps.app_r8_gate.outputs.passed == 'false' || steps.lib_r8_gate.outputs.passed == 'false' + run: | + echo "::error::R8 Regression Gate failed. See step summary or PR comment for details." + exit 1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..612ac77d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +repos: + - repo: local + hooks: + - id: r8-staged-diff-and-deps + name: R8 Keep-Rule Diff & Dependency Shrinking Check + entry: python3 scripts/check_staged_r8_diff.py + language: system + pass_filenames: false + files: '(\.pro$|gradle\.properties$|\.gradle\.kts$|libs\.versions\.toml$)' + stages: [pre-commit] + + - id: r8-library-shrinking-gate + name: R8 Module & Library Shrinking Percentage Gate (android analyze) + entry: python3 scripts/check_library_shrinking.py --scan-libraries + language: system + pass_filenames: false + files: '(\.pro$|\.gradle\.kts$|libs\.versions\.toml$)' + stages: [pre-push] + + - id: r8-app-bundle-score-gate + name: R8 App Bundle Score & DEX Size Gate (android analyze) + entry: python3 scripts/check_app_r8_score.py + language: system + pass_filenames: false + files: '(\.pro$|gradle\.properties$|\.gradle\.kts$|libs\.versions\.toml$|r8-baseline\.json$)' + stages: [pre-push] diff --git a/r8-baseline.json b/r8-baseline.json new file mode 100644 index 00000000..8f56d9d6 --- /dev/null +++ b/r8-baseline.json @@ -0,0 +1,34 @@ +{ + "results": [ + { + "optimization": { + "bundleAnalysisResult": { + "packageName": "com.android.developers.androidify", + "agpVersion": "9.2.1", + "r8Json": { + "version": "9.2.14", + "options": { + "isRepackageClassesEnabled": true, + "isProguardCompatibilityModeEnabled": false + }, + "stats": { + "shrinkingRatio": 0.5115, + "optimizationRatio": 0.50810003, + "obfuscationRatio": 0.5111 + }, + "resourceOptimizationStatus": "R8_RESOURCE_OPTIMIZATION_STATUS_OPTIMIZED", + "r8DexChecksumStatus": "EXACT_MATCH" + }, + "r8DexMarkerMetadata": { + "r8Enabled": true, + "d8Enabled": false, + "fullModeEnabled": true + }, + "mappingObfuscationRatio": 0.67831284, + "dexHeuristicObfuscationRatio": 0.67995834, + "uncompressedDexSizeBytes": "19210272" + } + } + } + ] +} diff --git a/scripts/check_app_r8_score.py b/scripts/check_app_r8_score.py new file mode 100755 index 00000000..e5373410 --- /dev/null +++ b/scripts/check_app_r8_score.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Standalone App Bundle (.aab) R8 Score & DEX Size Regression Gate powered by `android analyze`.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + +DEFAULT_AAB = "app/build/outputs/bundle/release/app-release.aab" +FALLBACK_AAB = "app/app-release.aab" +DEFAULT_BASELINE = "r8-baseline.json" + +RED = "\033[91m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +def run_android_analyze(artifact_path: Path) -> dict[str, Any]: + """Invokes `android analyze --analysis=optimization --format=json` on an AAB.""" + env = os.environ.copy() + env["ANDROID_CLI_ANALYZE"] = "1" + + cmd = [ + "android", + "analyze", + "--analysis=optimization", + "--format=json", + str(artifact_path), + ] + proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + if proc.returncode != 0: + print(f"{RED}Error running `{' '.join(cmd)}`:{RESET}", file=sys.stderr) + print(proc.stderr or proc.stdout, file=sys.stderr) + sys.exit(2) + + stdout = proc.stdout + json_start = stdout.find("{") + if json_start == -1: + print(f"{RED}Error: No JSON output returned from `android analyze`.{RESET}", file=sys.stderr) + print(stdout, file=sys.stderr) + sys.exit(2) + + return json.loads(stdout[json_start:]) + + +def _unwrap_optimization(data: dict[str, Any]) -> dict[str, Any]: + """Supports both `{"results": [{"optimization": ...}]}` and `{"optimization": ...}`.""" + if "optimization" in data and isinstance(data["optimization"], dict): + return data["optimization"] + results = data.get("results", []) + if isinstance(results, list) and results and isinstance(results[0], dict): + return results[0].get("optimization", {}) + return {} + + +def extract_bundle_metrics(data: dict[str, Any]) -> dict[str, Any]: + """Extracts R8 ratios, DEX size, and config flags from `android analyze` bundle JSON.""" + bundle = _unwrap_optimization(data).get("bundleAnalysisResult", {}) + if not bundle: + raise ValueError( + "No `bundleAnalysisResult` found in output. Make sure you pass a release `.aab` file." + ) + + r8_json = bundle.get("r8Json", {}) + stats = r8_json.get("stats", {}) + options = r8_json.get("options", {}) + marker = bundle.get("r8DexMarkerMetadata", {}) + + return { + "package_name": bundle.get("packageName", "N/A"), + "agp_version": bundle.get("agpVersion", "N/A"), + "r8_version": r8_json.get("version", "N/A"), + "r8_enabled": marker.get("r8Enabled", False), + "full_mode": marker.get( + "fullModeEnabled", not options.get("isProguardCompatibilityModeEnabled", True) + ), + "resource_opt": r8_json.get("resourceOptimizationStatus", "UNKNOWN"), + "shrinking_pct": float(stats.get("shrinkingRatio", 0.0)) * 100.0, + "optimization_pct": float(stats.get("optimizationRatio", 0.0)) * 100.0, + "obfuscation_pct": float(stats.get("obfuscationRatio", 0.0)) * 100.0, + "dex_bytes": int(bundle.get("uncompressedDexSizeBytes", 0)), + } + + +def format_mb(num_bytes: int) -> str: + return f"{num_bytes / (1024 * 1024):.2f} MB" + + +def build_markdown_summary( + cur_bundle: dict[str, Any], + bundle_rows: list[tuple[str, str, str, str, str, bool]], + failures: list[str], + baseline_path: Path, +) -> str: + lines: list[str] = [ + "## 🛡️ R8 Optimization & Shrinking Gate (`android analyze`)", + "", + "### 📦 App Bundle R8 Scores & DEX Size", + f"- **Package**: `{cur_bundle['package_name']}`", + f"- **Compiler**: AGP `{cur_bundle['agp_version']}` / R8 `{cur_bundle['r8_version']}` (Full Mode: `{cur_bundle['full_mode']}`)", + f"- **Baseline**: `{baseline_path}`", + "", + "| Metric | Baseline | Current | Delta | Threshold | Status |", + "| :--- | ---: | ---: | ---: | ---: | :---: |", + ] + for label, b_str, c_str, d_str, t_str, failed in bundle_rows: + icon = "❌ FAIL" if failed else "✅ PASS" + lines.append(f"| **{label}** | {b_str} | {c_str} | `{d_str}` | `{t_str}` | {icon} |") + lines.append("") + + if failures: + lines.append("### ❌ App Bundle R8 Regressions") + for f in failures: + lines.append(f"- {f}") + lines.append("") + + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--aab", + type=Path, + default=None, + help=f"Path to release AAB (defaults to {DEFAULT_AAB} or {FALLBACK_AAB})", + ) + parser.add_argument( + "--baseline", + type=Path, + default=Path(DEFAULT_BASELINE), + help=f"Path to baseline JSON (default: {DEFAULT_BASELINE})", + ) + parser.add_argument( + "--save-baseline", + action="store_true", + help="Write the current `android analyze` output as the new baseline and exit", + ) + parser.add_argument( + "--build", + action="store_true", + help="Run `./gradlew :app:bundleRelease` before analyzing", + ) + parser.add_argument( + "--max-score-drop", + type=float, + default=2.0, + help="Maximum allowed drop in R8 shrinking/optimization/obfuscation score in percentage points (default: 2.0)", + ) + parser.add_argument( + "--max-dex-growth-pct", + type=float, + default=5.0, + help="Maximum allowed uncompressed DEX size increase in %% (default: 5.0)", + ) + parser.add_argument( + "--max-dex-growth-kb", + type=float, + default=500.0, + help="Maximum allowed uncompressed DEX size increase in KB (default: 500.0)", + ) + parser.add_argument( + "--markdown-summary", + type=Path, + default=None, + help="Optional file path to write the Markdown summary report (in addition to $GITHUB_STEP_SUMMARY if set)", + ) + args = parser.parse_args() + + if args.build: + print(f"{BOLD}==> Building release app bundle (:app:bundleRelease)...{RESET}") + subprocess.run(["./gradlew", ":app:bundleRelease"], check=True) + + aab_path = args.aab + if aab_path is None: + if Path(DEFAULT_AAB).exists(): + aab_path = Path(DEFAULT_AAB) + elif Path(FALLBACK_AAB).exists(): + aab_path = Path(FALLBACK_AAB) + else: + print( + f"{RED}Error: No AAB found at {DEFAULT_AAB} or {FALLBACK_AAB}. " + f"Pass --aab or --build.{RESET}", + file=sys.stderr, + ) + return 2 + + print(f"{BOLD}==> Running `ANDROID_CLI_ANALYZE=1 android analyze` on {aab_path}...{RESET}") + current_raw = run_android_analyze(aab_path) + + if args.save_baseline: + args.baseline.write_text(json.dumps(current_raw, indent=2) + "\n") + cur = extract_bundle_metrics(current_raw) + print( + f"{GREEN}✓ Saved R8 baseline to {args.baseline}{RESET} " + f"(Shrinking: {cur['shrinking_pct']:.2f}%, " + f"Optimization: {cur['optimization_pct']:.2f}%, " + f"Obfuscation: {cur['obfuscation_pct']:.2f}%, " + f"DEX: {format_mb(cur['dex_bytes'])})" + ) + return 0 + + if not args.baseline.exists(): + print( + f"{RED}Error: Baseline file {args.baseline} not found. " + f"Run with --save-baseline first.{RESET}", + file=sys.stderr, + ) + return 2 + + baseline_raw = json.loads(args.baseline.read_text()) + base = extract_bundle_metrics(baseline_raw) + cur_bundle = extract_bundle_metrics(current_raw) + + print() + print(f"{BOLD}R8 Optimization & DEX Regression Gate{RESET}") + print(f" Package : {cur_bundle['package_name']}") + print( + f" Compiler: AGP {cur_bundle['agp_version']} / R8 {cur_bundle['r8_version']} " + f"(Full Mode: {cur_bundle['full_mode']})" + ) + print(f" Baseline: {args.baseline}") + print() + + header = f"{'Metric':<24} {'Baseline':>12} {'Current':>12} {'Delta':>18} {'Threshold':>16} {'Status'}" + print(BOLD + header + RESET) + print("-" * len(header)) + + failures: list[str] = [] + summary_rows: list[tuple[str, str, str, str, str, bool]] = [] + + score_checks = [ + ("Shrinking Score", "shrinking_pct"), + ("Optimization Score", "optimization_pct"), + ("Obfuscation Score", "obfuscation_pct"), + ] + + for label, key in score_checks: + b_val = base[key] + c_val = cur_bundle[key] + delta = c_val - b_val + failed = delta < -args.max_score_drop + status = f"{RED}FAIL ❌{RESET}" if failed else f"{GREEN}PASS ✅{RESET}" + delta_str = f"{delta:+.2f}% pts" + thresh_str = f"max -{args.max_score_drop:.2f}% pts" + print(f"{label:<24} {b_val:>11.2f}% {c_val:>11.2f}% {delta_str:>18} {thresh_str:>16} {status}") + summary_rows.append((label, f"{b_val:.2f}%", f"{c_val:.2f}%", delta_str, thresh_str, failed)) + if failed: + failures.append( + f"{label} dropped by {abs(delta):.2f}% pts ({b_val:.2f}% -> {c_val:.2f}%, allowed drop: {args.max_score_drop:.2f}% pts)" + ) + + b_dex = base["dex_bytes"] + c_dex = cur_bundle["dex_bytes"] + dex_diff_bytes = c_dex - b_dex + dex_diff_kb = dex_diff_bytes / 1024.0 + dex_diff_pct = (dex_diff_bytes / b_dex * 100.0) if b_dex > 0 else 0.0 + + dex_failed = (dex_diff_pct > args.max_dex_growth_pct) or (dex_diff_kb > args.max_dex_growth_kb) + dex_status = f"{RED}FAIL ❌{RESET}" if dex_failed else f"{GREEN}PASS ✅{RESET}" + dex_delta_str = f"{dex_diff_bytes / (1024 * 1024):+.2f} MB ({dex_diff_pct:+.1f}%)" + dex_thresh_str = f"max +{args.max_dex_growth_pct:.1f}% / +{args.max_dex_growth_kb:.0f}KB" + print( + f"{'Uncompressed DEX Size':<24} {format_mb(b_dex):>12} {format_mb(c_dex):>12} " + f"{dex_delta_str:>18} {dex_thresh_str:>16} {dex_status}" + ) + summary_rows.append( + ("Uncompressed DEX Size", format_mb(b_dex), format_mb(c_dex), dex_delta_str, dex_thresh_str, dex_failed) + ) + if dex_failed: + failures.append( + f"Uncompressed DEX size grew by {dex_diff_kb:+.1f} KB / {dex_diff_pct:+.2f}% " + f"({format_mb(b_dex)} -> {format_mb(c_dex)})" + ) + + if base["full_mode"] and not cur_bundle["full_mode"]: + failures.append("R8 Full Mode was disabled (regressed to ProGuard compatibility mode)") + + print("-" * len(header)) + print() + + md_summary = build_markdown_summary(cur_bundle, summary_rows, failures, args.baseline) + step_summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary_path: + with open(step_summary_path, "a", encoding="utf-8") as f: + f.write(md_summary) + if args.markdown_summary is not None: + args.markdown_summary.parent.mkdir(parents=True, exist_ok=True) + args.markdown_summary.write_text(md_summary, encoding="utf-8") + + if failures: + print(f"{RED}{BOLD}❌ App Bundle R8 Gate FAILED ({len(failures)} regression(s) detected):{RESET}") + for f in failures: + print(f" {RED}• {f}{RESET}") + return 1 + + print(f"{GREEN}{BOLD}✅ App Bundle R8 Gate PASSED!{RESET}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_library_shrinking.py b/scripts/check_library_shrinking.py new file mode 100755 index 00000000..bbfd7af9 --- /dev/null +++ b/scripts/check_library_shrinking.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Standalone Library (.aar / .jar) Shrinking & Consumer Keep-Rule Gate powered by `android analyze`.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + +RED = "\033[91m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +def run_android_analyze(artifact_path: Path) -> dict[str, Any]: + """Invokes `android analyze --analysis=optimization --format=json` on an AAR/JAR.""" + env = os.environ.copy() + env["ANDROID_CLI_ANALYZE"] = "1" + + cmd = [ + "android", + "analyze", + "--analysis=optimization", + "--format=json", + str(artifact_path), + ] + proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + if proc.returncode != 0: + print(f"{RED}Error running `{' '.join(cmd)}`:{RESET}", file=sys.stderr) + print(proc.stderr or proc.stdout, file=sys.stderr) + sys.exit(2) + + stdout = proc.stdout + json_start = stdout.find("{") + if json_start == -1: + print(f"{RED}Error: No JSON output returned from `android analyze`.{RESET}", file=sys.stderr) + print(stdout, file=sys.stderr) + sys.exit(2) + + return json.loads(stdout[json_start:]) + + +def _unwrap_optimization(data: dict[str, Any]) -> dict[str, Any]: + """Supports both `{"results": [{"optimization": ...}]}` and `{"optimization": ...}`.""" + if "optimization" in data and isinstance(data["optimization"], dict): + return data["optimization"] + results = data.get("results", []) + if isinstance(results, list) and results and isinstance(results[0], dict): + return results[0].get("optimization", {}) + return {} + + +def extract_library_metrics(artifact_path: Path, data: dict[str, Any]) -> dict[str, Any]: + """Extracts library shrinking percentages and consumer keep-rule metrics from AAR/JAR JSON.""" + lib = _unwrap_optimization(data).get("libraryAnalysisResult", {}) + if not lib: + raise ValueError( + f"No `libraryAnalysisResult` found for {artifact_path}. Make sure it is an `.aar` or `.jar`." + ) + + dex = lib.get("dex", {}) + d8_bytes = int(dex.get("d8CompileResult", {}).get("dexSizeBytes", 0)) + r8_compile = dex.get("r8CompileResult", {}) + r8_bytes = int(r8_compile.get("dexSizeBytes", 0)) + + keep_ratio_pct = (r8_bytes / d8_bytes * 100.0) if d8_bytes > 0 else 0.0 + dex_shrinking_pct = 100.0 - keep_ratio_pct if d8_bytes > 0 else 100.0 + + def _item_stats(section_name: str) -> tuple[int, int, float]: + sec = r8_compile.get(section_name, {}) + total = int(sec.get("itemCount", 0)) + kept = int(sec.get("keptCount", 0)) + shrunk_pct = ((total - kept) / total * 100.0) if total > 0 else 100.0 + return total, kept, shrunk_pct + + cls_total, cls_kept, cls_shrunk_pct = _item_stats("classes") + mth_total, mth_kept, mth_shrunk_pct = _item_stats("methods") + fld_total, fld_kept, fld_shrunk_pct = _item_stats("fields") + + validate = dex.get("validateConsumerKeepRulesResult", {}) + used_pkg_wide = int(validate.get("usedPackageWideKeepRules", 0)) + unused_pkg_wide = int(validate.get("unusedPackageWideKeepRules", 0)) + total_pkg_wide = used_pkg_wide + unused_pkg_wide + blocked_rules = [r.get("value", "") for r in validate.get("blockedKeepRules", [])] + + config_files = r8_compile.get("configuration", {}).get("files", []) + keep_rules = [r.strip() for f in config_files for r in f.get("keepRules", []) if r.strip()] + + return { + "name": artifact_path.name, + "path": str(artifact_path), + "r8_version": lib.get("r8Version", "N/A"), + "d8_bytes": d8_bytes, + "r8_bytes": r8_bytes, + "keep_ratio_pct": keep_ratio_pct, + "dex_shrinking_pct": dex_shrinking_pct, + "cls_total": cls_total, + "cls_kept": cls_kept, + "cls_shrunk_pct": cls_shrunk_pct, + "mth_total": mth_total, + "mth_kept": mth_kept, + "mth_shrunk_pct": mth_shrunk_pct, + "fld_total": fld_total, + "fld_kept": fld_kept, + "fld_shrunk_pct": fld_shrunk_pct, + "used_pkg_wide": used_pkg_wide, + "unused_pkg_wide": unused_pkg_wide, + "total_pkg_wide": total_pkg_wide, + "keep_rules": keep_rules, + "blocked_rules": blocked_rules, + } + + +def format_kb(num_bytes: int) -> str: + if num_bytes >= 1024 * 1024: + return f"{num_bytes / (1024 * 1024):.2f} MB" + return f"{num_bytes / 1024.0:.2f} KB" + + +def discover_module_aars(root: Path) -> list[Path]: + """Finds all module release AARs (`**/build/outputs/aar/*-release.aar`) in the repo.""" + return sorted(root.glob("**/build/outputs/aar/*-release.aar")) + + +def build_markdown_summary( + lib_metrics_list: list[dict[str, Any]], + min_lib_shrinking_pct: float, + fail_on_pkg_rules: bool, + failures: list[str], +) -> str: + lines: list[str] = [ + f"### 📚 Library Shrinking Percentage (Min Threshold: `{min_lib_shrinking_pct:.1f}%`)", + "", + "| Library (`.aar` / `.jar`) | D8 DEX | R8 Kept DEX | DEX Shrinking % | Classes Shrunk | Methods Shrunk | Pkg-Wide Rules | Status |", + "| :--- | ---: | ---: | ---: | ---: | ---: | ---: | :---: |", + ] + for lm in lib_metrics_list: + failed = ( + lm["dex_shrinking_pct"] < min_lib_shrinking_pct + or (fail_on_pkg_rules and lm["total_pkg_wide"] > 0) + or len(lm["blocked_rules"]) > 0 + ) + warn = (not failed) and lm["total_pkg_wide"] > 0 + icon = "❌ FAIL" if failed else ("⚠️ WARN" if warn else "✅ PASS") + cls_str = f"{lm['cls_shrunk_pct']:.1f}% ({lm['cls_total'] - lm['cls_kept']}/{lm['cls_total']})" + mth_str = f"{lm['mth_shrunk_pct']:.1f}% ({lm['mth_total'] - lm['mth_kept']}/{lm['mth_total']})" + lines.append( + f"| `{lm['name']}` | {format_kb(lm['d8_bytes'])} | {format_kb(lm['r8_bytes'])} | " + f"**{lm['dex_shrinking_pct']:.2f}%** | {cls_str} | {mth_str} | " + f"{lm['total_pkg_wide']} | {icon} |" + ) + lines.append("") + + if failures: + lines.append("### ❌ Library Shrinking Regressions") + for f in failures: + lines.append(f"- {f}") + lines.append("") + + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--library", + type=Path, + action="append", + default=[], + help="Path to a library (.aar or .jar) to check shrinking percentage (repeatable)", + ) + parser.add_argument( + "--scan-libraries", + action="store_true", + help="Automatically discover and analyze all module `*-release.aar` files in the workspace", + ) + parser.add_argument( + "--min-lib-shrinking-pct", + type=float, + default=90.0, + help="Minimum required library DEX shrinking percentage (100%% - Keep Ratio) for .aar/.jar files (default: 90.0)", + ) + parser.add_argument( + "--fail-on-package-wide-rules", + action="store_true", + help="Fail the gate if any analyzed library exports package-wide consumer keep rules (`-keep class pkg.** { *; }`)", + ) + parser.add_argument( + "--markdown-summary", + type=Path, + default=None, + help="Optional file path to write the Markdown summary report (in addition to $GITHUB_STEP_SUMMARY if set)", + ) + parser.add_argument( + "--append-summary", + action="store_true", + help="Append to --markdown-summary instead of overwriting", + ) + args = parser.parse_args() + + lib_paths: list[Path] = list(args.library) + if args.scan_libraries: + for p in discover_module_aars(Path(".")): + if p not in lib_paths: + lib_paths.append(p) + + if not lib_paths: + print( + f"{RED}Error: No libraries specified. Pass --library or --scan-libraries.{RESET}", + file=sys.stderr, + ) + return 2 + + print() + print( + f"{BOLD}Library Shrinking Percentage Gate " + f"(Min Shrinking: {args.min_lib_shrinking_pct:.1f}%, " + f"Max Keep Ratio: {100.0 - args.min_lib_shrinking_pct:.1f}%){RESET}" + ) + lib_header = ( + f"{'Library':<28} {'D8 DEX':>10} {'R8 Kept':>10} " + f"{'DEX Shrunk %':>13} {'Classes Shrunk':>18} {'Pkg-Wide Rules':>15} {'Status'}" + ) + print(BOLD + lib_header + RESET) + print("-" * len(lib_header)) + + failures: list[str] = [] + lib_metrics_list: list[dict[str, Any]] = [] + + for lib_path in lib_paths: + if not lib_path.exists(): + print(f"{RED}Error: Library file not found: {lib_path}{RESET}", file=sys.stderr) + return 2 + raw_lib = run_android_analyze(lib_path) + lm = extract_library_metrics(lib_path, raw_lib) + lib_metrics_list.append(lm) + + shrink_failed = lm["dex_shrinking_pct"] < args.min_lib_shrinking_pct + pkg_rule_failed = args.fail_on_package_wide_rules and lm["total_pkg_wide"] > 0 + blocked_failed = len(lm["blocked_rules"]) > 0 + failed = shrink_failed or pkg_rule_failed or blocked_failed + warn = (not failed) and lm["total_pkg_wide"] > 0 + + status = ( + f"{RED}FAIL ❌{RESET}" + if failed + else (f"{YELLOW}WARN ⚠️{RESET}" if warn else f"{GREEN}PASS ✅{RESET}") + ) + cls_shrunk_str = ( + f"{lm['cls_shrunk_pct']:.1f}% ({lm['cls_total'] - lm['cls_kept']}/{lm['cls_total']})" + ) + print( + f"{lm['name']:<28} {format_kb(lm['d8_bytes']):>10} {format_kb(lm['r8_bytes']):>10} " + f"{lm['dex_shrinking_pct']:>12.2f}% {cls_shrunk_str:>18} " + f"{lm['total_pkg_wide']:>15} {status}" + ) + + if shrink_failed: + failures.append( + f"Library `{lm['name']}` DEX shrinking is {lm['dex_shrinking_pct']:.2f}% " + f"(Keep Ratio: {lm['keep_ratio_pct']:.2f}%, required shrinking >= {args.min_lib_shrinking_pct:.1f}%)" + ) + if pkg_rule_failed: + rules_preview = ", ".join(lm["keep_rules"][:3]) + failures.append( + f"Library `{lm['name']}` exports {lm['total_pkg_wide']} package-wide consumer keep rule(s)" + + (f": {rules_preview}" if rules_preview else "") + ) + if blocked_failed: + failures.append( + f"Library `{lm['name']}` contains blocked consumer keep rule(s): {', '.join(lm['blocked_rules'])}" + ) + + print("-" * len(lib_header)) + print() + + md_summary = build_markdown_summary( + lib_metrics_list, args.min_lib_shrinking_pct, args.fail_on_package_wide_rules, failures + ) + step_summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary_path: + with open(step_summary_path, "a", encoding="utf-8") as f: + f.write(md_summary) + if args.markdown_summary is not None: + args.markdown_summary.parent.mkdir(parents=True, exist_ok=True) + mode = "a" if args.append_summary else "w" + with open(args.markdown_summary, mode, encoding="utf-8") as f: + f.write(md_summary) + + if failures: + print(f"{RED}{BOLD}❌ Library Shrinking Gate FAILED ({len(failures)} regression(s) detected):{RESET}") + for f in failures: + print(f" {RED}• {f}{RESET}") + return 1 + + print(f"{GREEN}{BOLD}✅ Library Shrinking Gate PASSED!{RESET}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_r8_regression.py b/scripts/check_r8_regression.py new file mode 100755 index 00000000..5c7009ce --- /dev/null +++ b/scripts/check_r8_regression.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Presubmit / post-build R8 regression & library shrinking gate powered by `android analyze`.""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +DEFAULT_AAB = "app/build/outputs/bundle/release/app-release.aab" +FALLBACK_AAB = "app/app-release.aab" +DEFAULT_BASELINE = "r8-baseline.json" + +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +def run_android_analyze(artifact_path: Path) -> dict[str, Any]: + """Invokes `android analyze --analysis=optimization --format=json` on an AAB/APK/AAR/JAR.""" + env = os.environ.copy() + env["ANDROID_CLI_ANALYZE"] = "1" + + cmd = [ + "android", + "analyze", + "--analysis=optimization", + "--format=json", + str(artifact_path), + ] + proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + if proc.returncode != 0: + print(f"{RED}Error running `{' '.join(cmd)}`:{RESET}", file=sys.stderr) + print(proc.stderr or proc.stdout, file=sys.stderr) + sys.exit(2) + + # Strip any leading progress output (e.g. "Downloading optimization pack...") + stdout = proc.stdout + json_start = stdout.find("{") + if json_start == -1: + print(f"{RED}Error: No JSON output returned from `android analyze`.{RESET}", file=sys.stderr) + print(stdout, file=sys.stderr) + sys.exit(2) + + return json.loads(stdout[json_start:]) + + +def _unwrap_optimization(data: dict[str, Any]) -> dict[str, Any]: + """Supports both `{"results": [{"optimization": ...}]}` and `{"optimization": ...}`.""" + if "optimization" in data and isinstance(data["optimization"], dict): + return data["optimization"] + results = data.get("results", []) + if isinstance(results, list) and results and isinstance(results[0], dict): + return results[0].get("optimization", {}) + return {} + + +def extract_bundle_metrics(data: dict[str, Any]) -> dict[str, Any]: + """Extracts R8 ratios, DEX size, and config flags from `android analyze` bundle JSON.""" + bundle = _unwrap_optimization(data).get("bundleAnalysisResult", {}) + if not bundle: + raise ValueError( + "No `bundleAnalysisResult` found in output. Make sure you pass a release `.aab` file." + ) + + r8_json = bundle.get("r8Json", {}) + stats = r8_json.get("stats", {}) + options = r8_json.get("options", {}) + marker = bundle.get("r8DexMarkerMetadata", {}) + + return { + "package_name": bundle.get("packageName", "N/A"), + "agp_version": bundle.get("agpVersion", "N/A"), + "r8_version": r8_json.get("version", "N/A"), + "r8_enabled": marker.get("r8Enabled", False), + "full_mode": marker.get( + "fullModeEnabled", not options.get("isProguardCompatibilityModeEnabled", True) + ), + "resource_opt": r8_json.get("resourceOptimizationStatus", "UNKNOWN"), + "shrinking_pct": float(stats.get("shrinkingRatio", 0.0)) * 100.0, + "optimization_pct": float(stats.get("optimizationRatio", 0.0)) * 100.0, + "obfuscation_pct": float(stats.get("obfuscationRatio", 0.0)) * 100.0, + "dex_bytes": int(bundle.get("uncompressedDexSizeBytes", 0)), + } + + +def extract_library_metrics(artifact_path: Path, data: dict[str, Any]) -> dict[str, Any]: + """Extracts library shrinking percentages and consumer keep-rule metrics from AAR/JAR JSON.""" + lib = _unwrap_optimization(data).get("libraryAnalysisResult", {}) + if not lib: + raise ValueError( + f"No `libraryAnalysisResult` found for {artifact_path}. Make sure it is an `.aar` or `.jar`." + ) + + dex = lib.get("dex", {}) + d8_bytes = int(dex.get("d8CompileResult", {}).get("dexSizeBytes", 0)) + r8_compile = dex.get("r8CompileResult", {}) + r8_bytes = int(r8_compile.get("dexSizeBytes", 0)) + + keep_ratio_pct = (r8_bytes / d8_bytes * 100.0) if d8_bytes > 0 else 0.0 + dex_shrinking_pct = 100.0 - keep_ratio_pct if d8_bytes > 0 else 100.0 + + def item_shrinking(section_name: str) -> tuple[int, int, float]: + sec = r8_compile.get(section_name, {}) + total = int(sec.get("itemCount", 0)) + kept = int(sec.get("keptItemCount", 0)) + shrunk_pct = ((total - kept) / total * 100.0) if total > 0 else 100.0 + return kept, total, shrunk_pct + + cls_kept, cls_total, cls_shrunk_pct = item_shrinking("classes") + mtd_kept, mtd_total, mtd_shrunk_pct = item_shrinking("methods") + fld_kept, fld_total, fld_shrunk_pct = item_shrinking("fields") + + config = r8_compile.get("configuration", {}) + used_pkg_rules = [r.get("source", "") for r in config.get("usedPackageWideKeepRules", [])] + unused_pkg_rules = [r.get("source", "") for r in config.get("unusedPackageWideKeepRules", [])] + keep_rules = config.get("keepRules", []) + + validate_res = dex.get("validateConsumerKeepRulesResult", {}) + blocked_rules = [r.get("source", "") for r in validate_res.get("blockedKeepRules", [])] + + return { + "name": artifact_path.name, + "path": str(artifact_path), + "r8_version": lib.get("r8Version", "N/A"), + "d8_bytes": d8_bytes, + "r8_bytes": r8_bytes, + "keep_ratio_pct": keep_ratio_pct, + "dex_shrinking_pct": dex_shrinking_pct, + "classes_kept": cls_kept, + "classes_total": cls_total, + "classes_shrinking_pct": cls_shrunk_pct, + "methods_kept": mtd_kept, + "methods_total": mtd_total, + "methods_shrinking_pct": mtd_shrunk_pct, + "fields_kept": fld_kept, + "fields_total": fld_total, + "fields_shrinking_pct": fld_shrunk_pct, + "used_pkg_rules": used_pkg_rules, + "unused_pkg_rules": unused_pkg_rules, + "keep_rules": keep_rules, + "blocked_rules": blocked_rules, + } + + +def format_mb(num_bytes: int) -> str: + return f"{num_bytes / (1024 * 1024):.2f} MB" + + +def format_kb(num_bytes: int) -> str: + if num_bytes >= 1024 * 1024: + return format_mb(num_bytes) + return f"{num_bytes / 1024.0:.2f} KB" + + +def discover_module_aars(root: Path) -> list[Path]: + """Finds all module release AARs (`**/build/outputs/aar/*-release.aar`) in the repo.""" + return sorted(root.glob("**/build/outputs/aar/*-release.aar")) + + +def inspect_staged_changes() -> tuple[list[str], list[Path]]: + """ + Inspects `git diff --cached` for: + 1. Broad package-wide `-keep class .** { *; }` rules or `android.enableR8.fullMode=false` + 2. Newly added Maven dependencies (`group:artifact:version`) and resolves their `.aar`/`.jar` + from `~/.gradle/caches/modules-2/files-2.1`. + """ + diff_failures: list[str] = [] + dep_archives: list[Path] = [] + + try: + pro_diff = subprocess.check_output( + ["git", "diff", "--cached", "-U0", "--", "*.pro", "gradle.properties"], + text=True, + stderr=subprocess.DEVNULL, + ) + except Exception: + pro_diff = "" + + current_file = "staged file" + for line in pro_diff.splitlines(): + if line.startswith("+++ b/"): + current_file = line[6:].strip() + elif line.startswith("+") and not line.startswith("+++"): + added = line[1:].strip() + if re.search(r"android\.enableR8\.fullMode\s*=\s*false", added): + diff_failures.append( + f"Staged change in `{current_file}` disables R8 Full Mode (`{added}`)" + ) + elif re.match( + r"^-keep\s+(class|interface)\s+[a-zA-Z0-9_.]+\.\*\*\s*\{\s*\*\s*;\s*\}", + added, + ): + diff_failures.append( + f"Staged rule in `{current_file}` introduces broad package-wide keep rule: `{added}`" + ) + + try: + gradle_diff = subprocess.check_output( + [ + "git", + "diff", + "--cached", + "-U0", + "--", + "*.gradle.kts", + "gradle/libs.versions.toml", + ], + text=True, + stderr=subprocess.DEVNULL, + ) + except Exception: + gradle_diff = "" + + coords: set[tuple[str, str, str]] = set() + for m in re.finditer( + r'^\+.*["\']([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)["\']', + gradle_diff, + re.M, + ): + coords.add((m.group(1), m.group(2), m.group(3))) + for m in re.finditer( + r'^\+.*group\s*=\s*"([^"]+)"\s*,\s*name\s*=\s*"([^"]+)"\s*,\s*version\s*=\s*"([^"]+)"', + gradle_diff, + re.M, + ): + coords.add((m.group(1), m.group(2), m.group(3))) + + cache_root = Path.home() / ".gradle" / "caches" / "modules-2" / "files-2.1" + if cache_root.exists(): + for group, name, version in sorted(coords): + pattern = str(cache_root / group / name / version / "*" / f"{name}-{version}.*") + for match in sorted(glob.glob(pattern)): + if match.endswith((".aar", ".jar")) and not match.endswith( + ("-sources.jar", "-javadoc.jar") + ): + dep_archives.append(Path(match)) + break + + return diff_failures, dep_archives + + +def build_markdown_summary( + cur_bundle: dict[str, Any] | None, + bundle_rows: list[tuple[str, str, str, str, str, bool]], + lib_metrics_list: list[dict[str, Any]], + min_lib_shrinking_pct: float, + fail_on_pkg_rules: bool, + failures: list[str], + baseline_path: Path, +) -> str: + """Builds a Markdown summary table for GitHub Actions / PR comments.""" + lines: list[str] = ["## 🛡️ R8 Optimization & Shrinking Gate (`android analyze`)", ""] + + if cur_bundle is not None: + lines.extend( + [ + "### 📦 App Bundle R8 Scores & DEX Size", + f"- **Package**: `{cur_bundle['package_name']}`", + f"- **Compiler**: AGP `{cur_bundle['agp_version']}` / R8 `{cur_bundle['r8_version']}` (Full Mode: `{cur_bundle['full_mode']}`)", + f"- **Baseline**: `{baseline_path}`", + "", + "| Metric | Baseline | Current | Delta | Threshold | Status |", + "| :--- | ---: | ---: | ---: | ---: | :---: |", + ] + ) + for label, b_str, c_str, delta_str, thresh_str, failed in bundle_rows: + status_md = "❌ FAIL" if failed else "✅ PASS" + lines.append( + f"| **{label}** | {b_str} | {c_str} | `{delta_str}` | `{thresh_str}` | {status_md} |" + ) + lines.append("") + + if lib_metrics_list: + lines.extend( + [ + f"### 📚 Library Shrinking Percentage (Min Threshold: `{min_lib_shrinking_pct:.1f}%`)", + "", + "| Library (`.aar` / `.jar`) | D8 DEX | R8 Kept DEX | DEX Shrinking % | Classes Shrunk | Methods Shrunk | Pkg-Wide Rules | Status |", + "| :--- | ---: | ---: | ---: | ---: | ---: | ---: | :---: |", + ] + ) + for lm in lib_metrics_list: + pkg_rule_count = len(lm["used_pkg_rules"]) + len(lm["unused_pkg_rules"]) + failed = ( + lm["dex_shrinking_pct"] < min_lib_shrinking_pct + or len(lm["blocked_rules"]) > 0 + or (fail_on_pkg_rules and pkg_rule_count > 0) + ) + warn = not failed and pkg_rule_count > 0 + status_md = "❌ FAIL" if failed else ("⚠️ WARN" if warn else "✅ PASS") + cls_str = f"{lm['classes_shrinking_pct']:.1f}% ({lm['classes_total'] - lm['classes_kept']}/{lm['classes_total']})" + mtd_str = f"{lm['methods_shrinking_pct']:.1f}% ({lm['methods_total'] - lm['methods_kept']}/{lm['methods_total']})" + lines.append( + f"| `{lm['name']}` | {format_kb(lm['d8_bytes'])} | {format_kb(lm['r8_bytes'])} | " + f"**{lm['dex_shrinking_pct']:.2f}%** | {cls_str} | {mtd_str} | {pkg_rule_count} | {status_md} |" + ) + lines.append("") + + if failures: + lines.append(f"### ❌ Gate Failed ({len(failures)} regression(s) detected)") + for item in failures: + lines.append(f"- {item}") + lines.append("") + lines.append( + "> 💡 **Next Step:** Download the `r8-config-analyzer-report` artifact from this workflow run " + "or run `./gradlew :app:analyzeReleaseR8Config` locally to inspect the keep rules with the largest blast radius." + ) + else: + lines.append("### ✅ Gate Passed! All R8 scores, library shrinking ratios, and DEX sizes are within thresholds.") + + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check release AAB R8 scores and library (.aar/.jar) shrinking percentages using `android analyze`." + ) + parser.add_argument( + "--aab", + type=Path, + default=None, + help=f"Path to release .aab (defaults to {DEFAULT_AAB} or {FALLBACK_AAB})", + ) + parser.add_argument( + "--skip-app", + action="store_true", + help="Skip the app bundle (.aab) check and only run library (.aar/.jar) checks", + ) + parser.add_argument( + "--baseline", + type=Path, + default=Path(DEFAULT_BASELINE), + help=f"Path to baseline JSON file (default: {DEFAULT_BASELINE})", + ) + parser.add_argument( + "--save-baseline", + action="store_true", + help="Write the current `android analyze` AAB output to --baseline and exit", + ) + parser.add_argument( + "--build", + action="store_true", + help="Run `./gradlew :app:bundleRelease` before analyzing", + ) + parser.add_argument( + "--max-score-drop", + type=float, + default=2.0, + help="Max allowed drop in R8 shrinking/optimization/obfuscation score in percentage points (default: 2.0)", + ) + parser.add_argument( + "--max-dex-growth-pct", + type=float, + default=5.0, + help="Max allowed uncompressed DEX size increase in percent (default: 5.0)", + ) + parser.add_argument( + "--max-dex-growth-kb", + type=float, + default=500.0, + help="Max allowed uncompressed DEX size increase in KB (default: 500.0)", + ) + parser.add_argument( + "--library", + type=Path, + action="append", + default=[], + help="Path to a library (.aar or .jar) to check shrinking percentage (can be specified multiple times)", + ) + parser.add_argument( + "--scan-libraries", + action="store_true", + help="Automatically discover and analyze all module `*-release.aar` files in the workspace", + ) + parser.add_argument( + "--min-lib-shrinking-pct", + type=float, + default=90.0, + help="Minimum required library DEX shrinking percentage (100%% - Keep Ratio) for .aar/.jar files (default: 90.0)", + ) + parser.add_argument( + "--fail-on-package-wide-rules", + action="store_true", + help="Fail the gate if any analyzed library exports package-wide consumer keep rules (`-keep class pkg.** { *; }`)", + ) + parser.add_argument( + "--check-staged", + action="store_true", + help="Inspect `git diff --cached` for broad keep rules and newly added Maven dependency AARs/JARs", + ) + parser.add_argument( + "--markdown-summary", + type=Path, + default=None, + help="Optional file path to write the Markdown summary report (in addition to $GITHUB_STEP_SUMMARY if set)", + ) + args = parser.parse_args() + + if args.build: + print(f"{BOLD}==> Building release app bundle (:app:bundleRelease)...{RESET}") + subprocess.run(["./gradlew", ":app:bundleRelease"], check=True) + + failures: list[str] = [] + summary_rows: list[tuple[str, str, str, str, str, bool]] = [] + cur_bundle: dict[str, Any] | None = None + + staged_dep_archives: list[Path] = [] + if args.check_staged: + staged_failures, staged_dep_archives = inspect_staged_changes() + failures.extend(staged_failures) + for dep_path in staged_dep_archives: + print( + f"{BOLD}==> Auditing newly staged dependency archive:{RESET} {dep_path.name}" + ) + + # 1. App Bundle (.aab) R8 Score & DEX Check + if not args.skip_app: + aab_path = args.aab + if aab_path is None: + if Path(DEFAULT_AAB).exists(): + aab_path = Path(DEFAULT_AAB) + elif Path(FALLBACK_AAB).exists(): + aab_path = Path(FALLBACK_AAB) + else: + print( + f"{RED}Error: No AAB found at {DEFAULT_AAB} or {FALLBACK_AAB}. " + f"Pass --aab , --build, or --skip-app.{RESET}", + file=sys.stderr, + ) + return 2 + + print(f"{BOLD}==> Running `ANDROID_CLI_ANALYZE=1 android analyze` on {aab_path}...{RESET}") + current_raw = run_android_analyze(aab_path) + + if args.save_baseline: + args.baseline.write_text(json.dumps(current_raw, indent=2) + "\n") + cur = extract_bundle_metrics(current_raw) + print( + f"{GREEN}✓ Saved R8 baseline to {args.baseline}{RESET} " + f"(Shrinking: {cur['shrinking_pct']:.2f}%, " + f"Optimization: {cur['optimization_pct']:.2f}%, " + f"Obfuscation: {cur['obfuscation_pct']:.2f}%, " + f"DEX: {format_mb(cur['dex_bytes'])})" + ) + return 0 + + if not args.baseline.exists(): + print( + f"{RED}Error: Baseline file {args.baseline} not found. " + f"Run with --save-baseline first.{RESET}", + file=sys.stderr, + ) + return 2 + + baseline_raw = json.loads(args.baseline.read_text()) + base = extract_bundle_metrics(baseline_raw) + cur_bundle = extract_bundle_metrics(current_raw) + + print() + print(f"{BOLD}R8 Optimization & DEX Regression Gate{RESET}") + print(f" Package : {cur_bundle['package_name']}") + print( + f" Compiler: AGP {cur_bundle['agp_version']} / R8 {cur_bundle['r8_version']} " + f"(Full Mode: {cur_bundle['full_mode']})" + ) + print(f" Baseline: {args.baseline}") + print() + + header = f"{'Metric':<24} {'Baseline':>12} {'Current':>12} {'Delta':>18} {'Threshold':>16} {'Status'}" + print(BOLD + header + RESET) + print("-" * len(header)) + + score_checks = [ + ("Shrinking Score", "shrinking_pct"), + ("Optimization Score", "optimization_pct"), + ("Obfuscation Score", "obfuscation_pct"), + ] + + for label, key in score_checks: + b_val = base[key] + c_val = cur_bundle[key] + delta = c_val - b_val + failed = delta < -args.max_score_drop + status = f"{RED}FAIL ❌{RESET}" if failed else f"{GREEN}PASS ✅{RESET}" + delta_str = f"{delta:+.2f}% pts" + thresh_str = f"max -{args.max_score_drop:.2f}% pts" + print(f"{label:<24} {b_val:>11.2f}% {c_val:>11.2f}% {delta_str:>18} {thresh_str:>16} {status}") + summary_rows.append((label, f"{b_val:.2f}%", f"{c_val:.2f}%", delta_str, thresh_str, failed)) + if failed: + failures.append( + f"{label} dropped by {abs(delta):.2f}% pts ({b_val:.2f}% -> {c_val:.2f}%, allowed drop: {args.max_score_drop:.2f}% pts)" + ) + + b_dex = base["dex_bytes"] + c_dex = cur_bundle["dex_bytes"] + dex_diff_bytes = c_dex - b_dex + dex_diff_kb = dex_diff_bytes / 1024.0 + dex_diff_pct = ((c_dex - b_dex) / b_dex * 100.0) if b_dex > 0 else 0.0 + + dex_failed = (dex_diff_pct > args.max_dex_growth_pct) or (dex_diff_kb > args.max_dex_growth_kb) + dex_status = f"{RED}FAIL ❌{RESET}" if dex_failed else f"{GREEN}PASS ✅{RESET}" + dex_delta_str = f"{dex_diff_bytes / (1024 * 1024):+.2f} MB ({dex_diff_pct:+.1f}%)" + dex_thresh_str = f"max +{args.max_dex_growth_pct:.1f}% / +{args.max_dex_growth_kb:.0f}KB" + print( + f"{'Uncompressed DEX Size':<24} {format_mb(b_dex):>12} {format_mb(c_dex):>12} " + f"{dex_delta_str:>18} {dex_thresh_str:>16} {dex_status}" + ) + summary_rows.append( + ("Uncompressed DEX Size", format_mb(b_dex), format_mb(c_dex), dex_delta_str, dex_thresh_str, dex_failed) + ) + if dex_failed: + failures.append( + f"Uncompressed DEX size grew by {dex_diff_kb:+.1f} KB / {dex_diff_pct:+.2f}% " + f"({format_mb(b_dex)} -> {format_mb(c_dex)})" + ) + + print("-" * len(header)) + + # 2. Library (.aar / .jar) Shrinking Percentage Check + lib_paths: list[Path] = list(args.library) + staged_dep_archives + if args.scan_libraries: + for p in discover_module_aars(Path(".")): + if p not in lib_paths: + lib_paths.append(p) + + lib_metrics_list: list[dict[str, Any]] = [] + if lib_paths: + print() + print( + f"{BOLD}Library Shrinking Percentage Gate " + f"(Min Shrinking: {args.min_lib_shrinking_pct:.1f}%, Max Keep Ratio: {100.0 - args.min_lib_shrinking_pct:.1f}%){RESET}" + ) + lib_header = ( + f"{'Library':<28} {'D8 DEX':>10} {'R8 Kept':>10} {'DEX Shrunk %':>13} " + f"{'Classes Shrunk':>18} {'Pkg-Wide Rules':>15} {'Status'}" + ) + print(BOLD + lib_header + RESET) + print("-" * len(lib_header)) + + for lib_path in lib_paths: + if not lib_path.exists(): + print(f"{RED}Error: Library file not found at {lib_path}{RESET}", file=sys.stderr) + return 2 + raw_lib = run_android_analyze(lib_path) + lm = extract_library_metrics(lib_path, raw_lib) + lib_metrics_list.append(lm) + + pkg_rule_count = len(lm["used_pkg_rules"]) + len(lm["unused_pkg_rules"]) + failed = ( + lm["dex_shrinking_pct"] < args.min_lib_shrinking_pct + or len(lm["blocked_rules"]) > 0 + or (args.fail_on_package_wide_rules and pkg_rule_count > 0) + ) + warn = not failed and pkg_rule_count > 0 + status = ( + f"{RED}FAIL ❌{RESET}" + if failed + else (f"{YELLOW}WARN ⚠️{RESET}" if warn else f"{GREEN}PASS ✅{RESET}") + ) + + cls_shrunk_count = lm["classes_total"] - lm["classes_kept"] + cls_str = f"{lm['classes_shrinking_pct']:5.1f}% ({cls_shrunk_count}/{lm['classes_total']})" + print( + f"{lm['name']:<28} {format_kb(lm['d8_bytes']):>10} {format_kb(lm['r8_bytes']):>10} " + f"{lm['dex_shrinking_pct']:>12.2f}% {cls_str:>18} {pkg_rule_count:>15} {status}" + ) + + if lm["dex_shrinking_pct"] < args.min_lib_shrinking_pct: + failures.append( + f"Library `{lm['name']}` DEX shrinking is {lm['dex_shrinking_pct']:.2f}% " + f"(Keep Ratio: {lm['keep_ratio_pct']:.2f}%, required shrinking >= {args.min_lib_shrinking_pct:.1f}%)" + ) + if lm["blocked_rules"]: + failures.append( + f"Library `{lm['name']}` contains {len(lm['blocked_rules'])} blocked consumer keep rule(s): " + f"{', '.join(lm['blocked_rules'][:3])}" + ) + if args.fail_on_package_wide_rules and pkg_rule_count > 0: + sample_rules = (lm["used_pkg_rules"] + lm["unused_pkg_rules"])[:3] + failures.append( + f"Library `{lm['name']}` exports {pkg_rule_count} package-wide consumer keep rule(s): " + f"{', '.join(sample_rules)}" + ) + + print("-" * len(lib_header)) + + md_text = build_markdown_summary( + cur_bundle, + summary_rows, + lib_metrics_list, + args.min_lib_shrinking_pct, + args.fail_on_package_wide_rules, + failures, + args.baseline, + ) + if args.markdown_summary: + args.markdown_summary.parent.mkdir(parents=True, exist_ok=True) + args.markdown_summary.write_text(md_text, encoding="utf-8") + + gh_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if gh_summary: + gh_summary_path = Path(gh_summary) + if not args.markdown_summary or gh_summary_path.resolve() != args.markdown_summary.resolve(): + with gh_summary_path.open("a", encoding="utf-8") as f: + f.write(md_text) + + if failures: + print(f"\n{RED}{BOLD}❌ R8 Presubmit Gate FAILED ({len(failures)} regression(s) detected):{RESET}") + for item in failures: + print(f" • {item}") + print( + f"\n{YELLOW}💡 Tip: Run `./gradlew :app:analyzeReleaseR8Config` to inspect the keep rules " + f"with the largest blast radius.{RESET}" + ) + return 1 + + print( + f"\n{GREEN}{BOLD}✅ R8 Presubmit Gate PASSED! All R8 scores, library shrinking ratios, " + f"and DEX sizes are within thresholds.{RESET}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_staged_r8_diff.py b/scripts/check_staged_r8_diff.py new file mode 100755 index 00000000..cd4f6cb7 --- /dev/null +++ b/scripts/check_staged_r8_diff.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Fast presubmit diff & newly added dependency auditor for `git commit` / CI.""" + +from __future__ import annotations + +import argparse +import glob +from pathlib import Path +import re +import subprocess +import sys + +RED = "\033[91m" +GREEN = "\033[92m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +def inspect_diff(diff_range: str | None) -> tuple[list[str], list[Path]]: + """ + Inspects `git diff --cached` (or `git diff `) for: + 1. Broad package-wide `-keep class .** { *; }` rules or `android.enableR8.fullMode=false` + 2. Newly added Maven dependencies (`group:artifact:version`) and resolves their `.aar`/`.jar` + from `~/.gradle/caches/modules-2/files-2.1`. + """ + diff_failures: list[str] = [] + dep_archives: list[Path] = [] + + base_cmd = ["git", "diff", "-U0"] + if diff_range: + base_cmd.append(diff_range) + else: + base_cmd.append("--cached") + + try: + pro_diff = subprocess.check_output( + [*base_cmd, "--", "*.pro", "gradle.properties"], + text=True, + stderr=subprocess.DEVNULL, + ) + except Exception: + pro_diff = "" + + current_file = "staged file" + for line in pro_diff.splitlines(): + if line.startswith("+++ b/"): + current_file = line[6:].strip() + elif line.startswith("+") and not line.startswith("+++"): + added = line[1:].strip() + if re.search(r"android\.enableR8\.fullMode\s*=\s*false", added): + diff_failures.append( + f"Change in `{current_file}` disables R8 Full Mode (`{added}`)" + ) + elif re.match( + r"^-keep\s+(class|interface)\s+[a-zA-Z0-9_.]+\.\*\*\s*\{\s*\*\s*;\s*\}", + added, + ): + diff_failures.append( + f"Rule in `{current_file}` introduces broad package-wide keep rule: `{added}`" + ) + + try: + gradle_diff = subprocess.check_output( + [*base_cmd, "--", "*.gradle.kts", "gradle/libs.versions.toml"], + text=True, + stderr=subprocess.DEVNULL, + ) + except Exception: + gradle_diff = "" + + coords: set[tuple[str, str, str]] = set() + for m in re.finditer( + r'^\+.*["\']([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)["\']', + gradle_diff, + re.M, + ): + coords.add((m.group(1), m.group(2), m.group(3))) + for m in re.finditer( + r'^\+.*group\s*=\s*"([^"]+)"\s*,\s*name\s*=\s*"([^"]+)"\s*,\s*version\s*=\s*"([^"]+)"', + gradle_diff, + re.M, + ): + coords.add((m.group(1), m.group(2), m.group(3))) + + cache_root = Path.home() / ".gradle" / "caches" / "modules-2" / "files-2.1" + if cache_root.exists(): + for group, name, version in sorted(coords): + pattern = str(cache_root / group / name / version / "*" / f"{name}-{version}.*") + for match in sorted(glob.glob(pattern)): + if match.endswith((".aar", ".jar")) and not match.endswith( + ("-sources.jar", "-javadoc.jar") + ): + dep_archives.append(Path(match)) + break + + return diff_failures, dep_archives + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--diff-range", + type=str, + default=None, + help="Optional git diff range (e.g. origin/main...HEAD). Defaults to --cached (staged changes).", + ) + parser.add_argument( + "--min-lib-shrinking-pct", + type=float, + default=90.0, + help="Minimum required library DEX shrinking percentage for newly added dependencies (default: 90.0)", + ) + args = parser.parse_args() + + diff_failures, dep_archives = inspect_diff(args.diff_range) + + lib_exit_code = 0 + if dep_archives: + for dep_path in dep_archives: + print(f"{BOLD}==> Auditing newly added dependency archive:{RESET} {dep_path.name}") + cmd = [ + sys.executable, + str(Path(__file__).parent / "check_library_shrinking.py"), + "--min-lib-shrinking-pct", + str(args.min_lib_shrinking_pct), + ] + for dep_path in dep_archives: + cmd.extend(["--library", str(dep_path)]) + lib_exit_code = subprocess.call(cmd) + + if diff_failures: + print(f"{RED}{BOLD}❌ Staged R8 Diff Check FAILED ({len(diff_failures)} issue(s)):{RESET}") + for f in diff_failures: + print(f" {RED}• {f}{RESET}") + print(f" 💡 Narrow the rule to specific classes/members or add allowshrinking,allowobfuscation.") + return 1 + + if lib_exit_code != 0: + return lib_exit_code + + print(f"{GREEN}{BOLD}✅ Staged R8 diff & dependency check passed.{RESET}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 00000000..616e8ac9 --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Copyright 2026 Google LLC +# +# Git pre-commit hook: runs the fast staged R8 diff & newly added dependency check +# (`check_staged_r8_diff.py`), plus `check_library_shrinking.py` on any modified module AAR. +# +# Install once per clone: +# git config core.hooksPath scripts/git-hooks + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "${REPO_ROOT}" + +CHANGED_R8_FILES="$(git diff --cached --name-only --diff-filter=ACMR -- \ + '*.pro' '*.gradle.kts' 'gradle/libs.versions.toml' 'gradle.properties' 'r8-baseline.json')" + +if [[ -z "${CHANGED_R8_FILES}" ]]; then + exit 0 +fi + +echo "==> [pre-commit] R8/build configuration changes detected:" +echo "${CHANGED_R8_FILES}" | sed 's/^/ • /' +echo "" + +# 1. If a library module's .pro file was modified, rebuild that module's release AAR (~2s) +AAR_TASKS=() +MODIFIED_AARS=() +while IFS= read -r file; do + if [[ "${file}" == *.pro && "${file}" != app/* ]]; then + MODULE_DIR="$(dirname "${file}")" + MODULE_NAME="$(basename "${MODULE_DIR}")" + GRADLE_MODULE=":${MODULE_DIR//\//:}" + AAR_TASKS+=("${GRADLE_MODULE}:bundleReleaseAar") + MODIFIED_AARS+=("${MODULE_DIR}/build/outputs/aar/${MODULE_NAME}-release.aar") + fi +done <<< "${CHANGED_R8_FILES}" + +if [[ ${#AAR_TASKS[@]} -gt 0 ]]; then + echo "==> [pre-commit] Rebuilding modified module AAR(s): ${AAR_TASKS[*]}..." + ./gradlew "${AAR_TASKS[@]}" --quiet +fi + +# 2. Run fast staged diff & newly added dependency auditor +DIFF_EXIT=0 +python3 ./scripts/check_staged_r8_diff.py || DIFF_EXIT=$? + +# 3. Audit modified module AAR(s) (or scan all module AARs if requested) +LIB_EXIT=0 +if [[ ${#MODIFIED_AARS[@]} -gt 0 ]]; then + LIB_ARGS=() + for aar in "${MODIFIED_AARS[@]}"; do + LIB_ARGS+=(--library "${aar}") + done + python3 ./scripts/check_library_shrinking.py "${LIB_ARGS[@]}" || LIB_EXIT=$? +fi + +if [[ "${DIFF_EXIT}" -ne 0 || "${LIB_EXIT}" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 00000000..7e54ad65 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Copyright 2026 Google LLC +# +# Git pre-push hook: runs `check_app_r8_score.py` and `check_library_shrinking.py` +# before pushing branches that touch ProGuard/R8 rules or Gradle build configs. + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "${REPO_ROOT}" + +BASE_REF="origin/main" +if ! git rev-parse --verify "${BASE_REF}" >/dev/null 2>&1; then + BASE_REF="HEAD~1" +fi + +CHANGED_R8_FILES="$(git diff --name-only "${BASE_REF}...HEAD" -- \ + '*.pro' '*.gradle.kts' 'gradle/libs.versions.toml' 'gradle.properties' 'r8-baseline.json' || true)" + +if [[ -z "${CHANGED_R8_FILES}" ]]; then + exit 0 +fi + +echo "==> [pre-push] R8/build changes detected relative to ${BASE_REF}:" +echo "${CHANGED_R8_FILES}" | sed 's/^/ • /' + +BUILD_FLAG=() +if [[ "${R8_GATE_BUILD:-0}" == "1" ]]; then + BUILD_FLAG=(--build) +fi + +APP_EXIT=0 +python3 ./scripts/check_app_r8_score.py "${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"}" || APP_EXIT=$? + +LIB_EXIT=0 +python3 ./scripts/check_library_shrinking.py --scan-libraries || LIB_EXIT=$? + +if [[ "${APP_EXIT}" -ne 0 || "${LIB_EXIT}" -ne 0 ]]; then + exit 1 +fi