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
16 changes: 16 additions & 0 deletions .github/coverage.runsettings
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Include>[DotNetProjects.Migrator*]*</Include>
<IncludeTestAssembly>false</IncludeTestAssembly>
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<ExcludeByFile>**/obj/**</ExcludeByFile>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
29 changes: 29 additions & 0 deletions .github/scripts/normalize-code-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Select one validated report, ignoring byte-identical VSTest attachment copies."""
import hashlib
from pathlib import Path
import sys
import xml.etree.ElementTree as ET


def normalize(directory):
directory = Path(directory)
destination = directory / "coverage" / "coverage.cobertura.xml"
reports = {}
for path in directory.rglob("coverage.cobertura.xml"):
if path == destination:
continue
data = path.read_bytes()
reports[hashlib.sha256(data).hexdigest()] = data
if len(reports) != 1:
raise ValueError(f"Expected one distinct Coverlet report, found {len(reports)}")
data = next(iter(reports.values()))
root = ET.fromstring(data)
if root.tag != "coverage" or int(root.get("lines-valid", "0")) <= 0:
raise ValueError("Coverlet report contains no instrumented lines")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
print(f"Validated Coverlet report: {destination}")


if __name__ == "__main__":
normalize(sys.argv[1])
70 changes: 70 additions & 0 deletions .github/scripts/summarize-code-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Compare merged Cobertura reports; missing baselines must never imply 0%."""

import os
from pathlib import Path
import xml.etree.ElementTree as ET


def read_coverage(path):
root = ET.parse(path).getroot()
counts = {}
for metric in ("lines", "branches"):
covered = int(root.attrib[f"{metric}-covered"])
total = int(root.attrib[f"{metric}-valid"])
if not 0 <= covered <= total:
raise ValueError(f"Invalid {metric} coverage in {path}")
counts[metric] = (covered, total)
if counts["lines"][1] == 0:
raise ValueError(f"No instrumented source lines in {path}")
return counts


def percentage(count):
covered, total = count
return 100 * covered / total if total else None


def display(count):
rate = percentage(count)
return f"{rate:.2f}% ({count[0]}/{count[1]})" if rate is not None else "N/A"


def summary(current, baseline=None):
rows = [
"## Code coverage (Coverlet)",
"",
"Combined Unit and all database suites; production assemblies only.",
"",
"| Metric | Current | PR base | Change (percentage points) |",
"| --- | ---: | ---: | ---: |",
]
for metric in ("lines", "branches"):
previous = display(baseline[metric]) if baseline else "Unavailable"
before = percentage(baseline[metric]) if baseline else None
after = percentage(current[metric])
delta = f"{after - before:+.2f}" if before is not None and after is not None else "N/A"
rows.append(f"| {metric.title()} | {display(current[metric])} | {previous} | {delta} |")
if baseline is None:
rows.extend(["", "No baseline available. A successful push or manual run on the exact PR target commit must first publish a code-coverage artifact (retained for 90 days). No change is inferred."])
rows.extend(["", "Download the **code-coverage** artifact and open **index.html** for coverage by assembly, class and source line. Coverage changes are informational; no minimum threshold is enforced."])
return "\n".join(rows) + "\n"


def main():
current = read_coverage(Path("artifacts/coverage/Cobertura.xml"))
baseline_path = Path("artifacts/baseline/Cobertura.xml")
baseline = read_coverage(baseline_path) if baseline_path.exists() else None
report = summary(current, baseline)
for label, variable in (("Measured commit (PR merge result)", "HEAD_SHA"), ("PR target commit", "BASE_SHA")):
value = os.environ.get(variable)
if value:
report += f"\n{label}: `{value}`\n"
Path("artifacts/coverage/summary.md").write_text(report, encoding="utf-8")
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as output:
output.write(report)
print(report)


if __name__ == "__main__":
main()
74 changes: 74 additions & 0 deletions .github/scripts/test-code-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Regression checks for coverage comparison edge cases."""

import importlib.util
from pathlib import Path
import tempfile
import unittest
import sys

sys.dont_write_bytecode = True

spec = importlib.util.spec_from_file_location("coverage_summary", Path(__file__).with_name("summarize-code-coverage.py"))
coverage = importlib.util.module_from_spec(spec)
spec.loader.exec_module(coverage)

normalizer_spec = importlib.util.spec_from_file_location("normalize_coverage", Path(__file__).with_name("normalize-code-coverage.py"))
normalizer = importlib.util.module_from_spec(normalizer_spec)
normalizer_spec.loader.exec_module(normalizer)


class CoverageSummaryTests(unittest.TestCase):
def test_vstest_attachment_copies_are_deduplicated(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
data = b'<coverage lines-valid="10" lines-covered="5"/>'
for relative in ("guid/coverage.cobertura.xml", "runner/In/host/coverage.cobertura.xml"):
path = root / relative
path.parent.mkdir(parents=True)
path.write_bytes(data)
normalizer.normalize(root)
self.assertEqual((root / "coverage/coverage.cobertura.xml").read_bytes(), data)
normalizer.normalize(root) # Idempotent; the canonical copy is ignored.

def test_missing_empty_and_conflicting_reports_fail(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with self.assertRaises(ValueError):
normalizer.normalize(root)
first = root / "one/coverage.cobertura.xml"
first.parent.mkdir()
first.write_text('<coverage lines-valid="0"/>')
with self.assertRaises(ValueError):
normalizer.normalize(root)
first.write_text('<coverage lines-valid="10"/>')
second = root / "two/coverage.cobertura.xml"
second.parent.mkdir()
second.write_text('<coverage lines-valid="20"/>')
with self.assertRaises(ValueError):
normalizer.normalize(root)

def test_delta_uses_percentage_points_and_different_denominators(self):
current = {"lines": (90, 120), "branches": (1, 4)}
base = {"lines": (80, 100), "branches": (1, 8)}
report = coverage.summary(current, base)
self.assertIn("75.00% (90/120) | 80.00% (80/100) | -5.00", report)
self.assertIn("25.00% (1/4) | 12.50% (1/8) | +12.50", report)

def test_missing_baseline_and_zero_branches(self):
report = coverage.summary({"lines": (1, 2), "branches": (0, 0)})
self.assertIn("50.00% (1/2) | Unavailable | N/A", report)
self.assertIn("Branches | N/A | Unavailable | N/A", report)
self.assertIn("No baseline available", report)

def test_cobertura_counts_and_empty_report_rejection(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "coverage.xml"
path.write_text('<coverage lines-covered="3" lines-valid="4" branches-covered="0" branches-valid="0"/>')
self.assertEqual(coverage.read_coverage(path)["lines"], (3, 4))
path.write_text('<coverage lines-covered="0" lines-valid="0" branches-covered="0" branches-valid="0"/>')
with self.assertRaises(ValueError):
coverage.read_coverage(path)


if __name__ == "__main__":
unittest.main()
6 changes: 4 additions & 2 deletions .github/scripts/test.ps1
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
param(
[ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana')]
[string]$Database = 'Unit'
[string]$Database = 'Unit',
[switch]$Coverage
)
$ErrorActionPreference = 'Stop'
$databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana')
$filter = if ($Database -eq 'Unit') { ($databases | ForEach-Object { "TestCategory!=$_" }) -join '&' } else { "TestCategory=$Database" }
$xmlDirectory = Join-Path (Get-Location) "TestResults/$Database"
dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults -- NUnit.NumberOfTestWorkers=0 "NUnit.TestOutputXml=$xmlDirectory"
$coverageArguments = if ($Coverage) { @('--collect', 'XPlat Code Coverage', '--settings', "$PSScriptRoot/../coverage.runsettings") } else { @() }
dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults @coverageArguments -- NUnit.NumberOfTestWorkers=0 "NUnit.TestOutputXml=$xmlDirectory"
if ($LASTEXITCODE -ne 0) { throw "Tests failed for $Database" }
[xml]$results = Get-Content "TestResults/$Database.trx"
$counters = $results.TestRun.ResultSummary.Counters
Expand Down
60 changes: 57 additions & 3 deletions .github/workflows/dotnetpull.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ jobs:
fi
- name: Test
shell: pwsh
run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }}
run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} -Coverage
- name: Verify coverage was collected
run: python3 .github/scripts/normalize-code-coverage.py TestResults
- name: Collect database logs
if: always()
run: |
Expand All @@ -72,14 +74,66 @@ jobs:
docker rm -fv migrator-db
fi
coverage:
name: Verify complete test coverage
name: Code coverage and PR comparison
needs: test
runs-on: ubuntu-22.04
timeout-minutes: 5
timeout-minutes: 10
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: TestResults
- run: python3 .github/scripts/verify-test-coverage.py TestResults
- run: python3 .github/scripts/test-code-coverage.py
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Merge coverage and generate HTML report
run: |
dotnet tool install dotnet-reportgenerator-globaltool --version 5.4.18 --tool-path "$RUNNER_TEMP/reportgenerator"
"$RUNNER_TEMP/reportgenerator/reportgenerator" '-reports:TestResults/*/coverage/coverage.cobertura.xml' '-targetdir:artifacts/coverage' '-reporttypes:Html;Cobertura'
- name: Find coverage for the exact PR base commit
if: github.event_name == 'pull_request'
id: baseline
uses: actions/github-script@v7
with:
script: |
const base = context.payload.pull_request.base;
// Only trust reports from successful push/manual runs in this repository.
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
...context.repo, workflow_id: 'dotnetpull.yml', head_sha: base.sha,
status: 'success', per_page: 100
});
for (const run of runs) {
if (!['push', 'workflow_dispatch'].includes(run.event) || run.head_branch !== base.ref) continue;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
...context.repo, run_id: run.id, per_page: 100
});
if (artifacts.some(a => a.name === 'code-coverage' && !a.expired)) {
core.setOutput('run-id', String(run.id));
break;
}
}
- name: Download baseline coverage
if: steps.baseline.outputs.run-id != ''
uses: actions/download-artifact@v4
with:
name: code-coverage
path: artifacts/baseline
github-token: ${{ github.token }}
run-id: ${{ steps.baseline.outputs.run-id }}
- name: Write coverage and delta to the PR check summary
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
run: python3 .github/scripts/summarize-code-coverage.py
- uses: actions/upload-artifact@v4
with:
name: code-coverage
path: artifacts/coverage/
if-no-files-found: error
retention-days: 90
Loading
Loading