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 @@
+