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
27 changes: 26 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
1 change: 1 addition & 0 deletions coverage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schemaVersion": 1, "label": "coverage", "message": "91.0%", "color": "brightgreen"}
53 changes: 53 additions & 0 deletions tools/coverage_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

import argparse
import json
import os
import shutil
import subprocess
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
114 changes: 114 additions & 0 deletions tools/coverage_check_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import io
import json
import os
import subprocess
import sys
Expand All @@ -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,
)


Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions tools/readme_template.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
Loading