Skip to content

Commit 9a253a6

Browse files
cuihairuclaude
andcommitted
fix(ci): merge C# coverage into single file for Codecov
Codecov silently fails to process large Cobertura XML files with generated code. Merge both coverage reports into one clean file that only contains source files (170KB vs 2.8MB). This avoids: - Large files with 94 generated protobuf classes confusing processing - Duplicate source files across two reports causing merge issues - Codecov returning 'No coverage information found on head' Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7a916da commit 9a253a6

2 files changed

Lines changed: 119 additions & 22 deletions

File tree

.github/scripts/normalize_csharp_coverage.py

Lines changed: 116 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
#!/usr/bin/env python3
2-
"""Normalize C# Cobertura paths so Codecov can match repository files."""
2+
"""Normalize C# Cobertura paths so Codecov can match repository files.
3+
4+
Merges all coverlet coverage reports into a single clean Cobertura XML
5+
that only contains source files (no generated code). This avoids:
6+
1. Codecov silently failing on large files with mostly generated code
7+
2. Duplicate source files across multiple coverage reports confusing merging
8+
"""
39

410
from __future__ import annotations
511

@@ -10,6 +16,10 @@
1016
REPO_PREFIX = "sdks/csharp/"
1117
REPORT_GLOB = "sdks/csharp/TestResults/*/coverage.cobertura.xml"
1218
OUTPUT_DIR = Path("sdks/csharp/TestResults/codecov")
19+
OUTPUT_FILE = OUTPUT_DIR / "coverage.xml"
20+
21+
# Files to exclude from the merged report
22+
IGNORE_PATTERNS = ["/generated/", "/obj/"]
1323

1424

1525
def normalize_filename(filename: str, source: str) -> str:
@@ -27,18 +37,106 @@ def normalize_filename(filename: str, source: str) -> str:
2737
return filename
2838

2939

30-
def normalize_report(report: Path, target: Path) -> None:
31-
text = report.read_text(encoding="utf-8")
32-
source_match = re.search(r"<source>(.*?)</source>", text)
33-
source = source_match.group(1) if source_match else ""
34-
35-
text = re.sub(
36-
r'filename="([^"]+)"',
37-
lambda match: f'filename="{normalize_filename(match.group(1), source)}"',
38-
text,
39-
)
40-
text = re.sub(r"<source>.*?</source>", "<source>.</source>", text)
41-
target.write_text(text, encoding="utf-8")
40+
def should_ignore(filename: str) -> bool:
41+
for pat in IGNORE_PATTERNS:
42+
if pat in filename:
43+
return True
44+
if not filename.startswith(REPO_PREFIX):
45+
return True
46+
return False
47+
48+
49+
def merge_reports(reports: list[Path], target: Path) -> None:
50+
"""Merge multiple Cobertura XML reports into one clean report."""
51+
# {filename: [(class_name, line_rate, branch_rate, complexity, lines_xml), ...]}
52+
file_classes: dict[str, list[tuple[str, float, float, int, str]]] = {}
53+
54+
for report in reports:
55+
text = report.read_text(encoding="utf-8")
56+
source_match = re.search(r"<source>(.*?)</source>", text)
57+
source = source_match.group(1) if source_match else ""
58+
59+
class_pattern = re.compile(
60+
r'<class\s+name="([^"]+)"\s+filename="([^"]+)"\s+'
61+
r'line-rate="([^"]+)"\s+branch-rate="([^"]+)"\s+complexity="([^"]+)"'
62+
r'(.*?)(?=</class>)',
63+
re.DOTALL,
64+
)
65+
66+
for m in class_pattern.finditer(text):
67+
class_name = m.group(1)
68+
raw_filename = m.group(2)
69+
line_rate = float(m.group(3))
70+
branch_rate = float(m.group(4))
71+
complexity = int(m.group(5))
72+
class_body = m.group(6)
73+
74+
norm_fn = normalize_filename(raw_filename, source)
75+
if should_ignore(norm_fn):
76+
continue
77+
78+
lines_section = re.search(r'<lines>(.*?)</lines>', class_body, re.DOTALL)
79+
lines_xml = lines_section.group(0) if lines_section else "<lines/>"
80+
81+
if norm_fn not in file_classes:
82+
file_classes[norm_fn] = []
83+
file_classes[norm_fn].append((class_name, line_rate, branch_rate, complexity, lines_xml))
84+
85+
if not file_classes:
86+
raise SystemExit("no source files found after filtering")
87+
88+
# Build merged XML
89+
classes_xml_parts = []
90+
total_lines_covered = 0
91+
total_lines_valid = 0
92+
total_branches_covered = 0
93+
total_branches_valid = 0
94+
total_complexity = 0
95+
96+
for norm_fn in sorted(file_classes.keys()):
97+
classes = file_classes[norm_fn]
98+
for class_name, lr, br, cx, lines_xml in classes:
99+
total_complexity += cx
100+
# Count lines
101+
line_hits = re.findall(r'number="\d+"\s+hits="(\d+)"', lines_xml)
102+
for h in line_hits:
103+
total_lines_valid += 1
104+
if int(h) > 0:
105+
total_lines_covered += 1
106+
# Count branches
107+
conditions = re.findall(r'coverage="(\d+)%"', lines_xml)
108+
total_branches_valid += len(conditions)
109+
for cov in conditions:
110+
if int(cov) > 0:
111+
total_branches_covered += 1
112+
113+
classes_xml_parts.append(
114+
f' <class name="{class_name}" filename="{norm_fn}" '
115+
f'line-rate="{lr}" branch-rate="{br}" complexity="{cx}">'
116+
f'{lines_xml}'
117+
f'</class>'
118+
)
119+
120+
line_rate = total_lines_covered / total_lines_valid if total_lines_valid else 0
121+
branch_rate = total_branches_covered / total_branches_valid if total_branches_valid else 0
122+
123+
classes_xml = "\n".join(classes_xml_parts)
124+
125+
xml_content = f'''<?xml version="1.0" encoding="utf-8"?>
126+
<coverage line-rate="{line_rate:.4f}" branch-rate="{branch_rate:.4f}" version="1.9" timestamp="0" lines-covered="{total_lines_covered}" lines-valid="{total_lines_valid}" branches-covered="{total_branches_covered}" branches-valid="{total_branches_valid}">
127+
<sources>
128+
<source>.</source>
129+
</sources>
130+
<packages>
131+
<package name="Croupier.Sdk" line-rate="{line_rate:.4f}" branch-rate="{branch_rate:.4f}" complexity="{total_complexity}">
132+
<classes>
133+
{classes_xml}
134+
</classes>
135+
</package>
136+
</packages>
137+
</coverage>
138+
'''
139+
target.write_text(xml_content, encoding="utf-8")
42140

43141

44142
def main() -> None:
@@ -47,13 +145,12 @@ def main() -> None:
47145
raise SystemExit("no C# Cobertura coverage reports found")
48146

49147
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
50-
for stale_report in OUTPUT_DIR.glob("coverage-*.cobertura.xml"):
51-
stale_report.unlink()
148+
for stale in OUTPUT_DIR.glob("coverage*.xml"):
149+
stale.unlink()
52150

53-
for index, report in enumerate(reports, start=1):
54-
target = OUTPUT_DIR / f"coverage-{index}.cobertura.xml"
55-
normalize_report(report, target)
56-
print(target)
151+
target = OUTPUT_DIR / "coverage.xml"
152+
merge_reports(reports, target)
153+
print(target)
57154

58155

59156
if __name__ == "__main__":

.github/workflows/ci-sdk-csharp.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ jobs:
4242
run: python3 .github/scripts/normalize_csharp_coverage.py
4343
- name: Verify C# coverage contains repo-relative SDK paths
4444
run: |
45-
test -n "$(find "sdks/csharp/TestResults/codecov" -name "coverage-*.cobertura.xml" -print -quit)"
46-
grep -R -q 'filename="sdks/csharp/src/Croupier.Sdk/' "sdks/csharp/TestResults/codecov"
45+
test -f "sdks/csharp/TestResults/codecov/coverage.xml"
46+
grep -q 'filename="sdks/csharp/src/Croupier.Sdk/' "sdks/csharp/TestResults/codecov/coverage.xml"
4747
- uses: codecov/codecov-action@v7
4848
with:
4949
token: ${{ secrets.CODECOV_TOKEN }}
50-
files: sdks/csharp/TestResults/codecov/coverage-*.cobertura.xml
50+
files: sdks/csharp/TestResults/codecov/coverage.xml
5151
flags: csharp-sdk
5252
disable_search: true
5353
fail_ci_if_error: true

0 commit comments

Comments
 (0)