diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f79f309..d54b0ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,18 +7,20 @@ on: pull_request: branches: - main - release: - types: [published] permissions: contents: read + pull-requests: write jobs: build: 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: 'Release' + config: 'Debug' DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -37,8 +39,89 @@ 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: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: | + 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() }} + run: | + if [ -f coverage-report/FileSummary.md ]; then + cat coverage-report/FileSummary.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/FileSummary.md') != '' }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage + path: coverage-report/FileSummary.md + + # 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 + + - 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() @@ -49,62 +132,3 @@ jobs: **/TestResults/**/*.log **/TestResults/**/*.trx 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. - 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" - - - 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 0000000..b86ec60 --- /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 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..dc7381a --- /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 diff --git a/scripts/coverage-summary.py b/scripts/coverage-summary.py new file mode 100755 index 0000000..7472706 --- /dev/null +++ b/scripts/coverage-summary.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""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 +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 argparse +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: + path = path.replace("\\", "/") + marker = "/src/" + idx = path.find(marker) + if idx >= 0: + return path[idx + 1:] + return path.lstrip("/") + + +_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}>" + + +@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). + + 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. + """ + 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 (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]]: + 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 + file_lines = per_file[filename] + for line in cls.iter("line"): + num = int(line.get("number", "0")) + hits = int(line.get("hits", "0")) + entry = file_lines[num] + if hits > 0: + entry[0] = True + if line.get("branch") == "true": + cond = line.get("condition-coverage", "") + if "(" in cond and "/" in cond: + frac = cond.split("(", 1)[1].rstrip(")") + 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: + return {line.strip() for line in f if line.strip()} + except FileNotFoundError: + return None + + +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()) + 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, + )) + 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)}) |" + ) + + +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_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 = 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 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("") + + 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"
{project} — " + 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) + 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)} ({f_lc}/{f_lt}), " + f"branches {pct(f_bc, f_bt)} ({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("") + + out.append("
") + out.append("") + + with open(output_path, "w", encoding="utf-8") as f: + 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__": + main() diff --git a/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj b/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj index fd75039..868a07b 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 169dc98..d2d7ecf 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 2eeaedb..3451813 100644 --- a/src/StrongTypes.Tests/StrongTypes.Tests.csproj +++ b/src/StrongTypes.Tests/StrongTypes.Tests.csproj @@ -11,6 +11,7 @@ +