detectGitHubRepo($projectRoot); if ($repo === null) { return false; } $this->output->writeln(sprintf( ' TIA fetching baseline from %s…', $repo, )); $payload = $this->download($repo); if ($payload === null) { $this->emitPublishInstructions($repo); return false; } if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) { return false; } if ($payload['coverage'] !== null) { $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']); } $this->output->writeln(sprintf( ' TIA baseline ready (%s).', $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), )); return true; } /** * Prints actionable instructions for publishing a first baseline when * the consumer-side fetch finds nothing. * * Behaviour splits on environment: * - **CI:** a single line. The current run is almost certainly *the* * publisher (it's what this workflow does by definition), so * printing the whole recipe again is redundant and noisy. * - **Local:** the full recipe, adapted to Laravel's pre-test steps * (`.env.example` copy + `artisan key:generate`) when the framework * is present. Generic PHP projects get a slimmer skeleton. */ private function emitPublishInstructions(string $repo): void { if ($this->isCi()) { $this->output->writeln( ' TIA no baseline yet — this run will produce one.', ); return; } $yaml = $this->isLaravel() ? $this->laravelWorkflowYaml() : $this->genericWorkflowYaml(); $preamble = [ ' TIA no baseline published yet — recording locally.', '', ' To share the baseline with your team, add this workflow to the repo:', '', ' .github/workflows/tia-baseline.yml', '', ]; $indentedYaml = array_map( static fn (string $line): string => ' '.$line, explode("\n", $yaml), ); $trailer = [ '', sprintf(' Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo), ' Details: https://pestphp.com/docs/tia/ci', '', ]; $this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]); } /** * True when running inside a CI provider. Conservative list — only the * three providers Pest formally supports / sees in the wild. `CI=true` * alone is ambiguous (users set it locally too) so we require a * provider-specific flag. */ private function isCi(): bool { return getenv('GITHUB_ACTIONS') === 'true' || getenv('GITLAB_CI') === 'true' || getenv('CIRCLECI') === 'true'; } private function isLaravel(): bool { return class_exists(InstalledVersions::class) && InstalledVersions::isInstalled('laravel/framework'); } /** * Laravel projects need a populated `.env` and a generated `APP_KEY` * before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException` * fires during `setUp`. Include the standard pre-test dance plus the * extension set typical Laravel apps rely on. */ private function laravelWorkflowYaml(): string { return <<<'YAML' name: TIA Baseline on: push: { branches: [main] } schedule: [{ cron: '0 3 * * *' }] workflow_dispatch: jobs: baseline: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: xdebug extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl - run: cp .env.example .env - run: composer install --no-interaction --prefer-dist - run: php artisan key:generate - run: ./vendor/bin/pest --parallel --tia --coverage - uses: actions/upload-artifact@v4 with: name: pest-tia-baseline path: vendor/pestphp/pest/.temp/tia/ retention-days: 30 YAML; } private function genericWorkflowYaml(): string { return <<<'YAML' name: TIA Baseline on: push: { branches: [main] } schedule: [{ cron: '0 3 * * *' }] workflow_dispatch: jobs: baseline: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: shivammathur/setup-php@v2 with: { php-version: '8.4', coverage: xdebug } - run: composer install --no-interaction --prefer-dist - run: ./vendor/bin/pest --parallel --tia --coverage - uses: actions/upload-artifact@v4 with: name: pest-tia-baseline path: vendor/pestphp/pest/.temp/tia/ retention-days: 30 YAML; } /** * Parses `.git/config` for the `origin` remote and extracts * `org/repo`. Supports the two URL flavours git emits out of the box. * Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which * silently opts the repo out of auto-sync. */ private function detectGitHubRepo(string $projectRoot): ?string { $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; if (! is_file($gitConfig)) { return null; } $content = @file_get_contents($gitConfig); if ($content === false) { return null; } // Find the `[remote "origin"]` section and the first `url` line // inside it. Tolerates INI whitespace quirks (tabs, CRLF). if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) { return null; } $url = $match[1]; // SSH: git@github.com:org/repo(.git) if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) { return $m[1]; } // HTTPS: https://github.com/org/repo(.git) (optional trailing slash) if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) { return $m[1]; } return null; } /** * Two-step fetch: find the latest successful run of the baseline * workflow, then download the named artifact from it. Returns * `['graph' => bytes, 'coverage' => bytes|null]` on success, or null * if `gh` is unavailable, the workflow hasn't run yet, the artifact * is missing, or any shell step fails. * * @return array{graph: string, coverage: ?string}|null */ private function download(string $repo): ?array { if (! $this->commandExists('gh')) { return null; } $runId = $this->latestSuccessfulRunId($repo); if ($runId === null) { return null; } $tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4)); if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) { return null; } $process = new Process([ 'gh', 'run', 'download', $runId, '-R', $repo, '-n', self::ARTIFACT_NAME, '-D', $tmpDir, ]); $process->setTimeout(120.0); $process->run(); if (! $process->isSuccessful()) { $this->cleanup($tmpDir); return null; } $graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET; $coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET; $graph = is_file($graphPath) ? @file_get_contents($graphPath) : false; if ($graph === false) { $this->cleanup($tmpDir); return null; } $coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false; $this->cleanup($tmpDir); return [ 'graph' => $graph, 'coverage' => $coverage === false ? null : $coverage, ]; } /** * Queries GitHub for the most recent successful run of the baseline * workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found" * into an empty string, which we map to null. */ private function latestSuccessfulRunId(string $repo): ?string { $process = new Process([ 'gh', 'run', 'list', '-R', $repo, '--workflow', self::WORKFLOW_FILE, '--status', 'success', '--limit', '1', '--json', 'databaseId', '--jq', '.[0].databaseId // empty', ]); $process->setTimeout(30.0); $process->run(); if (! $process->isSuccessful()) { return null; } $runId = trim($process->getOutput()); return $runId === '' ? null : $runId; } private function commandExists(string $cmd): bool { $probe = new Process(['command', '-v', $cmd]); $probe->run(); if ($probe->isSuccessful()) { return true; } $which = new Process(['which', $cmd]); $which->run(); return $which->isSuccessful(); } private function cleanup(string $dir): void { if (! is_dir($dir)) { return; } $entries = glob($dir.DIRECTORY_SEPARATOR.'*'); if ($entries !== false) { foreach ($entries as $entry) { if (is_file($entry)) { @unlink($entry); } } } @rmdir($dir); } private function formatSize(int $bytes): string { if ($bytes >= 1024 * 1024) { return sprintf('%.1f MB', $bytes / 1024 / 1024); } if ($bytes >= 1024) { return sprintf('%.1f KB', $bytes / 1024); } return $bytes.' B'; } }