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
19 changes: 17 additions & 2 deletions .github/workflows/code-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,27 @@ jobs:
needs: [uv]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
# No merge-multiple: each project's coverage.xml has the same filename, so
# flattening would overwrite them. Download each artifact into its own
# coverage-reports/<artifact-name>/ subdirectory instead.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
pattern: coverage-*
merge-multiple: true
path: coverage-reports
# Merge every project's coverage.xml into one report. This used to call
# the `cobertura-merge-globby` npm package, which uses `util.isString()`
# (removed in Node 24) and now crashes on the runner. A stdlib Python
# script does the same job with no npm/Node dependency.
- run: |
npx cobertura-merge-globby -o output.xml --files=python/**/coverage.xml
shopt -s globstar nullglob
files=(coverage-reports/**/coverage.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No coverage.xml artifacts found to merge" >&2
exit 1
fi
python3 scripts/merge-cobertura.py -o coverage.xml "${files[@]}"
shell: bash
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coverage.xml
Expand Down
123 changes: 123 additions & 0 deletions scripts/merge-cobertura.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Merge multiple Cobertura coverage XML files into a single report.

Replaces the unmaintained `cobertura-merge-globby` npm package, which calls
`util.isString()` -- a Node API removed in Node 24 -- and crashes on the
current GitHub Actions runner image.

Each input is a Cobertura `<coverage>` document whose real content lives under
`<packages>`. Merging concatenates every input's `<package>` elements into one
output document and recomputes the top-level line-rate / branch-rate from the
combined hit counts, which is what downstream consumers (e.g. orgoro/coverage)
read. Inputs that are missing or unparsable are skipped with a warning rather
than aborting the merge -- some projects (stestr-based) never emit a
coverage.xml, so the set of inputs is expected to vary.
"""

from __future__ import annotations

import argparse
import sys
import xml.etree.ElementTree as ET
from pathlib import Path


def _rate(covered: int, total: int) -> float:
return covered / total if total else 0.0


def merge(paths: list[Path], output: Path) -> int:
merged_packages: list[ET.Element] = []
sources: list[str] = []

lines_covered = lines_valid = 0
branches_covered = branches_valid = 0
parsed = 0

for path in paths:
if not path.is_file():
print(f"warning: skipping missing coverage file: {path}", file=sys.stderr)
continue
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
print(f"warning: skipping unparsable {path}: {exc}", file=sys.stderr)
continue

parsed += 1

# Cobertura stores per-line/branch totals as attributes on <coverage>.
# Accumulate them so the merged top-level rates stay accurate.
lines_covered += int(root.get("lines-covered", 0))
lines_valid += int(root.get("lines-valid", 0))
branches_covered += int(root.get("branches-covered", 0))
branches_valid += int(root.get("branches-valid", 0))

for sources_el in root.findall("sources"):
for source in sources_el.findall("source"):
if source.text and source.text not in sources:
sources.append(source.text)

for packages_el in root.findall("packages"):
merged_packages.extend(packages_el.findall("package"))

if parsed == 0:
print(
"error: no coverage files could be parsed; nothing to merge",
file=sys.stderr,
)
return 1

coverage = ET.Element("coverage")
coverage.set("line-rate", f"{_rate(lines_covered, lines_valid):.4f}")
coverage.set("branch-rate", f"{_rate(branches_covered, branches_valid):.4f}")
coverage.set("lines-covered", str(lines_covered))
coverage.set("lines-valid", str(lines_valid))
coverage.set("branches-covered", str(branches_covered))
coverage.set("branches-valid", str(branches_valid))
coverage.set("version", "1.0")
coverage.set("timestamp", "0")

if sources:
sources_el = ET.SubElement(coverage, "sources")
for src in sources:
ET.SubElement(sources_el, "source").text = src

packages_out = ET.SubElement(coverage, "packages")
for package in merged_packages:
packages_out.append(package)

tree = ET.ElementTree(coverage)
ET.indent(tree, space=" ")
output.write_bytes(
b'<?xml version="1.0" ?>\n' + ET.tostring(coverage, encoding="unicode").encode()
)
print(
f"merged {parsed} coverage file(s), "
f"{len(merged_packages)} package(s) -> {output}",
file=sys.stderr,
)
return 0


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-o",
"--output",
type=Path,
required=True,
help="path to write the merged coverage XML",
)
parser.add_argument(
"inputs",
nargs="+",
type=Path,
help="Cobertura coverage XML files to merge",
)
args = parser.parse_args(argv)
return merge(args.inputs, args.output)


if __name__ == "__main__":
raise SystemExit(main())
Loading