Skip to content

[Dont merge] Add R8 regression check workflow #8

[Dont merge] Add R8 regression check workflow

[Dont merge] Add R8 regression check workflow #8

# 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 }}