Vetrix Docs

coverage-summary.json — the well-known pipeline-coverage artifact

coverage-summary.json is a well-known coverage-artifact filename, the same way vetrix-ci.yml is the well-known pipeline-config filename. Any pipeline job that writes a coverage-summary.json at its workspace root has that file auto-collected as a pipeline artifact and used to populate the pipeline Coverage Report — the job does not have to declare it in artifacts.paths.

The user owns the step that produces the file (any language, any toolchain); the platform recognizes the standardized output. This is convention over configuration: emit the file under the well-known name and coverage shows up.


How auto-collection works

After a job finishes, the runner collects two sets of files:

  1. Declared artifacts — every glob under the job's artifacts.paths (unchanged behaviour).
  2. The well-known coverage file — if a regular file named coverage-summary.json exists at the workspace root, it is merged into the collected set even when the job declares no artifacts.paths at all.

Details that make the convention safe and predictable (internal/cicd/runner_artifacts.go, collectWellKnownCoverage):

  • No double-collection. A job that does list coverage-summary.json in artifacts.paths is collected exactly once — the well-known pass dedupes against the declared set and does not create a duplicate artifact row.
  • Schema-validated before storage. The auto-collected file is validated against the schema below (validateCoverageSummary). A present-but-malformed file is skipped with a WARN, not stored as garbage, so a non-conforming coverage-summary.json never lands as the coverage artifact.
  • Absence is silent. Most jobs emit no coverage file; a missing coverage-summary.json is a normal no-op, not a warning.
  • Containment. The file is resolved through the same symlink/containment guard the declared paths use, so a configured name can never resolve or symlink outside the per-job workspace.

Customizing the well-known filename

The well-known name defaults to coverage-summary.json (cicd.DefaultCoverageSummaryFile) and is operator-configurable via the worker env var RUNNER_COVERAGE_SUMMARY_FILE (RunnerConfig.WellKnownCoverageFile, wired in cmd/worker). Set it to rename the workspace-root file the runner auto-collects; empty (the default) keeps coverage-summary.json.

# worker/runner environment
RUNNER_COVERAGE_SUMMARY_FILE=coverage-summary.json   # default; rename to override

The frontend Coverage Report consumes the literal coverage-summary.json today (DEFAULT_COVERAGE_SUMMARY_ARTIFACT in CoverageSummary.tsx). If you override RUNNER_COVERAGE_SUMMARY_FILE, the FE will not find the renamed artifact until the configured name is plumbed through to the consumer; keep the default unless you have a specific reason to rename.


Schema (v1)

{
  "schema_version": "1",
  "generated_at": "2026-05-19T19:58:48Z",
  "commit": "0f2df23972c794d3d80ae95eaaa1effa05bbbbf4",
  "total_pct": 73.45,
  "total_covered_stmts": 12345,
  "total_stmts": 16809,
  "packages": [
    {
      "package": "vetrix/internal/api",
      "covered_stmts": 1234,
      "total_stmts": 1680,
      "pct": 73.45
    },
    {
      "package": "vetrix/internal/cicd",
      "covered_stmts": 980,
      "total_stmts": 1502,
      "pct": 65.25
    }
  ]
}

Field reference

Field Type Notes
schema_version string Always "1" for this format. The auto-collect validator requires schema_version == "1" and rejects any other value.
total_pct number (0–100, 2 decimals) Total covered statements ÷ total statements × 100. NOT the average of per-package pct. The validator requires this to be a JSON number (a string such as "73.45" is rejected).
packages array of objects One object per package in the coverage profile, sorted by package (import-path) ascending. May be empty. Each element must carry all four fields below or the file fails validation.
packages[].package string Package / import-path key, e.g. "vetrix/internal/api".
packages[].covered_stmts number Statements in this package whose execution count was > 0.
packages[].total_stmts number Statements declared in this package's profile.
packages[].pct number (0–100, 2 decimals) covered_stmts / total_stmts * 100. Zero when total_stmts == 0.
Tolerated optional fields

These are emitted by the reference producer and consumed by the FE when present, but are not required by the auto-collect validator:

Field Type Notes
generated_at string (ISO-8601 UTC, Z suffix) Wall-clock time the summary was produced.
commit string (SHA) The commit the run was against. May be "" when unavailable — consumers render "unknown commit" in that case.
total_covered_stmts number Sum of covered_stmts across all packages.
total_stmts number Sum of total_stmts across all packages. May be 0 (job ran but had nothing to summarise); the FE treats this as a graceful empty state, not an error.

What the auto-collect validator checks

The runner stores the file as the coverage artifact only if all of the following hold (validateCoverageSummary); otherwise it skips with a WARN:

  • The body is a valid JSON object.
  • schema_version is present and equals "1".
  • total_pct is present and is a JSON number (not a string/object/null).
  • packages is present, and every element carries package, covered_stmts, total_stmts, and pct.

Compatibility guarantees

  • New optional fields MAY be added without bumping schema_version; consumers must ignore unknown keys.
  • Existing field names, types, and semantics are frozen at v1.
  • Removing, renaming, or retyping a field bumps schema_version to "2".

Producing the file

You own the producing step. Any tool that writes a schema-v1 coverage-summary.json at the workspace root works; you do not need to declare it in artifacts.paths.

The reference converter is scripts/coverage-summary.py, which parses a Go coverage profile directly (so per-package counters are ground-truth rather than re-derived from the rounded go tool cover -func text). The minimal, single-suite form is a job that writes one profile and converts it:

coverage:
  stage: test
  image: golang:1.25
  commands:
    - go test -count=1 -covermode=atomic -coverprofile=coverage.out ./...
    - apt-get update && apt-get install -y --no-install-recommends python3
    - python3 scripts/coverage-summary.py coverage.out coverage-summary.json
  # No artifacts.paths entry needed: coverage-summary.json is auto-collected
  # by the well-known-filename convention.

Vetrix's own pipeline does not stop at a single suite: it merges a unit and an integration profile and produces the summary from the merged result. See Vetrix's own pipeline: merged unit + integration profile below.

Declaring it explicitly still works and is collected exactly once:

  artifacts:
    paths:
      - coverage-summary.json

For a non-Go toolchain, point any coverage tool at the same output name — e.g. a script that converts your tool's report into the v1 shape and writes coverage-summary.json at the workspace root.


Vetrix's own pipeline: merged unit + integration profile

Vetrix's pipeline produces coverage-summary.json from a merged unit + integration coverage profile in a single job, go-integration-test (in vetrix-ci.yml). The reported figure therefore reflects everything the test suite exercises — the unit tests and the integration-tagged tests, which are the only exercise of several store / settings layers.

There is exactly one producer of coverage-summary.json in the pipeline, by design. The runner auto-promotes the well-known artifact to the per-pipeline coverage_records row, keyed by pipeline_id alone (one row per pipeline) with last-write-wins. Two jobs each emitting coverage-summary.json would race the reported number; a single producer makes the published figure deterministic.

The merge has to happen inside one job because the v1 pipeline schema is frozen and has no needs: / cross-job artifact hand-off, and every job runs in its own workspace — so no separate "merge job" could read another job's profile. go-integration-test already produces the integration profile, so it also runs the unit pass and the merge. In order:

  1. Unit passgo test -covermode=atomic -coverpkg=./... … writes coverage.out. (-coverpkg=./... attributes coverage repo-wide, not just to the package under test.)

  2. Integration pass — the integration-tagged suite (split into a db and a rest run) is folded into a single coverage.integration.out.

  3. Merge — concatenate the two profiles into coverage.merged.out, keeping a single mode: header and every coverage block from both inputs:

    awk 'FNR==1 && /^mode:/ {if (seen++) next} {print}' \
        coverage.out coverage.integration.out > coverage.merged.out
    
  4. Regenerate from the merged profilecoverage.html, coverage-func.txt, and coverage-summary.json are all produced from coverage.merged.out, never from either input alone:

    go tool cover -html=coverage.merged.out -o coverage.html
    go tool cover -func=coverage.merged.out | tee coverage-func.txt
    python3 scripts/coverage-summary.py coverage.merged.out coverage-summary.json
    

So the coverage-summary.json that the runner collects and promotes to coverage_records is computed from the merged profile and reflects both suites.

Why concatenation is a correct merge (set-union semantics)

A Go profile is a list of <location> <numStmts> <count> blocks under a single mode: header. scripts/coverage-summary.py — like go tool cover -funcfolds duplicate block locations by summing their hit counts, and treats a statement as covered when the folded count is > 0. That fold already exists because go test ./... under -coverpkg=./... emits the same block once per test binary; merging the unit and integration profiles is the same fold applied across the two suites, so no format change is needed for the merge.

The consequence is set-union coverage: a statement covered in either the unit or the integration profile is covered in the merged summary. Because the fold sums the counts before the > 0 test (rather than appending rows or letting one profile win), the deduped totals are identical to running go tool cover -func directly on coverage.merged.out — no double-counting, and neither profile takes precedence.


Frontend consumer route

The pipeline Coverage Report fetches the artifact by name from the pipeline-scoped artifact endpoint:

GET /api/v1/repos/{owner}/{repo}/pipelines/{id}/artifacts/coverage-summary.json

(internal/api/pipelines.goPipelineHandler.GetArtifact; consumed by pipelinesApi.getArtifactJsonCoverageSummary.tsx.) On 404 the FE renders the empty state ("no coverage data for this pipeline"); on a payload that does not match the v1 shape it renders the error state. The companion listing endpoint is GET /api/v1/repos/{owner}/{repo}/pipelines/{id}/artifacts.


References

  • Backend auto-collect: internal/cicd/runner_artifacts.go, RUNNER_COVERAGE_SUMMARY_FILE.
  • User how-to: How to surface pipeline coverage in user-docs.
  • Reference producer: scripts/coverage-summary.py in vetrix/vetrix.
  • Merge step: the go-integration-test job in vetrix-ci.yml (unit + integration profile merge → coverage.merged.out).
  • Promotion to coverage_records: keyed by pipeline_id alone (one row per pipeline), single-producer.