Skip to content

Coverage in package_id, per-configuration test jobs, and a report-only Coverage gate - #128

Merged
wdolinar merged 1 commit into
masterfrom
task/coverage-in-package-id
Aug 31, 2026
Merged

Coverage in package_id, per-configuration test jobs, and a report-only Coverage gate#128
wdolinar merged 1 commit into
masterfrom
task/coverage-in-package-id

Conversation

@wdolinar

@wdolinar wdolinar commented Aug 30, 2026

Copy link
Copy Markdown
Member

Post-mortem items 1-3 from the xmsvtk coverage investigation, plus the pipeline restructure that item 3 turned out to require. Thirteen commits; each stands alone, and they are ordered because everything downstream depends on item 1's option being what carries instrumentation.

1. Coverage instrumentation is part of the package identity (653dadb)

Instrumentation reached the build through XMS_COVERAGE=1 in a profile's [buildenv], which conan does not see. An instrumented binary and a production binary therefore shared a package_id, so a coverage run could satisfy --build=missing for a production build — and vice versa.

  • The generated recipe grows a coverage option, [True, False], default False, so instrumentation travels with the package_id.
  • package_id() dels the option when False, keeping every uninstrumented id byte-identical to the ids this recipe produced before the option existed. No republish, no cache invalidation for normal builds.
  • plan_cmake_presets writes XMS_COVERAGE into every configure preset's cacheVariables, because a raw cmake --preset build never runs the recipe's build().
  • The generated CMakeLists.txt no longer reads $ENV{XMS_COVERAGE} as a fallback. The option is the sole driver.
  • bd32377 narrows it further: only the two coverage legs are instrumented, not every configuration the matrix builds.

2. One test job per staged testing configuration (298fb5c)

The split-out GitLab test job found its artifact directory by falling back to the first of Debug-testing, Release-testing that existed. A matrix building both therefore compiled the Release runner on every pipeline and never executed it, with nothing red to show for it — the warning naming the choice went to a job log nobody reads.

  • ci_filter_effects reports test_labels, the Linux testing configurations that survive the filter. Deliberately not derived from ci_build_types, which counts every survivor: a pybind = true pin keeps Release configurations while staging no runner at all.
  • The template emits one Run C++ Tests - <label> job per label, each passing --label and uploading only its own test_artifacts/<label>/.
  • _config_label became the module-level config_label, so the generator names the directories with the same function the build writes them with.
  • split_tests with no surviving testing configuration is rejected at generation: the suite would run nowhere and the pipeline would pass having tested nothing.
  • 6aeaf28 raises a shard's timeout to 20 minutes; 11153ad lets ctest parallelize the instrumented suite again.

3. Coverage splits into phases (754e5ba, d180151)

The Coverage job did everything — two instrumented conan builds, gcovr, the HTML render, and the threshold gate — in one job at the end of the pipeline. The slowest work started last, and the gate could only be exercised by driving two real conan builds.

  • run_coverage takes --phase. collect does both builds, gcovr, and every rendered artifact; report applies the gate. The default, all, still does both in one process.
  • coverage-status.json carries what no report file records: whether either leg's tests failed, and whether each layer was measured. Thresholds stay out of it — report re-reads build.toml, so tightening one re-gates existing artifacts without rebuilding.
  • allow_failure: exit_codes: [3] stays on the report job only. Collect cannot produce a 3, so anything it fails on is a real breakage.
  • The coverage: regex reads the Coverage total: <pct>% line the report phase prints; an unmeasured C++ layer prints n/a and matches nothing, so it cannot publish a percentage that looks like a measurement.
  • 04607f7 documents why the legs combine as gcovr tracefiles rather than merged .gcda; 9a0d226 logs how much gcov data each build folder holds, which is the measurement the next section rests on.

4. The concurrent build stage (73f4916, 087575d, 51eb94d)

Splitting collect from report exposed the real cost: Coverage Build recompiled the Debug testing and pybind configurations because instrumentation is part of the package_id, so no production binary could satisfy it. 503 of the 1334 objects a pipeline compiled were that second copy, and the single Conan Build compiled its whole matrix sequentially inside one container.

  • One build job per surviving Linux configuration, each declaring needs: [] so it starts at t=0 rather than at its stage's turn.
  • A third phase, --phase measure --leg <cpp|python>, builds one instrumented leg and reads its .gcda where it was compiled. The instrumented jobs are ordinary Build-stage jobs; Coverage Build is gone. Each leg writes a gcovr tracefile of a few hundred KB, so no build folder travels — at 245 MiB of .gcno against 5.5 MiB of .gcda, moving one would cost more than the compile it saves.
  • Tag pipelines build only what ships: testing configurations are branch-only, and the instrumented pybind job has a clean only: tags twin, since --coverage changes the binary a release publishes.
  • Every taggable job exports its own cache tarball. With one container per configuration, conan cache save in the pybind job sees the pybind package and nothing else, so naming a single exporter would publish one package id and silently drop the rest.
  • 087575d gates the whole shape on [matrix].wheel_only. It was designed and measured against a wheel_only matrix, which has no library configurations; a repository that publishes library packages keeps what it had.
  • 51eb94d runs wheel repair only in the pipeline that builds a wheel.

5. Job names, and the keys a narrowed [filter] can empty (a700775)

From the review on this PR.

  • [matrix].pybind_build_types accepts two build types, and the pybind job name carried the ABI but not the build type — so the Release configuration's only: tags twin and the Debug configuration's job both rendered as "Python Build". A GitLab job name is a YAML mapping key, so the later block silently replaced the earlier and that configuration was never built. The build type now joins the name when the fan-out spans two, a second pybind build type is no longer instrumented (the Python leg's filter pins one, via the shared COVERAGE_PYBIND_BUILD_TYPE), and naming ends with a uniqueness check that raises.
  • dependencies: on the deploy job and needs: on the Coverage job are guarded on a non-empty list. Both render as null under a narrowing filter, and GitLab rejects the file — including in the case coverage_conflicts() warns about and then generates anyway.
  • The empty-job warning names the wheel_only fan-out rather than the Conan Build job that shape does not emit.
  • A coverage status file holding valid JSON that is not an object is treated as unreadable rather than reaching the union's .get.

Verification

  • 2325 passed, 5 skipped; flake8 clean across the repository.
  • Regenerating xmsvtk's pipeline from this branch into a scratch directory produces exactly the intended diff, with xmsvtk's own checkout untouched.
  • Exercised on a real GitLab runner via a temporary install-from-branch commit on an xmsvtk branch, the same pattern used for the xvfb fix in Wait for each shard's X server before starting its runner #127. Pipeline 63136 validated the concurrent stage; 63162 and 63164 confirm the gate's failure path — the instrumented builds and the Coverage stage pass while a genuinely failing xmsvtk C++ test fails its own Run C++ Tests - Release-testing job, which is the behavior the split was meant to preserve.

@wdolinar
wdolinar force-pushed the task/coverage-in-package-id branch from 5725256 to d180151 Compare August 31, 2026 16:52
@wdolinar

Copy link
Copy Markdown
Member Author

Review: xmsconan PR #128 — coverage in package_id, per-configuration test jobs, report-only Coverage gate

Verdict: REQUEST CHANGES (C=0 M=2 m=36)

What changed

17 files: the recipe template gains a coverage option that carries instrumentation into the package_id, run_coverage gains a measure phase with a --leg selector, build_filter.ci_build_jobs starts planning per-configuration Linux jobs, and gitlab-ci.yml.jinja forks on a new ci_wheel_only key into a second whole-pipeline shape. This is generator-and-template surface, so every defect here is a defect in every consumer repo's regenerated CI — the blast radius is wider than the line count suggests. Note that the diff is meaningfully larger than the three commits the description covers (see Claim verification).

Seven specialists reviewed it. Twenty CRITICAL/MAJOR candidates went through a one-validator-each confirm-or-drop pass; 12 were dropped, including both CRITICALs. Two survive at MAJOR, seven were downgraded to MINOR. What follows is only what survived.


Major items

M1: xmsconan/coverage_tools/coverage_generator.py:996-1004PHASE_MEASURE dispatch and its --leg guard have zero tests

    if phase == PHASE_MEASURE:
        if leg is None:
            raise ValueError(
                f"The {PHASE_MEASURE!r} phase measures one leg, so it needs "
                f"--leg (one of {', '.join(COVERAGE_LEGS)}). Without it there "
                "is no way to tell which build to run, and guessing would "
                "silently measure one layer while reporting for both."
            )
        return _measure_coverage(toml_file_path, version, output_dir, leg)

Change: Add a TestMeasurePhase class in tests/test_coverage_generator.py, alongside the existing per-phase classes. Minimum: run_coverage(phase=PHASE_MEASURE, leg="cpp") and leg="python" through the mocked-collect harness at tests/test_coverage_generator.py:1000-1900, plus a pytest.raises(ValueError) for leg=None.

Why: PHASE_MEASURE and _measure_coverage appear nowhere under tests/ — no run_coverage(...) call site passes phase="measure" or any leg=, no test parametrizes over COVERAGE_PHASES or COVERAGE_LEGS, and the main() tests at :1928-1971 monkeypatch run_coverage to a MagicMock. The "--phase measure" strings in test_ci_file_generator.py:969, 2478-2479 assert on generated YAML text, not execution. --leg is declared default=None at :1577-1584, so the :997 guard is the sole enforcement and is reachable from a real CLI invocation. Every sibling phase — all, collect, report — has a dedicated test class; measure is the one shipping with none, and it is the path the generated pipeline actually runs (gitlab-ci.yml.jinja:130).

M2: xmsconan/generator_tools/build_filter.py:488-501 — two pybind build types collapse onto one GitLab job key

    pybind_jobs = [job for job in jobs
                   if job["kind"] == "pybind" and job["tag_policy"] != "only"]
    for job in jobs:
        if job["kind"] == "pybind":
            base = "Python"
        elif job["kind"] == "library":
            base = f"{job['build_type']} Library"
        else:
            base = job["build_type"]
        middle = " Instrumented" if job["instrumented"] else ""
        name = f"{base}{middle} Build"
        if job["kind"] == "pybind" and len(pybind_jobs) > 1:
            name = f"{name} - py{job['python_version']}"

Change: Include build_type in the pybind name when more than one pybind build type is present — the same "only disambiguate when there is something to disambiguate from" rule the ABI suffix already follows. Then add an assertion in ci_build_jobs (or a test) that the returned names are unique, so the next naming axis fails at generation instead of in rendered YAML.

Why: build_type feeds the library (:494) and testing (:496) names but never the pybind one, and packager.py:681 accepts pybind_build_types = ('Release', 'Debug')resolve_matrix (:751-771) keeps any subset and _pybind_variants (:1140-1152) emits a pybind copy per Python version for each listed build type. The only multi-entry guard is GitHub-specific (ci_file_generator.py:345-352); nothing restricts it on GitLab. With one Linux ABI on a coverage repo, four pybind jobs collapse onto two names, and the only: tags twins created at :464-467 go through the same naming. gitlab-ci.yml.jinja:58-59 renders job.name as the YAML mapping key, so this is a duplicate key — last wins, Debug is last, and the Release pybind job silently disappears. The deploy at :513-514/:542 then emits a --restore .export/<lib>-linux-<label>-...tar.gz for a tarball no job wrote, keyed on the label, which does include the build type (packager.py:393-405). Nothing asserts name uniqueness and there are no tests for ci_build_jobs at all. The validator noted this needs a documented but non-default [matrix] setting, so MINOR is arguable — MAJOR because the failure is silent and the lost artifact surfaces at deploy time.


Minor items

These seven are validator-confirmed; each was found at MAJOR and downgraded because no defect is demonstrated, the trigger is a non-default configuration, or the predicted consequence did not survive checking.

  • docs/USAGE.md:681-689 and :701 — §11.4's phase table lists only collect/report/all, while the shipped CLI adds --phase measure and --leg (coverage_generator.py:1564-1584); zero occurrences of either in USAGE.md. Separately, :701 states the Coverage stage holds two jobs and that Coverage Build runs --phase collect, which gitlab-ci.yml.jinja:621-622 explicitly contradicts for ci_wheel_only. Both surfaces are added by this diff, which also rewrote §11 wholesale; document measure, --leg, and the wheel_only shape.
  • PR description — under-describes the diff (details below); edit the PR text.
  • xmsconan/coverage_tools/coverage_generator.py:1280-1365_measure_coverage has no exercising test. The untested surface is the measure-specific wiring: leg→build-kind selection (:1315-1321), the per-leg tracefile name the report phase globs (:1340-1342 against :1489), py_measured (:1362), and the missing-package EXIT_ERROR path (:1325-1337). Every helper it composes is already exercised; add the single-leg composition test.
  • xmsconan/generator_tools/build_filter.py:428-431 — both pybind build types get coverage_leg = "python", so both write cov-cpp-tracefile-python.json/coverage-status-python.json into one artifact space, and --leg python pins "build_type": "Release" (:1067-1074) so the Debug pybind job compiles Release twice and the Debug module never gets built on a branch pipeline. The merged number stays correct; instrument only the coverage-pinned pybind build type.
  • xmsconan/generator_tools/ci_file_generator.py:255_emitted_ci_jobs returns ["Conan Build"] gated only on ci_linux, but the branch this diff added at gitlab-ci.yml.jinja:37-59 emits per-configuration names and never "Conan Build", so the warning at :268-273 names a job block the generated file does not contain and the docstring at :241 is false for that shape. Name the Linux block — feeding ci_build_jobs names straight into empty_ci_jobs would KeyError at build_filter.py:522.
  • xmsconan/generator_tools/ci_templates/gitlab-ci.yml.jinja:512-515 — bare dependencies: above an unguarded loop renders dependencies: with nothing under it when ci_linux_export_jobs is empty; wrap it the way artifacts: paths: is wrapped at :82-89. (GitLab most likely reads dependencies: null as unset rather than rejecting the file, and the triggering configuration is already broken by a wider margin.)
  • xmsconan/generator_tools/ci_templates/gitlab-ci.yml.jinja:641-647 — the same shape for needs: over ci_instrumented_build_jobs. Reachable with wheel_only + coverage + [filter] build_type = "Release", options.testing = true; coverage_conflicts only logs. The suite repeatedly asserts "coverage = true must emit at least one instrumented build job" (tests/test_ci_file_generator.py:997, 1027, 1050, 1471) — an invariant nothing enforces at generation time. A one-line guard, or the raise, matching ci_file_generator.py:422-428.
Unvalidated MINOR pool (29 entries) — reported by specialists, not individually validated. Treat these as leads at lower confidence than the seven above: line citations and consequences have not been independently checked.
  • tests/ci_helpers.py:16NON_JOB_SHAPE_KEYS includes pages, and test_ci_file_generator.py:1213/:1304 use it to build exact job-inventory sets, so a missing or unexpected pages job passes silently. Filter pages only for shape checks.
  • tests/test_ci_file_generator.py:1 — 2806 lines, zero test classes, ~140 module-level tests, helpers defined mid-file at :141, :783, :1504, :1665, :2413 so tests above them cannot see them; this diff grew it by ~400 lines. Group by feature; promote shared helpers to ci_helpers.py.
  • tests/test_ci_file_generator.py:637-672 — eager test verifying XMS_SKIP_CXX_TESTS, build fan-out, needs: [], job existence, stage, and allow_failure; the last is already covered at :697 and the fan-out at :788. Split it.
  • tests/test_ci_file_generator.py:648-655, 690-691, 1319-1320, 2424-2425 — four in-test definitions of "the Linux build job", free to drift. One shared _linux_build_jobs(parsed) helper.
  • tests/test_ci_file_generator.py:788-801, 974-994, 1033-1047, 1312, 1340-1360, 1403, 1423, 1446-1462 — new tests re-inline the write-toml/generate/read/yaml.safe_load idiom _gitlab_jobs (:1504) already encapsulates.
  • tests/test_ci_file_generator.py:2708-2782_jobs_reaching/_supplies_a_wheel reimplement GitLab gate semantics in ~45 lines of untested branching; _gate reports "always" for rules:-gated jobs (:2513), so the invariant can pass vacuously. Treat rules: explicitly.
  • tests/test_coverage_generator.py:283pytest.raises(RuntimeError) with no match (also :296, :317), while :247 pins match="pybind=True".
  • tests/test_coverage_generator.py:2377-2393 — phase dispatch verified by patching private _collect_coverage/_report_coverage and asserting call counts. Assert on exit code and written files.
  • tests/test_coverage_status.py:11 — the module drives two private functions; the union cases are observable through run_coverage(phase=report) if _report_workspace could write per-leg status files.
  • tests/test_xms_conan2_file.py:587-591, 799-803os.environ["XMS_COVERAGE"] = "1" with finally: del restores to unset, destroying an exported value on the very machines the file's comment at :1533 says to export it on. monkeypatch.setenv or the existing patch_env (used correctly at :1540).
  • tests/test_xms_conan2_file.py:623 — asserts by substring-matching str(mock_call) reprs ("--cov" not in pytest_cmd at :805). Inspect call.args[0].
  • tests/test_xms_conan2_file.py:816-828, 835-847, 858-870 — three configure() tests repeat the same 12-13 line MagicMock stub, one copy newly added. Creation method plus parametrize.
  • xmsconan/ci_tools/test_shards.py:1 — a production module matching pytest's default test_*.py glob; with no testpaths in pyproject.toml a bare pytest at the repo root imports it during collection. Set testpaths = ["tests"].
  • xmsconan/coverage_tools/coverage_generator.py:959phase: str/leg: Optional[str] do not express COVERAGE_PHASES/COVERAGE_LEGS; _write_coverage_status(leg=...) at :1370 turns any string into an artifact filename the report glob at :1435 will union. Literal[...] aliases.
  • xmsconan/coverage_tools/coverage_generator.py:1090_CoverageRun annotates config: object (it is a BuildToml), and neither config nor toml_file is ever read; coverage_cfg: dict erases the frozen CoverageTable. Drop the dead fields; type the rest.
  • xmsconan/coverage_tools/coverage_generator.py:1114-1120 — comment says "Regenerate with XMS_COVERAGE set", but _run inherits the ambient env and env["XMS_COVERAGE"] is only assigned at :1123, so the gen step writes XMS_COVERAGE=0 into CMakePresets.json during a coverage run. Pass the env or fix the comment.
  • xmsconan/coverage_tools/coverage_generator.py:1403_read_one_status declares -> Optional[dict] but returns whatever json.loads yields; a valid-JSON non-object artifact reaches s.get(...) at :1442-1444 as an uncaught AttributeError in the gate job.
  • xmsconan/coverage_tools/coverage_generator.py:1507py_raw = ... if py_summary.exists() else 0.0 turns an absent cov-py-summary.json into a measured-looking 0.0 that clears the default python_threshold = 0; only py_measured, from a different file crossing the job boundary separately, prevents a green gate. The C++ side fails loudly instead (:1495-1502).
  • xmsconan/generator_tools/build_filter.py:295ci_filter_effects returns a 3-key untyped dict unpacked by string at ci_file_generator.py:485/491/519-521. NamedTuple.
  • xmsconan/generator_tools/build_filter.py:302-304coverage_python_version=None documented as a meaningful mode, but the sole caller always supplies a resolved value.
  • xmsconan/generator_tools/build_filter.py:522CI_JOB_SETTINGS[ci_type][job_name] is an unguarded lookup on a vocabulary maintained separately in _emitted_ci_jobs. .get the settings.
  • xmsconan/generator_tools/ci_file_generator.py:413, 484_resolve_coverage_python_version(config) evaluated twice in one function. Hoist.
  • xmsconan/generator_tools/ci_file_generator.py:458 — the whole GitLab pipeline shape is keyed to [matrix].wheel_only, a matrix-scope flag, and the coupling is undocumented in USAGE.md §5.4.1 and §10.
  • xmsconan/generator_tools/ci_templates/github-ci.yaml.jinja:18-39 — the new comment block and <% set %> were inserted between "…rather than in a separate" (line 18) and "# downstream job, so…" (line 39), splitting one emitted sentence across 20 lines of template source.
  • xmsconan/generator_tools/ci_templates/github-ci.yaml.jinja:38 — ~215-character set combining a &&/|| conditional with two format() calls and doubled-brace escaping. Build the strings in the generator.
  • xmsconan/generator_tools/ci_templates/gitlab-ci.yml.jinja:68, 70, 81coverage_python_version if job.instrumented else (job.python_version or gitlab_linux_single_py) written out three times in one job block. <% set job_py = ... %> once.
  • xmsconan/generator_tools/ci_templates/gitlab-ci.yml.jinja:88, 97-99 — instrumented pybind jobs list wheelhouse/ in artifacts: paths: but --phase measure never passes --wheel-dir, so the path never exists. Gate job_wheels on not job.instrumented.
  • xmsconan/generator_tools/ci_templates/gitlab-ci.yml.jinja:248-284 and 309-344 — the Run C++ Tests - <label> job is emitted twice, ~28 of 33 lines identical, differing only in the loop source and the needs: name. One Jinja macro.
  • xmsconan/package_tools/packager.py:441 — the two newly-promoted public helpers disagree on the configuration record's contract: is_instrumented_configuration requires combination['options'] while config_label tolerates its absence via .get('options', {}) at :397. A shared Configuration TypedDict.

Checked and cleared

Two of the loudest claims in the raw review were chased down and do not hold. Recording them so you do not have to re-derive the answers:

  • "A failing Debug C++ test yields a green pipeline" (raised as CRITICAL) — does not hold. The structural premise is right: instrumented configurations are filtered out of ci_linux_test_jobs, so no blocking Run C++ Tests - Debug-testing is emitted under wheel_only + coverage. The chain breaks at the next link. The coverage build is a plain conan create with no sharding (coverage_generator.py:217-219, packager.py:1266), so the suite runs inside the recipe's build() and run_cxx_tests re-raises the ctest failure (xms_conan2_file.py:638-646, 767-775); a failed create registers no package — which the repo asserts itself at coverage_generator.py:528-535 — so _measure_coverage returns EXIT_ERROR at :1325-1337, one branch before the EXIT_OK at :1365. That exit 1 comes out of Debug Instrumented Build, a Build-stage job with no allow_failure, and the Coverage job never runs. A failing Debug test still blocks; it blocks from the build job instead of a test job. The residual is diagnostic quality — you get "No testing coverage package found… Did the build complete?" instead of a JUnit report. One narrow shape was not ruled out and is offered only as context: if a repo's cpp-leg filter matches more than one configuration, one failing and one succeeding leaves a package with tests_failed=True, which reaches the forgiven exit 3. Not the default matrix.
  • Missing DESIGN: / HYPOTHESIS: / EVIDENCE: markers in the PR descriptiondropped, no action. A repo-wide grep returns zero matches; nothing in CLAUDE.md, the docs, or any commit template asks for them, and all 12 commit subjects on the branch are plain imperative subjects. Those are session-time response artifacts, not PR-description artifacts.

Claim verification

Specialists checked the description's claims against the code. Most held (package_id() dropping the option, the CMakeLists.txt env fallback removal, plan_cmake_presets, the config_label promotion, the split_tests rejection, the coverage-status.json contents, thresholds re-read from build.toml, allow_failure: exit_codes: [3] on the report job only, the Coverage total: regex and its n/a behavior). Four did not, and they are worth fixing in the PR text:

  1. "The report phase is pure JSON in, exit code out" — not accurate. _report_coverage shells out to the gcovr binary to merge per-leg tracefiles:
    tracefiles = sorted(output_dir.glob("cov-cpp-tracefile-*.json"))
    if tracefiles and not cpp_summary.exists():
        LOGGER.info("Merging %d coverage tracefile(s) into the C++ report: %s",
                    len(tracefiles), ", ".join(f.name for f in tracefiles))
        _merge_tracefiles(tracefiles, Path.cwd(), output_dir)

coverage_generator.py:1489-1493, and the generated job installs gcovr>=7,<9 at gitlab-ci.yml.jinja:664. The gate is newly unit-testable — tests/test_coverage_status.py proves it — but the report job still needs a toolchain, which matters for anyone sizing its image.

  1. The description's scope is narrower than the diff. Also landed and unmentioned: the ci_wheel_only template key that replaces Conan Build with a per-configuration concurrent build stage, --phase measure and --leg/COVERAGE_LEGS, Repair Wheel moving to only: tags, DEFAULT_SHARD_TIMEOUT 600→1200 in ci_tools/test_shards.py, Conan Deploy - Linux being rewritten, and XMS_GTEST_DISCOVER_TESTS. The tag-gating of Repair Wheel is the one a reviewer most needs surfaced. The three SHAs the description cites (2424920, 65c430e, 9924c42) were rewritten by a rebase to 653dadb, 298fb5c, 754e5ba — the text predates nine of the branch's twelve commits, which explains the drift.

  2. "The template emits one Run C++ Tests - <label> job per label" and "The template emits Coverage Build (collect) with needs: []" — true on the non-wheel_only branch only. The wheel_only branch loops ci_linux_test_jobs and skips instrumented configurations (gitlab-ci.yml.jinja:267-296), and has no Coverage Build at all (:621-631); Coverage Build exists only in the <% else %> branch at :741.

  3. coverage-status.json's tests_failed is bool(failed) over any non-zero build.py exit (coverage_generator.py:343-350), so a compile or environment failure is handed to the gate as a test failure and becomes the forgivable exit 3. Masked in practice, because a failed leg usually leaves no package and _measure_coverage returns EXIT_ERROR first — but the field name promises more than it delivers.


Strengths

Drawn from the code, not the description:

  • The package_id trick is the right one and it is explained where it happens. xms_conan2_file.py:426-429 deletes the option when False so uninstrumented ids stay byte-identical to pre-option ids, with a docstring at :419-424 saying exactly why no released binary is invalidated. Both specialists that checked this agreed independently.
  • The cache filter that the option makes possible is implemented and tested. coverage_generator.py:414-420 skips uninstrumented siblings with a comment naming the hazard ("has no .gcda and must never be handed to gcovr"), and tests/test_coverage_generator.py::test_skips_uninstrumented_sibling_package asserts a newer uninstrumented sibling loses to the older instrumented package — the exact bug the filter prevents.
  • ci_file_generator.py:422-428 raises rather than warns when split_tests survives with no testing configuration, and the message names the two ways out ("Drop split_tests or widen the filter"). That is the right shape for a "green pipeline that tested nothing" hazard, and it is the pattern the two null-key MINORs above are asking you to reuse.
  • The report phase's failure messages are operational, not diagnostic. coverage_generator.py:1470-1476 and :1495-1501 tell the reader which job should have produced the missing artifact and to check that it reached this one — considerably more useful than a traceback in a CI log.
  • Rounding is handled deliberately. :1504-1507 compares raw percentages "so a 69.95% build does not sneak past a 70.0 threshold via display rounding."
  • The template comments justify decisions rather than restate the code — the compact-separators rationale at build_filter.py:413-417 (a colon-space would end a YAML plain scalar and reject the pipeline), the tag-policy reasoning at :435-444, and the instrumented-pybind twin at :456-467. Reviewing an unfamiliar generator fork was materially easier because of these.

Verdict: REQUEST CHANGES — C=0, M=2, m=36 (7 validator-confirmed, 29 unvalidated). Both CRITICALs were investigated and dropped; nothing here blocks the design. M1 is a missing test class for the phase this PR exists to add; M2 is a silent job-key collision under a documented non-default [matrix] setting. The seven confirmed MINORs are mostly a docs pass over §11 plus two one-line template guards.

@wdolinar

Copy link
Copy Markdown
Member Author

Addressed in a700775. What changed, what did not, and one question back.

Applied

M2 — two pybind build types render one GitLab job key. Confirmed, and worse than stated: the collision is not between the two instrumented jobs, it is between the Release configuration's only: tags twin and the Debug configuration's job, both of which are uninstrumented and so both named "Python Build". Three changes, because the naming fix alone does not close it:

  • The build type joins a pybind job's name when the fan-out spans two, measured over every pybind job including the twins. The ABI suffix could skip the twins; the build type cannot.
  • A second pybind build type is no longer instrumented at all. _coverage_legs pins one build type for the Python leg, so the other would compile an instrumented module --leg python never reads, and write coverage-status-python.json and cov-cpp-tracefile-python.json over the leg that is read — both files are named for the leg, neither for the build type. The pinned value is now COVERAGE_PYBIND_BUILD_TYPE in packager.py, imported by both the CI planner and the coverage leg so they cannot drift apart.
  • _name_build_jobs now ends with a uniqueness check that raises. A duplicate GitLab job name is silently last-wins, so the next axis someone adds should stop generation rather than ship a pipeline that quietly builds less than the matrix asked for.

M1 — PHASE_MEASURE / _measure_coverage had no test coverage. Confirmed by grep: zero references anywhere under tests/. Added TestMeasurePhase — the --leg-required guard, the unknown-leg raise, both legs' per-leg status and tracefile output, the two legs not overwriting each other in a shared output dir, and the missing-package path returning EXIT_ERROR with nothing measured and no empty tracefile written.

The two unguarded YAML keys (dependencies: on Conan Deploy - Linux, needs: on Coverage). Verified against the pre-fix template: with a testing-only filter, and with a filter excluding both coverage legs, both keys render as null and GitLab rejects the file. The second case is the one that matters — coverage_conflicts() warns about it and generation continues, so the warning was followed by an unparseable pipeline, which reads as a template bug rather than the filter problem it is. Both are now guarded, with tests pinning the omission.

_emitted_ci_jobs naming a job wheel_only never emits. Fixed, though not the way the finding suggested: enumerating the fan-out's real job names would have silenced the warning in the case it exists for, because an os pin empties the fan-out to zero jobs and there would be nothing left to warn with. It now reports one entry for the fan-out, borrowing the Linux runner's settings entry.

_read_one_status accepting non-dict JSON. A payload that is a list or a bare string passed the is not None filter and reached .get, raising AttributeError from inside the gate job. Now treated as unreadable.

Docs. Broader than the finding stated: docs/USAGE.md:709 itself says "the report phase is pure JSON in, exit code out", which _report_coverage contradicts by shelling to gcovr to merge per-leg tracefiles. Corrected, plus the measure row and --leg in the §11.4 table, the wheel_only Coverage-stage shape in §11.5, and a scope note on §10.2, which describes the single-Conan Build shape throughout.

testpaths added to pyproject.toml, and the two os.environ[...] = ... / finally: del blocks in test_xms_conan2_file.py replaced with monkeypatch.setenv — the manual form deletes the variable on exit rather than restoring it.

Not applied

Both CRITICALs — dropped on evidence during validation, before this commit:

  • "A failing Debug C++ test yields a green pipeline." The chain breaks where --phase measure is claimed to return EXIT_OK. A failed conan create registers no package, so _locate_coverage_build returns None and _measure_coverage returns EXIT_ERROR at coverage_generator.py:1325-1337 — one branch before the EXIT_OK at :1365. That exit 1 surfaces from Debug Instrumented Build, a Build-stage job with no allow_failure. Pipelines 63162 and 63164 are consistent with this: the failing C++ test failed its job.
  • "Commits are missing DESIGN: / HYPOTHESIS: / EVIDENCE: markers." Not a convention this repository has. Repo-wide grep returns zero matches and none of the branch's commit subjects carry one.

~19 test-organization and template-duplication entries from the unvalidated minor pool. Declined as scope: they are pre-existing structure, not something this branch introduced, and folding them in would make the diff harder to review than the restructure it is reviewing.

Question back

coverage_generator.py:1114 — commit 73f4916 (this PR) rewrote the comment to say the generation step runs with XMS_COVERAGE set, but env is built at :1122 and never passed to that _run call. packager.py:648 defaults the coverage flag from os.environ.get('XMS_COVERAGE') == '1' and :1988 stamps cache_variables['XMS_COVERAGE'] = '1' if self._coverage else '0', so a coverage run leaves the tree's generated presets uninstrumented. It does not break the coverage run itself, which drives conan and the recipe option rather than the presets — but it does mean a local cmake --preset after xmsconan coverage builds clean, which is the opposite of what §11.3 documents. Should the comment come down to match the code, or should the call pass the env? The second is the behavior change, so I left it for you.

Post-mortem items 1-3 from the xmsvtk coverage investigation. Item 1 is the
root cause; items 2 and 3 are what it was hiding, and the pipeline
restructure is what item 3 turned out to require.

Instrumentation reached the build through XMS_COVERAGE=1 in a profile's
[buildenv], which conan does not see. An instrumented binary and a
production binary therefore shared a package_id, so a coverage run could
satisfy --build=missing for a production build and vice versa. The
generated recipe grows a `coverage` option instead, so instrumentation
travels with the identity; package_id() dels it when False, which keeps
every uninstrumented id byte-identical to the ids this recipe produced
before the option existed -- no republish, no cache invalidation for normal
builds. The generated CMakeLists.txt no longer reads $ENV{XMS_COVERAGE} as
a fallback, and plan_cmake_presets writes the variable into every configure
preset's cacheVariables, because a raw `cmake --preset` build never runs the
recipe's build(). Only the two coverage legs are instrumented, not every
configuration the matrix builds.

The split-out GitLab test job found its artifact directory by falling back
to the first of Debug-testing, Release-testing that existed. A matrix
building both therefore compiled the Release runner on every pipeline and
never executed it, with nothing red to show for it -- the warning naming the
choice went to a job log nobody reads. The template now emits one
"Run C++ Tests - <label>" job per surviving testing configuration, each
passing --label and uploading only its own test_artifacts/<label>/. The
labels come from ci_filter_effects rather than from ci_build_types, which
counts every survivor: a `pybind = true` pin keeps Release configurations
while staging no runner at all. _config_label became the module-level
config_label, so the generator names those directories with the same
function the build writes them with; a template concatenating
"<build_type>-testing" would be a second implementation free to drift.
split_tests with no surviving testing configuration is now rejected at
generation, because splitting exports XMS_SKIP_CXX_TESTS=1 and the suite
would run nowhere while the pipeline passed having tested nothing.

The Coverage job did everything -- two instrumented conan builds, gcovr, the
HTML render, and the threshold gate -- in one job at the end of the
pipeline, so the slowest work started last and the gate could only be
exercised by driving two real conan builds. run_coverage now takes --phase.
coverage-status.json carries what no report file records: whether either
leg's tests failed, and whether each layer was measured. Thresholds stay out
of it, so tightening one re-gates existing artifacts without rebuilding.
allow_failure: exit_codes: [3] stays on the report job only -- collect
cannot produce a 3, so anything it fails on is a real breakage -- and the
coverage: regex reads the report phase's own "Coverage total: <pct>%" line,
so an unmeasured C++ layer prints n/a and cannot publish a percentage that
looks like a measurement.

Splitting collect from report exposed the real cost. "Coverage Build"
recompiled the Debug testing and pybind configurations, because
instrumentation is part of the package_id and no production binary can
satisfy it: 503 of the 1334 objects a pipeline compiled were that second
copy, and the single "Conan Build" compiled its whole matrix sequentially
inside one container. So a wheel_only repository now gets one build job per
surviving Linux configuration, each declaring `needs: []` so it starts at
t=0 rather than at its stage's turn, and a third phase -- `--phase measure
--leg <cpp|python>` -- builds one instrumented leg and reads its .gcda where
it was compiled. What leaves each job is a tracefile of a few hundred KB: at
245 MiB of .gcno against 5.5 MiB of .gcda, moving a build folder would cost
more than the compile it saves. Tag pipelines build only what ships, so
testing configurations are branch-only and the instrumented pybind job has a
clean `only: tags` twin, since --coverage changes the binary a release
publishes. Every taggable job exports its own cache tarball, because with
one container per configuration `conan cache save` in the pybind job sees
the pybind package and nothing else -- naming a single exporter would
publish one package id and silently drop the rest. The shape is gated on
[matrix].wheel_only: it was designed and measured against a matrix with no
library configurations, and a repository that publishes library packages
keeps what it had until that shape is proven too.

Two build types in [matrix].pybind_build_types would have collided in the
new fan-out. A pybind job's name carried the ABI but not the build type, so
the Release configuration's `only: tags` twin and the Debug configuration's
job both rendered as "Python Build" -- and a GitLab job name is a YAML
mapping key, so the later block silently replaced the earlier and that
configuration was never built. The build type now joins the name when the
fan-out spans two, a second pybind build type is no longer instrumented
(the Python leg's filter pins one, via a shared COVERAGE_PYBIND_BUILD_TYPE
the CI planner and the coverage run both read), and naming ends with a
uniqueness check that raises rather than shipping a pipeline that quietly
builds less than the matrix asked for. Alongside it, `dependencies:` on the
deploy job and `needs:` on the Coverage job are guarded on a non-empty
list: both render as null under a narrowing filter and GitLab rejects the
file, including in the case coverage_conflicts() warns about and then
generates anyway.

USAGE.md documents the measure phase and --leg, corrects the claim that the
report phase is pure JSON in (it shells out to gcovr to merge per-leg
tracefiles), and describes the wheel_only Coverage stage.
@wdolinar
wdolinar force-pushed the task/coverage-in-package-id branch from a700775 to 756f949 Compare August 31, 2026 22:36
@wdolinar
wdolinar merged commit c576aa1 into master Aug 31, 2026
8 checks passed
@wdolinar
wdolinar deleted the task/coverage-in-package-id branch August 31, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant