mirror of
https://github.com/pestphp/pest.git
synced 2026-04-21 22:47:27 +02:00
wip
This commit is contained in:
@ -74,8 +74,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
||||
|
||||
private const string PUBLISH_OPTION = '--tia-publish';
|
||||
|
||||
/**
|
||||
* State keys under which TIA persists its blobs. Kept here as constants
|
||||
* (rather than scattered strings) so the storage layout is visible in
|
||||
@ -308,16 +306,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||
|
||||
// `--tia-publish` is its own entry point: it neither records nor
|
||||
// replays, it just uploads whatever baseline is already on disk
|
||||
// and exits. Handled before the usual `--tia` gating so users can
|
||||
// publish without also triggering a suite run.
|
||||
if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) {
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
exit($this->baselineSync->publish($projectRoot));
|
||||
}
|
||||
|
||||
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
||||
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
@ -15,15 +14,21 @@ use Symfony\Component\Process\Process;
|
||||
* contributors and fresh CI workspaces start in replay mode instead of
|
||||
* paying the ~30s record cost.
|
||||
*
|
||||
* The baseline lives as a GitHub Release with a fixed tag containing two
|
||||
* assets — the graph JSON and the coverage cache. The repo is inferred
|
||||
* from `.git/config`'s `origin` remote, so no per-project configuration
|
||||
* is required. Non-GitHub remotes silently opt out.
|
||||
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
||||
* suite under `--tia` and uploads the resulting `tia.json` +
|
||||
* `tia-coverage.bin` as a named artifact (`pest-tia-baseline`). On dev
|
||||
* machines, this class finds the latest successful run of that workflow
|
||||
* and downloads the artifact via `gh`.
|
||||
*
|
||||
* Fetching is attempted in order:
|
||||
* 1. `gh release download` — uses the user's existing GitHub auth,
|
||||
* works for private repos.
|
||||
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
|
||||
* Why artifacts, not releases:
|
||||
* - No tag is created → no `push` event cascade into CI workflows.
|
||||
* - No release event → no deploy workflows tied to `release:published`.
|
||||
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
|
||||
* a single floating tag.
|
||||
* - Publishing is strictly CI-only: artifacts can't be produced from a
|
||||
* developer's laptop. This enforces the "CI is the authoritative
|
||||
* publisher" policy that local-publish paths would otherwise erode.
|
||||
*
|
||||
* Fingerprint validation happens back in `Tia::handleParent` after the
|
||||
* blobs are written: a mismatched environment (different PHP version,
|
||||
@ -35,14 +40,20 @@ use Symfony\Component\Process\Process;
|
||||
final class BaselineSync
|
||||
{
|
||||
/**
|
||||
* Conventional tag the CI recipe publishes under. Not configurable for
|
||||
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
|
||||
* var is the likely escape hatch.
|
||||
* Conventional workflow filename teams publish from. Not configurable
|
||||
* for MVP — teams that outgrow the default can set
|
||||
* `PEST_TIA_BASELINE_WORKFLOW` later.
|
||||
*/
|
||||
private const string RELEASE_TAG = 'pest-tia-baseline';
|
||||
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
||||
|
||||
/**
|
||||
* Asset filenames within the release — mirror the state keys so the
|
||||
* Artifact name the workflow uploads under. The artifact is a zip
|
||||
* containing `tia.json` (always) + `tia-coverage.bin` (optional).
|
||||
*/
|
||||
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
||||
|
||||
/**
|
||||
* Asset filenames inside the artifact — mirror the state keys so the
|
||||
* CI publisher and the sync consumer stay in lock-step.
|
||||
*/
|
||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||
@ -52,14 +63,13 @@ final class BaselineSync
|
||||
public function __construct(
|
||||
private readonly State $state,
|
||||
private readonly OutputInterface $output,
|
||||
private readonly InputInterface $input,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attempts the full detect → prompt → download flow. Returns true when
|
||||
* the graph blob was pulled and written to state. Coverage is best-
|
||||
* effort: its absence doesn't fail the sync, since plain `--tia` (no
|
||||
* `--coverage`) works fine without it.
|
||||
* Detects the repo, fetches the latest baseline artifact, writes its
|
||||
* contents into the TIA state store. Returns true when the graph blob
|
||||
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||
* never reads it.
|
||||
*/
|
||||
public function fetchIfAvailable(string $projectRoot): bool
|
||||
{
|
||||
@ -74,9 +84,9 @@ final class BaselineSync
|
||||
$repo,
|
||||
));
|
||||
|
||||
$graphJson = $this->download($repo, self::GRAPH_ASSET);
|
||||
$payload = $this->download($repo);
|
||||
|
||||
if ($graphJson === null) {
|
||||
if ($payload === null) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
);
|
||||
@ -84,228 +94,22 @@ final class BaselineSync
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
|
||||
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Coverage cache is optional. The baseline is useful even without
|
||||
// it (plain `--tia` never needs it) so don't fail the whole sync
|
||||
// just because this asset is missing or slow.
|
||||
$coverageBin = $this->download($repo, self::COVERAGE_ASSET);
|
||||
|
||||
if ($coverageBin !== null) {
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin);
|
||||
if ($payload['coverage'] !== null) {
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
|
||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the *local* baseline to GitHub Releases under the
|
||||
* conventional tag, creating the release on first run or uploading
|
||||
* into the existing one otherwise.
|
||||
*
|
||||
* Uploading from a developer workstation is intentionally discouraged
|
||||
* — CI is the authoritative publisher because its environment is
|
||||
* reproducible, its working tree is clean, and its result cache
|
||||
* isn't contaminated by local flakiness. The prompt here defaults to
|
||||
* *No* to keep this an explicit, opt-in action.
|
||||
*
|
||||
* Returns a CLI-style exit code so the caller can `exit()` on it.
|
||||
*/
|
||||
public function publish(string $projectRoot): int
|
||||
{
|
||||
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
|
||||
|
||||
if ($graphBytes === null) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=red>TIA</> no local baseline to publish.',
|
||||
' Run <fg=cyan>./vendor/bin/pest --tia</> first to record one, then retry.',
|
||||
'',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
|
||||
if ($repo === null) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
|
||||
' Publishing is supported only for GitHub-hosted projects.',
|
||||
'',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! $this->commandExists('gh')) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=red>TIA</> publishing requires the <fg=cyan>gh</> CLI.',
|
||||
' Install: <fg=gray>https://cli.github.com</>',
|
||||
'',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=black;bg=yellow> WARNING </> Publishing local baselines is discouraged.',
|
||||
'',
|
||||
' Local runs can bake flaky results or dirty working-tree state into the',
|
||||
' baseline, which your team then replays. CI-published baselines are safer.',
|
||||
' See <fg=gray>https://pestphp.com/docs/tia/ci</> for the recommended workflow.',
|
||||
'',
|
||||
' Release is created as a <fg=cyan>draft</> to avoid firing `release:published`',
|
||||
' workflows. Consumers must authenticate `gh` — anonymous downloads are out.',
|
||||
'',
|
||||
]);
|
||||
|
||||
if (! $this->confirmPublish($repo)) {
|
||||
$this->output->writeln(' <fg=yellow>TIA</> publish cancelled.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4));
|
||||
|
||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to create temp dir for upload.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
|
||||
if (@file_put_contents($graphPath, $graphBytes) === false) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$filesToUpload = [$graphPath];
|
||||
|
||||
$coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if ($coverageBytes !== null) {
|
||||
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
|
||||
if (@file_put_contents($coveragePath, $coverageBytes) !== false) {
|
||||
$filesToUpload[] = $coveragePath;
|
||||
}
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> publishing to <fg=white>%s</> (tag <fg=white>%s</>)…',
|
||||
$repo,
|
||||
self::RELEASE_TAG,
|
||||
));
|
||||
|
||||
$exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload);
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->output->writeln(' <fg=red>TIA</> <fg=cyan>gh release</> failed.');
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline published (%s).',
|
||||
$this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))),
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads into the existing release if present, falls back to
|
||||
* creating the release with the assets attached on first run.
|
||||
*
|
||||
* The release is always created as a **draft**. Drafts don't fire the
|
||||
* `release:published` GitHub Actions event, which matters a lot for
|
||||
* repos that tie deploys to release events (e.g. `laravel/cloud`'s
|
||||
* `deploy.yml` fires `deploy-staging → deploy-production` on any
|
||||
* published release). Authenticated collaborators can still
|
||||
* `gh release download` draft assets. Anonymous HTTPS downloads of
|
||||
* draft assets don't work — acceptable for a feature aimed at teams
|
||||
* that authenticate `gh` anyway.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
*/
|
||||
private function ghReleaseUploadOrCreate(string $repo, array $files): int
|
||||
{
|
||||
$uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber'];
|
||||
$upload = new Process($uploadArgs);
|
||||
$upload->setTimeout(300.0);
|
||||
$upload->run(function (string $_, string $buffer): void {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
|
||||
if ($upload->isSuccessful()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Release likely doesn't exist yet — create it as a draft so it
|
||||
// doesn't trigger release-gated workflows, attaching the files.
|
||||
$createArgs = [
|
||||
'gh', 'release', 'create', self::RELEASE_TAG,
|
||||
...$files,
|
||||
'-R', $repo,
|
||||
'--draft',
|
||||
'--title', 'Pest TIA baseline',
|
||||
'--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.',
|
||||
];
|
||||
$create = new Process($createArgs);
|
||||
$create->setTimeout(300.0);
|
||||
$create->run(function (string $_, string $buffer): void {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
|
||||
return $create->isSuccessful() ? 0 : 1;
|
||||
}
|
||||
|
||||
private function confirmPublish(string $repo): bool
|
||||
{
|
||||
if (! $this->isTerminal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
|
||||
$repo,
|
||||
self::RELEASE_TAG,
|
||||
));
|
||||
|
||||
$handle = @fopen('php://stdin', 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$line = fgets($handle);
|
||||
fclose($handle);
|
||||
|
||||
if ($line === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unlike the fetch prompt, this one defaults to *No*. Empty input
|
||||
// or anything other than an explicit "y"/"yes" cancels.
|
||||
$line = strtolower(trim($line));
|
||||
|
||||
return $line === 'y' || $line === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `.git/config` for the `origin` remote and extracts
|
||||
* `org/repo`. Supports the two URL flavours git emits out of the box.
|
||||
@ -348,49 +152,26 @@ final class BaselineSync
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true
|
||||
* unless `--no-interaction` is explicitly passed, which would make
|
||||
* scripted invocations (CI, pipes, subshells) hang at a prompt nobody
|
||||
* sees. Combining both signals is the safe default.
|
||||
*/
|
||||
private function isTerminal(): bool
|
||||
{
|
||||
if (! $this->input->isInteractive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! defined('STDIN')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (function_exists('posix_isatty')) {
|
||||
return @posix_isatty(STDIN) === true;
|
||||
}
|
||||
|
||||
if (function_exists('stream_isatty')) {
|
||||
return @stream_isatty(STDIN) === true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a release asset via `gh release download`. Returns the raw
|
||||
* asset bytes, or null if `gh` isn't installed, the user isn't
|
||||
* authenticated, the release is missing, or I/O fails.
|
||||
* 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.
|
||||
*
|
||||
* We publish baselines as **draft** releases (see
|
||||
* `ghReleaseUploadOrCreate`) so `release:published` doesn't trigger
|
||||
* deploy-gated workflows. Draft assets aren't served via anonymous
|
||||
* HTTPS, so `gh` + repo auth is the only viable transport — no
|
||||
* plain-HTTPS fallback.
|
||||
* @return array{graph: string, coverage: ?string}|null
|
||||
*/
|
||||
private function download(string $repo, string $asset): ?string
|
||||
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)) {
|
||||
@ -398,29 +179,67 @@ final class BaselineSync
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'gh', 'release', 'download', self::RELEASE_TAG,
|
||||
'gh', 'run', 'download', $runId,
|
||||
'-R', $repo,
|
||||
'-p', $asset,
|
||||
'-n', self::ARTIFACT_NAME,
|
||||
'-D', $tmpDir,
|
||||
'--clobber',
|
||||
]);
|
||||
$process->setTimeout(120.0);
|
||||
$process->run();
|
||||
|
||||
$payload = null;
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
if ($process->isSuccessful()) {
|
||||
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
|
||||
|
||||
if (is_file($path)) {
|
||||
$content = @file_get_contents($path);
|
||||
$payload = $content === false ? null : $content;
|
||||
}
|
||||
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 $payload;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user