From 577f604caf8a6f7e155fc5f2e0962eb3fb3a2905 Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Fri, 23 Oct 2026 21:45:57 +0530 Subject: [PATCH 1/9] Add R8 regression & library shrinking gate with android analyze --- .github/workflows/r8_regression_check.yml | 251 ++++++++++ .pre-commit-config.yaml | 32 ++ r8-baseline.json | 30 ++ scripts/check_r8_regression.py | 539 ++++++++++++++++++++++ scripts/git-hooks/pre-commit | 62 +++ scripts/git-hooks/pre-push | 34 ++ 6 files changed, 948 insertions(+) create mode 100644 .github/workflows/r8_regression_check.yml create mode 100644 .pre-commit-config.yaml create mode 100644 r8-baseline.json create mode 100755 scripts/check_r8_regression.py create mode 100755 scripts/git-hooks/pre-commit create mode 100755 scripts/git-hooks/pre-push diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml new file mode 100644 index 00000000..61480d07 --- /dev/null +++ b/.github/workflows/r8_regression_check.yml @@ -0,0 +1,251 @@ +# 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_r8_regression.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_r8_regression.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: Run R8 Score, Library Shrinking & DEX Regression Gate + id: r8_gate + env: + ANDROID_CLI_ANALYZE: '1' + run: | + set +e + python3 ./scripts/check_r8_regression.py \ + --aab app/build/outputs/bundle/release/app-release.aab \ + --baseline r8-baseline.json \ + --max-score-drop "${{ inputs.max_score_drop || '2.0' }}" \ + --max-dex-growth-pct "${{ inputs.max_dex_growth_pct || '5.0' }}" \ + --scan-libraries \ + --min-lib-shrinking-pct "${{ inputs.min_lib_shrinking_pct || '90.0' }}" \ + --fail-on-package-wide-rules \ + --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: Update R8 Baseline on Merge to main + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.r8_gate.outputs.passed == 'true' + env: + ANDROID_CLI_ANALYZE: '1' + run: | + python3 ./scripts/check_r8_regression.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.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.r8_gate.outputs.passed == 'false' + id: gemini_review + uses: google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489 # v0.1.22 + continue-on-error: true + 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.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.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..7b60a954 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +# 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-keep-rules-diff-check + name: R8 Keep Rules & FullMode Diff Check + entry: scripts/git-hooks/pre-commit + language: script + pass_filenames: false + files: '(\.pro$|gradle\.properties$|\.gradle\.kts$|libs\.versions\.toml$|r8-baseline\.json$)' + stages: [pre-commit] + + - id: r8-regression-gate + name: R8 Regression & Library Shrinking Gate (android analyze) + entry: python3 scripts/check_r8_regression.py --scan-libraries --fail-on-package-wide-rules + 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..0a6f28eb --- /dev/null +++ b/r8-baseline.json @@ -0,0 +1,30 @@ +{ + "optimization": { + "bundleAnalysisResult": { + "packageName": "com.android.developers.androidify", + "agpVersion": "9.3.2", + "r8Json": { + "version": "9.3.16", + "options": { + "isRepackageClassesEnabled": true, + "isProguardCompatibilityModeEnabled": false + }, + "stats": { + "shrinkingRatio": 0.7028, + "optimizationRatio": 0.7012, + "obfuscationRatio": 0.7027 + }, + "resourceOptimizationStatus": "R8_RESOURCE_OPTIMIZATION_STATUS_OPTIMIZED", + "r8DexChecksumStatus": "EXACT_MATCH" + }, + "r8DexMarkerMetadata": { + "r8Enabled": true, + "d8Enabled": false, + "fullModeEnabled": true + }, + "mappingObfuscationRatio": 0.7842, + "dexHeuristicObfuscationRatio": 0.7865, + "uncompressedDexSizeBytes": "15204352" + } + } +} diff --git a/scripts/check_r8_regression.py b/scripts/check_r8_regression.py new file mode 100755 index 00000000..1030ce12 --- /dev/null +++ b/scripts/check_r8_regression.py @@ -0,0 +1,539 @@ +#!/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 json +import os +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 extract_bundle_metrics(data: dict[str, Any]) -> dict[str, Any]: + """Extracts R8 ratios, DEX size, and config flags from `android analyze` bundle JSON.""" + bundle = data.get("optimization", {}).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 = data.get("optimization", {}).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 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( + "--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 + + # 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) + 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/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 00000000..1525f1d8 --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Copyright 2026 Google LLC +# +# Git pre-commit hook: runs R8 keep-rule lint + `android analyze` regression gate +# whenever ProGuard/R8 rules or Gradle build files are staged. +# +# 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 "" + +DIFF_FAILED=0 + +# 1. Fast diff check: flag disabling R8 Full Mode or adding unrestricted package-wide `-keep class pkg.** { *; }` +ADDED_PRO_LINES="$(git diff --cached -U0 -- '*.pro' 'gradle.properties' | grep -E '^\+[^+]' || true)" + +if echo "${ADDED_PRO_LINES}" | grep -Eq 'android\.enableR8\.fullMode[[:space:]]*=[[:space:]]*false'; then + echo "❌ [pre-commit] Staged diff disables R8 Full Mode (android.enableR8.fullMode=false)." + DIFF_FAILED=1 +fi + +BROAD_KEEP_RULES="$(echo "${ADDED_PRO_LINES}" | grep -E '^\+[[:space:]]*-keep[[:space:]]+(class|interface)[[:space:]]+[a-zA-Z0-9_.]+\.\*\*[[:space:]]*\{[[:space:]]*\*;[[:space:]]*\}' || true)" +if [[ -n "${BROAD_KEEP_RULES}" ]]; then + echo "❌ [pre-commit] Broad package-wide keep rule(s) introduced in staged changes:" + echo "${BROAD_KEEP_RULES}" | sed 's/^\+/ /' + echo "💡 Narrow the rule to specific classes/members or add allowshrinking,allowobfuscation." + echo "" + DIFF_FAILED=1 +fi + +# 2. Run `android analyze` R8 score & library shrinking gate +EXTRA_FLAGS=() +if [[ "${R8_GATE_BUILD:-0}" == "1" ]]; then + EXTRA_FLAGS+=(--build) +fi +if [[ -n "${R8_GATE_AAB:-}" ]]; then + EXTRA_FLAGS+=(--aab "${R8_GATE_AAB}") +fi + +GATE_FAILED=0 +python3 ./scripts/check_r8_regression.py \ + "${EXTRA_FLAGS[@]+"${EXTRA_FLAGS[@]}"}" \ + --scan-libraries \ + --fail-on-package-wide-rules || GATE_FAILED=$? + +if [[ "${DIFF_FAILED}" -ne 0 || "${GATE_FAILED}" -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..2c1d4d9d --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Copyright 2026 Google LLC +# +# Git pre-push hook: runs `check_r8_regression.py` before pushing commits that +# modify ProGuard/R8 rules or Gradle build configurations. +# +# 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 --name-only origin/main -- '*.pro' '*.gradle.kts' 'gradle/libs.versions.toml' 'gradle.properties' 'r8-baseline.json' 2>/dev/null || true + git ls-files --others --exclude-standard -- '*.pro' 'r8-baseline.json' 2>/dev/null || true +} | sort -u)" + +if [[ -z "${CHANGED_R8_FILES}" ]]; then + exit 0 +fi + +echo "==> [pre-push] R8/build changes detected relative to origin/main; running R8 Regression Gate..." + +BUILD_FLAG=() +if [[ "${R8_GATE_BUILD:-0}" == "1" ]]; then + BUILD_FLAG=(--build) +fi + +python3 ./scripts/check_r8_regression.py \ + "${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"}" \ + --scan-libraries \ + --fail-on-package-wide-rules From a87895b22d118e67326807b631638f44ecf6f7f9 Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Fri, 23 Oct 2026 22:24:48 +0530 Subject: [PATCH 2/9] Fix android analyze JSON results unwrapping, zizmor env inputs, and AGP 9.2.1 baseline --- .github/workflows/r8_regression_check.yml | 11 +++-- r8-baseline.json | 58 ++++++++++++----------- scripts/check_r8_regression.py | 14 +++++- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml index 61480d07..07df44d5 100644 --- a/.github/workflows/r8_regression_check.yml +++ b/.github/workflows/r8_regression_check.yml @@ -144,16 +144,18 @@ jobs: id: 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' }} + MIN_LIB_SHRINKING_PCT: ${{ inputs.min_lib_shrinking_pct || '90.0' }} run: | set +e python3 ./scripts/check_r8_regression.py \ --aab app/build/outputs/bundle/release/app-release.aab \ --baseline r8-baseline.json \ - --max-score-drop "${{ inputs.max_score_drop || '2.0' }}" \ - --max-dex-growth-pct "${{ inputs.max_dex_growth_pct || '5.0' }}" \ + --max-score-drop "${MAX_SCORE_DROP}" \ + --max-dex-growth-pct "${MAX_DEX_GROWTH_PCT}" \ --scan-libraries \ - --min-lib-shrinking-pct "${{ inputs.min_lib_shrinking_pct || '90.0' }}" \ - --fail-on-package-wide-rules \ + --min-lib-shrinking-pct "${MIN_LIB_SHRINKING_PCT}" \ --markdown-summary .gemini/r8_summary.md EXIT_CODE=$? set -e @@ -184,7 +186,6 @@ jobs: if: steps.r8_gate.outputs.passed == 'false' id: gemini_review uses: google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489 # v0.1.22 - continue-on-error: true env: GEMINI_CLI_TRUST_WORKSPACE: 'true' GITHUB_TOKEN: ${{ secrets.PAT || github.token }} diff --git a/r8-baseline.json b/r8-baseline.json index 0a6f28eb..8f56d9d6 100644 --- a/r8-baseline.json +++ b/r8-baseline.json @@ -1,30 +1,34 @@ { - "optimization": { - "bundleAnalysisResult": { - "packageName": "com.android.developers.androidify", - "agpVersion": "9.3.2", - "r8Json": { - "version": "9.3.16", - "options": { - "isRepackageClassesEnabled": true, - "isProguardCompatibilityModeEnabled": false - }, - "stats": { - "shrinkingRatio": 0.7028, - "optimizationRatio": 0.7012, - "obfuscationRatio": 0.7027 - }, - "resourceOptimizationStatus": "R8_RESOURCE_OPTIMIZATION_STATUS_OPTIMIZED", - "r8DexChecksumStatus": "EXACT_MATCH" - }, - "r8DexMarkerMetadata": { - "r8Enabled": true, - "d8Enabled": false, - "fullModeEnabled": true - }, - "mappingObfuscationRatio": 0.7842, - "dexHeuristicObfuscationRatio": 0.7865, - "uncompressedDexSizeBytes": "15204352" + "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_r8_regression.py b/scripts/check_r8_regression.py index 1030ce12..0141f0d2 100755 --- a/scripts/check_r8_regression.py +++ b/scripts/check_r8_regression.py @@ -64,9 +64,19 @@ def run_android_analyze(artifact_path: Path) -> dict[str, Any]: 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 = data.get("optimization", {}).get("bundleAnalysisResult", {}) + bundle = _unwrap_optimization(data).get("bundleAnalysisResult", {}) if not bundle: raise ValueError( "No `bundleAnalysisResult` found in output. Make sure you pass a release `.aab` file." @@ -95,7 +105,7 @@ def extract_bundle_metrics(data: dict[str, Any]) -> dict[str, Any]: 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 = data.get("optimization", {}).get("libraryAnalysisResult", {}) + 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`." From 583e3599dbd0ca2f869b0eaf26b6d274bc6924ec Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Mon, 26 Oct 2026 19:26:33 +0530 Subject: [PATCH 3/9] Pre-commit-hook --- scripts/check_r8_regression.py | 100 ++++++++++++++++++++++++++++++++- scripts/git-hooks/pre-commit | 40 +++++-------- 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/scripts/check_r8_regression.py b/scripts/check_r8_regression.py index 0141f0d2..5c7009ce 100755 --- a/scripts/check_r8_regression.py +++ b/scripts/check_r8_regression.py @@ -17,8 +17,10 @@ from __future__ import annotations import argparse +import glob import json import os +import re import subprocess import sys from pathlib import Path @@ -177,6 +179,88 @@ def discover_module_aars(root: Path) -> list[Path]: 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]], @@ -321,6 +405,11 @@ def main() -> int: 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, @@ -337,6 +426,15 @@ def main() -> int: 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 @@ -441,7 +539,7 @@ def main() -> int: print("-" * len(header)) # 2. Library (.aar / .jar) Shrinking Percentage Check - lib_paths: list[Path] = list(args.library) + 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: diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 1525f1d8..4d240875 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -23,26 +23,21 @@ echo "==> [pre-commit] R8/build configuration changes detected:" echo "${CHANGED_R8_FILES}" | sed 's/^/ • /' echo "" -DIFF_FAILED=0 - -# 1. Fast diff check: flag disabling R8 Full Mode or adding unrestricted package-wide `-keep class pkg.** { *; }` -ADDED_PRO_LINES="$(git diff --cached -U0 -- '*.pro' 'gradle.properties' | grep -E '^\+[^+]' || true)" - -if echo "${ADDED_PRO_LINES}" | grep -Eq 'android\.enableR8\.fullMode[[:space:]]*=[[:space:]]*false'; then - echo "❌ [pre-commit] Staged diff disables R8 Full Mode (android.enableR8.fullMode=false)." - DIFF_FAILED=1 -fi - -BROAD_KEEP_RULES="$(echo "${ADDED_PRO_LINES}" | grep -E '^\+[[:space:]]*-keep[[:space:]]+(class|interface)[[:space:]]+[a-zA-Z0-9_.]+\.\*\*[[:space:]]*\{[[:space:]]*\*;[[:space:]]*\}' || true)" -if [[ -n "${BROAD_KEEP_RULES}" ]]; then - echo "❌ [pre-commit] Broad package-wide keep rule(s) introduced in staged changes:" - echo "${BROAD_KEEP_RULES}" | sed 's/^\+/ /' - echo "💡 Narrow the rule to specific classes/members or add allowshrinking,allowobfuscation." - echo "" - DIFF_FAILED=1 +# If a library module's .pro file was modified, rebuild that module's release AAR (~2s) +AAR_TASKS=() +while IFS= read -r file; do + if [[ "${file}" == *.pro && "${file}" != app/* ]]; then + MODULE_DIR="$(dirname "${file}")" + GRADLE_MODULE=":${MODULE_DIR//\//:}" + AAR_TASKS+=("${GRADLE_MODULE}:bundleReleaseAar") + 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 `android analyze` R8 score & library shrinking gate EXTRA_FLAGS=() if [[ "${R8_GATE_BUILD:-0}" == "1" ]]; then EXTRA_FLAGS+=(--build) @@ -51,12 +46,7 @@ if [[ -n "${R8_GATE_AAB:-}" ]]; then EXTRA_FLAGS+=(--aab "${R8_GATE_AAB}") fi -GATE_FAILED=0 python3 ./scripts/check_r8_regression.py \ "${EXTRA_FLAGS[@]+"${EXTRA_FLAGS[@]}"}" \ - --scan-libraries \ - --fail-on-package-wide-rules || GATE_FAILED=$? - -if [[ "${DIFF_FAILED}" -ne 0 || "${GATE_FAILED}" -ne 0 ]]; then - exit 1 -fi + --check-staged \ + --scan-libraries From c5181a4a6bcb5e867d39845703f576b8fd9deb5e Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Tue, 27 Oct 2026 14:18:29 +0530 Subject: [PATCH 4/9] Split R8 regression gate into modular app, library, and staged-diff scripts --- .github/workflows/r8_regression_check.yml | 43 ++- .pre-commit-config.yaml | 24 +- scripts/check_app_r8_score.py | 322 ++++++++++++++++++++++ scripts/check_library_shrinking.py | 320 +++++++++++++++++++++ scripts/check_staged_r8_diff.py | 159 +++++++++++ scripts/git-hooks/pre-commit | 34 ++- scripts/git-hooks/pre-push | 34 ++- 7 files changed, 888 insertions(+), 48 deletions(-) create mode 100755 scripts/check_app_r8_score.py create mode 100755 scripts/check_library_shrinking.py create mode 100755 scripts/check_staged_r8_diff.py diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml index 07df44d5..b6c851b6 100644 --- a/.github/workflows/r8_regression_check.yml +++ b/.github/workflows/r8_regression_check.yml @@ -28,7 +28,7 @@ on: - '**/build.gradle.kts' - 'gradle/libs.versions.toml' - 'gradle.properties' - - 'scripts/check_r8_regression.py' + - 'scripts/check_*.py' pull_request: branches: [ '*' ] paths: @@ -39,7 +39,7 @@ on: - 'gradle/libs.versions.toml' - 'gradle.properties' - 'r8-baseline.json' - - 'scripts/check_r8_regression.py' + - 'scripts/check_*.py' - '.github/workflows/r8_regression_check.yml' workflow_dispatch: inputs: @@ -140,23 +140,40 @@ jobs: - name: Build Release App Bundle (.aab) & Module Libraries (.aar) run: ./gradlew :app:bundleRelease assembleRelease -x test -x uploadCrashlyticsMappingFileRelease --no-configuration-cache - - name: Run R8 Score, Library Shrinking & DEX Regression Gate - id: r8_gate + - 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' }} - MIN_LIB_SHRINKING_PCT: ${{ inputs.min_lib_shrinking_pct || '90.0' }} run: | set +e - python3 ./scripts/check_r8_regression.py \ + 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 + --markdown-summary .gemini/r8_summary.md \ + --append-summary EXIT_CODE=$? set -e if [[ ${EXIT_CODE} -eq 0 ]]; then @@ -166,24 +183,24 @@ jobs: fi - name: Update R8 Baseline on Merge to main - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.r8_gate.outputs.passed == 'true' + 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_r8_regression.py \ + 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.r8_gate.outputs.passed == 'true' + 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.r8_gate.outputs.passed == 'false' + 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: @@ -238,7 +255,7 @@ jobs: rm -f "${COMMENT_FILE}" - name: Upload R8 Configuration Analyzer HTML Report - if: steps.r8_gate.outputs.passed == 'false' + 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 @@ -246,7 +263,7 @@ jobs: if-no-files-found: ignore - name: Enforce R8 Regression Gate - if: steps.r8_gate.outputs.passed == 'false' + 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 index 7b60a954..612ac77d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,17 +15,25 @@ repos: - repo: local hooks: - - id: r8-keep-rules-diff-check - name: R8 Keep Rules & FullMode Diff Check - entry: scripts/git-hooks/pre-commit - language: script + - 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$|r8-baseline\.json$)' + files: '(\.pro$|gradle\.properties$|\.gradle\.kts$|libs\.versions\.toml$)' stages: [pre-commit] - - id: r8-regression-gate - name: R8 Regression & Library Shrinking Gate (android analyze) - entry: python3 scripts/check_r8_regression.py --scan-libraries --fail-on-package-wide-rules + - 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$)' 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_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 index 4d240875..616e8ac9 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Copyright 2026 Google LLC # -# Git pre-commit hook: runs R8 keep-rule lint + `android analyze` regression gate -# whenever ProGuard/R8 rules or Gradle build files are staged. +# 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 @@ -23,13 +23,16 @@ echo "==> [pre-commit] R8/build configuration changes detected:" echo "${CHANGED_R8_FILES}" | sed 's/^/ • /' echo "" -# If a library module's .pro file was modified, rebuild that module's release AAR (~2s) +# 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}" @@ -38,15 +41,20 @@ if [[ ${#AAR_TASKS[@]} -gt 0 ]]; then ./gradlew "${AAR_TASKS[@]}" --quiet fi -EXTRA_FLAGS=() -if [[ "${R8_GATE_BUILD:-0}" == "1" ]]; then - EXTRA_FLAGS+=(--build) -fi -if [[ -n "${R8_GATE_AAB:-}" ]]; then - EXTRA_FLAGS+=(--aab "${R8_GATE_AAB}") +# 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 -python3 ./scripts/check_r8_regression.py \ - "${EXTRA_FLAGS[@]+"${EXTRA_FLAGS[@]}"}" \ - --check-staged \ - --scan-libraries +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 index 2c1d4d9d..7e54ad65 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -1,34 +1,40 @@ #!/usr/bin/env bash # Copyright 2026 Google LLC # -# Git pre-push hook: runs `check_r8_regression.py` before pushing commits that -# modify ProGuard/R8 rules or Gradle build configurations. -# -# Install once per clone: -# git config core.hooksPath scripts/git-hooks +# 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}" -CHANGED_R8_FILES="$({ - git diff --name-only origin/main -- '*.pro' '*.gradle.kts' 'gradle/libs.versions.toml' 'gradle.properties' 'r8-baseline.json' 2>/dev/null || true - git ls-files --others --exclude-standard -- '*.pro' 'r8-baseline.json' 2>/dev/null || true -} | sort -u)" +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 origin/main; running R8 Regression Gate..." +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 -python3 ./scripts/check_r8_regression.py \ - "${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"}" \ - --scan-libraries \ - --fail-on-package-wide-rules +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 From be01dc1f5eda0db9c1cefc82b10a2f7f40ae397f Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Thu, 29 Oct 2026 14:06:25 +0530 Subject: [PATCH 5/9] Address review feedback on the R8 regression gate - check_library_shrinking.py: read the keys `android analyze` actually outputs (keptItemCount, configuration.used/unusedPackageWideKeepRules, blockedKeepRules[].source) and count rules with len(). Package-wide consumer rules were always reported as 0 before (watchface has 11). Drop the redundant keep ratio and rename format_kb to format_size. - check_app_r8_score.py: single default AAB path, drop --build and the old JSON shape, read the package name from --analysis=basic. - Remove ANDROID_CLI_ANALYZE (no longer needed) and --no-configuration-cache from the scripts and workflow. - Always run the git hooks, the pre-commit config hooks and the CI workflow instead of filtering by changed files. - Post one PR comment and edit it on later runs. - Remove check_r8_regression.py, superseded by the split scripts. --- .github/workflows/r8_regression_check.yml | 49 +- .pre-commit-config.yaml | 10 +- scripts/check_app_r8_score.py | 62 +-- scripts/check_library_shrinking.py | 59 +- scripts/check_r8_regression.py | 647 ---------------------- scripts/git-hooks/pre-commit | 43 +- scripts/git-hooks/pre-push | 27 +- 7 files changed, 75 insertions(+), 822 deletions(-) delete mode 100755 scripts/check_r8_regression.py diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml index b6c851b6..1fccdf17 100644 --- a/.github/workflows/r8_regression_check.yml +++ b/.github/workflows/r8_regression_check.yml @@ -21,26 +21,8 @@ permissions: 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: @@ -138,12 +120,11 @@ jobs: fi - name: Build Release App Bundle (.aab) & Module Libraries (.aar) - run: ./gradlew :app:bundleRelease assembleRelease -x test -x uploadCrashlyticsMappingFileRelease --no-configuration-cache + run: ./gradlew :app:bundleRelease assembleRelease -x test -x uploadCrashlyticsMappingFileRelease - 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: | @@ -165,7 +146,6 @@ jobs: - 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 @@ -184,8 +164,6 @@ jobs: - 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 \ @@ -234,6 +212,7 @@ jobs: if: steps.context.outputs.pr_number != '' && always() env: GH_TOKEN: ${{ secrets.PAT || github.token }} + HAS_PAT: ${{ secrets.PAT != '' }} PR_NUMBER: ${{ steps.context.outputs.pr_number }} REVIEW_SUMMARY: ${{ steps.gemini_review.outputs.summary }} run: | @@ -241,17 +220,31 @@ jobs: if [[ ! -f .gemini/r8_summary.md ]]; then exit 0 fi + MARKER='' COMMENT_FILE="$(mktemp)" - cat .gemini/r8_summary.md > "${COMMENT_FILE}" - if [[ -n "${REVIEW_SUMMARY}" ]]; then - { + { + echo "${MARKER}" + cat .gemini/r8_summary.md + if [[ -n "${REVIEW_SUMMARY}" ]]; then echo "" echo "---" echo "" printf '%s\n' "${REVIEW_SUMMARY}" - } >> "${COMMENT_FILE}" + fi + } > "${COMMENT_FILE}" + # Keep one comment per PR: edit this workflow's earlier comment instead of adding a new one. + AUTHOR='github-actions[bot]' + if [[ "${HAS_PAT}" == "true" ]]; then + AUTHOR="$(gh api user --jq .login)" + fi + COMMENTS="repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" + COMMENT_ID="$(gh api "${COMMENTS}" --paginate \ + --jq ".[] | select(.user.login == \"${AUTHOR}\" and (.body | startswith(\"${MARKER}\"))) | .id" | sed -n '1p')" + if [[ -n "${COMMENT_ID}" ]]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" -F body=@"${COMMENT_FILE}" > /dev/null + else + gh api --method POST "${COMMENTS}" -F body=@"${COMMENT_FILE}" > /dev/null fi - gh pr comment "${PR_NUMBER}" --body-file "${COMMENT_FILE}" rm -f "${COMMENT_FILE}" - name: Upload R8 Configuration Analyzer HTML Report diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 612ac77d..d211a5b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,21 +20,21 @@ repos: entry: python3 scripts/check_staged_r8_diff.py language: system pass_filenames: false - files: '(\.pro$|gradle\.properties$|\.gradle\.kts$|libs\.versions\.toml$)' + always_run: true 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 + entry: bash -c './gradlew bundleReleaseAar --quiet && python3 scripts/check_library_shrinking.py --scan-libraries' language: system pass_filenames: false - files: '(\.pro$|\.gradle\.kts$|libs\.versions\.toml$)' + always_run: true 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 + entry: bash -c './gradlew :app:bundleRelease -x uploadCrashlyticsMappingFileRelease --quiet && python3 scripts/check_app_r8_score.py' language: system pass_filenames: false - files: '(\.pro$|gradle\.properties$|\.gradle\.kts$|libs\.versions\.toml$|r8-baseline\.json$)' + always_run: true stages: [pre-push] diff --git a/scripts/check_app_r8_score.py b/scripts/check_app_r8_score.py index e5373410..bf2ec170 100755 --- a/scripts/check_app_r8_score.py +++ b/scripts/check_app_r8_score.py @@ -25,7 +25,6 @@ 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" @@ -36,18 +35,15 @@ 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" - + """Invokes `android analyze --analysis=basic,optimization --format=json` on an AAB.""" cmd = [ "android", "analyze", - "--analysis=optimization", + "--analysis=basic,optimization", "--format=json", str(artifact_path), ] - proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + proc = subprocess.run(cmd, 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) @@ -63,19 +59,10 @@ def run_android_analyze(artifact_path: Path) -> dict[str, Any]: 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", {}) + result = data["results"][0] + bundle = result.get("optimization", {}).get("bundleAnalysisResult", {}) if not bundle: raise ValueError( "No `bundleAnalysisResult` found in output. Make sure you pass a release `.aab` file." @@ -87,7 +74,7 @@ def extract_bundle_metrics(data: dict[str, Any]) -> dict[str, Any]: marker = bundle.get("r8DexMarkerMetadata", {}) return { - "package_name": bundle.get("packageName", "N/A"), + "package_name": result.get("basic", {}).get("appInfo", {}).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), @@ -142,8 +129,8 @@ def main() -> int: parser.add_argument( "--aab", type=Path, - default=None, - help=f"Path to release AAB (defaults to {DEFAULT_AAB} or {FALLBACK_AAB})", + default=Path(DEFAULT_AAB), + help=f"Path to release AAB (default: {DEFAULT_AAB})", ) parser.add_argument( "--baseline", @@ -156,11 +143,6 @@ def main() -> int: 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, @@ -187,26 +169,16 @@ def main() -> int: ) 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 + if not args.aab.exists(): + print( + f"{RED}Error: No AAB found at {args.aab}. " + f"Run `./gradlew :app:bundleRelease` or pass --aab .{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) + print(f"{BOLD}==> Running `android analyze` on {args.aab}...{RESET}") + current_raw = run_android_analyze(args.aab) if args.save_baseline: args.baseline.write_text(json.dumps(current_raw, indent=2) + "\n") diff --git a/scripts/check_library_shrinking.py b/scripts/check_library_shrinking.py index bbfd7af9..e10a1398 100755 --- a/scripts/check_library_shrinking.py +++ b/scripts/check_library_shrinking.py @@ -33,9 +33,6 @@ 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", @@ -43,7 +40,7 @@ def run_android_analyze(artifact_path: Path) -> dict[str, Any]: "--format=json", str(artifact_path), ] - proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + proc = subprocess.run(cmd, 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) @@ -59,19 +56,9 @@ def run_android_analyze(artifact_path: Path) -> dict[str, Any]: 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", {}) + lib = data["results"][0].get("optimization", {}).get("libraryAnalysisResult", {}) if not lib: raise ValueError( f"No `libraryAnalysisResult` found for {artifact_path}. Make sure it is an `.aar` or `.jar`." @@ -82,13 +69,12 @@ def extract_library_metrics(artifact_path: Path, data: dict[str, Any]) -> dict[s 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 + dex_shrinking_pct = (1 - r8_bytes / d8_bytes) * 100.0 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)) + kept = int(sec.get("keptItemCount", 0)) shrunk_pct = ((total - kept) / total * 100.0) if total > 0 else 100.0 return total, kept, shrunk_pct @@ -96,14 +82,13 @@ def _item_stats(section_name: str) -> tuple[int, int, float]: 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 = r8_compile.get("configuration", {}) + used_pkg_wide = config.get("usedPackageWideKeepRules", []) + unused_pkg_wide = config.get("unusedPackageWideKeepRules", []) + pkg_wide_rules = [r.get("source", "") for r in used_pkg_wide + unused_pkg_wide] - 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()] + validate = dex.get("validateConsumerKeepRulesResult", {}) + blocked_rules = [r.get("source", "") for r in validate.get("blockedKeepRules", [])] return { "name": artifact_path.name, @@ -111,7 +96,6 @@ def _item_stats(section_name: str) -> tuple[int, int, float]: "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, @@ -122,15 +106,15 @@ def _item_stats(section_name: str) -> tuple[int, int, float]: "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, + "used_pkg_wide": len(used_pkg_wide), + "unused_pkg_wide": len(unused_pkg_wide), + "total_pkg_wide": len(pkg_wide_rules), + "pkg_wide_rules": pkg_wide_rules, "blocked_rules": blocked_rules, } -def format_kb(num_bytes: int) -> str: +def format_size(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" @@ -164,7 +148,7 @@ def build_markdown_summary( 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['name']}` | {format_size(lm['d8_bytes'])} | {format_size(lm['r8_bytes'])} | " f"**{lm['dex_shrinking_pct']:.2f}%** | {cls_str} | {mth_str} | " f"{lm['total_pkg_wide']} | {icon} |" ) @@ -197,7 +181,7 @@ def main() -> int: "--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)", + help="Minimum required library DEX shrinking percentage, (1 - R8 DEX size / D8 DEX size) x 100, for .aar/.jar files (default: 90.0)", ) parser.add_argument( "--fail-on-package-wide-rules", @@ -233,8 +217,7 @@ def main() -> int: 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}" + f"(Min Shrinking: {args.min_lib_shrinking_pct:.1f}%){RESET}" ) lib_header = ( f"{'Library':<28} {'D8 DEX':>10} {'R8 Kept':>10} " @@ -269,7 +252,7 @@ def main() -> int: 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['name']:<28} {format_size(lm['d8_bytes']):>10} {format_size(lm['r8_bytes']):>10} " f"{lm['dex_shrinking_pct']:>12.2f}% {cls_shrunk_str:>18} " f"{lm['total_pkg_wide']:>15} {status}" ) @@ -277,10 +260,10 @@ def main() -> int: 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}%)" + f"(required >= {args.min_lib_shrinking_pct:.1f}%)" ) if pkg_rule_failed: - rules_preview = ", ".join(lm["keep_rules"][:3]) + rules_preview = ", ".join(lm["pkg_wide_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 "") diff --git a/scripts/check_r8_regression.py b/scripts/check_r8_regression.py deleted file mode 100755 index 5c7009ce..00000000 --- a/scripts/check_r8_regression.py +++ /dev/null @@ -1,647 +0,0 @@ -#!/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/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 616e8ac9..e55f167b 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -2,7 +2,7 @@ # 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. +# (`check_staged_r8_diff.py`), plus `check_library_shrinking.py` on every module AAR. # # Install once per clone: # git config core.hooksPath scripts/git-hooks @@ -12,48 +12,17 @@ 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 +# 1. Rebuild all module release AARs (incremental, so unchanged modules stay UP-TO-DATE) +echo "==> [pre-commit] Rebuilding module release AARs..." +./gradlew bundleReleaseAar --quiet # 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) +# 3. Audit all module AARs 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 +python3 ./scripts/check_library_shrinking.py --scan-libraries || LIB_EXIT=$? if [[ "${DIFF_EXIT}" -ne 0 || "${LIB_EXIT}" -ne 0 ]]; then exit 1 diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index 7e54ad65..faf28b9a 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -1,36 +1,19 @@ #!/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. +# Git pre-push hook: rebuilds the release app bundle and module AARs, then runs +# `check_app_r8_score.py` and `check_library_shrinking.py` before every push. 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 +echo "==> [pre-push] Building release app bundle & module AARs..." +./gradlew :app:bundleRelease bundleReleaseAar -x uploadCrashlyticsMappingFileRelease APP_EXIT=0 -python3 ./scripts/check_app_r8_score.py "${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"}" || APP_EXIT=$? +python3 ./scripts/check_app_r8_score.py || APP_EXIT=$? LIB_EXIT=0 python3 ./scripts/check_library_shrinking.py --scan-libraries || LIB_EXIT=$? From af13a4b3b173ca868ce429a74eeec3020e139ef2 Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Thu, 29 Oct 2026 17:00:26 +0530 Subject: [PATCH 6/9] Add an R8 keep rule diff to the R8 regression gate On every PR, post a GitHub-style diff of the keep rules that changed compared with main, and the classes they keep. It covers rules that come with library upgrades and existing wildcard rules that start keeping new classes, neither of which shows up in the PR's own diff. It doesn't build anything. With AGP 9.3.2, every release build writes the keep rule .pb file (app/build/outputs/mapping/release/configanalyzer.pb) by default, so the gate uploads the file from the build it already runs. - keep_rule_diff.yml: reusable workflow, given the .pb file's path. On pushes to main it caches the file per commit; on PRs it diffs against main's cached file, writes the job summary and keeps one PR comment up to date. - r8_regression_check.yml: upload the .pb file after the release build and call keep_rule_diff.yml in a second job, which also runs when the gate fails. Optionally skip the diff when the R8 scores didn't change (KEEP_RULE_DIFF_ONLY_IF_SCORES_CHANGED). - scripts/keep_rule_diff.py and scripts/schema_pb2.py: compare two keep rule .pb files and write the Markdown report. Needs AGP 9.3 or newer to write the .pb file; until then the diff is skipped. --- .github/workflows/keep_rule_diff.yml | 181 +++++++++++++++++ .github/workflows/r8_regression_check.yml | 44 ++++ scripts/keep_rule_diff.py | 233 ++++++++++++++++++++++ scripts/schema_pb2.py | 75 +++++++ 4 files changed, 533 insertions(+) create mode 100644 .github/workflows/keep_rule_diff.yml create mode 100755 scripts/keep_rule_diff.py create mode 100644 scripts/schema_pb2.py diff --git a/.github/workflows/keep_rule_diff.yml b/.github/workflows/keep_rule_diff.yml new file mode 100644 index 00000000..128fc759 --- /dev/null +++ b/.github/workflows/keep_rule_diff.yml @@ -0,0 +1,181 @@ +# 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. +# +# Shows which R8 keep rules a PR adds, removes or changes compared with main, like a GitHub diff. +# It doesn't build anything: a workflow that already runs a release build calls it, and hands over +# the keep rule .pb file that build wrote. See scripts/KEEP_RULE_DIFF.md. +name: R8 Keep Rule Diff + +on: + workflow_call: + inputs: + path: + description: >- + Path of the keep rule .pb file in the calling job, for example + app/build/outputs/mapping/release/configanalyzer.pb. The calling job uploads that file + as an artifact named r8-config-analyzer. + required: true + type: string + skip-reason: + description: >- + If set on a pull request, no diff is made, and the report says it was skipped for this + reason. main's .pb file is still saved on pushes. + required: false + type: string + default: '' + +jobs: + keep-rule-diff: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write # create and update the report comment + + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download the Keep Rule .pb File + id: download + # A missing .pb file (for example when the build failed) only skips the diff. + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: r8-config-analyzer + path: ${{ runner.temp }}/r8-config-analyzer + + - name: Check the Keep Rule .pb File + id: pb + if: steps.download.outcome == 'success' + env: + INPUT_PATH: ${{ inputs.path }} + run: | + # The artifact holds the file under its own name, without the calling job's folders. + PB_FILE="${RUNNER_TEMP}/r8-config-analyzer/$(basename "${INPUT_PATH}")" + if [[ -f "${PB_FILE}" ]]; then + echo "file=${PB_FILE}" >> "${GITHUB_OUTPUT}" + else + echo "::warning::The r8-config-analyzer artifact has no $(basename "${INPUT_PATH}")." + fi + + - name: Find the Cache Key for main's Keep Rules + id: key + if: steps.pb.outputs.file != '' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + # main's keep rules are cached per commit: this commit on main, or the commit the PR is + # based on. Commits that only update r8-baseline.json (the R8 Regression Gate's bot commit) + # don't change the build, so they are skipped. + SHA="$(git log -1 --first-parent --format=%H "${BASE_SHA:-HEAD}" -- . ':(exclude)r8-baseline.json')" + echo "rules_key=r8-rules-${SHA}" >> "${GITHUB_OUTPUT}" + + - name: Collect main's Keep Rules + if: steps.pb.outputs.file != '' && github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + env: + PB_FILE: ${{ steps.pb.outputs.file }} + run: | + mkdir -p r8-base + cp "${PB_FILE}" r8-base/rules.pb + + - name: Save main's Keep Rules + if: steps.pb.outputs.file != '' && github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: r8-base + key: ${{ steps.key.outputs.rules_key }} + + - name: Write the Skipped Report + id: skipped + if: github.event_name == 'pull_request' && inputs.skip-reason != '' + env: + REASON: ${{ inputs.skip-reason }} + run: | + printf '%s\n' '' '## 🔍 R8 keep rule diff' '' "⏭️ Skipped: ${REASON}." \ + > "${RUNNER_TEMP}/keep_rule_diff.md" + cat "${RUNNER_TEMP}/keep_rule_diff.md" >> "${GITHUB_STEP_SUMMARY}" + echo "report=${RUNNER_TEMP}/keep_rule_diff.md" >> "${GITHUB_OUTPUT}" + + - name: Restore main's Keep Rules + id: base + if: steps.pb.outputs.file != '' && github.event_name == 'pull_request' && inputs.skip-reason == '' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: r8-base + key: ${{ steps.key.outputs.rules_key }} + restore-keys: r8-rules- + + - name: Set up Python + if: steps.pb.outputs.file != '' && github.event_name == 'pull_request' && inputs.skip-reason == '' + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + + - name: Diff Keep Rules Against main + id: diff + if: steps.pb.outputs.file != '' && github.event_name == 'pull_request' && inputs.skip-reason == '' + env: + PB_FILE: ${{ steps.pb.outputs.file }} + CACHE_HIT: ${{ steps.base.outputs.cache-hit }} + MATCHED_KEY: ${{ steps.base.outputs.cache-matched-key }} + run: | + set -euo pipefail + if [[ ! -f r8-base/rules.pb ]]; then + echo "No keep rules from main in the cache yet, so there is nothing to compare against." | tee -a "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + pip install --quiet 'protobuf>=6.33.4' + python3 ./scripts/keep_rule_diff.py r8-base/rules.pb "${PB_FILE}" -o "${RUNNER_TEMP}/report.md" + { + head -n 2 "${RUNNER_TEMP}/report.md" + if [[ "${CACHE_HIT}" != "true" ]]; then + echo "" + echo "> ⚠️ Compared against an older main build (\`${MATCHED_KEY#r8-rules-}\`), so some of these changes may come from main rather than this PR." + fi + tail -n +3 "${RUNNER_TEMP}/report.md" + } > "${RUNNER_TEMP}/keep_rule_diff.md" + cat "${RUNNER_TEMP}/keep_rule_diff.md" >> "${GITHUB_STEP_SUMMARY}" + echo "report=${RUNNER_TEMP}/keep_rule_diff.md" >> "${GITHUB_OUTPUT}" + # The report ends with "✅ No keep rules were added..." when nothing changed. + if grep -q '^✅ No keep rules' "${RUNNER_TEMP}/keep_rule_diff.md"; then + echo "changed=false" >> "${GITHUB_OUTPUT}" + else + echo "changed=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Post or Update the PR Comment + if: github.event_name == 'pull_request' && (steps.diff.outputs.report != '' || steps.skipped.outputs.report != '') + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPORT: ${{ steps.diff.outputs.report || steps.skipped.outputs.report }} + CHANGED: ${{ steps.diff.outputs.changed || 'false' }} + run: | + set -euo pipefail + # One comment per PR, found by the marker the report starts with. It is only created when a + # keep rule changed; after that it is kept up to date, including "no changes" and "skipped". + MARKER='' + COMMENTS="repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" + COMMENT_ID="$(gh api "${COMMENTS}" --paginate \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"${MARKER}\"))) | .id" | sed -n '1p')" + if [[ -n "${COMMENT_ID}" ]]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" -F body=@"${REPORT}" > /dev/null + elif [[ "${CHANGED}" == "true" ]]; then + gh api --method POST "${COMMENTS}" -F body=@"${REPORT}" > /dev/null + fi diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml index 1fccdf17..21c66702 100644 --- a/.github/workflows/r8_regression_check.yml +++ b/.github/workflows/r8_regression_check.yml @@ -49,11 +49,18 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Optional: set to 'true' to skip the keep rule diff on PRs whose R8 scores (shrinking, + # optimization, obfuscation) are the same as main's in r8-baseline.json. + KEEP_RULE_DIFF_ONLY_IF_SCORES_CHANGED: 'false' + 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) + outputs: + keep_rule_skip_reason: ${{ steps.r8_scores.outputs.skip_reason }} steps: - name: Checkout Repository @@ -120,8 +127,19 @@ jobs: fi - name: Build Release App Bundle (.aab) & Module Libraries (.aar) + # With AGP 9.3.2, the release R8 run also writes the keep rule .pb file by default, to + # app/build/outputs/mapping/release/configanalyzer.pb. The keep-rule-diff job below reads it. run: ./gradlew :app:bundleRelease assembleRelease -x test -x uploadCrashlyticsMappingFileRelease + - name: Upload Keep Rule .pb File + # Jobs run on separate machines, so the keep-rule-diff job gets the .pb file as an artifact. + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: r8-config-analyzer + path: app/build/outputs/mapping/release/configanalyzer.pb + if-no-files-found: warn + retention-days: 1 + - name: Check App Bundle R8 Scores & DEX Size id: app_r8_gate env: @@ -162,6 +180,19 @@ jobs: echo "passed=false" >> "${GITHUB_OUTPUT}" fi + - name: Check Whether the R8 Scores Changed (optional) + id: r8_scores + if: github.event_name == 'pull_request' && env.KEEP_RULE_DIFF_ONLY_IF_SCORES_CHANGED == 'true' + run: | + set -euo pipefail + android analyze --analysis=optimization --format=json app/build/outputs/bundle/release/app-release.aab \ + | sed -n '/^{/,$p' > "${RUNNER_TEMP}/scores.json" + STATS='.results[0].optimization.bundleAnalysisResult.r8Json.stats' + MAIN="$(jq -cS "${STATS}" r8-baseline.json)" + if [[ "${MAIN}" == "$(jq -cS "${STATS}" "${RUNNER_TEMP}/scores.json")" && "${MAIN}" != "null" ]]; then + echo "skip_reason=this PR's R8 scores are the same as main's (r8-baseline.json)" >> "${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' run: | @@ -260,3 +291,16 @@ jobs: run: | echo "::error::R8 Regression Gate failed. See step summary or PR comment for details." exit 1 + + keep-rule-diff: + # Diffs the keep rules against main, using the .pb file the release build above wrote, without + # building again. It also runs when the gate finds a regression, when the diff helps most. + needs: r8-regression-gate + if: ${{ !cancelled() && needs.r8-regression-gate.result != 'skipped' }} + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/keep_rule_diff.yml + with: + path: app/build/outputs/mapping/release/configanalyzer.pb + skip-reason: ${{ needs.r8-regression-gate.outputs.keep_rule_skip_reason }} diff --git a/scripts/keep_rule_diff.py b/scripts/keep_rule_diff.py new file mode 100755 index 00000000..b89e023f --- /dev/null +++ b/scripts/keep_rule_diff.py @@ -0,0 +1,233 @@ +#!/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. +"""GitHub-style diff of the keep rules a PR adds, removes or changes, and the classes they keep. + +Compares two keep rule .pb files written by R8's configuration analyzer: one from the base branch, +one from the PR. With AGP 9.3.2, every release build writes one by default, to +app/build/outputs/mapping/release/configanalyzer.pb. `./gradlew :app:analyzeReleaseR8Config` writes +the same file to app/build/reports/r8/r8-config-analyzer-release.pb. + + python3 scripts/keep_rule_diff.py base.pb pr.pb -o keep_rule_diff.md + +Reading .pb files needs `pip install 'protobuf>=6.33.4'` and schema_pb2.py next to this script. +See KEEP_RULE_DIFF.md for what the report shows and how CI runs it. +""" +import argparse +import json +import os +import re +import sys + +MARKER = "" # lets CI find and update its earlier comment +SCHEMA_DIR = os.path.dirname(os.path.abspath(__file__)) +PRIMITIVES = {"V": "void", "Z": "boolean", "B": "byte", "C": "char", "S": "short", + "I": "int", "J": "long", "F": "float", "D": "double"} +NO_CHANGES = "✅ No keep rules were added, removed or changed, and they keep the same classes." + + +# ---------- Reading the .pb files ---------- + +def read_pb(path): + """Returns the .pb file (or convert.py's JSON) as a dict keyed by proto field names.""" + if not path.endswith(".pb"): + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError(f"{path}: expected the JSON object that convert.py writes") + return snake_keys(data) + if SCHEMA_DIR not in sys.path: + sys.path.insert(0, SCHEMA_DIR) + try: + import schema_pb2 + from google.protobuf.json_format import MessageToDict + from google.protobuf.message import DecodeError + except Exception as e: # protobuf missing, or older than the one schema_pb2.py was generated with + raise ImportError(f"reading .pb files needs protobuf>=6.33.4 (pip install 'protobuf>=6.33.4'): {e}") from e + container = schema_pb2.BlastRadiusContainer() + with open(path, "rb") as f: + try: + container.ParseFromString(f.read()) + except DecodeError as e: + raise ValueError(f"{path}: not an R8 blast radius file ({e})") from e + return MessageToDict(container, preserving_proto_field_name=True) + + +def snake_keys(obj): + """Also accepts protobuf's default camelCase JSON (keepRuleBlastRadiusTable, ...).""" + if isinstance(obj, dict): + return {re.sub("([A-Z])", r"_\1", k).lower(): snake_keys(v) for k, v in obj.items()} + if isinstance(obj, list): + return [snake_keys(v) for v in obj] + return obj + + +def java_type(desc): + """JVM descriptor to Java name: 'I' -> 'int', '[Ljava/lang/String;' -> 'java.lang.String[]'.""" + base = desc.lstrip("[") + dims = len(desc) - len(base) + if base.startswith("L") and base.endswith(";"): + base = base[1:-1].replace("/", ".") + else: + base = PRIMITIVES.get(base, base) + return base + "[]" * dims + + +def short_path(path): + """Drops machine-specific prefixes so the same rule reads the same on every machine.""" + path = path.replace("\\", "/") + if "/transformed/" in path: # Gradle transform cache: keep '/proguard.txt' + return path.split("/transformed/", 1)[1] + cwd = os.getcwd().replace("\\", "/").rstrip("/") + "/" + return path[len(cwd):] if path.startswith(cwd) else path + + +def load(path): + """Indexes one .pb file: keep rules by their text, each with where it comes from and the classes it keeps.""" + d = read_pb(path) + + def table(name): # proto3 leaves out zero values, so a missing id is 0 + return {row.get("id", 0): row for row in d.get(name, [])} + + types = {i: java_type(t.get("java_descriptor", "")) for i, t in table("type_reference_table").items()} + files = table("file_origin_table") + field_refs, method_refs = table("field_reference_table"), table("method_reference_table") + + def origin(o): + """(source, file, line): the rule's file, or the library it ships in, for grouping and sorting.""" + f = files.get((o or {}).get("file_origin_id", 0), {}) + name = short_path(f.get("filename", "unknown file")).split(" (extracted file:")[0] + maven = f.get("maven_coordinate") + if maven: + source = f"{maven.get('group_id', '')}:{maven.get('artifact_id', '')}:{maven.get('version', '')}" + else: + source = name + return source, name, int((o or {}).get("line_number", 0)) + + def owner(refs, ref_id): + return types.get(refs.get(ref_id, {}).get("class_reference_id", 0), "?") + + # Every kept class, field and method, mapped to the class it is in. + classes_of = { + "class": {k.get("id", 0): types.get(k.get("class_reference_id", 0), "?") + for k in d.get("kept_class_info_table", [])}, + "field": {k.get("id", 0): owner(field_refs, k.get("field_reference_id", 0)) + for k in d.get("kept_field_info_table", [])}, + "method": {k.get("id", 0): owner(method_refs, k.get("method_reference_id", 0)) + for k in d.get("kept_method_info_table", [])}, + } + + rules = {} + for row in d.get("keep_rule_blast_radius_table", []): + text = " ".join(row.get("source", "").split()) # one line; whitespace edits keep the same rule + rule = rules.setdefault(text, {"text": text, "classes": set(), "origins": set()}) + radius = row.get("blast_radius", {}) + for kind, owners in classes_of.items(): + rule["classes"].update(owners[i] for i in radius.get(f"{kind}_blast_radius", []) if i in owners) + rule["origins"].add(origin(row.get("origin"))) + + global_rules = {} + for row in d.get("global_keep_rule_blast_radius_table", []): + text = " ".join(row.get("source", "").split()) + global_rules.setdefault(text, {"text": text, "classes": set(), "origins": set()})["origins"].add( + origin(row.get("origin"))) + + return {"rules": rules, "global_rules": global_rules} + + +# ---------- Comparing ---------- + +def compare(base, pr): + """Rules added, removed, or keeping different classes in the PR.""" + b, p = base["rules"], pr["rules"] + changed = [] + for k in b.keys() & p.keys(): + gained, lost = p[k]["classes"] - b[k]["classes"], b[k]["classes"] - p[k]["classes"] + if gained or lost: + changed.append(dict(p[k], gained=gained, lost=lost)) + bg, pg = base["global_rules"], pr["global_rules"] + return { + "added": [p[k] for k in p.keys() - b.keys()] + [pg[k] for k in pg.keys() - bg.keys()], + "removed": [b[k] for k in b.keys() - p.keys()] + [bg[k] for k in bg.keys() - pg.keys()], + "changed": changed, + } + + +# ---------- Markdown ---------- + +def class_lines(sign, classes, max_items): + names = sorted(classes) + lines = [f"{sign} {name}" for name in names[:max_items]] + if len(names) > max_items: + lines.append(f"{sign} … and {len(names) - max_items:,} more") + return lines + + +def render(base, pr, max_items=50, max_chars=60000): + """A ```diff block, grouped by the file (or library) each rule comes from, in file order.""" + diff = compare(base, pr) + head = f"{MARKER}\n## 🔍 R8 keep rule diff" + hunks = [] # (origin, lines) + for sign, key in (("+", "added"), ("-", "removed")): + for rule in diff[key]: + hunks.append((min(rule["origins"]), [f"{sign} {rule['text']}"] + class_lines(sign, rule["classes"], max_items))) + for rule in diff["changed"]: + hunks.append((min(rule["origins"]), [f" {rule['text']}"] + class_lines("+", rule["gained"], max_items) + + class_lines("-", rule["lost"], max_items))) + if not hunks: + return f"{head}\n\n{NO_CHANGES}\n" + + lines, source, cut = [], None, 0 + budget = max_chars - len(head) - 600 if max_chars else None # room for the legend and the notice + for origin, hunk in sorted(hunks, key=lambda h: (h[0], h[1][0][2:])): + piece = ([f"@@ {origin[0]} @@"] if origin[0] != source else []) + hunk + if cut or (budget is not None and sum(len(x) + 1 for x in lines + piece) > budget): + cut += len(hunk) + continue + source = origin[0] + lines += piece + + text = "\n".join(lines) + fence = "`" * max(3, max(map(len, re.findall("`+", text)), default=0) + 1) # build text can't end the block + out = (f"{head}\n\n`+` and `-` lines are keep rules the PR adds or removes. The indented lines under a rule " + f"are the classes it keeps. A rule with no sign didn't change, but now keeps different classes.\n\n" + f"{fence}diff\n{text}\n{fence}\n") + if cut: + out += (f"\n> ✂️ {cut:,} more line{'' if cut == 1 else 's'} not shown, to keep this report under " + f"{max_chars:,} characters. Run `scripts/keep_rule_diff.py --max-chars 0` to see them all.\n") + return out + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Write a GitHub-style diff of the keep rules that changed between two keep rule .pb files.") + parser.add_argument("base", help="main's .pb file (or the JSON from convert.py)") + parser.add_argument("pr", help="the PR's .pb file (or .json)") + parser.add_argument("-o", "--output", default="keep_rule_diff.md", help="Markdown file to write (default: %(default)s)") + parser.add_argument("--max-items", type=int, default=50, metavar="N", + help="classes listed per rule (default: %(default)s)") + parser.add_argument("--max-chars", type=int, default=60000, metavar="N", + help="report size cap, 0 for none; GitHub comments allow 65,536 (default: %(default)s)") + args = parser.parse_args(argv) + try: + base, pr = load(args.base), load(args.pr) + except (OSError, ValueError, ImportError) as e: + sys.exit(f"error: {e}") + with open(args.output, "w", encoding="utf-8") as f: + f.write(render(base, pr, args.max_items, args.max_chars)) + print(f"Report written to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/schema_pb2.py b/scripts/schema_pb2.py new file mode 100644 index 00000000..fcaf802d --- /dev/null +++ b/scripts/schema_pb2.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: schema.proto +# Protobuf Python Version: 6.33.4 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 4, + '', + 'schema.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cschema.proto\x12&com.android.tools.r8.blastradius.proto\"\x9f\x02\n\x13KeepRuleBlastRadius\x12\n\n\x02id\x18\x01 \x01(\r\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x16\n\x0e\x63onstraints_id\x18\x03 \x01(\r\x12\x46\n\x06origin\x18\x04 \x01(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.TextFileOrigin\x12I\n\x0c\x62last_radius\x18\x05 \x01(\x0b\x32\x33.com.android.tools.r8.blastradius.proto.BlastRadius\x12\x41\n\x04tags\x18\x06 \x03(\x0e\x32\x33.com.android.tools.r8.blastradius.proto.KeepRuleTag\"w\n\x0b\x42lastRadius\x12\x13\n\x0bsubsumed_by\x18\x01 \x03(\r\x12\x1a\n\x12\x63lass_blast_radius\x18\x02 \x03(\r\x12\x1a\n\x12\x66ield_blast_radius\x18\x03 \x03(\r\x12\x1b\n\x13method_blast_radius\x18\x04 \x03(\r\"\x7f\n\x19GlobalKeepRuleBlastRadius\x12\n\n\x02id\x18\x01 \x01(\r\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x46\n\x06origin\x18\x03 \x01(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.TextFileOrigin\"j\n\x0fKeepConstraints\x12\n\n\x02id\x18\x01 \x01(\r\x12K\n\x0b\x63onstraints\x18\x02 \x03(\x0e\x32\x36.com.android.tools.r8.blastradius.proto.KeepConstraint\"`\n\rKeptClassInfo\x12\n\n\x02id\x18\x01 \x01(\r\x12\x1a\n\x12\x63lass_reference_id\x18\x02 \x01(\r\x12\x16\n\x0e\x66ile_origin_id\x18\x03 \x01(\r\x12\x0f\n\x07kept_by\x18\x04 \x03(\r\"`\n\rKeptFieldInfo\x12\n\n\x02id\x18\x01 \x01(\r\x12\x1a\n\x12\x66ield_reference_id\x18\x02 \x01(\r\x12\x16\n\x0e\x66ile_origin_id\x18\x03 \x01(\r\x12\x0f\n\x07kept_by\x18\x04 \x03(\r\"b\n\x0eKeptMethodInfo\x12\n\n\x02id\x18\x01 \x01(\r\x12\x1b\n\x13method_reference_id\x18\x02 \x01(\r\x12\x16\n\x0e\x66ile_origin_id\x18\x03 \x01(\r\x12\x0f\n\x07kept_by\x18\x04 \x03(\r\"a\n\x0e\x46ieldReference\x12\n\n\x02id\x18\x01 \x01(\r\x12\x1a\n\x12\x63lass_reference_id\x18\x02 \x01(\r\x12\x19\n\x11type_reference_id\x18\x03 \x01(\r\x12\x0c\n\x04name\x18\x04 \x01(\t\"c\n\x0fMethodReference\x12\n\n\x02id\x18\x01 \x01(\r\x12\x1a\n\x12\x63lass_reference_id\x18\x02 \x01(\r\x12\x1a\n\x12proto_reference_id\x18\x03 \x01(\r\x12\x0c\n\x04name\x18\x04 \x01(\t\"K\n\x0eProtoReference\x12\n\n\x02id\x18\x01 \x01(\r\x12\x15\n\rparameters_id\x18\x02 \x01(\r\x12\x16\n\x0ereturn_type_id\x18\x03 \x01(\r\"4\n\rTypeReference\x12\n\n\x02id\x18\x01 \x01(\r\x12\x17\n\x0fjava_descriptor\x18\x02 \x01(\t\";\n\x11TypeReferenceList\x12\n\n\x02id\x18\x01 \x01(\r\x12\x1a\n\x12type_reference_ids\x18\x02 \x03(\r\"}\n\nFileOrigin\x12\n\n\x02id\x18\x01 \x01(\r\x12\x10\n\x08\x66ilename\x18\x02 \x01(\t\x12Q\n\x10maven_coordinate\x18\x03 \x01(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.MavenCoordinate\"I\n\x14\x43lassFileInJarOrigin\x12\n\n\x02id\x18\x01 \x01(\r\x12\x16\n\x0e\x66ile_origin_id\x18\x02 \x01(\r\x12\r\n\x05\x65ntry\x18\x03 \x01(\t\"T\n\x0eTextFileOrigin\x12\x16\n\x0e\x66ile_origin_id\x18\x01 \x01(\r\x12\x13\n\x0bline_number\x18\x02 \x01(\r\x12\x15\n\rcolumn_number\x18\x03 \x01(\r\"U\n\x0fMavenCoordinate\x12\n\n\x02id\x18\x01 \x01(\r\x12\x10\n\x08group_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61rtifact_id\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\"\x9a\x01\n\tBuildInfo\x12\x13\n\x0b\x63lass_count\x18\x01 \x01(\r\x12\x13\n\x0b\x66ield_count\x18\x02 \x01(\r\x12\x14\n\x0cmethod_count\x18\x03 \x01(\r\x12\x18\n\x10live_class_count\x18\x04 \x01(\r\x12\x18\n\x10live_field_count\x18\x05 \x01(\r\x12\x19\n\x11live_method_count\x18\x06 \x01(\r\"\xd5\n\n\x14\x42lastRadiusContainer\x12M\n\x11\x66ile_origin_table\x18\x01 \x03(\x0b\x32\x32.com.android.tools.r8.blastradius.proto.FileOrigin\x12\x64\n\x1e\x63lass_file_in_jar_origin_table\x18\x02 \x03(\x0b\x32<.com.android.tools.r8.blastradius.proto.ClassFileInJarOrigin\x12W\n\x16maven_coordinate_table\x18\x03 \x03(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.MavenCoordinate\x12U\n\x15\x66ield_reference_table\x18\x04 \x03(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.FieldReference\x12W\n\x16method_reference_table\x18\x05 \x03(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.MethodReference\x12U\n\x15proto_reference_table\x18\x06 \x03(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.ProtoReference\x12S\n\x14type_reference_table\x18\x07 \x03(\x0b\x32\x35.com.android.tools.r8.blastradius.proto.TypeReference\x12\\\n\x19type_reference_list_table\x18\x08 \x03(\x0b\x32\x39.com.android.tools.r8.blastradius.proto.TypeReferenceList\x12T\n\x15kept_class_info_table\x18\t \x03(\x0b\x32\x35.com.android.tools.r8.blastradius.proto.KeptClassInfo\x12T\n\x15kept_field_info_table\x18\n \x03(\x0b\x32\x35.com.android.tools.r8.blastradius.proto.KeptFieldInfo\x12V\n\x16kept_method_info_table\x18\x0b \x03(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.KeptMethodInfo\x12W\n\x16keep_constraints_table\x18\x0c \x03(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.KeepConstraints\x12\x61\n\x1ckeep_rule_blast_radius_table\x18\r \x03(\x0b\x32;.com.android.tools.r8.blastradius.proto.KeepRuleBlastRadius\x12n\n#global_keep_rule_blast_radius_table\x18\x0e \x03(\x0b\x32\x41.com.android.tools.r8.blastradius.proto.GlobalKeepRuleBlastRadius\x12\x45\n\nbuild_info\x18\x0f \x01(\x0b\x32\x31.com.android.tools.r8.blastradius.proto.BuildInfo*\x1f\n\x0bKeepRuleTag\x12\x10\n\x0cPACKAGE_WIDE\x10\x00*H\n\x0eKeepConstraint\x12\x12\n\x0e\x44ONT_OBFUSCATE\x10\x00\x12\x11\n\rDONT_OPTIMIZE\x10\x01\x12\x0f\n\x0b\x44ONT_SHRINK\x10\x02\x42\x45\n&com.android.tools.r8.blastradius.protoB\x19KeepRuleBlastRadiusProtosP\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'schema_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n&com.android.tools.r8.blastradius.protoB\031KeepRuleBlastRadiusProtosP\001' + _globals['_KEEPRULETAG']._serialized_start=3292 + _globals['_KEEPRULETAG']._serialized_end=3323 + _globals['_KEEPCONSTRAINT']._serialized_start=3325 + _globals['_KEEPCONSTRAINT']._serialized_end=3397 + _globals['_KEEPRULEBLASTRADIUS']._serialized_start=57 + _globals['_KEEPRULEBLASTRADIUS']._serialized_end=344 + _globals['_BLASTRADIUS']._serialized_start=346 + _globals['_BLASTRADIUS']._serialized_end=465 + _globals['_GLOBALKEEPRULEBLASTRADIUS']._serialized_start=467 + _globals['_GLOBALKEEPRULEBLASTRADIUS']._serialized_end=594 + _globals['_KEEPCONSTRAINTS']._serialized_start=596 + _globals['_KEEPCONSTRAINTS']._serialized_end=702 + _globals['_KEPTCLASSINFO']._serialized_start=704 + _globals['_KEPTCLASSINFO']._serialized_end=800 + _globals['_KEPTFIELDINFO']._serialized_start=802 + _globals['_KEPTFIELDINFO']._serialized_end=898 + _globals['_KEPTMETHODINFO']._serialized_start=900 + _globals['_KEPTMETHODINFO']._serialized_end=998 + _globals['_FIELDREFERENCE']._serialized_start=1000 + _globals['_FIELDREFERENCE']._serialized_end=1097 + _globals['_METHODREFERENCE']._serialized_start=1099 + _globals['_METHODREFERENCE']._serialized_end=1198 + _globals['_PROTOREFERENCE']._serialized_start=1200 + _globals['_PROTOREFERENCE']._serialized_end=1275 + _globals['_TYPEREFERENCE']._serialized_start=1277 + _globals['_TYPEREFERENCE']._serialized_end=1329 + _globals['_TYPEREFERENCELIST']._serialized_start=1331 + _globals['_TYPEREFERENCELIST']._serialized_end=1390 + _globals['_FILEORIGIN']._serialized_start=1392 + _globals['_FILEORIGIN']._serialized_end=1517 + _globals['_CLASSFILEINJARORIGIN']._serialized_start=1519 + _globals['_CLASSFILEINJARORIGIN']._serialized_end=1592 + _globals['_TEXTFILEORIGIN']._serialized_start=1594 + _globals['_TEXTFILEORIGIN']._serialized_end=1678 + _globals['_MAVENCOORDINATE']._serialized_start=1680 + _globals['_MAVENCOORDINATE']._serialized_end=1765 + _globals['_BUILDINFO']._serialized_start=1768 + _globals['_BUILDINFO']._serialized_end=1922 + _globals['_BLASTRADIUSCONTAINER']._serialized_start=1925 + _globals['_BLASTRADIUSCONTAINER']._serialized_end=3290 +# @@protoc_insertion_point(module_scope) From b8ad536a9db83c66d3e8916c284e9449a299336c Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Thu, 29 Oct 2026 17:08:02 +0530 Subject: [PATCH 7/9] Set the R8 regression gate's permissions per job zizmor's excessive-permissions audit flags workflow-level write permissions once a workflow has more than one job, which the new keep-rule-diff job made it. Move them to the r8-regression-gate job; keep-rule-diff already sets its own (contents: read, pull-requests: write). --- .github/workflows/r8_regression_check.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml index 21c66702..e9d72683 100644 --- a/.github/workflows/r8_regression_check.yml +++ b/.github/workflows/r8_regression_check.yml @@ -13,10 +13,8 @@ # limitations under the License. name: R8 Regression Gate -permissions: - contents: write - issues: write - pull-requests: write +# Each job sets the permissions it needs. +permissions: {} on: push: @@ -59,6 +57,10 @@ jobs: 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) + permissions: + contents: write # commit the updated r8-baseline.json on main + issues: write + pull-requests: write # post the findings comment outputs: keep_rule_skip_reason: ${{ steps.r8_scores.outputs.skip_reason }} From 8d47ff1e36cf4db496118a07fc6e3e9d298fea4c Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Thu, 29 Oct 2026 17:19:16 +0530 Subject: [PATCH 8/9] Updates the keep rules and agp version --- app/proguard-rules.pro | 4 ---- gradle/libs.versions.toml | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bbbcc092..0ad808dd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -19,8 +19,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile --keep class com.firebase.** { *; } --keep interface com.firebase.** { *; } -keep class org.apache.** { *; } -keepnames class com.fasterxml.jackson.** { *; } -keepnames class javax.servlet.** { *; } @@ -45,8 +43,6 @@ -dontwarn javax.lang.model.** # OkHttp --keep class okhttp3.** { *; } --keep interface okhttp3.** { *; } -dontwarn okhttp3.** # Ignore SAX parser warning diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d7da77e..21c12a22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ appVersionCode = "11" appVersionName = "1.4.0" appVersionWearOffset = "60000000" -agp = "9.2.1" +agp = "9.3.2" bcpkixJdk18on = "1.84" core = "1.7.0" extensionsXr = "1.3.0" From be4972147f72496f81fa96d2026bd59bf4e1ad4b Mon Sep 17 00:00:00 2001 From: Ajesh R Date: Thu, 29 Oct 2026 18:11:40 +0530 Subject: [PATCH 9/9] TEMPORARY: seed main's keep rules for this PR's keep rule diff main doesn't run the keep rule diff yet, so the cache has no keep rules from main to compare this PR against. A temporary job builds the PR's base commit once, with AGP 9.3.2 so that it writes the keep rule .pb file, and caches it under the key the keep-rule-diff job looks for. To be removed once the diff has run. --- .github/workflows/r8_regression_check.yml | 87 ++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/.github/workflows/r8_regression_check.yml b/.github/workflows/r8_regression_check.yml index e9d72683..dbbfd229 100644 --- a/.github/workflows/r8_regression_check.yml +++ b/.github/workflows/r8_regression_check.yml @@ -297,7 +297,7 @@ jobs: keep-rule-diff: # Diffs the keep rules against main, using the .pb file the release build above wrote, without # building again. It also runs when the gate finds a regression, when the diff helps most. - needs: r8-regression-gate + needs: [ r8-regression-gate, seed-keep-rule-base ] # TEMPORARY: drop seed-keep-rule-base with that job if: ${{ !cancelled() && needs.r8-regression-gate.result != 'skipped' }} permissions: contents: read @@ -306,3 +306,88 @@ jobs: with: path: app/build/outputs/mapping/release/configanalyzer.pb skip-reason: ${{ needs.r8-regression-gate.outputs.keep_rule_skip_reason }} + + seed-keep-rule-base: + # TEMPORARY, for PR #219 only. main doesn't run the keep rule diff yet, so the cache has no keep + # rules from main to compare against. This builds the PR's base commit once, with AGP 9.3.2 so + # that it writes the keep rule .pb file, and caches that under the key the keep-rule-diff job + # looks for. Later runs of this PR find the cache and skip the build. Remove this job once main + # has run the keep rule diff. + if: github.repository == 'android/androidify' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Find the Cache Key for main's Keep Rules + id: key + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + # The same key the keep-rule-diff job computes. + SHA="$(git log -1 --first-parent --format=%H "${BASE_SHA}" -- . ':(exclude)r8-baseline.json')" + echo "sha=${SHA}" >> "${GITHUB_OUTPUT}" + echo "rules_key=r8-rules-${SHA}" >> "${GITHUB_OUTPUT}" + + - name: Check Whether main's Keep Rules Are Cached + id: cached + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: r8-base + key: ${{ steps.key.outputs.rules_key }} + lookup-only: true + + - name: Check Out main's Commit with AGP 9.3.2 + if: steps.cached.outputs.cache-hit != 'true' + env: + BASE: ${{ steps.key.outputs.sha }} + run: | + set -euo pipefail + git checkout --detach "${BASE}" + sed -i 's/^agp = .*/agp = "9.3.2"/' gradle/libs.versions.toml + grep '^agp ' gradle/libs.versions.toml + + - name: Set up JDK 21 + if: steps.cached.outputs.cache-hit != 'true' + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + if: steps.cached.outputs.cache-hit != 'true' + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Decode google-services.json + if: steps.cached.outputs.cache-hit != 'true' + 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: Write main's Keep Rule .pb File + if: steps.cached.outputs.cache-hit != 'true' + run: | + set -euo pipefail + ./gradlew :app:analyzeReleaseR8Config + mkdir -p r8-base + cp app/build/reports/r8/r8-config-analyzer-release.pb r8-base/rules.pb + + - name: Save main's Keep Rules + if: steps.cached.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: r8-base + key: ${{ steps.key.outputs.rules_key }}