From 3ad254c82abbb2aaf8deafc1b530cca45a803a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Mon, 2 Nov 2026 20:16:34 +0100 Subject: [PATCH] ci: regenerate Baseline Profiles before each release Add a workflow that regenerates the library Baseline Profiles on an emulator and opens a pull request when they changed. release-please.yml calls it when the release pull request is created or updated; it skips the emulator run when the library sources did not change since the last profiles, and fails if a module comes out nearly empty. It can also be run by hand. release-please.yml now sets its permissions per job. --- .github/scripts/compare_baseline_profiles.py | 129 +++++++++++++ .github/workflows/baseline-profiles.yml | 179 +++++++++++++++++++ .github/workflows/release-please.yml | 24 ++- 3 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/compare_baseline_profiles.py create mode 100644 .github/workflows/baseline-profiles.yml diff --git a/.github/scripts/compare_baseline_profiles.py b/.github/scripts/compare_baseline_profiles.py new file mode 100644 index 000000000..cf754b50e --- /dev/null +++ b/.github/scripts/compare_baseline_profiles.py @@ -0,0 +1,129 @@ +#!/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. + +"""Compare regenerated Baseline Profiles with the committed ones. + +Used by the Regenerate Baseline Profiles workflow. For each module it reads the +committed profile (copied to --before before regenerating) and the regenerated +one in the working tree, then: + +- fails if a module lost most of its rules. A profile generated without a valid + Maps API key, or from demos that close themselves, comes out nearly empty and + must never be proposed. +- reports whether the change is large enough to be worth a pull request. Two + runs never record exactly the same methods, so small differences are noise. + +It writes a Markdown summary to --summary and `significant=true|false` to +$GITHUB_OUTPUT when that variable is set. +""" + +import argparse +import os +import sys +from pathlib import Path + +PROFILE = Path("src/main/generated/baselineProfiles/baseline-prof.txt") + + +def read_rules(path: Path) -> set[str]: + if not path.is_file(): + return set() + return {line.strip() for line in path.read_text().splitlines() if line.strip()} + + +def owner(rule: str) -> str: + """The class a rule belongs to, without nested classes: HSPLcom/a/B$c;->m()V -> com.a.B.""" + start = rule.find("L") + end = rule.find(";", start) + if start < 0 or end < 0: + return rule + return rule[start + 1:end].split("$")[0].replace("/", ".") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--before", required=True, type=Path, + help="Directory with the committed profiles, laid out as /baseline-prof.txt") + parser.add_argument("--summary", required=True, type=Path, help="Markdown file to write") + parser.add_argument("--min-ratio", type=float, default=0.5, + help="Fail if a module keeps fewer than this share of its committed rules") + parser.add_argument("--threshold", type=float, default=0.05, + help="Share of changed rules above which the change is significant") + parser.add_argument("modules", nargs="+") + args = parser.parse_args() + + rows = [] + errors = [] + changed_total = 0 + before_total = 0 + details = [] + + for module in args.modules: + before = read_rules(args.before / module / "baseline-prof.txt") + after = read_rules(Path(module) / PROFILE) + added = after - before + removed = before - after + changed_total += len(added) + len(removed) + before_total += len(before) + rows.append(f"| `{module}` | {len(before)} | {len(after)} | +{len(added)} | -{len(removed)} |") + + if not after: + errors.append(f"{module}: the regenerated profile is empty.") + elif before and len(after) < len(before) * args.min_ratio: + errors.append( + f"{module}: {len(after)} rules, down from {len(before)}. " + "Check that the Maps API key is valid and that the demos load their maps." + ) + + new_classes = sorted({owner(r) for r in added} - {owner(r) for r in before}) + gone_classes = sorted({owner(r) for r in removed} - {owner(r) for r in after}) + if new_classes or gone_classes: + lines = [f"**`{module}`**"] + lines += [f"- added `{c}`" for c in new_classes] + lines += [f"- removed `{c}`" for c in gone_classes] + details.append("\n".join(lines)) + + significant = before_total == 0 or changed_total > before_total * args.threshold + + summary = [ + "| Module | Committed rules | Regenerated rules | Added | Removed |", + "| --- | ---: | ---: | ---: | ---: |", + *rows, + "", + f"{changed_total} of {before_total} committed rules changed " + f"({'above' if significant else 'below'} the {args.threshold:.0%} threshold).", + ] + if details: + summary += ["", "Classes added to or removed from the profiles:", "", *details] + if errors: + summary += ["", "**Errors:**", *[f"- {e}" for e in errors]] + args.summary.write_text("\n".join(summary) + "\n") + print("\n".join(summary)) + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as out: + out.write(f"significant={'true' if significant and not errors else 'false'}\n") + + if errors: + for error in errors: + print(f"::error::{error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/baseline-profiles.yml b/.github/workflows/baseline-profiles.yml new file mode 100644 index 000000000..34b060d82 --- /dev/null +++ b/.github/workflows/baseline-profiles.yml @@ -0,0 +1,179 @@ +# 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. + +# Regenerates the Baseline Profiles of the library modules on an emulator and opens a pull request +# when they changed, so the profiles shipped in a release match its code. release-please.yml calls +# it whenever the release pull request is created or updated; it can also be run by hand. +name: Regenerate Baseline Profiles + +on: + workflow_dispatch: + inputs: + force: + description: Regenerate even if the library sources did not change since the last profiles + type: boolean + default: false + workflow_call: + secrets: + ACTIONS_API_KEY: + required: true + SYNCED_GITHUB_TOKEN_REPO: + required: true + +permissions: + contents: read + +concurrency: + group: baseline-profiles + cancel-in-progress: true + +env: + MODULES: library clustering heatmaps data ui + BRANCH: chore/regenerate-baseline-profiles + +jobs: + regenerate: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + # The release pull request is updated after almost every merge, so skip the emulator run + # unless the library code changed since the profiles were last regenerated. + - name: Check whether the profiles are stale + id: stale + env: + FORCE: ${{ inputs.force }} + run: | + profiles=() + sources=() + for module in $MODULES; do + profiles+=("$module/src/main/generated/baselineProfiles") + sources+=("$module/src/main" ":(exclude)$module/src/main/generated") + done + last=$(git log -1 --format=%H -- "${profiles[@]}") + if [ "$FORCE" = "true" ] || [ -z "$last" ] || ! git diff --quiet "$last" HEAD -- "${sources[@]}"; then + echo "stale=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::No library source changes since $last, the profiles are up to date." + echo "stale=false" >> "$GITHUB_OUTPUT" + fi + + # Without a real key the maps never load and the profiles come out nearly empty. + - name: Write Maps API key + if: steps.stale.outputs.stale == 'true' + env: + MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} + run: | + if [ -z "$MAPS_API_KEY" ]; then + echo "::error::ACTIONS_API_KEY is not available, the profiles cannot be generated." + exit 1 + fi + echo "MAPS_API_KEY=$MAPS_API_KEY" > secrets.properties + + - name: Keep the committed profiles + if: steps.stale.outputs.stale == 'true' + run: | + for module in $MODULES; do + mkdir -p "$RUNNER_TEMP/before/$module" + cp "$module/src/main/generated/baselineProfiles/baseline-prof.txt" "$RUNNER_TEMP/before/$module/" || true + done + + - name: Set up JDK 21 + if: steps.stale.outputs.stale == 'true' + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + if: steps.stale.outputs.stale == 'true' + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + + - name: Enable KVM + if: steps.stale.outputs.stale == 'true' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build the demo and the generator + if: steps.stale.outputs.stale == 'true' + run: ./gradlew :demo:assembleStandardNonMinifiedRelease :baselineprofile:assembleNonMinifiedRelease --stacktrace + + - name: Generate Baseline Profiles + if: steps.stale.outputs.stale == 'true' + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + # Same image as the demo smoke test, whose bundled Play services runs every demo. + api-level: 36 + target: google_apis + arch: x86_64 + disable-animations: true + script: >- + ./gradlew :library:generateBaselineProfile :clustering:generateBaselineProfile + :heatmaps:generateBaselineProfile :data:generateBaselineProfile + :ui:generateBaselineProfile --stacktrace + + - name: Compare with the committed profiles + id: compare + if: steps.stale.outputs.stale == 'true' + run: | + # shellcheck disable=SC2086 + python3 .github/scripts/compare_baseline_profiles.py \ + --before "$RUNNER_TEMP/before" --summary "$RUNNER_TEMP/summary.md" $MODULES + cat "$RUNNER_TEMP/summary.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload profiles and test reports + if: always() && steps.stale.outputs.stale == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: baseline-profiles + path: | + */src/main/generated/baselineProfiles/baseline-prof.txt + baselineprofile/build/reports/androidTests + baselineprofile/build/outputs/connected_android_test_additional_output + + # Opened with the bot token so that the pull request triggers the usual checks, which pull + # requests opened with GITHUB_TOKEN do not. An open pull request from a previous run is updated. + - name: Open or update the pull request + if: steps.compare.outputs.significant == 'true' + env: + GH_TOKEN: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + for module in $MODULES; do + git add "$module/src/main/generated/baselineProfiles/baseline-prof.txt" + done + git commit -m "chore: regenerate Baseline Profiles" + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + + { + echo "Regenerated by the [Regenerate Baseline Profiles](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) workflow on an API 36 emulator. Merge it before the release pull request so the release ships profiles that match its code." + echo + cat "$RUNNER_TEMP/summary.md" + } > "$RUNNER_TEMP/body.md" + + if [ -n "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then + gh pr edit "$BRANCH" --body-file "$RUNNER_TEMP/body.md" + else + gh pr create --base main --head "$BRANCH" --title "chore: regenerate Baseline Profiles" --body-file "$RUNNER_TEMP/body.md" + fi diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 948951756..23f2a257e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -18,18 +18,34 @@ on: - main workflow_dispatch: -permissions: - contents: write - pull-requests: write +permissions: {} name: release-please jobs: release-please: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + prs_created: ${{ steps.release.outputs.prs_created }} steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + id: release with: token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} config-file: release-please-config.json - manifest-file: .release-please-manifest.json \ No newline at end of file + manifest-file: .release-please-manifest.json + + # Keeps the Baseline Profiles in line with the code about to be released. The workflow skips the + # emulator run when the library sources did not change since the profiles were last regenerated. + baseline-profiles: + needs: release-please + if: needs.release-please.outputs.prs_created == 'true' + permissions: + contents: read + uses: ./.github/workflows/baseline-profiles.yml + secrets: + ACTIONS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} + SYNCED_GITHUB_TOKEN_REPO: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }}