Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/scripts/compare_baseline_profiles.py
Original file line number Diff line number Diff line change
@@ -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 <module>/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())
179 changes: 179 additions & 0 deletions .github/workflows/baseline-profiles.yml
Original file line number Diff line number Diff line change
@@ -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: maps-compose maps-compose-utils maps-compose-widgets
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@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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 :maps-app:assembleNonMinifiedRelease :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 :maps-compose:generateBaselineProfile
:maps-compose-utils:generateBaselineProfile
:maps-compose-widgets: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
22 changes: 19 additions & 3 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,34 @@ on:
branches:
- main

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.0.0
id: release
with:
token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }}
config-file: release-please-config.json
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 }}
Loading