diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2bdc730..16f9ee9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,12 @@ on: jobs: test-jre21: runs-on: ${{ matrix.os }} + # contents:write is needed by the "Publish coverage badge" step below, which + # commits the regenerated coverage.json back to master on push events. The + # step itself is gated on push + canonical matrix entry, but the permission + # has to be declared at the job level. + permissions: + contents: write strategy: fail-fast: false matrix: @@ -54,7 +60,26 @@ jobs: # (seen flaking on macos-latest x bazel 9.x). USE_BAZEL_VERSION: ${{ matrix.bazel }} COVERAGE_THRESHOLD: '90' - run: ~/go/bin/bazelisk run //tools:coverage-check -- bazel-out/_coverage/_coverage_report.dat + run: ~/go/bin/bazelisk run //tools:coverage-check -- --badge-json coverage.json bazel-out/_coverage/_coverage_report.dat + - name: Publish coverage badge to master + # Only the canonical Linux + Bazel 9.x runner pushes the badge so the + # other matrix entries don't race to commit the same file. Skipped on + # pull_request because forks lack write access and the badge should + # only ever reflect master. + if: github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest' && matrix.bazel == '9.x' + run: | + if [ -n "$(git status --porcelain coverage.json)" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add coverage.json + # [skip ci] is belt-and-suspenders — GITHUB_TOKEN pushes already do + # not retrigger workflows, but the marker makes the intent explicit + # in the log. + git commit -m "ci: update coverage badge [skip ci]" + git push origin HEAD:master + else + echo "coverage.json unchanged; nothing to publish." + fi - name: Upload test logs uses: actions/upload-artifact@v4 if: always() diff --git a/README.md b/README.md index 9c9a053..742ef08 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # bazel-diff [![Build status](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml) +[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Tinder/bazel-diff/master/coverage.json)](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml) `bazel-diff` is a command line tool for Bazel projects that allows users to determine the exact affected set of impacted targets between two Git revisions. Using this set, users can test or build the exact modified set of targets. diff --git a/coverage.json b/coverage.json new file mode 100644 index 0000000..d5f083d --- /dev/null +++ b/coverage.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "label": "coverage", "message": "91.0%", "color": "brightgreen"} diff --git a/tools/coverage_check.py b/tools/coverage_check.py index 0ec5df2..ce2989a 100644 --- a/tools/coverage_check.py +++ b/tools/coverage_check.py @@ -20,6 +20,7 @@ """ import argparse +import json import os import shutil import subprocess @@ -121,6 +122,43 @@ def format_report( return "\n".join(lines) +def badge_color(pct: float) -> str: + """Pick a shields.io color band that visually tracks the project's 90% gate. + + Anything at-or-above the gate is brightgreen; below the gate degrades through + yellow/orange/red so the badge becomes a quick visual signal of how far the + main-source coverage has drifted. + """ + if pct >= 90.0: + return "brightgreen" + if pct >= 75.0: + return "yellow" + if pct >= 60.0: + return "orange" + return "red" + + +def write_badge_json(path: str, overall_pct: float) -> None: + """Write a shields.io endpoint badge JSON describing the overall coverage. + + See https://shields.io/endpoint — the schema is `{schemaVersion, label, + message, color}`. Consumed by a README badge URL of the form + `https://img.shields.io/endpoint?url=…/coverage.json`. + """ + payload = { + "schemaVersion": 1, + "label": "coverage", + "message": f"{overall_pct:.1f}%", + "color": badge_color(overall_pct), + } + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f) + f.write("\n") + + def generate_html_report(lcov_path: str, output_dir: str) -> str: """Render an annotated HTML coverage report from `lcov_path` into `output_dir`. @@ -198,6 +236,17 @@ def main(argv: List[str] | None = None) -> int: "only adds an additional artifact for interactive inspection." ), ) + parser.add_argument( + "--badge-json", + metavar="PATH", + help=( + "Also write a shields.io endpoint JSON describing the overall " + "main-source coverage to PATH. Consumed by the README badge " + "(https://img.shields.io/endpoint?url=…). Written regardless of " + "whether the threshold passes so a regression is visible on the " + "badge instead of being silently skipped." + ), + ) args = parser.parse_args(argv) if not os.path.isfile(args.lcov): @@ -244,6 +293,10 @@ def main(argv: List[str] | None = None) -> int: return 2 overall = total_lh / total_lf * 100.0 + + if args.badge_json: + write_badge_json(args.badge_json, overall) + # Allow a tiny epsilon so 89.99999... that displays as 90.00 still passes a 90 # threshold. Use the raw value, not the rounded one, otherwise 89.99 would slip # through. diff --git a/tools/coverage_check_test.py b/tools/coverage_check_test.py index fdfdb73..05480a6 100644 --- a/tools/coverage_check_test.py +++ b/tools/coverage_check_test.py @@ -5,6 +5,7 @@ """ import io +import json import os import subprocess import sys @@ -16,10 +17,12 @@ import coverage_check from coverage_check import ( FileCoverage, + badge_color, filter_main_source, format_report, main, parse_lcov, + write_badge_json, ) @@ -328,5 +331,116 @@ def test_html_generated_even_when_threshold_fails(self): self.assertIn("FAIL", stderr) +class BadgeColorTest(unittest.TestCase): + def test_color_bands_track_the_gate(self): + # Boundaries: <60 red, <75 orange, <90 yellow, >=90 brightgreen. + # The 90% boundary is the project's enforced gate; coverage at-or-above + # it must read brightgreen so the badge confirms the gate is met. + self.assertEqual(badge_color(0.0), "red") + self.assertEqual(badge_color(59.99), "red") + self.assertEqual(badge_color(60.0), "orange") + self.assertEqual(badge_color(74.99), "orange") + self.assertEqual(badge_color(75.0), "yellow") + self.assertEqual(badge_color(89.99), "yellow") + self.assertEqual(badge_color(90.0), "brightgreen") + self.assertEqual(badge_color(100.0), "brightgreen") + + +class WriteBadgeJsonTest(unittest.TestCase): + def _read_json(self, path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + def test_writes_shields_endpoint_schema(self): + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + self.addCleanup(os.remove, path) + + write_badge_json(path, 92.345) + payload = self._read_json(path) + + # https://shields.io/endpoint requires schemaVersion=1 plus label/message/color. + self.assertEqual(payload["schemaVersion"], 1) + self.assertEqual(payload["label"], "coverage") + self.assertEqual(payload["message"], "92.3%") + self.assertEqual(payload["color"], "brightgreen") + + def test_creates_parent_directory(self): + # The CI step writes into the repo root; we shouldn't require the caller + # to pre-create intermediate directories when they pass a nested path. + tmp = tempfile.mkdtemp() + self.addCleanup(lambda: __import__("shutil").rmtree(tmp)) + path = os.path.join(tmp, "nested", "deeper", "badge.json") + + write_badge_json(path, 50.0) + + self.assertTrue(os.path.isfile(path)) + self.assertEqual(self._read_json(path)["color"], "red") + + def test_below_threshold_still_writes(self): + # write_badge_json itself doesn't gate on the threshold — the caller does. + # We want the badge to surface a regression rather than freeze on the + # last-good value, so a sub-90% number renders honestly (yellow here). + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + self.addCleanup(os.remove, path) + + write_badge_json(path, 80.0) + payload = self._read_json(path) + + self.assertEqual(payload["message"], "80.0%") + self.assertEqual(payload["color"], "yellow") + + +class MainBadgeJsonTest(unittest.TestCase): + def _write_lcov(self, content: str) -> str: + fd, path = tempfile.mkstemp(suffix=".dat") + with os.fdopen(fd, "w") as f: + f.write(content) + self.addCleanup(os.remove, path) + return path + + def _run_main(self, argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(argv) + return rc, out.getvalue(), err.getvalue() + + def test_badge_json_emitted_when_threshold_passes(self): + path = self._write_lcov(SIMPLE_LCOV) + fd, badge_path = tempfile.mkstemp(suffix=".json") + os.close(fd) + self.addCleanup(os.remove, badge_path) + + rc, _, _ = self._run_main( + [path, "--threshold", "50", "--badge-json", badge_path] + ) + + self.assertEqual(rc, 0) + with open(badge_path) as f: + payload = json.load(f) + # SIMPLE_LCOV main-source = 9 hit / 15 total = 60.0% + self.assertEqual(payload["message"], "60.0%") + + def test_badge_json_emitted_when_threshold_fails(self): + # A sub-threshold run must still update the badge — otherwise a regression + # silently keeps the old badge value and the README lies. The exit code + # still fails the gate; only the badge artifact is decoupled. + path = self._write_lcov(SIMPLE_LCOV) + fd, badge_path = tempfile.mkstemp(suffix=".json") + os.close(fd) + self.addCleanup(os.remove, badge_path) + + rc, _, _ = self._run_main( + [path, "--threshold", "90", "--badge-json", badge_path] + ) + + self.assertEqual(rc, 1) + with open(badge_path) as f: + payload = json.load(f) + self.assertEqual(payload["message"], "60.0%") + self.assertEqual(payload["color"], "orange") + + if __name__ == "__main__": unittest.main() diff --git a/tools/readme_template.md b/tools/readme_template.md index 52d9414..23e906c 100644 --- a/tools/readme_template.md +++ b/tools/readme_template.md @@ -1,6 +1,7 @@ # bazel-diff [![Build status](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml) +[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Tinder/bazel-diff/master/coverage.json)](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml) `bazel-diff` is a command line tool for Bazel projects that allows users to determine the exact affected set of impacted targets between two Git revisions. Using this set, users can test or build the exact modified set of targets.