From 661c35b1e897c8ca6ae2c5dbcb28260bedbd0f8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 17:05:03 +0000 Subject: [PATCH 01/10] Collect and report code coverage in CI Adds Microsoft.Testing.Extensions.CodeCoverage to each test project and wires ReportGenerator into the build workflow so every run emits a GitHub-flavored markdown summary. The summary is appended to the job summary and, on pull requests, posted as a sticky comment so authors see at a glance which areas they touched without tests. https://claude.ai/code/session_01FjeHRA2Wauszz7JaEE8dAR --- .github/workflows/build.yml | 53 ++++++++++++++++++- .../StrongTypes.Analyzers.Tests.csproj | 1 + .../StrongTypes.Api.IntegrationTests.csproj | 1 + .../StrongTypes.Tests.csproj | 1 + 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f79f3095..cf83637b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,7 @@ on: permissions: contents: read + pull-requests: write jobs: build: @@ -37,8 +38,58 @@ jobs: - name: Build run: dotnet build --configuration $config --no-restore + # `-- --coverage ...` forwards flags to Microsoft.Testing.Platform's code + # coverage extension; each test project emits a Cobertura XML under its + # own TestResults/ folder, which ReportGenerator merges in the next step. - name: Test - run: dotnet test --no-restore --no-build --configuration $config + run: >- + dotnet test --no-restore --no-build --configuration $config + -- + --coverage + --coverage-output-format cobertura + + - name: Generate coverage report + if: ${{ !cancelled() }} + run: | + shopt -s globstar nullglob + files=(**/TestResults/**/*.cobertura.xml) + if [ ${#files[@]} -eq 0 ]; then + echo "No coverage files found; skipping report generation." + exit 0 + fi + dotnet tool install -g dotnet-reportgenerator-globaltool + reportgenerator \ + "-reports:**/TestResults/**/*.cobertura.xml" \ + "-targetdir:coverage-report" \ + "-reporttypes:MarkdownSummaryGithub;HtmlInline;Cobertura" \ + "-title:StrongTypes" + + - name: Append coverage summary to job summary + if: ${{ !cancelled() }} + run: | + if [ -f coverage-report/SummaryGithub.md ]; then + cat coverage-report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" + else + echo "No coverage summary to append." + fi + + # Sticky comment: subsequent runs on the same PR edit the existing comment + # rather than piling up new ones, so authors get a single up-to-date view + # of coverage without inbox noise. + - name: Post coverage summary as PR comment + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage-report/SummaryGithub.md') != '' }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage + path: coverage-report/SummaryGithub.md + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('coverage-report/**') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: coverage-report + retention-days: 14 - name: Upload test logs if: always() diff --git a/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj b/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj index fd750396..868a07b0 100644 --- a/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj +++ b/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj b/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj index 169dc980..d2d7ecf0 100644 --- a/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj +++ b/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj @@ -13,6 +13,7 @@ + diff --git a/src/StrongTypes.Tests/StrongTypes.Tests.csproj b/src/StrongTypes.Tests/StrongTypes.Tests.csproj index 2eeaedb5..34518133 100644 --- a/src/StrongTypes.Tests/StrongTypes.Tests.csproj +++ b/src/StrongTypes.Tests/StrongTypes.Tests.csproj @@ -11,6 +11,7 @@ + From 016e6c50941153c4b063898dba8481667654df39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 18:42:20 +0000 Subject: [PATCH 02/10] Group coverage summary by source file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportGenerator's Markdown outputs group by class, which surfaces every compiler-generated closure type as its own row — in extension-method-heavy code like Maybe, a single MaybeExtensions.cs turns into MaybeExtensions, MaybeExtensions, MaybeExtensions, MaybeExtensions, … rows, all with near-identical numbers. Cobertura tags every with its source filename, so we can group on that attribute instead. Adds a small Python post-processor that collapses the merged Cobertura.xml into one row per .cs file and highlights files below 50% line coverage up top, so reviewers see the "add tests here" nudge first. https://claude.ai/code/session_01FjeHRA2Wauszz7JaEE8dAR --- .github/workflows/build.yml | 17 +++-- scripts/coverage-summary.py | 128 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 5 deletions(-) create mode 100755 scripts/coverage-summary.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf83637b..42e209f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,14 +61,21 @@ jobs: reportgenerator \ "-reports:**/TestResults/**/*.cobertura.xml" \ "-targetdir:coverage-report" \ - "-reporttypes:MarkdownSummaryGithub;HtmlInline;Cobertura" \ + "-reporttypes:HtmlInline;Cobertura" \ "-title:StrongTypes" + # ReportGenerator's markdown outputs group by class, which surfaces every + # compiler-generated closure type as its own row (e.g. `MaybeExtensions`, + # `MaybeExtensions`, …). Our own summary groups by source file instead. + - name: Render file-grouped coverage summary + if: ${{ !cancelled() && hashFiles('coverage-report/Cobertura.xml') != '' }} + run: python3 scripts/coverage-summary.py coverage-report/Cobertura.xml coverage-report/FileSummary.md + - name: Append coverage summary to job summary if: ${{ !cancelled() }} run: | - if [ -f coverage-report/SummaryGithub.md ]; then - cat coverage-report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" + if [ -f coverage-report/FileSummary.md ]; then + cat coverage-report/FileSummary.md >> "$GITHUB_STEP_SUMMARY" else echo "No coverage summary to append." fi @@ -77,11 +84,11 @@ jobs: # rather than piling up new ones, so authors get a single up-to-date view # of coverage without inbox noise. - name: Post coverage summary as PR comment - if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage-report/SummaryGithub.md') != '' }} + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage-report/FileSummary.md') != '' }} uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage - path: coverage-report/SummaryGithub.md + path: coverage-report/FileSummary.md - name: Upload coverage report if: ${{ !cancelled() && hashFiles('coverage-report/**') != '' }} diff --git a/scripts/coverage-summary.py b/scripts/coverage-summary.py new file mode 100755 index 00000000..a2f60854 --- /dev/null +++ b/scripts/coverage-summary.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Render a file-grouped Markdown coverage summary from a merged Cobertura XML. + +ReportGenerator's Markdown outputs group by class, which gets noisy for +extension-method-heavy code: compiler-generated closure types surface as +separate rows like `MaybeExtensions`, `MaybeExtensions`, …, all +coming from the same .cs file. Cobertura's `` attribute +lets us collapse those back to one row per source file. +""" +from __future__ import annotations + +import os +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict + + +LOW_COVERAGE_THRESHOLD = 50.0 + + +def repo_relative(path: str) -> str: + path = path.replace("\\", "/") + marker = "/src/" + idx = path.find(marker) + if idx >= 0: + return path[idx + 1 :] + return path.lstrip("/") + + +def render(cobertura_path: str, output_path: str) -> None: + tree = ET.parse(cobertura_path) + root = tree.getroot() + + # filename -> [lines_covered, lines_total, branches_covered, branches_total] + by_file: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0, 0]) + + for cls in root.iter("class"): + filename = cls.get("filename") + if not filename: + continue + bucket = by_file[filename] + for line in cls.iter("line"): + hits = int(line.get("hits", "0")) + bucket[1] += 1 + if hits > 0: + bucket[0] += 1 + if line.get("branch") == "true": + cond = line.get("condition-coverage", "") + # condition-coverage looks like "50% (1/2)" + if "(" in cond and "/" in cond: + frac = cond.split("(", 1)[1].rstrip(")") + covered, total = frac.split("/") + bucket[2] += int(covered) + bucket[3] += int(total) + + rows = sorted( + ( + (repo_relative(fname), cov, tot, bcov, btot) + for fname, (cov, tot, bcov, btot) in by_file.items() + ), + key=lambda r: r[0], + ) + + total_cov = sum(r[1] for r in rows) + total_lines = sum(r[2] for r in rows) + total_bcov = sum(r[3] for r in rows) + total_branches = sum(r[4] for r in rows) + + def pct(covered: int, total: int) -> str: + return f"{(covered / total * 100):.1f}%" if total else "n/a" + + low = [r for r in rows if r[2] > 0 and (r[1] / r[2] * 100) < LOW_COVERAGE_THRESHOLD] + + lines: list[str] = [] + lines.append("## Coverage") + lines.append("") + lines.append( + f"**Lines:** {total_cov} / {total_lines} " + f"({pct(total_cov, total_lines)})    " + f"**Branches:** {total_bcov} / {total_branches} " + f"({pct(total_bcov, total_branches)})" + ) + lines.append("") + + if low: + lines.append(f"### Files below {LOW_COVERAGE_THRESHOLD:.0f}% line coverage") + lines.append("") + lines.append("Consider adding tests — these files drag the number down.") + lines.append("") + lines.append("| File | Lines | Branches |") + lines.append("|---|---:|---:|") + for fname, cov, tot, bcov, btot in low: + lines.append( + f"| `{fname}` | {cov} / {tot} ({pct(cov, tot)}) | " + f"{bcov} / {btot} ({pct(bcov, btot)}) |" + ) + lines.append("") + else: + lines.append( + f"No files below {LOW_COVERAGE_THRESHOLD:.0f}% line coverage." + ) + lines.append("") + + lines.append("
All files") + lines.append("") + lines.append("| File | Lines | Branches |") + lines.append("|---|---:|---:|") + for fname, cov, tot, bcov, btot in rows: + lines.append( + f"| `{fname}` | {cov} / {tot} ({pct(cov, tot)}) | " + f"{bcov} / {btot} ({pct(bcov, btot)}) |" + ) + lines.append("") + lines.append("
") + lines.append("") + + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print( + f"usage: {os.path.basename(sys.argv[0])} ", + file=sys.stderr, + ) + sys.exit(2) + render(sys.argv[1], sys.argv[2]) From a35a1483f468826d9be178568632ae40f9bd4640 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 20:58:19 +0200 Subject: [PATCH 03/10] Fix inflated coverage totals and scope PR table to changed files Cobertura emits one per constructed generic, so summing entries naively multiplied physical lines by instantiation count. Dedupe by (filename, line number) to count each source line once and take the max of branch coverage across instantiations. Also compact source-generator output paths to `generated/`, translate backtick arity to `` so the filename stays inside its markdown code span, and replace the "below 50%" section with a PR-scoped "Files changed in this PR" table driven by `gh pr view`. --- .github/workflows/build.yml | 15 ++- scripts/coverage-summary.py | 226 ++++++++++++++++++++++-------------- 2 files changed, 155 insertions(+), 86 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 42e209f9..001948ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,12 +64,25 @@ jobs: "-reporttypes:HtmlInline;Cobertura" \ "-title:StrongTypes" + # List the files this PR touches so the summary can highlight their coverage + # instead of drowning in a `below 50%` list dominated by unmigrated legacy code. + - name: List changed files + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path' > changed-files.txt + # ReportGenerator's markdown outputs group by class, which surfaces every # compiler-generated closure type as its own row (e.g. `MaybeExtensions`, # `MaybeExtensions`, …). Our own summary groups by source file instead. - name: Render file-grouped coverage summary if: ${{ !cancelled() && hashFiles('coverage-report/Cobertura.xml') != '' }} - run: python3 scripts/coverage-summary.py coverage-report/Cobertura.xml coverage-report/FileSummary.md + run: | + args=(coverage-report/Cobertura.xml coverage-report/FileSummary.md) + if [ -f changed-files.txt ]; then + args+=(--changed-files changed-files.txt) + fi + python3 scripts/coverage-summary.py "${args[@]}" - name: Append coverage summary to job summary if: ${{ !cancelled() }} diff --git a/scripts/coverage-summary.py b/scripts/coverage-summary.py index a2f60854..6d2535ba 100755 --- a/scripts/coverage-summary.py +++ b/scripts/coverage-summary.py @@ -1,128 +1,184 @@ #!/usr/bin/env python3 """Render a file-grouped Markdown coverage summary from a merged Cobertura XML. -ReportGenerator's Markdown outputs group by class, which gets noisy for -extension-method-heavy code: compiler-generated closure types surface as -separate rows like `MaybeExtensions`, `MaybeExtensions`, …, all -coming from the same .cs file. Cobertura's `` attribute -lets us collapse those back to one row per source file. +Cobertura emits one ```` per constructed generic type, and each one +carries a full copy of the source file's ```` entries. Summing naively +multiplies physical lines by the number of instantiations (e.g. a 200-line +file materialized as 700 closed generics becomes 140,000 "lines"). We dedupe +by (filename, line number) so each physical line is counted once, and take +the max of branch coverage across instantiations so a branch exercised by any +instantiation is credited at the source level. """ from __future__ import annotations -import os -import sys +import argparse +import re import xml.etree.ElementTree as ET from collections import defaultdict -LOW_COVERAGE_THRESHOLD = 50.0 - - def repo_relative(path: str) -> str: path = path.replace("\\", "/") marker = "/src/" idx = path.find(marker) if idx >= 0: - return path[idx + 1 :] + return path[idx + 1:] return path.lstrip("/") -def render(cobertura_path: str, output_path: str) -> None: - tree = ET.parse(cobertura_path) - root = tree.getroot() +_ARITY_RE = re.compile(r"`(\d+)") + + +def _format_arity(match: re.Match[str]) -> str: + n = int(match.group(1)) + params = "T" if n == 1 else ",".join(f"T{i}" for i in range(1, n + 1)) + return f"<{params}>" + - # filename -> [lines_covered, lines_total, branches_covered, branches_total] - by_file: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0, 0]) +def display_path(path: str) -> str: + """Compact noisy paths for readability in the summary table. + + Source-generator output lives under ``src//obj////…``. + Those long prefixes push the table too wide, so collapse them to + ``generated/`` and translate ``Foo`1`` → ``Foo`` so the filename + doesn't contain a stray backtick that breaks out of the markdown code span. + """ + rel = repo_relative(path) + if "/obj/" in rel and rel.endswith(".g.cs"): + basename = rel.rsplit("/", 1)[-1] + if basename.startswith("StrongTypes."): + basename = basename[len("StrongTypes."):] + basename = _ARITY_RE.sub(_format_arity, basename) + return f"generated/{basename}" + return rel + + +def parse_cobertura(path: str) -> dict[str, dict[int, list]]: + tree = ET.parse(path) + root = tree.getroot() + # entry schema: [any_hit, branches_covered_max, branches_total_max] + per_file: dict[str, dict[int, list]] = defaultdict( + lambda: defaultdict(lambda: [False, 0, 0]) + ) for cls in root.iter("class"): filename = cls.get("filename") if not filename: continue - bucket = by_file[filename] + file_lines = per_file[filename] for line in cls.iter("line"): + num = int(line.get("number", "0")) hits = int(line.get("hits", "0")) - bucket[1] += 1 + entry = file_lines[num] if hits > 0: - bucket[0] += 1 + entry[0] = True if line.get("branch") == "true": cond = line.get("condition-coverage", "") - # condition-coverage looks like "50% (1/2)" if "(" in cond and "/" in cond: frac = cond.split("(", 1)[1].rstrip(")") - covered, total = frac.split("/") - bucket[2] += int(covered) - bucket[3] += int(total) - - rows = sorted( - ( - (repo_relative(fname), cov, tot, bcov, btot) - for fname, (cov, tot, bcov, btot) in by_file.items() - ), - key=lambda r: r[0], - ) - - total_cov = sum(r[1] for r in rows) - total_lines = sum(r[2] for r in rows) - total_bcov = sum(r[3] for r in rows) - total_branches = sum(r[4] for r in rows) - - def pct(covered: int, total: int) -> str: - return f"{(covered / total * 100):.1f}%" if total else "n/a" - - low = [r for r in rows if r[2] > 0 and (r[1] / r[2] * 100) < LOW_COVERAGE_THRESHOLD] - - lines: list[str] = [] - lines.append("## Coverage") - lines.append("") - lines.append( + covered_s, total_s = frac.split("/") + covered, total = int(covered_s), int(total_s) + if covered > entry[1]: + entry[1] = covered + if total > entry[2]: + entry[2] = total + return per_file + + +def pct(covered: int, total: int) -> str: + return f"{(covered / total * 100):.1f}%" if total else "n/a" + + +def load_changed_files(path: str | None) -> set[str] | None: + if not path: + return None + try: + with open(path, encoding="utf-8") as f: + entries = {line.strip() for line in f if line.strip()} + except FileNotFoundError: + return None + return entries + + +def render(cobertura_path: str, output_path: str, changed_files: set[str] | None) -> None: + per_file = parse_cobertura(cobertura_path) + + rows = [] + for filename, lines in per_file.items(): + lines_total = len(lines) + lines_cov = sum(1 for e in lines.values() if e[0]) + branches_cov = sum(e[1] for e in lines.values()) + branches_total = sum(e[2] for e in lines.values()) + rows.append(( + display_path(filename), + repo_relative(filename), + lines_cov, + lines_total, + branches_cov, + branches_total, + )) + rows.sort(key=lambda r: r[0]) + + total_cov = sum(r[2] for r in rows) + total_lines = sum(r[3] for r in rows) + total_bcov = sum(r[4] for r in rows) + total_branches = sum(r[5] for r in rows) + + out: list[str] = [] + out.append("## Coverage") + out.append("") + out.append( f"**Lines:** {total_cov} / {total_lines} " f"({pct(total_cov, total_lines)})    " f"**Branches:** {total_bcov} / {total_branches} " f"({pct(total_bcov, total_branches)})" ) - lines.append("") - - if low: - lines.append(f"### Files below {LOW_COVERAGE_THRESHOLD:.0f}% line coverage") - lines.append("") - lines.append("Consider adding tests — these files drag the number down.") - lines.append("") - lines.append("| File | Lines | Branches |") - lines.append("|---|---:|---:|") - for fname, cov, tot, bcov, btot in low: - lines.append( - f"| `{fname}` | {cov} / {tot} ({pct(cov, tot)}) | " - f"{bcov} / {btot} ({pct(bcov, btot)}) |" - ) - lines.append("") - else: - lines.append( - f"No files below {LOW_COVERAGE_THRESHOLD:.0f}% line coverage." - ) - lines.append("") - - lines.append("
All files") - lines.append("") - lines.append("| File | Lines | Branches |") - lines.append("|---|---:|---:|") - for fname, cov, tot, bcov, btot in rows: - lines.append( - f"| `{fname}` | {cov} / {tot} ({pct(cov, tot)}) | " + out.append("") + + if changed_files is not None: + changed_rows = [r for r in rows if r[1] in changed_files] + out.append("### Files changed in this PR") + out.append("") + if changed_rows: + out.append("| File | Lines | Branches |") + out.append("|---|---:|---:|") + for disp, _rel, cov, tot, bcov, btot in changed_rows: + out.append( + f"| `{disp}` | {cov} / {tot} ({pct(cov, tot)}) | " + f"{bcov} / {btot} ({pct(bcov, btot)}) |" + ) + else: + out.append("_No coverage-instrumented files changed in this PR._") + out.append("") + + out.append("
All files") + out.append("") + out.append("| File | Lines | Branches |") + out.append("|---|---:|---:|") + for disp, _rel, cov, tot, bcov, btot in rows: + out.append( + f"| `{disp}` | {cov} / {tot} ({pct(cov, tot)}) | " f"{bcov} / {btot} ({pct(bcov, btot)}) |" ) - lines.append("") - lines.append("
") - lines.append("") + out.append("") + out.append("
") + out.append("") with open(output_path, "w", encoding="utf-8") as f: - f.write("\n".join(lines)) + f.write("\n".join(out)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("cobertura") + parser.add_argument("output") + parser.add_argument( + "--changed-files", + help="Path to a newline-delimited list of repo-relative paths changed in this PR.", + ) + args = parser.parse_args() + render(args.cobertura, args.output, load_changed_files(args.changed_files)) if __name__ == "__main__": - if len(sys.argv) != 3: - print( - f"usage: {os.path.basename(sys.argv[0])} ", - file=sys.stderr, - ) - sys.exit(2) - render(sys.argv[1], sys.argv[2]) + main() From 9307753811ee8d35da9a4c1cfaa90ca6e2c54cd0 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 21:07:20 +0200 Subject: [PATCH 04/10] Group coverage summary by project and folder Each project gets its own collapsible
block with project-level totals in the summary line; within a project, files are grouped under bold folder headers with per-folder percentages. Source-generator output folds into a `generated` folder under its originating project instead of sitting at top level. --- scripts/coverage-summary.py | 173 +++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 50 deletions(-) diff --git a/scripts/coverage-summary.py b/scripts/coverage-summary.py index 6d2535ba..7f8834af 100755 --- a/scripts/coverage-summary.py +++ b/scripts/coverage-summary.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Render a file-grouped Markdown coverage summary from a merged Cobertura XML. +"""Render a project/folder-grouped Markdown coverage summary from a merged Cobertura XML. Cobertura emits one ```` per constructed generic type, and each one carries a full copy of the source file's ```` entries. Summing naively @@ -15,6 +15,11 @@ import re import xml.etree.ElementTree as ET from collections import defaultdict +from dataclasses import dataclass + + +ROOT_FOLDER = "(root)" +GENERATED_FOLDER = "generated" def repo_relative(path: str) -> str: @@ -35,22 +40,46 @@ def _format_arity(match: re.Match[str]) -> str: return f"<{params}>" -def display_path(path: str) -> str: - """Compact noisy paths for readability in the summary table. +@dataclass +class Row: + project: str + folder: str + filename: str # display basename (arity-normalized for generated code) + rel: str # repo-relative path used for change-set matching + lines_cov: int + lines_total: int + branches_cov: int + branches_total: int + + +def classify(rel: str) -> tuple[str, str, str]: + """Split a repo-relative path into (project, folder, display filename). - Source-generator output lives under ``src//obj////…``. - Those long prefixes push the table too wide, so collapse them to - ``generated/`` and translate ``Foo`1`` → ``Foo`` so the filename - doesn't contain a stray backtick that breaks out of the markdown code span. + Normal files live under ``src///``. Source-generator + output lives under ``src//obj////.g.cs``; + we bucket those into a ``generated`` folder under the same project with a + compacted filename, since the long obj-path prefix is noise in a table. """ - rel = repo_relative(path) - if "/obj/" in rel and rel.endswith(".g.cs"): - basename = rel.rsplit("/", 1)[-1] + parts = rel.split("/") + if len(parts) < 2 or parts[0] != "src": + return ("(other)", ROOT_FOLDER, rel) + + project = parts[1] + remaining = parts[2:] + + if len(remaining) >= 2 and remaining[0] == "obj" and rel.endswith(".g.cs"): + basename = remaining[-1] if basename.startswith("StrongTypes."): basename = basename[len("StrongTypes."):] basename = _ARITY_RE.sub(_format_arity, basename) - return f"generated/{basename}" - return rel + return (project, GENERATED_FOLDER, basename) + + if len(remaining) == 1: + return (project, ROOT_FOLDER, remaining[0]) + + folder = "/".join(remaining[:-1]) + filename = remaining[-1] + return (project, folder, filename) def parse_cobertura(path: str) -> dict[str, dict[int, list]]: @@ -94,75 +123,119 @@ def load_changed_files(path: str | None) -> set[str] | None: return None try: with open(path, encoding="utf-8") as f: - entries = {line.strip() for line in f if line.strip()} + return {line.strip() for line in f if line.strip()} except FileNotFoundError: return None - return entries - -def render(cobertura_path: str, output_path: str, changed_files: set[str] | None) -> None: - per_file = parse_cobertura(cobertura_path) - rows = [] +def build_rows(per_file: dict[str, dict[int, list]]) -> list[Row]: + rows: list[Row] = [] for filename, lines in per_file.items(): lines_total = len(lines) lines_cov = sum(1 for e in lines.values() if e[0]) branches_cov = sum(e[1] for e in lines.values()) branches_total = sum(e[2] for e in lines.values()) - rows.append(( - display_path(filename), - repo_relative(filename), - lines_cov, - lines_total, - branches_cov, - branches_total, + rel = repo_relative(filename) + project, folder, display = classify(rel) + rows.append(Row( + project=project, + folder=folder, + filename=display, + rel=rel, + lines_cov=lines_cov, + lines_total=lines_total, + branches_cov=branches_cov, + branches_total=branches_total, )) - rows.sort(key=lambda r: r[0]) + return rows + + +def _row_cells(r: Row, path_override: str | None = None) -> str: + label = path_override if path_override is not None else r.filename + return ( + f"| `{label}` | {r.lines_cov} / {r.lines_total} ({pct(r.lines_cov, r.lines_total)}) | " + f"{r.branches_cov} / {r.branches_total} ({pct(r.branches_cov, r.branches_total)}) |" + ) + - total_cov = sum(r[2] for r in rows) - total_lines = sum(r[3] for r in rows) - total_bcov = sum(r[4] for r in rows) - total_branches = sum(r[5] for r in rows) +def render(cobertura_path: str, output_path: str, changed_files: set[str] | None) -> None: + rows = build_rows(parse_cobertura(cobertura_path)) + + total_lc = sum(r.lines_cov for r in rows) + total_lt = sum(r.lines_total for r in rows) + total_bc = sum(r.branches_cov for r in rows) + total_bt = sum(r.branches_total for r in rows) out: list[str] = [] out.append("## Coverage") out.append("") out.append( - f"**Lines:** {total_cov} / {total_lines} " - f"({pct(total_cov, total_lines)})    " - f"**Branches:** {total_bcov} / {total_branches} " - f"({pct(total_bcov, total_branches)})" + f"**Lines:** {total_lc} / {total_lt} " + f"({pct(total_lc, total_lt)})    " + f"**Branches:** {total_bc} / {total_bt} " + f"({pct(total_bc, total_bt)})" ) out.append("") if changed_files is not None: - changed_rows = [r for r in rows if r[1] in changed_files] + changed_rows = sorted( + (r for r in rows if r.rel in changed_files), + key=lambda r: (r.project, r.folder, r.filename.lower()), + ) out.append("### Files changed in this PR") out.append("") if changed_rows: out.append("| File | Lines | Branches |") out.append("|---|---:|---:|") - for disp, _rel, cov, tot, bcov, btot in changed_rows: - out.append( - f"| `{disp}` | {cov} / {tot} ({pct(cov, tot)}) | " - f"{bcov} / {btot} ({pct(bcov, btot)}) |" - ) + for r in changed_rows: + # Full project-qualified path helps readers locate the file + # when the table is flat across projects. + label = f"{r.project}/{r.folder}/{r.filename}" if r.folder != ROOT_FOLDER else f"{r.project}/{r.filename}" + out.append(_row_cells(r, path_override=label)) else: out.append("_No coverage-instrumented files changed in this PR._") out.append("") - out.append("
All files") - out.append("") - out.append("| File | Lines | Branches |") - out.append("|---|---:|---:|") - for disp, _rel, cov, tot, bcov, btot in rows: + projects: dict[str, list[Row]] = defaultdict(list) + for r in rows: + projects[r.project].append(r) + + for project in sorted(projects): + proj_rows = projects[project] + p_lc = sum(r.lines_cov for r in proj_rows) + p_lt = sum(r.lines_total for r in proj_rows) + p_bc = sum(r.branches_cov for r in proj_rows) + p_bt = sum(r.branches_total for r in proj_rows) out.append( - f"| `{disp}` | {cov} / {tot} ({pct(cov, tot)}) | " - f"{bcov} / {btot} ({pct(bcov, btot)}) |" + f"
{project} — " + f"lines {pct(p_lc, p_lt)} ({p_lc}/{p_lt}), " + f"branches {pct(p_bc, p_bt)} ({p_bc}/{p_bt})" ) - out.append("") - out.append("
") - out.append("") + out.append("") + + folders: dict[str, list[Row]] = defaultdict(list) + for r in proj_rows: + folders[r.folder].append(r) + + for folder in sorted(folders): + f_rows = sorted(folders[folder], key=lambda r: r.filename.lower()) + f_lc = sum(r.lines_cov for r in f_rows) + f_lt = sum(r.lines_total for r in f_rows) + f_bc = sum(r.branches_cov for r in f_rows) + f_bt = sum(r.branches_total for r in f_rows) + out.append( + f"**{folder}** — " + f"lines {pct(f_lc, f_lt)}, branches {pct(f_bc, f_bt)}" + ) + out.append("") + out.append("| File | Lines | Branches |") + out.append("|---|---:|---:|") + for r in f_rows: + out.append(_row_cells(r)) + out.append("") + + out.append("
") + out.append("") with open(output_path, "w", encoding="utf-8") as f: f.write("\n".join(out)) From a31e644d77a9c0643e06b2615fb5f36eaced5e48 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 21:31:23 +0200 Subject: [PATCH 05/10] Make coverage folders nested collapsibles with absolute counts Each folder becomes its own
block under its project, so readers can collapse noisy areas (e.g. _Old folders) while keeping the rest expanded. Folder headers now carry absolute covered/total counts alongside percentages, matching the project summary line. A
after the project summary adds vertical breathing room before the first folder. --- scripts/coverage-summary.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/coverage-summary.py b/scripts/coverage-summary.py index 7f8834af..74727068 100755 --- a/scripts/coverage-summary.py +++ b/scripts/coverage-summary.py @@ -211,6 +211,10 @@ def render(cobertura_path: str, output_path: str, changed_files: set[str] | None f"lines {pct(p_lc, p_lt)} ({p_lc}/{p_lt}), " f"branches {pct(p_bc, p_bt)} ({p_bc}/{p_bt})" ) + # Blank line lets markdown re-parse inside
; the
gives + # the collapsed-header row breathing room above the first folder. + out.append("") + out.append("
") out.append("") folders: dict[str, list[Row]] = defaultdict(list) @@ -224,8 +228,9 @@ def render(cobertura_path: str, output_path: str, changed_files: set[str] | None f_bc = sum(r.branches_cov for r in f_rows) f_bt = sum(r.branches_total for r in f_rows) out.append( - f"**{folder}** — " - f"lines {pct(f_lc, f_lt)}, branches {pct(f_bc, f_bt)}" + f"
{folder} — " + f"lines {pct(f_lc, f_lt)} ({f_lc}/{f_lt}), " + f"branches {pct(f_bc, f_bt)} ({f_bc}/{f_bt})" ) out.append("") out.append("| File | Lines | Branches |") @@ -233,6 +238,8 @@ def render(cobertura_path: str, output_path: str, changed_files: set[str] | None for r in f_rows: out.append(_row_cells(r)) out.append("") + out.append("
") + out.append("") out.append("
") out.append("") From 2c4e4b7c2a42680b5012ed9284d74e855818b678 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 21:45:35 +0200 Subject: [PATCH 06/10] Run PR tests and coverage in Debug; test Release only at publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug builds preserve per-statement sequence points, so coverage lines up 1:1 with source. Release merges those points and folds constant branches, which skews the coverage numbers slightly. Switch the PR/push build to Debug and drop it on release events — the publish job now Release-builds, runs the full test suite against those binaries, and only pushes to nuget.org if they pass. --- .github/workflows/build.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 001948ec..90a3c5fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,10 +16,15 @@ permissions: jobs: build: + # Release events publish via the `publish` job below, which does its + # own Release build + tests before shipping. PRs and pushes to main + # use Debug here so coverage sequence points line up 1:1 with source + # (Release merges sequence points and folds constant branches). + if: github.event_name != 'release' runs-on: ubuntu-latest env: - config: 'Release' + config: 'Debug' DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -122,9 +127,11 @@ jobs: retention-days: 5 publish: - needs: build # Gate on main: a Release drafted through the UI carries the selected # branch in target_commitish. Only releases targeted at main publish. + # No `needs: build` — the PR/push `build` job doesn't run on release + # events, so this job validates Release binaries itself via the Test + # step below before pushing to nuget.org. if: github.event_name == 'release' && github.event.release.target_commitish == 'main' runs-on: ubuntu-latest environment: Nuget.org @@ -172,6 +179,23 @@ jobs: -p:PackageReleaseNotes="See $RELEASE_URL" \ -p:PackageOutputPath="$PWD/out" + # Validate the Release binaries we're about to publish. PRs already + # ran the full suite in Debug; this step catches config-specific + # regressions (compiler optimizations, trimming, etc.) before nupkgs + # leave the runner. + - name: Test + run: dotnet test --no-restore --no-build --configuration $config + + - name: Upload test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-logs-release + path: | + **/TestResults/**/*.log + **/TestResults/**/*.trx + retention-days: 5 + - name: Publish packages run: dotnet nuget push ./out/*.nupkg --source nuget.org --api-key ${{ secrets.NUGET_TOKEN }} From f43ee0a4cb7628d66de4ced80612454151dfac8b Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 21:48:31 +0200 Subject: [PATCH 07/10] Split CI and release workflows The two paths now share no steps and no dependencies: ci.yml runs on PR/push, builds Debug, runs tests with coverage, posts the sticky PR comment; release.yml runs on release-published, builds Release, runs tests against shipping binaries, pushes to nuget.org, and attaches the nupkgs to the release. Each file now has a single `on:` trigger and no job-level conditionals, which reads much more cleanly than the prior single-file fork. --- .github/workflows/{build.yml => ci.yml} | 92 ++----------------------- .github/workflows/release.yml | 84 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 87 deletions(-) rename .github/workflows/{build.yml => ci.yml} (54%) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/ci.yml similarity index 54% rename from .github/workflows/build.yml rename to .github/workflows/ci.yml index 90a3c5fe..f6f68e77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: build +name: ci on: push: @@ -7,22 +7,18 @@ on: pull_request: branches: - main - release: - types: [published] permissions: contents: read pull-requests: write jobs: - build: - # Release events publish via the `publish` job below, which does its - # own Release build + tests before shipping. PRs and pushes to main - # use Debug here so coverage sequence points line up 1:1 with source - # (Release merges sequence points and folds constant branches). - if: github.event_name != 'release' + test: runs-on: ubuntu-latest + # Debug keeps per-statement sequence points so coverage lines up 1:1 + # with source. Release merges points and folds constant branches, + # which skews the numbers; shipping validation happens in release.yml. env: config: 'Debug' DOTNET_NOLOGO: true @@ -125,81 +121,3 @@ jobs: **/TestResults/**/*.log **/TestResults/**/*.trx retention-days: 5 - - publish: - # Gate on main: a Release drafted through the UI carries the selected - # branch in target_commitish. Only releases targeted at main publish. - # No `needs: build` — the PR/push `build` job doesn't run on release - # events, so this job validates Release binaries itself via the Test - # step below before pushing to nuget.org. - if: github.event_name == 'release' && github.event.release.target_commitish == 'main' - runs-on: ubuntu-latest - environment: Nuget.org - permissions: - contents: write - - env: - config: 'Release' - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - - - name: Parse version from tag - id: version - run: | - TAG="${GITHUB_REF_NAME}" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - - # GeneratePackageOnBuild=true in the csproj files means `dotnet build` - # produces the nupkg as a side-effect; no separate `dotnet pack` step - # needed. -p:Version cascades into referenced projects (EfCore's - # ProjectReference to core), so EfCore's nuspec pins its core - # dependency to the same version. - # Build the whole solution; non-publishable projects (tests, internal - # source generators, analyzers) set IsPackable=false so they produce no - # nupkg. GeneratePackageOnBuild=true on publishable csprojs means the - # build itself emits .nupkg files into PackageOutputPath. New publishable - # packages are picked up automatically by setting the same two properties. - - name: Build and pack - env: - RELEASE_URL: ${{ github.event.release.html_url }} - VERSION: ${{ steps.version.outputs.version }} - run: | - dotnet build StrongTypes.slnx \ - -c $config \ - -p:Version="$VERSION" \ - -p:PackageReleaseNotes="See $RELEASE_URL" \ - -p:PackageOutputPath="$PWD/out" - - # Validate the Release binaries we're about to publish. PRs already - # ran the full suite in Debug; this step catches config-specific - # regressions (compiler optimizations, trimming, etc.) before nupkgs - # leave the runner. - - name: Test - run: dotnet test --no-restore --no-build --configuration $config - - - name: Upload test logs - if: always() - uses: actions/upload-artifact@v7 - with: - name: test-logs-release - path: | - **/TestResults/**/*.log - **/TestResults/**/*.trx - retention-days: 5 - - - name: Publish packages - run: dotnet nuget push ./out/*.nupkg --source nuget.org --api-key ${{ secrets.NUGET_TOKEN }} - - - name: Attach packages to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "$GITHUB_REF_NAME" ./out/*.nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b86ec606 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + # Gate on main: a Release drafted through the UI carries the selected + # branch in target_commitish. Only releases targeted at main publish. + if: github.event.release.target_commitish == 'main' + runs-on: ubuntu-latest + environment: Nuget.org + permissions: + contents: write + + env: + config: 'Release' + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Parse version from tag + id: version + run: | + TAG="${GITHUB_REF_NAME}" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + # GeneratePackageOnBuild=true in the csproj files means `dotnet build` + # produces the nupkg as a side-effect; no separate `dotnet pack` step + # needed. -p:Version cascades into referenced projects (EfCore's + # ProjectReference to core), so EfCore's nuspec pins its core + # dependency to the same version. + # Build the whole solution; non-publishable projects (tests, internal + # source generators, analyzers) set IsPackable=false so they produce no + # nupkg. GeneratePackageOnBuild=true on publishable csprojs means the + # build itself emits .nupkg files into PackageOutputPath. New publishable + # packages are picked up automatically by setting the same two properties. + - name: Build and pack + env: + RELEASE_URL: ${{ github.event.release.html_url }} + VERSION: ${{ steps.version.outputs.version }} + run: | + dotnet build StrongTypes.slnx \ + -c $config \ + -p:Version="$VERSION" \ + -p:PackageReleaseNotes="See $RELEASE_URL" \ + -p:PackageOutputPath="$PWD/out" + + # Validate the Release binaries we're about to publish. PRs already + # ran the full suite in Debug; this step catches config-specific + # regressions (compiler optimizations, trimming, etc.) before nupkgs + # leave the runner. + - name: Test + run: dotnet test --no-restore --no-build --configuration $config + + - name: Upload test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-logs-release + path: | + **/TestResults/**/*.log + **/TestResults/**/*.trx + retention-days: 5 + + - name: Publish packages + run: dotnet nuget push ./out/*.nupkg --source nuget.org --api-key ${{ secrets.NUGET_TOKEN }} + + - name: Attach packages to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "$GITHUB_REF_NAME" ./out/*.nupkg From 43669aaad93b8a4a293a73101991b526f28c6760 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 21:50:34 +0200 Subject: [PATCH 08/10] Keep workflow as build.yml and add release.yml alongside Rename ci.yml back to build.yml (workflow name `build`, job name `build`) so existing branch protections and status-check names keep working. The new release.yml sits next to it, unchanged. --- .github/workflows/{ci.yml => build.yml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{ci.yml => build.yml} (99%) diff --git a/.github/workflows/ci.yml b/.github/workflows/build.yml similarity index 99% rename from .github/workflows/ci.yml rename to .github/workflows/build.yml index f6f68e77..a5182d78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: ci +name: build on: push: @@ -13,7 +13,7 @@ permissions: pull-requests: write jobs: - test: + build: runs-on: ubuntu-latest # Debug keeps per-statement sequence points so coverage lines up 1:1 From 52775f1aac9a0b3cb4552cc5e026cead59007e3c Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 22:09:16 +0200 Subject: [PATCH 09/10] Run codecov alongside the custom coverage comment for comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload the merged Cobertura.xml to codecov.io via the codecov-action and configure components per project so the PR comment groups coverage by StrongTypes / Analyzers / EfCore / Api / FsCheck. Left `informational: true` on both project and patch statuses so codecov can't fail the PR while we're evaluating. The custom sticky comment still posts — both appear on the PR and we pick one afterwards. --- .github/workflows/build.yml | 11 +++++++++++ codecov.yml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 codecov.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5182d78..c619b119 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,6 +104,17 @@ jobs: header: coverage path: coverage-report/FileSummary.md + # Evaluation mode alongside the custom sticky comment above. No token + # needed for public repos; codecov-action uses OIDC. Requires the repo + # to be enabled at https://app.codecov.io/ — sign in with GitHub and + # add KaliCZ/StrongTypes. + - name: Upload coverage to Codecov + if: ${{ !cancelled() && hashFiles('coverage-report/Cobertura.xml') != '' }} + uses: codecov/codecov-action@v5 + with: + files: coverage-report/Cobertura.xml + fail_ci_if_error: false + - name: Upload coverage report if: ${{ !cancelled() && hashFiles('coverage-report/**') != '' }} uses: actions/upload-artifact@v7 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..dc7381a0 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,32 @@ +component_management: + individual_components: + - component_id: strongtypes + name: StrongTypes + paths: ["src/StrongTypes/**"] + - component_id: analyzers + name: StrongTypes.Analyzers + paths: ["src/StrongTypes.Analyzers/**"] + - component_id: efcore + name: StrongTypes.EfCore + paths: ["src/StrongTypes.EfCore/**"] + - component_id: api + name: StrongTypes.Api + paths: ["src/StrongTypes.Api/**"] + - component_id: fscheck + name: StrongTypes.FsCheck + paths: ["src/StrongTypes.FsCheck/**"] + +comment: + layout: "header, diff, components, tree" + require_changes: false + +coverage: + status: + # `informational: true` posts a status but never fails the PR. Keeps + # codecov in evaluation mode alongside our custom sticky comment. + project: + default: + informational: true + patch: + default: + informational: true From 746a64abca0e49ed1919079aa775a17066bd4be9 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 21 Apr 2026 22:15:47 +0200 Subject: [PATCH 10/10] Pass CODECOV_TOKEN to codecov-action --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c619b119..d54b0ac4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,14 +104,14 @@ jobs: header: coverage path: coverage-report/FileSummary.md - # Evaluation mode alongside the custom sticky comment above. No token - # needed for public repos; codecov-action uses OIDC. Requires the repo - # to be enabled at https://app.codecov.io/ — sign in with GitHub and - # add KaliCZ/StrongTypes. + # Evaluation mode alongside the custom sticky comment above. The repo + # must be enabled at https://app.codecov.io/ with CODECOV_TOKEN stored + # as a repo secret. - name: Upload coverage to Codecov if: ${{ !cancelled() && hashFiles('coverage-report/Cobertura.xml') != '' }} uses: codecov/codecov-action@v5 with: + token: ${{ secrets.CODECOV_TOKEN }} files: coverage-report/Cobertura.xml fail_ci_if_error: false