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
58 changes: 46 additions & 12 deletions .github/actions/sort-coverage-table/sort.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,64 @@
# Copyright (c) 2026 Peaceful Studio OÜ
# SPDX-License-Identifier: Apache-2.0

SEPARATOR_CHARSET = set('-: \t')


def _cells(line: str) -> list[str]:
stripped = line.strip()
if '|' not in stripped:
return []
return stripped.strip('|').split('|')


def _is_table_row(line: str) -> bool:
return len(_cells(line)) >= 2


def _is_separator_row(line: str) -> bool:
cells = _cells(line)
return len(cells) >= 2 and all(cell.strip() and set(cell) <= SEPARATOR_CHARSET for cell in cells)


def _is_table_start(lines: list[str], i: int) -> bool:
return (
_is_table_row(lines[i])
and not _is_separator_row(lines[i])
and i + 1 < len(lines)
and _is_separator_row(lines[i + 1])
)


def _first_cell(row: str) -> str:
return _cells(row)[0].strip()


def contains_table(text: str) -> bool:
lines = text.splitlines(keepends=True)
return any(_is_table_start(lines, i) for i in range(len(lines)))


def sort_coverage_table(text: str) -> str:
first_cell = lambda row: row.split('|')[1].strip()
lines = text.splitlines(keepends=True)
result, i, sorted_first = [], 0, False
while i < len(lines):
line = lines[i]
if not sorted_first and line.startswith('|'):
result.append(line)
i += 1
if i < len(lines) and lines[i].startswith('|'):
result.append(lines[i])
i += 1
if not sorted_first and _is_table_start(lines, i):
result.append(lines[i])
result.append(lines[i + 1])
i += 2
data, summary = [], []
while i < len(lines) and lines[i].startswith('|'):
if first_cell(lines[i]).startswith('**'):
while i < len(lines) and _is_table_row(lines[i]):
if _first_cell(lines[i]).startswith('**'):
summary.append(lines[i])
else:
data.append(lines[i])
i += 1
data.sort(key=lambda r: first_cell(r).lower())
data.sort(key=lambda row: _first_cell(row).lower())
result.extend(data)
result.extend(summary)
sorted_first = True
else:
result.append(line)
result.append(lines[i])
i += 1
return ''.join(result)

Expand All @@ -35,6 +67,8 @@ def sort_coverage_table(text: str) -> str:
import os
with open('code-coverage-results.md') as f:
text = f.read()
if not contains_table(text):
print('::warning::sort-coverage-table: no markdown table detected in code-coverage-results.md — output left unchanged')
with open('code-coverage-results.md.tmp', 'w') as f:
f.write(sort_coverage_table(text))
os.replace('code-coverage-results.md.tmp', 'code-coverage-results.md')
3 changes: 3 additions & 0 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,6 @@ jobs:
set -euo pipefail
bash test/push-nuget_test.sh
bash test/normalize-ci-matrix_test.sh

- name: Run python tests
run: python3 -m unittest discover -s test -p '*_test.py'
66 changes: 44 additions & 22 deletions .github/workflows/csharp-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ on:
description: >-
JSON array of runner labels for the build-and-test matrix.
Examples: '["ubuntu-latest"]', '["ubuntu-latest","macos-latest","windows-latest"]'.
Coverage report generation (summary, PR comment, job summary)
Coverage report generation (merge, summary, PR comment, job summary)
only runs on the `ubuntu-latest` shard; if your matrix excludes
`ubuntu-latest`, no coverage report will be produced. Ignored when
`build-matrix` is set. When both `os-list` and `build-matrix` are
Expand Down Expand Up @@ -115,14 +115,14 @@ on:
tests-glob:
description: >-
Glob (relative to working-directory) used to locate the per-project
`*.cobertura.xml` files emitted by MTP's coverage extension, both
to assert coverage files were produced and as the `filename`
passed to irongut/CodeCoverageSummary, which aggregates all
matching files into the coverage report. The default narrows to
`bin/Release/net*/` so that stale cobertura files left over in
source-controlled or scratch directories do not get picked up.
Adjust if your tests live outside a top-level `tests/` directory
or target a non-Release configuration.
`*.cobertura.xml` files emitted by MTP's coverage extension. The
matching files are union-merged into a single report
(`coverage/merged.cobertura.xml`) by `dotnet-coverage merge`
before irongut/CodeCoverageSummary summarizes it. The default
narrows to `bin/Release/net*/` so that stale cobertura files
left over in source-controlled or scratch directories do not get
merged. Adjust if your tests live outside a top-level `tests/`
directory or target a non-Release configuration.
required: false
type: string
default: 'tests/**/bin/Release/net*/**/*.cobertura.xml'
Expand Down Expand Up @@ -221,6 +221,30 @@ jobs:
dotnet-version: ${{ inputs.dotnet-version }}
global-json-file: ${{ inputs.dotnet-version == '' && ((inputs.working-directory == '.' || inputs.working-directory == '') && 'global.json' || format('{0}/global.json', inputs.working-directory)) || '' }}

- name: Checkout CI helpers
if: ${{ matrix.coverage }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers

- name: Resolve dotnet-coverage version
if: ${{ matrix.coverage }}
id: dotnet-coverage
run: |
set -euo pipefail
version=$(python3 "$GITHUB_WORKSPACE/.github-actions-helpers/scripts/resolve-dotnet-coverage-version.py" .)
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Install dotnet-coverage
if: ${{ matrix.coverage }}
env:
DOTNET_COVERAGE_VERSION: ${{ steps.dotnet-coverage.outputs.version }}
run: |
set -euo pipefail
dotnet tool update -g dotnet-coverage --version "$DOTNET_COVERAGE_VERSION"

- name: Restore
env:
GITHUB_USERNAME: ${{ github.actor }}
Expand Down Expand Up @@ -249,38 +273,36 @@ jobs:
fi
dotnet "${args[@]}"

- name: Assert coverage files produced
- name: Merge per-project cobertura reports
# irongut/CodeCoverageSummary v1.3.0 concatenates multiple cobertura files instead of union-merging them — duplicate package rows with diluted totals (see #18-adjacent fix, daml-codegen-csharp-internal#311) — so the files must be merged into one before it runs
if: ${{ matrix.coverage }}
env:
TESTS_GLOB: ${{ inputs.tests-glob }}
run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/coverage"
shopt -s globstar nullglob
# shellcheck disable=SC2206 # intentional word-split: $TESTS_GLOB is a glob pattern (sourced from inputs.tests-glob via env:), expanded under globstar+nullglob
cobertura_files=($TESTS_GLOB)
if [ ${#cobertura_files[@]} -eq 0 ]; then
echo "::error::No .cobertura.xml files produced under '$TESTS_GLOB' — did MTP coverage extension run? See the 'Caller prerequisites' subsection under csharp-ci.yaml in the peacefulstudio/github-actions README."
exit 1
fi
echo "Found ${#cobertura_files[@]} cobertura files: ${cobertura_files[*]}"
echo "Merging ${#cobertura_files[@]} cobertura files: ${cobertura_files[*]}"
dotnet-coverage merge \
-o "$GITHUB_WORKSPACE/coverage/merged.cobertura.xml" \
-f cobertura \
"${cobertura_files[@]}"

- name: Code Coverage Summary Report
if: ${{ matrix.coverage }}
uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0
with:
filename: ${{ (inputs.working-directory == '.' || inputs.working-directory == '') && inputs.tests-glob || format('{0}/{1}', inputs.working-directory, inputs.tests-glob) }}
filename: coverage/merged.cobertura.xml
badge: true
format: markdown
output: both

- name: Checkout CI helpers
if: ${{ matrix.coverage }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers

- name: Sort coverage table alphabetically
if: ${{ matrix.coverage }}
uses: ./.github-actions-helpers/.github/actions/sort-coverage-table
Expand All @@ -293,7 +315,7 @@ jobs:
run: |
set -euo pipefail
if [ ! -f code-coverage-results.md ]; then
echo "::error::code-coverage-results.md not produced by irongut/CodeCoverageSummary — check that tests-glob matches the cobertura files MTP produced"
echo "::error::code-coverage-results.md not produced by irongut/CodeCoverageSummary — check that tests-glob matched valid cobertura files and that the merge step succeeded"
exit 1
fi
printf '## %s\n\n' "$COVERAGE_TITLE" | cat - code-coverage-results.md > code-coverage-results.tmp
Expand All @@ -313,7 +335,7 @@ jobs:
run: |
set -euo pipefail
if [ ! -f code-coverage-results.md ]; then
echo "::error::code-coverage-results.md not produced by irongut/CodeCoverageSummary — check that tests-glob matches the cobertura files MTP produced"
echo "::error::code-coverage-results.md not produced by irongut/CodeCoverageSummary — check that tests-glob matched valid cobertura files and that the merge step succeeded"
exit 1
fi
cat code-coverage-results.md >> "$GITHUB_STEP_SUMMARY"
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Fix the coverage-table sort being a silent no-op for C# coverage comments — `irongut/CodeCoverageSummary` `format: markdown` emits tables without leading pipes, which the `sort-coverage-table` action did not recognize as tables; it now detects a header line followed by a `---` separator line with or without leading pipes. (#18)
- Fix diluted C# coverage numbers when multiple test projects cover the same assemblies: `csharp-ci.yaml` again union-merges the per-project cobertura files (matched by `tests-glob`) into one report via the `dotnet-coverage` global tool (version resolved from the caller's `Directory.Packages.props` pin of `Microsoft.Testing.Extensions.CodeCoverage`, nested files under `working-directory` included — no input; a missing, non-literal, or conflicting pin fails loud) before `irongut/CodeCoverageSummary` runs, instead of letting irongut concatenate them with duplicated, partial package rows. The input/secret contract is unchanged.

## [2.0.0] - 2026-06-12

**Migration:** reference workflows and actions at `@v2` (e.g. `peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v2`). The floating `v1` tag is frozen at the v1.5.x state and will no longer advance.
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,13 @@ stay on a previous SHA / tag until you've migrated the items below.

- **`Directory.Packages.props` pinning**:
- `xunit.v3` — `3.2.2`
- `Microsoft.Testing.Extensions.CodeCoverage` — `18.0.6`.
- `Microsoft.Testing.Extensions.CodeCoverage` — required: the workflow
resolves the `dotnet-coverage` merge-tool version from this pin
(scanning every `Directory.Packages.props` under `working-directory`,
nested files included), so the two are aligned automatically. A
missing pin, an MSBuild-property version, or conflicting versions
across files fails the coverage shard loud — like a missing
`global.json`. The version must be a literal (e.g. `18.8.0`).
See [`canton-ledger-api-csharp#79`](https://github.com/peacefulstudio/canton-ledger-api-csharp/pull/79)
for the MTP 1.x / 2.x compatibility rationale: do not bump
`CodeCoverage` past 18.0.x until `xunit.v3` ships an MTP 2.x build —
Expand Down
65 changes: 65 additions & 0 deletions scripts/resolve-dotnet-coverage-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Peaceful Studio OÜ
# SPDX-License-Identifier: Apache-2.0
import sys
import xml.etree.ElementTree as ElementTree
from pathlib import Path

PACKAGE_ID = 'Microsoft.Testing.Extensions.CodeCoverage'
PROPS_FILENAME = 'Directory.Packages.props'


class VersionResolutionError(Exception):
pass


def _pinned_versions(props_file: Path) -> list[str]:
try:
tree = ElementTree.parse(props_file)
except ElementTree.ParseError:
print(
f'::warning::resolve-dotnet-coverage-version: skipping unparseable XML file {props_file}',
file=sys.stderr,
)
return []
return [
element.attrib['Version']
for element in tree.iter('PackageVersion')
if (element.get('Include') or element.get('Update')) == PACKAGE_ID
and 'Version' in element.attrib
]


def collect_pins(root_dir: str) -> list[tuple[Path, str]]:
return [
(props_file, version)
for props_file in sorted(Path(root_dir).rglob(PROPS_FILENAME))
for version in _pinned_versions(props_file)
]


def resolve_version(root_dir: str) -> str:
pins = collect_pins(root_dir)
if not pins:
raise VersionResolutionError(
f"no {PACKAGE_ID} pin found in any {PROPS_FILENAME} under '{root_dir}' — "
f'pin it in {PROPS_FILENAME}; it is required for MTP to emit cobertura files at all'
)
property_pins = [(path, version) for path, version in pins if '$(' in version]
if property_pins:
listing = ', '.join(f'{path}: {version}' for path, version in property_pins)
raise VersionResolutionError(
f'{PACKAGE_ID} version must be a literal, not an MSBuild property: {listing}'
)
if len({version for _, version in pins}) > 1:
listing = ', '.join(f'{path}: {version}' for path, version in pins)
raise VersionResolutionError(f'conflicting {PACKAGE_ID} versions: {listing}')
return pins[0][1]


if __name__ == '__main__':
try:
print(resolve_version(sys.argv[1]))
except VersionResolutionError as error:
print(f'::error::{error}', file=sys.stderr)
sys.exit(1)
Loading