Parallel test runners (paratest / Pest --parallel)
Coverage state is per-process. Under parallel runners — brianium/paratest or pest --parallel (which delegates to paratest) — each worker boots its own PHPUnit, runs a slice of the suite, and would otherwise emit its own slice report. Without coordination the output_file ends up containing whichever worker finished last, and the GITHUB_STEP_SUMMARY ends up with N partial reports stacked on top of each other.
The coverage extension solves this with a two-step workflow that mirrors phpunit/php-code-coverage:
- Workers drop a JSON sidecar per process. The extension auto-detects paratest by looking at
TEST_TOKEN(set in every paratest child) and short-circuits rendering — no console output, nooutput_filewrite, noGITHUB_STEP_SUMMARYappend from the worker.GESSO_SIDECAR_TOKENrequests the same thing explicitly, for runners nothing detects; see Sharded CI jobs. - A single merge step reads the sidecars, union-merges them via the same rules
OpenApiCoverageTracker::recordResponse()applies, and emits the combined report.
Workflow
# 1. Run tests in parallel — workers write sidecars only.
vendor/bin/pest --parallel --processes=4
# (or `vendor/bin/paratest --processes=4`)
# 2. Merge sidecars into a single coverage report.
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--specs=front,admin \
--output-file=coverage-report.mdvendor/bin/gesso coverage:merge flags:
| Flag | Default | Description |
|---|---|---|
--spec-base-path=<path> | — (required) | Path to bundled spec directory |
--specs=<a,b> | front | Comma-separated spec names |
--strip-prefixes=<a,b> | — | Comma-separated request-path prefixes to strip |
--sidecar-dir=<path> | sys_get_temp_dir()/openapi-coverage-sidecars | Where workers wrote sidecars |
--output-file=<path> | — | Markdown report output path |
--junit-output=<path> | — | JUnit XML report output path (CI dashboards). See Coverage output formats |
--json-output=<path> | — | Machine-readable JSON report output path. Schema: coverage-json-schema.md |
--html-output=<path> | — | Self-contained HTML report output path. See coverage-html-output.md |
--github-step-summary=<path> | $GITHUB_STEP_SUMMARY | Append Markdown report to this file |
--console-output=<mode> | default | default / all / uncovered_only |
--min-endpoint-coverage=<pct> | — | Threshold gate (see Coverage threshold gate) |
--min-response-coverage=<pct> | — | Threshold gate at (method, path, status, content-type) granularity |
--min-sdk-exercise-coverage=<pct> | — | Threshold gate for eligible response schemas attempted by SDK decoder callbacks |
--min-coverage-strict | false (warn-only) | Treat threshold misses as exit non-zero |
--strict-required=<mode> | off | off / warn / fail. Assert no schema under-description drift across worker observations. See strict-required.md |
--strict-additional-properties=<mode> | off | off / warn / fail. Report returned response properties absent from schema declarations. See strict-additional-properties.md |
--baseline-file=<path> | — | Union the violation-baseline halves staged by an GESSO_BASELINE_GENERATE=1 parallel run and write the merged baseline. See baseline.md |
--coverage-baseline-file=<path> | — | Gate the merged coverage against a committed set of known-uncovered responses; writes the file instead when GESSO_BASELINE_GENERATE=1 is set. See coverage-baseline.md |
--coverage-baseline-stale=<mode> | note | off / note / fail. How baseline entries that are covered now are reported |
--expect-sidecars=<n> | — | Fail unless exactly n sidecars are present. See Sharded CI jobs |
--no-cleanup | (cleanup is on by default) | Keep sidecar files after merge |
Sidecar dir defaults are deliberately stable — workers and the merge CLI use the same sys_get_temp_dir()/openapi-coverage-sidecars path, so a trivial CI step has no extra config to keep in sync. Set sidecar_dir (in phpunit.xml) and --sidecar-dir= (on the merge CLI) to the same custom path if sys_get_temp_dir() is unavailable in your runner.
On POSIX systems, Gesso creates a missing sidecar directory with mode 0700 and publishes sidecars and worker-failure markers with mode 0600. For local-user safety, both workers and the merge command reject a sidecar_dir that is itself a symbolic link or is writable by group or other users. The merge command also rejects symbolic-link sidecar files instead of following them, and requires each sidecar to be non-group/world-writable. The permission-bit checks are not applied on Windows, whose ACL model is not represented by POSIX mode bits. Use a dedicated directory rather than /tmp itself; the default already does this.
Sharded CI jobs
Splitting a suite across CI jobs — one runner per testsuite, to keep each job inside a time budget — puts every runner in the same position as a paratest worker: it holds one slice of the state and nothing else can see it. Nothing in the environment says so, though, so each job has to name itself:
strategy:
matrix:
suite: [Unit, Feature, E2E]
steps:
- run: vendor/bin/phpunit --testsuite=${{ matrix.suite }}
env:
GESSO_SIDECAR_TOKEN: ${{ matrix.suite }}
- uses: actions/upload-artifact@v4
with:
name: gesso-sidecars-${{ matrix.suite }}
path: /tmp/openapi-coverage-sidecarsGESSO_SIDECAR_TOKEN does exactly what TEST_TOKEN does for paratest — the process writes its sidecar and renders nothing — and its value names the sidecar (part-<token>-<pid>.json), so artifacts downloaded from several runners into one directory cannot collide. It takes priority over TEST_TOKEN, so a sharded job may still run paratest inside it: the shard name namespaces the files and the pid keeps that job's workers apart. A blank or unset value leaves the run rendering in process, unchanged.
The merge job then downloads every artifact into one directory and produces the single report and the single gate evaluation for the whole suite:
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--sidecar-dir=sidecars \
--expect-sidecars=3 \
--strict-required=fail \
--min-response-coverage=80--expect-sidecars=<n> is worth setting whenever the shard count is known up front. A shard that was cancelled — or that died before PHPUnit finished — writes neither a sidecar nor a failed-*.json marker, because the marker only covers "ran but could not write". The declared count is the only evidence that absence leaves, and without it the merge reports the surviving shards' coverage as if it were the whole suite. A mismatch in either direction is FATAL (exit 1) and leaves the sidecars in place: a surplus means the directory still holds a previous run's files, which inflates coverage just as quietly.
Each shard is a partial run on its own, which is why none of them writes output_file / json_output / GITHUB_STEP_SUMMARY or evaluates a gate — see Partial test runs. The merge step is the full-suite view, and it owns those decisions for the whole matrix.
Sidecar compatibility
Sidecars are a versioned worker-to-merge protocol, separate from the coverage report produced by json_output. The current writer emits an envelopeVersion: 8 envelope containing coverage state version: 1, strict-required state version: 2, and strict-additional-properties state version: 1, plus SDK exercise state version: 1 and deprecation state version: 1. A baseline-generation run (GESSO_BASELINE_GENERATE=1) emits envelopeVersion: 9 and adds the violation-baseline document (baseline_version: 1).
The merge reader also accepts envelopes 2–7 and the older bare coverage state version: 1, so HTTP coverage can still be combined while a worker fleet is being upgraded. Envelopes 2/3 have no strict-additional-properties state, envelopes 2–5 have no SDK exercise state, and envelopes 2–7 have no deprecation state. A strict SDK exercise threshold therefore requires every worker on v6+; warn-only mode reports missing worker state and evaluates the available SDK observations. Likewise, plain envelopes carry no baseline data, so --baseline-file requires every worker to use a corresponding baseline envelope.
Deprecation counts travel the same way. A worker never reaches the end-of-run report, so it stages its per-id counts in the envelope and coverage:merge writes the one summed [Gesso deprecation] line — silence there means no worker used a deprecated surface. When some sidecar predates the deprecation channel the merge says so instead, because a missing half and "this worker used none" are the same absence.
Unknown envelope or tracker versions fail the merge rather than being guessed. Strict-required state version: 1 is also rejected because merging it with the current nested-pointer shape would silently lose information. Keep workers on one version when using the strict-required gate. See the complete versioning policy before changing a sidecar shape or filename pattern.
Notes
- Sequential runs are unchanged. Without
TEST_TOKENorGESSO_SIDECAR_TOKENthe extension renders inline as before. There is no need to wire the merge CLI into non-parallel CI jobs. - Pest plugin works under
--parallel. The expectations registered by the Pest plugin record coverage through the sameOpenApiCoverageTrackerstatic, so each Pest worker drops a sidecar exactly like a paratest worker would. No additional wiring needed beyond the merge step shown above. strict_requiredaggregates across workers. Workers always export observations via the versioned sidecar envelope. The merge CLI's--strict-requiredflag decides whether to assert the gate; thestrict_requiredparameter on the PHPUnit extension does not propagate to the merge step. Seestrict-required.md.- Baseline generation aggregates across workers too. An
GESSO_BASELINE_GENERATE=1parallel run stages fingerprints in the sidecars; pass--baseline-file=<path>to the merge to write the union. Seebaseline.md. - Worker counts are not exposed by paratest. A child cannot reliably tell how many siblings it has, so the merge has to run as a separate step rather than auto-firing from "the last worker." This matches how PHPUnit's own coverage merging works (
phpcov merge). - Sidecars are cleaned up by default. Run with
--no-cleanupif you want to inspect the per-worker JSON for debugging. - A failed sidecar write does not fail the test run. Workers log a warning to
STDERRand let the suite finish — your contract assertions already passed; sidecar I/O is a CI artifact concern. The exception is a baseline-generation run (GESSO_BASELINE_GENERATE=1): the worker demoted its failures on the promise that the merge unions its sidecar, so losing the sidecar fails the worker — otherwise the merge could write an incomplete baseline from the remaining workers. - Stale sidecars across runs. Cleanup-on-success removes sidecars after every successful merge. If a previous run crashed before the merge step, any leftover sidecars in the dir will be picked up by the next merge — delete the sidecar dir at the start of CI if you can't trust the previous run's exit code.
- Worker write failures fail the merge loudly. When a worker can't persist its sidecar, it drops a
failed-<token>.jsonmarker. The merge CLI exits non-zero (FATAL) when any markers are present, since a missing worker would silently under-count coverage. - HTTP
$refauto-resolution from the merge CLI. The CLI callsOpenApiSpecLoader::configure()with onlyspec_base_pathandstrip_prefixes—allowRemoteRefscannot be set via CLI flags. If your spec uses HTTP(S)$ref, run the merge step from a process that callsOpenApiSpecLoader::configure(..., allowRemoteRefs: true, allowedRemoteRefHosts: ['specs.example.com'], ...)first (e.g. a Composer script), or pre-bundle remote refs offline.