Skip to content
Merged
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
42 changes: 41 additions & 1 deletion .github/scripts/coverage_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

summary print the parsed coverage as JSON (debugging / ad-hoc use)
append append one row per module to the append-only history CSV
merge re-apply locally added rows onto a refreshed history CSV, used
when a push to the coverage-history branch is rejected
render regenerate the human-readable COVERAGE.md from that CSV
compare render a markdown coverage diff against the last recorded entry,
for posting on a pull request
Expand Down Expand Up @@ -354,6 +356,37 @@ def render_current(rows: list[dict], suite: str, heading: str, note: str) -> lis
return lines


def row_key(row: dict) -> tuple[str, str, str]:
"""Identity of a history row: one module, one suite, one commit."""
return (row.get("commit", ""), row.get("suite", ""), row.get("module", ""))


def cmd_merge(args: argparse.Namespace) -> None:
"""Re-apply locally added rows on top of a refreshed history file.

Two runs appending to the end of the CSV cannot be merged by git: both
touch the same region and the rebase conflicts. The rows are independent
records, though, so the correct resolution is simply "keep both". This
reads the rows we added, rebases them onto whatever is now on the branch,
and drops any that are already there.
"""
base = read_history(args.csv)
ours = read_history(args.ours)

present = {row_key(row) for row in base}
added = [row for row in ours if row_key(row) not in present]
if not added:
print("No local rows to re-apply.")
return

with open(args.csv, "a", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS, lineterminator="\n")
if not base:
writer.writeheader()
writer.writerows(added)
print(f"Re-applied {len(added)} row(s) onto {args.csv}")


def cmd_render(args: argparse.Namespace) -> None:
rows = read_history(args.csv)
if not rows:
Expand Down Expand Up @@ -508,7 +541,7 @@ def delta(current: float, module: str, field: str) -> str:
lines += [
"",
f"<sub>Line and branch coverage from {args.suite} test reports. History "
f"is recorded in `coverage/history.csv` after each merge to "
f"is recorded on the `coverage-history` branch after each merge to "
f"`{args.base}`.</sub>",
"",
]
Expand Down Expand Up @@ -563,6 +596,13 @@ def main() -> None:
)
append.set_defaults(func=cmd_append)

merge = subparsers.add_parser(
"merge", help="re-apply locally added rows onto a refreshed history CSV"
)
merge.add_argument("--csv", default=DEFAULT_CSV, help="refreshed file to merge into")
merge.add_argument("--ours", required=True, help="our copy, holding the new rows")
merge.set_defaults(func=cmd_merge)

render = subparsers.add_parser("render", help="regenerate COVERAGE.md from the CSV")
render.add_argument("--csv", default=DEFAULT_CSV)
render.add_argument("--out", default=DEFAULT_MARKDOWN)
Expand Down
88 changes: 88 additions & 0 deletions .github/scripts/push_coverage_history.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# 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.
#
# Commits and pushes the coverage history to its own branch.
#
# Usage: push_coverage_history.sh "<commit message>"
#
# The history deliberately does not live on main. Branch protection there uses
# strict required status checks, so every commit pushed to main marks all open
# pull requests out-of-date and forces contributors to update their branch and
# re-run CI.
#
# If the branch moved under us, the update is redone on top of the new tip
# rather than rebased. Both sides append to the end of history.csv, so a rebase
# conflicts every time; the rows are independent records, so the right
# resolution is to keep both and regenerate the report.

set -euo pipefail

MESSAGE="${1:?commit message required}"
DATA_DIR="${DATA_DIR:-coverage-data}"
DATA_BRANCH="${DATA_BRANCH:-coverage-history}"
SCRIPT="${SCRIPT:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/coverage_history.py}"

# Every generated file published on the data branch. COVERAGE.md is derived
# from history.csv, so it is regenerated, not merged.
FILES=(history.csv COVERAGE.md)

cd "$DATA_DIR"

git config user.name 'googlemaps-bot'
git config user.email 'googlemaps-bot@google.com'

# Stage first and compare the index: a newly generated file is untracked, and
# "git diff" does not see untracked files, so checking before staging would
# skip the commit the first time a new output appears.
git add -- "${FILES[@]}"

if git diff --cached --quiet -- "${FILES[@]}"; then
echo "Coverage history unchanged; nothing to commit."
exit 0
fi

git commit -m "$MESSAGE"

for attempt in 1 2 3; do
if git push origin "HEAD:$DATA_BRANCH"; then
echo "Pushed to $DATA_BRANCH."
exit 0
fi

echo "Push rejected (attempt $attempt); redoing the update on the new tip."

# Keep our rows, take the branch as it now stands, then put ours back.
OURS="${RUNNER_TEMP:-/tmp}/coverage-history-ours.csv"
cp history.csv "$OURS"

git fetch origin "$DATA_BRANCH"
git reset --hard "origin/$DATA_BRANCH"

python3 "$SCRIPT" merge --csv history.csv --ours "$OURS"
python3 "$SCRIPT" render --csv history.csv --out COVERAGE.md

git add -- "${FILES[@]}"

if git diff --cached --quiet -- "${FILES[@]}"; then
echo "Our rows are already on $DATA_BRANCH; nothing left to push."
exit 0
fi

git commit -m "$MESSAGE"
done

echo "Could not push to $DATA_BRANCH after 3 attempts." >&2
exit 1
158 changes: 127 additions & 31 deletions .github/workflows/coverage-history.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.

# Records unit test coverage in coverage/history.csv after every merge to main,
# so coverage can be tracked over time. Release-please commits and the
# workflow's own commits are skipped.
# Records unit test coverage after every merge to main.
#
# The history is written to the "coverage-history" branch, never to main.
# Branch protection on main uses strict required status checks, so any commit
# pushed to main marks every open pull request out-of-date and forces
# contributors to update their branch and re-run CI. Keeping the data on its
# own branch means recording coverage costs contributors nothing.
#
# The first run creates the branch, seeded with the history that used to live
# in coverage/ on main, so the trend stays continuous.
name: Record coverage history

on:
Expand All @@ -25,31 +32,117 @@ on:
permissions:
contents: read

# Serialize runs so two quick merges cannot race on pushing the history file.
# Serialize runs so two merges cannot race on pushing the history branch.
concurrency:
group: coverage-history
cancel-in-progress: false

env:
# Branch holding history.csv and COVERAGE.md.
DATA_BRANCH: coverage-history

jobs:
record:
# Skip release-please's release commits and this workflow's own commits.
# (The commit below carries [skip ci] as well, and pushes made with
# GITHUB_TOKEN do not trigger workflows, so this is belt and braces.)
# Skip release-please's release commits. The history no longer lands on
# main, so this workflow cannot retrigger itself.
if: >-
!startsWith(github.event.head_commit.message, 'chore(main): release') &&
!startsWith(github.event.head_commit.message, 'chore: update coverage history')
!startsWith(github.event.head_commit.message, 'chore(main): release')
runs-on: ubuntu-latest
permissions:
contents: write

steps:
# Credentials are deliberately persisted here: the final step pushes the
# updated history back to main, so persist-credentials must stay enabled.
- name: Checkout Repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Check for the coverage history branch
id: branch
run: |
set +e
git ls-remote --exit-code --heads origin "$DATA_BRANCH" > /dev/null
STATUS=$?
set -e
case "$STATUS" in
0) echo "exists=true" >> "$GITHUB_OUTPUT" ;;
2) echo "exists=false" >> "$GITHUB_OUTPUT"
echo "$DATA_BRANCH does not exist yet; it will be created." ;;
*) echo "Could not query $DATA_BRANCH (git exit $STATUS)." >&2
exit 1 ;;
esac

# Credentials are deliberately persisted on this checkout only: the steps
# below push the updated history to the data branch.
- name: Checkout coverage history branch
if: steps.branch.outputs.exists == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.DATA_BRANCH }}
path: coverage-data
token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO || secrets.GITHUB_TOKEN }}

# First run only. Full history is fetched so the seed step can find the
# last version of coverage/ on main, even after it has been removed there.
- name: Checkout main to seed the coverage history branch
if: steps.branch.outputs.exists == 'false'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: coverage-data
fetch-depth: 0
token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO || secrets.GITHUB_TOKEN }}

- name: Seed the coverage history branch from main
if: steps.branch.outputs.exists == 'false'
working-directory: coverage-data
run: |
# The newest commit that touched coverage/history.csv. If that commit
# removed the file, its parent holds the last version.
SOURCE=$(git rev-list -1 HEAD -- coverage/history.csv)
if [ -n "$SOURCE" ] && ! git cat-file -e "$SOURCE:coverage/history.csv" 2>/dev/null; then
SOURCE="$SOURCE^"
fi

git checkout --orphan "$DATA_BRANCH"
git rm -rfq .
git clean -fdxq

if [ -n "$SOURCE" ]; then
echo "Seeding from coverage/ at $(git rev-parse --short "$SOURCE")."
git show "$SOURCE:coverage/history.csv" > history.csv
git show "$SOURCE:coverage/COVERAGE.md" > COVERAGE.md 2>/dev/null || rm -f COVERAGE.md
else
echo "No earlier coverage history found; starting empty."
fi

cat > README.md <<'README'
# Coverage history

Generated data. Do not edit by hand, and do not open pull requests against
this branch.

| File | Contents |
| --- | --- |
| `history.csv` | One row per module per merged commit (unit tests). |
| `COVERAGE.md` | Human-readable report, regenerated from `history.csv`. |

Both are written by `.github/scripts/coverage_history.py` on `main`, run by the
`Record coverage history` workflow after every merge to `main`.

This data lives on its own branch rather than on `main` on purpose. Branch
protection on `main` uses strict required status checks, so every commit
pushed there marks all open pull requests out-of-date and forces contributors
to update their branch and re-run CI. Keeping coverage data here means
recording it costs contributors nothing.
README

git config user.name 'googlemaps-bot'
git config user.email 'googlemaps-bot@google.com'
git add -- README.md
[ -f history.csv ] && git add -- history.csv
[ -f COVERAGE.md ] && git add -- COVERAGE.md
git commit -qm 'chore: seed coverage history from main'

- name: Set up JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
Expand All @@ -62,33 +155,36 @@ jobs:
- name: Create dummy secrets.properties
run: echo "MAPS_API_KEY=dummy" > secrets.properties

- name: Run unit tests and generate coverage reports
run: ./gradlew koverXmlReportDebug --stacktrace

- name: Append to coverage history
env:
COMMIT_SHA: ${{ github.sha }}
- name: Resolve commit metadata
id: meta
run: |
SUBJECT=$(git log -1 --pretty=%s)
# Squash-merged PRs end their subject with "(#123)". The number is
# recorded in its own column, so drop it from the subject text.
PR=$(printf '%s' "$SUBJECT" | sed -n 's/.*(#\([0-9]\{1,\}\))$/\1/p')
SUBJECT=$(printf '%s' "$SUBJECT" | sed 's/ (#[0-9]\{1,\})$//')
{
echo "pr=$PR"
echo "subject<<EOF"
echo "$SUBJECT"
echo "EOF"
} >> "$GITHUB_OUTPUT"

python3 .github/scripts/coverage_history.py append --suite unit \
--commit "$COMMIT_SHA" --pr "$PR" --subject "$SUBJECT"
- name: Run unit tests and generate coverage reports
run: ./gradlew koverXmlReportDebug --stacktrace

python3 .github/scripts/coverage_history.py render
- name: Record unit coverage
env:
COMMIT_SHA: ${{ github.sha }}
PR_NUMBER: ${{ steps.meta.outputs.pr }}
SUBJECT: ${{ steps.meta.outputs.subject }}
run: |
python3 .github/scripts/coverage_history.py append --suite unit \
--csv coverage-data/history.csv \
--commit "$COMMIT_SHA" --pr "$PR_NUMBER" --subject "$SUBJECT"
python3 .github/scripts/coverage_history.py render \
--csv coverage-data/history.csv --out coverage-data/COVERAGE.md

- name: Commit coverage history
- name: Push unit coverage
run: |
git config user.name 'googlemaps-bot'
git config user.email 'googlemaps-bot@google.com'
git add coverage/history.csv coverage/COVERAGE.md
if git diff --staged --quiet; then
echo "Coverage history unchanged; nothing to commit."
exit 0
fi
git commit -m 'chore: update coverage history [skip ci]'
git pull --rebase origin main
git push origin HEAD:main
.github/scripts/push_coverage_history.sh 'chore: record unit coverage'
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ Shared Gradle conventions are in `build-logic/` (included build).

Coverage is tracked over time. After every merge to `main`, the
`Record coverage history` workflow appends a row per module to
`coverage/history.csv` and regenerates `coverage/COVERAGE.md`. Both files are
generated by `.github/scripts/coverage_history.py`. Never edit them by hand.
`history.csv` and regenerates `COVERAGE.md`, both of which live on the
[`coverage-history`](../../tree/coverage-history) branch. They are generated by
`.github/scripts/coverage_history.py`. Never edit them by hand.

The data is kept off `main` deliberately. Branch protection on `main` uses
strict required status checks, so any commit pushed there marks every open
pull request out-of-date and forces contributors to update their branch and
re-run CI.

The history records unit test coverage. The CSV carries a `suite` column so
emulator coverage could be added later without changing the format.
Expand Down
Loading
Loading