diff --git a/.changelog/pending-status-diagnostics.md b/.changelog/pending-status-diagnostics.md new file mode 100644 index 000000000..d0c5b1629 --- /dev/null +++ b/.changelog/pending-status-diagnostics.md @@ -0,0 +1,6 @@ +--- +category: fixed +type: patch +--- + +Distinguish pending test-status publisher identity, attempt and lifecycle validation failures without exposing response values or weakening fail-closed checks. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 03e69403d..e207cbd4a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -147,12 +147,20 @@ jobs: throw new RuntimeException('Duplicate PHP test matrix entry.'); } $run = githubApi(["repos/$repository/actions/runs/$runId"]); - if ((string) ($run['id'] ?? '') !== $runId - || ($run['repository']['full_name'] ?? null) !== $repository - || !is_int($run['run_attempt'] ?? null) - || (string) $run['run_attempt'] !== $attempt - || ($run['status'] ?? null) !== 'in_progress') { - throw new RuntimeException('The current workflow attempt is not running.'); + if ((string) ($run['id'] ?? '') !== $runId) { + throw new RuntimeException('Workflow run ID does not match the current run.'); + } + if (($run['repository']['full_name'] ?? null) !== $repository) { + throw new RuntimeException('Workflow repository does not match the current repository.'); + } + if (!is_int($run['run_attempt'] ?? null)) { + throw new RuntimeException('Workflow run attempt is not an integer.'); + } + if ((string) $run['run_attempt'] !== $attempt) { + throw new RuntimeException('Workflow run attempt does not match the current attempt.'); + } + if (($run['status'] ?? null) !== 'in_progress') { + throw new RuntimeException('Workflow run status is not in_progress.'); } // Validate the full matrix and current attempt before the first write. diff --git a/tests/GitHubActions/PendingTestStatusWorkflowTest.php b/tests/GitHubActions/PendingTestStatusWorkflowTest.php new file mode 100644 index 000000000..99ad67dce --- /dev/null +++ b/tests/GitHubActions/PendingTestStatusWorkflowTest.php @@ -0,0 +1,130 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitHubActions; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Yaml\Yaml; +use function Safe\fclose; +use function Safe\fwrite; +use function Safe\proc_open; +use function Safe\stream_get_contents; + +#[CoversNothing] +final class PendingTestStatusWorkflowTest extends TestCase +{ + /** + * @return iterable, string}> + */ + public static function invalidRuns(): iterable + { + yield 'wrong ID' => [['id' => 8], 'Workflow run ID does not match the current run.']; + yield 'missing ID' => [['id' => null], 'Workflow run ID does not match the current run.']; + yield 'wrong repository' => [['repository' => ['full_name' => 'other/repo']], 'Workflow repository does not match the current repository.']; + yield 'missing repository' => [['repository' => null], 'Workflow repository does not match the current repository.']; + yield 'string attempt' => [['run_attempt' => '1'], 'Workflow run attempt is not an integer.']; + yield 'missing attempt' => [['run_attempt' => null], 'Workflow run attempt is not an integer.']; + yield 'different attempt' => [['run_attempt' => 2], 'Workflow run attempt does not match the current attempt.']; + foreach (['queued', 'completed', 'cancelled', null, "untrusted\nTOKEN_MARKER"] as $status) { + yield 'status ' . ($status ?? 'missing') => [['status' => $status], 'Workflow run status is not in_progress.']; + } + } + + /** + * @param array $overrides + */ + #[Test] + #[DataProvider('invalidRuns')] + public function pendingPublisherWillExplainEachRejectedRunWithoutWriting(array $overrides, string $diagnostic): void + { + [$code, $output] = $this->executePublisher($overrides); + + self::assertNotSame(0, $code); + self::assertStringContainsString($diagnostic, $output); + self::assertStringNotContainsString('STATUS_WRITE', $output); + self::assertStringNotContainsString('TOKEN_MARKER', $output); + } + + #[Test] + public function pendingPublisherWillKeepTheValidRunPublishingPath(): void + { + [$code, $output] = $this->executePublisher([]); + + self::assertSame(0, $code, $output); + self::assertSame(3, substr_count($output, 'STATUS_WRITE')); + foreach (['8.3', '8.4', '8.5'] as $version) { + $expected = [ + '--method', 'POST', 'repos/example/repo/statuses/' . str_repeat('a', 40), + '-f', 'state=pending', + '-f', "context=Run Tests ($version)", + '-f', "description=PHP $version matrix job is pending for attempt 1.", + '-f', 'target_url=https://github.com/example/repo/actions/runs/7', + ]; + self::assertStringContainsString(var_export($expected, true), $output); + } + } + + /** + * Execute the actual embedded program with only its GitHub transport substituted. + * + * @param array $overrides + * @return array{int, string} + */ + private function executePublisher(array $overrides): array + { + $workflow = Yaml::parseFile(__DIR__ . '/../../.github/workflows/tests.yml'); + $shell = $workflow['jobs']['publish_pending_statuses']['steps'][0]['run']; + self::assertIsString($shell); + $program = substr($shell, strpos($shell, ' 7, + 'repository' => ['full_name' => 'example/repo'], + 'run_attempt' => 1, + 'status' => 'in_progress', + ], $overrides); + $transport = 'function githubApi(array $arguments): array {' + . 'if ($arguments[0] === "--method") { echo "STATUS_WRITE\\n" . var_export($arguments, true) . "\\n"; return []; }' + . 'return ' . var_export($run, true) . '; }' . "\n"; + $program = substr($program, 0, $start) . $transport . substr($program, $end); + $process = proc_open(escapeshellarg(PHP_BINARY), [['pipe', 'r'], ['pipe', 'w'], ['redirect', 1]], $pipes, null, array_merge(getenv(), [ + 'GITHUB_REPOSITORY' => 'example/repo', + 'GITHUB_RUN_ID' => '7', + 'GITHUB_RUN_ATTEMPT' => '1', + 'TARGET_SHA' => str_repeat('a', 40), + 'TARGET_URL' => 'https://github.com/example/repo/actions/runs/7', + 'TEST_MATRIX' => '{"php-version":["8.3","8.4","8.5"]}', + ])); + self::assertIsResource($process); + fwrite($pipes[0], $program); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + self::assertIsString($output); + + return [proc_close($process), $output]; + } +}