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:
- Declared artifacts — every glob under the job's
artifacts.paths(unchanged behaviour). - The well-known coverage file — if a regular file named
coverage-summary.jsonexists at the workspace root, it is merged into the collected set even when the job declares noartifacts.pathsat 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.jsoninartifacts.pathsis 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-conformingcoverage-summary.jsonnever lands as the coverage artifact. - Absence is silent. Most jobs emit no coverage file; a missing
coverage-summary.jsonis 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.jsontoday (DEFAULT_COVERAGE_SUMMARY_ARTIFACTinCoverageSummary.tsx). If you overrideRUNNER_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_versionis present and equals"1".total_pctis present and is a JSON number (not a string/object/null).packagesis present, and every element carriespackage,covered_stmts,total_stmts, andpct.
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_versionto"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:
-
Unit pass —
go test -covermode=atomic -coverpkg=./... …writescoverage.out. (-coverpkg=./...attributes coverage repo-wide, not just to the package under test.) -
Integration pass — the integration-tagged suite (split into a db and a rest run) is folded into a single
coverage.integration.out. -
Merge — concatenate the two profiles into
coverage.merged.out, keeping a singlemode:header and every coverage block from both inputs:awk 'FNR==1 && /^mode:/ {if (seen++) next} {print}' \ coverage.out coverage.integration.out > coverage.merged.out -
Regenerate from the merged profile —
coverage.html,coverage-func.txt, andcoverage-summary.jsonare all produced fromcoverage.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 -func —
folds 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.go → PipelineHandler.GetArtifact; consumed by
pipelinesApi.getArtifactJson → CoverageSummary.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.pyinvetrix/vetrix. - Merge step: the
go-integration-testjob invetrix-ci.yml(unit + integration profile merge →coverage.merged.out). - Promotion to
coverage_records: keyed by pipeline_id alone (one row per pipeline), single-producer.