#1917·pest

TIA: the documented CI baseline command records no graph, and unlocking it would record an under-selecting one

Author: calebdwCreated Sep 16, 2026Updated Sep 16, 2026

Summary

The Sharing The Baseline From CI workflow records the baseline with:

bash
./vendor/bin/pest --parallel --tia --coverage --fresh

Both the GitHub Actions and the GitLab CI starter jobs in the docs use this line. On a CI runner — where no graph exists yet — it never records a graph. The run prints the "TIA is skipped" notice and exits normally, but the storage directory that pest --baseline points at contains only coverage.bin.gz.

The published artifact therefore has no graph.json, and every consumer that fetches it hits BaselineSync::validateDownloadedArtifact():

Baseline downloaded but the artifact is missing expected files (graph.json).
Your CI publish step is broken — check the job that uploads the TIA baseline artifact.

So the documented workflow cannot produce a usable baseline, and the failure surfaces on the developer's machine rather than in the job that is actually wrong.

Reproduced on v5.2.0 (PHP 8.5, Xdebug 3, XDEBUG_MODE=coverage).

Reproduction

bash
# minimal project: src/{Calc,Other}.php, tests/{Add,Mul,Other}Test.php,
# phpunit.xml with <source><include><directory>src</directory></include></source>

BASE=$(./vendor/bin/pest --baseline)
rm -rf "$BASE"

XDEBUG_MODE=coverage ./vendor/bin/pest --parallel --tia --coverage --fresh
#   ─ Running in TIA mode, however TIA is skipped as an active coverage report
#     narrows the edges it could record.
#   ─ Record the baseline with a plain --tia run first; coverage runs then reuse it.

ls -1 "$BASE"
# coverage.bin.gz          <- no graph.json

Why

Tia::handleParent() bails before recording when no graph exists and coverage is active:

php
// src/Plugins/Tia.php
$coverageCacheOwned = $this->piggybackCoverage && $this->pestCoverageActive();

if ($coverageCacheOwned) {
    $this->state->write(self::KEY_COVERAGE_MARKER, '');   // cache will still be written
}

if (! $graph instanceof Graph && $this->piggybackCoverage) {
    $this->emitCoverageScopedRecordSkipped();

    return $arguments;                                    // graph never recorded
}

The marker is written immediately above the guard, which is why the artifact ends up populated-looking but unusable.

Note also that there is no coverage form that avoids this. coverageReportActive() checks pestCoverageActive() first, so plain --coverage trips the guard exactly like --coverage-cobertura does:

php
private function coverageReportActive(): bool
{
    if ($this->pestCoverageActive()) {
        return true;
    }

    return array_any(self::COVERAGE_REPORT_FLAGS, fn (string $flag): bool => $this->hasArgument($flag, $this->originalArguments));
}

Removing the guard is not the fix

The obvious patch — dropping the guard so a full run under --coverage records — does produce both files in one pass, and the result replays. But the graph it builds silently under-selects tests.

In piggyback mode the per-test file edges come from PHPUnit's CodeCoverage instance, which honours the phpunit.xml <source> filter. The recorder's own driver is built from SourceScope::fromProjectRoot(), which is phpunit's <source> plus every top-level project directory. So a piggyback-recorded graph is a strict subset, and it is still marked complete.

Demonstration — lib/helpers.php holds a function one test calls, and lib is outside <source>:

graph recorded by edit lib/helpers.php
--tia --fresh (recorder's own driver) 1 affected, 1 replayed
--tia --fresh --coverage, guard removed 2 replayed, 0 affected

The test that calls the changed function is not re-run, with no warning. For a Laravel application whose <source> is typically app, that silently covers database/, routes/, bootstrap/ and test helpers.

Suggested direction

  1. Docs: change both starter jobs to record with a plain --tia --fresh, since the graph alone is what makes a fetched baseline replay. If the coverage cache is wanted too it needs a second pass (--tia --coverage), which is worth stating explicitly rather than implying one command does both.
  2. Fail loudly in the publisher: when a run skips recording and the user asked for --fresh, exiting non-zero (or at least warning at --baseline time that no graph exists) would move this failure into the CI job that caused it.
  3. Longer term: decoupling the edge-collection filter from the report filter would let one pass do both, which is what the documented command already implies. That is the only version of "unlock the guard" that is safe.

Related

  • #1796 — the shipped coverage.bin.gz carries the recording machine's absolute paths and fails open cross-machine. Still reproduces on v5.2.0: with the recording root absent, a replayed --coverage --min=90 exits 0 on a project whose real coverage is 50%.

The two compose badly for anyone following the docs today: the published artifact is either missing its graph (this issue), or — if the guard were simply removed — carries a graph that under-selects and a cache that fails open.