Stop the coverage-map bot pushing a no-op commit on every refresh#1683
Conversation
…refresh coverage-refresh.yml pushed a "test: refresh coverage map" commit on nearly every master push, because three separate things made an unchanged map look changed: 1. param_hash embedded the runner's absolute checkout path. to_case() absolutizes file-valued params so a test can run from any cwd, which for the 8 STL cases put the checkout path into stl_models(1)%model_filepath and therefore into the key. Those 8 entries churned out of the map on every refresh, and mismatched on PR runners, dropping the tests to rung 5 (unmapped -> always run). coverage_key() now hashes the repo-relative form. 2. save_map used gzip.open(), which stamps the current time and the source filename into the gzip header, so identical maps still wrote different bytes. Now mtime=0 with an empty filename. 3. The commit guard ran `git diff --quiet` on the .gz. _meta (built_at, git_sha) moves on every rebuild, so that check could never pass. It now compares coverage entries via .github/scripts/coverage_map_changed.py. Over 7/17-7/26 this takes 7 pushes down to 3, and each survivor is a real change (n_tests 610 -> 622). Once the bot stops pushing on no-op refreshes, built_at freezes during quiet stretches, so the health check's "stale after 10 days" rule would misfire (the largest gap between coverage-relevant commits in the last 2.5 months is 8.9 days). Replace it with the question it was approximating: does the map's git_sha contain the last commit touching src/**/*.fpp or cases.py? That needs no heartbeat commit, and it fires on the next relevant push after a refresh breaks instead of 10 days later. The wall-clock rule remains as the fallback when git cannot answer.
There was a problem hiding this comment.
Pull request overview
This PR fixes the coverage-map refresh bot repeatedly pushing no-op commits by making coverage keys and map serialization deterministic, and by switching the commit guard from byte-level diffs to entry-level comparisons. It also improves the “stale map” health check to use git ancestry (last relevant source change) instead of wall-clock age when determinable.
Changes:
- Canonicalize in-repo absolute parameter paths to repo-relative form when hashing coverage keys (prevents checkout-path churn).
- Write
tests/coverage_map.json.gzwith a deterministic gzip header (mtime=0, empty filename) and add an entry-based change detector for the refresh workflow. - Update the health check workflow/script to validate freshness via git history (requires full fetch depth).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| toolchain/mfc/test/coverage.py | Adds path canonicalization for hashing, deterministic gzip writing, and extends map_health to accept git-based freshness input. |
| toolchain/mfc/test/case.py | Switches coverage_key() to hash canonicalized params (repo-relative paths). |
| toolchain/mfc/test/test_coverage_unit.py | Adds targeted unit tests for canonicalization, deterministic gzip header, entry comparison, and new map_health behavior. |
| .github/workflows/coverage-refresh.yml | Replaces byte-diff guard with a script that compares coverage entries and resets the working tree when unchanged. |
| .github/workflows/coverage-health.yml | Fetches full git history to support ancestry-based freshness checks. |
| .github/scripts/coverage_map_changed.py | New helper to decide whether a rebuilt map is substantively changed (entries). |
| .github/scripts/check_coverage_map_health.py | Adds git-based “built after last relevant change” computation and threads it into map_health. |
|
Claude Code Review Head SHA: 0730715 Files changed:
Findings:
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1683 +/- ##
=======================================
Coverage 61.04% 61.04%
=======================================
Files 83 83
Lines 20978 20978
Branches 3099 3099
=======================================
Hits 12807 12807
Misses 6126 6126
Partials 2045 2045 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Two follow-ups from review of the coverage-map churn fix.
coverage_map_changed.py signalled "unchanged, skip" with exit 1 -- the same
code an uncaught Python exception produces, and the same code a missing
interpreter path would land near. coverage-refresh.yml only treated 2 as an
error, so any crash in the comparison fell through to the unchanged branch:
the rebuilt map was discarded, nothing was pushed, and the job went green. A
permanently broken guard would have looked exactly like a permanently quiet
repo. "Unchanged" is now 10, and the workflow treats every code outside
{0, 10} as a hard error.
canonicalize_param_paths() rejected any relpath result starting with "..",
which also rejects an in-repo file whose name merely begins with dots
(..foo). Such a path would have stayed absolute and kept churning its key --
the exact bug the function exists to remove. Test the leading ".." component,
not the string prefix.
Description
coverage-refresh.ymlhas been pushing atest: refresh coverage map [skip ci]commit on nearly every master push (7/17, 7/19, 7/20 ×2, 7/21, 7/23, 7/25, 7/26 …). It has agit diff --quiet tests/coverage_map.json.gzguard, but that guard can never pass. Three independent causes:1.
param_hashembedded the runner's absolute checkout path.TestCaseBuilder.to_case()absolutizes file-valued params so a test runs from any cwd. For the 8 STL cases that puts the checkout path intostl_models(1)%model_filepath, and hence into the key. Cloning master into two directories and computing all 651 keys from each:Those 8 entries churned out of the map on every refresh. In 4 of the last 7 map commits, 100% of added keys had byte-identical coverage lists to a removed key — the same tests, rehashed. They also mismatched on PR runners, dropping those tests to rung 5 (unmapped → always run).
coverage_key()now hashes the repo-relative form.2.
save_mapwrote a non-deterministic gzip header.gzip.open()stamps the current time and source filename, so identical maps still produced different bytes. Header bytes from consecutive commits:...088f6b5f6a...,...08486a5a6a...,...08c5bb5c6a.... Nowmtime=0, empty filename.3. The guard compared bytes, not coverage.
_meta(built_at,git_sha) moves on every rebuild, sogit diffalways reported a change. Now compared via.github/scripts/coverage_map_changed.py(exit 0 = changed, 10 = unchanged, anything else = error).unchangedis deliberately not 1: an uncaught Python exception exits 1, and a missing interpreter exits 127, so a crash in the comparison would otherwise be indistinguishable from "nothing to push" — rebuilt map silently discarded, job green, a dead guard looking exactly like a quiet repo. Every code outside{0, 10}fails the workflow.Replayed over 7/17–7/26: 7 pushes → 3, and every survivor is a real change (
n_tests610 → 622). Commit96358736was+0 −0against its predecessor — a provably pointless push.Health-check follow-on
Once the bot stops pushing on no-op refreshes,
built_atfreezes during quiet stretches and the "stale after 10 days" rule misfires — the largest gap between coverage-relevant commits in the last 2.5 months is 8.9 days, already close.Replaced with the question that rule was approximating: does the map's
git_shacontain the last commit touchingsrc/**/*.fpporcases.py? The map already recordsgit_sha, so this is onegit merge-base --is-ancestorcall — no API, no token, no heartbeat commits. It also detects a dead refresh on the next relevant push rather than 10 days later. The wall-clock rule stays as the fallback when git can't answer (built_after_last_change=None), so existing behavior is preserved where it still applies.coverage-health.ymlgainsfetch-depth: 0, which the ancestry query needs.Type of change
Testing
toolchain/mfc/test/test_coverage_unit.py(19 new, written failing first; all 39 pre-existing pass unchanged)._metachanged → skip whilegit diffstill reports a change (the everyday case). These build a throwaway git repo and run the script as a subprocess, so they assert the exit codes the workflow actually branches on rather than a mock of them.run:block (git stubbed): 0 → push; 10 → discard rebuilt map; 1, 2 and 127 →::errorand job failure. Codes 1 and 127 took the silent-skip path before..fppcommit →STALE, exit 1, despitebuilt_atbeing today.ruff checkandruff format --checkclean on all touched files; both workflow YAMLs parse. (Two pre-existingrufffailures under.github/scripts/are untouched — they are already on master.)Expected on merge: the first refresh legitimately rewrites the map once, as the 8 STL keys move to canonical form.
Checklist