Repository navigation
[Dont merge] Add R8 regression check workflow #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 | |
| # Each job sets the permissions it needs. | |
| permissions: {} | |
| on: | |
| push: | |
| branches: [ 'main' ] | |
| pull_request: | |
| branches: [ '*' ] | |
| 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 | |
| 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) | |
| 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 }} | |
| 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) | |
| # 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: | |
| MAX_SCORE_DROP: ${{ inputs.max_score_drop || '2.0' }} | |
| MAX_DEX_GROWTH_PCT: ${{ inputs.max_dex_growth_pct || '5.0' }} | |
| run: | | |
| set +e | |
| python3 ./scripts/check_app_r8_score.py \ | |
| --aab app/build/outputs/bundle/release/app-release.aab \ | |
| --baseline r8-baseline.json \ | |
| --max-score-drop "${MAX_SCORE_DROP}" \ | |
| --max-dex-growth-pct "${MAX_DEX_GROWTH_PCT}" \ | |
| --markdown-summary .gemini/r8_summary.md | |
| EXIT_CODE=$? | |
| set -e | |
| if [[ ${EXIT_CODE} -eq 0 ]]; then | |
| echo "passed=true" >> "${GITHUB_OUTPUT}" | |
| else | |
| echo "passed=false" >> "${GITHUB_OUTPUT}" | |
| fi | |
| - name: Check Module & Library Shrinking Percentage | |
| id: lib_r8_gate | |
| env: | |
| MIN_LIB_SHRINKING_PCT: ${{ inputs.min_lib_shrinking_pct || '90.0' }} | |
| run: | | |
| set +e | |
| python3 ./scripts/check_library_shrinking.py \ | |
| --scan-libraries \ | |
| --min-lib-shrinking-pct "${MIN_LIB_SHRINKING_PCT}" \ | |
| --markdown-summary .gemini/r8_summary.md \ | |
| --append-summary | |
| EXIT_CODE=$? | |
| set -e | |
| if [[ ${EXIT_CODE} -eq 0 ]]; then | |
| echo "passed=true" >> "${GITHUB_OUTPUT}" | |
| else | |
| echo "passed=false" >> "${GITHUB_OUTPUT}" | |
| fi | |
| - name: 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: | | |
| python3 ./scripts/check_app_r8_score.py \ | |
| --aab app/build/outputs/bundle/release/app-release.aab \ | |
| --baseline r8-baseline.json \ | |
| --save-baseline | |
| - name: Commit Updated R8 Baseline | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.app_r8_gate.outputs.passed == 'true' && steps.lib_r8_gate.outputs.passed == 'true' | |
| uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5 | |
| with: | |
| commit_message: "🤖 Update r8-baseline.json after merge [skip ci]" | |
| file_pattern: 'r8-baseline.json' | |
| - name: Run R8 Configuration Analyzer & Skill Review (on Regression) | |
| if: steps.app_r8_gate.outputs.passed == 'false' || steps.lib_r8_gate.outputs.passed == 'false' | |
| id: gemini_review | |
| uses: google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489 # v0.1.22 | |
| env: | |
| GEMINI_CLI_TRUST_WORKSPACE: 'true' | |
| GITHUB_TOKEN: ${{ secrets.PAT || github.token }} | |
| with: | |
| gemini_api_key: ${{ secrets.GEMINI_API_KEY }} | |
| github_pr_number: ${{ steps.context.outputs.pr_number }} | |
| upload_artifacts: 'true' | |
| settings: | | |
| { | |
| "model": { | |
| "maxSessionTurns": 50 | |
| }, | |
| "context": { | |
| "fileFiltering": { | |
| "respectGitIgnore": false, | |
| "respectGeminiIgnore": false | |
| } | |
| }, | |
| "fileFiltering": { | |
| "respectGitIgnore": false, | |
| "respectGeminiIgnore": false | |
| } | |
| } | |
| prompt: | | |
| An R8 score, library shrinking, or DEX size regression was detected by `android analyze` (see `.gemini/r8_summary.md`). | |
| Use the `r8-analyzer` skill located at `.agents/skills/r8-analyzer/SKILL.md` to review the ProGuard/R8 and build configuration changes in `.gemini/pr_diff.patch` and identify which keep rules or dependency changes caused the regression, along with concrete recommendations to remove or refine them. | |
| - name: Post Findings on Pull Request | |
| if: steps.context.outputs.pr_number != '' && always() | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT || github.token }} | |
| HAS_PAT: ${{ secrets.PAT != '' }} | |
| 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 | |
| MARKER='<!-- r8-regression-gate -->' | |
| COMMENT_FILE="$(mktemp)" | |
| { | |
| echo "${MARKER}" | |
| cat .gemini/r8_summary.md | |
| if [[ -n "${REVIEW_SUMMARY}" ]]; then | |
| echo "" | |
| echo "---" | |
| echo "" | |
| printf '%s\n' "${REVIEW_SUMMARY}" | |
| 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 | |
| rm -f "${COMMENT_FILE}" | |
| - name: Upload R8 Configuration Analyzer HTML Report | |
| if: steps.app_r8_gate.outputs.passed == 'false' || steps.lib_r8_gate.outputs.passed == 'false' | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: r8-config-analyzer-report | |
| path: app/build/reports/r8/r8-config-analyzer-release.html | |
| if-no-files-found: ignore | |
| - name: Enforce R8 Regression Gate | |
| if: steps.app_r8_gate.outputs.passed == 'false' || steps.lib_r8_gate.outputs.passed == 'false' | |
| run: | | |
| echo "::error::R8 Regression Gate failed. See step summary or PR comment for details." | |
| exit 1 | |
| 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 }} |