From ef85fb29aa2c8029c0ebccdbac80f3e814b8209b Mon Sep 17 00:00:00 2001 From: Josiah King Date: Mon, 10 Aug 2026 15:54:30 +0100 Subject: [PATCH 1/3] chore: add release split and consumer validation --- .github/workflows/quality.yml | 3 + CHANGELOG.md | 1 + .../EvolvePhp2ContinuousIntegrationTest.php | 14 +- ...2ReleaseSplitAndConsumerValidationTest.php | 207 ++++++++ workspace/README.md | 39 +- workspace/composer.json | 2 + workspace/tools/release-validation-common.php | 469 ++++++++++++++++++ workspace/tools/validate-package-splits.php | 180 +++++++ .../tools/validate-prerelease-consumers.php | 332 +++++++++++++ 9 files changed, 1245 insertions(+), 2 deletions(-) create mode 100644 tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php create mode 100644 workspace/tools/release-validation-common.php create mode 100644 workspace/tools/validate-package-splits.php create mode 100644 workspace/tools/validate-prerelease-consumers.php diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 38d664c..bf38b0c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,6 +40,9 @@ jobs: - name: Install workspace dependencies run: composer --working-dir=workspace install --no-interaction --no-progress --prefer-dist + - name: Run release package split validation + run: composer --working-dir=workspace release:split:validate + - name: Run workspace supply-chain checks run: composer --working-dir=workspace supply-chain diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d53829..06fd133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Documentation and governance +- Added Phase 2.10B deterministic package split and prerelease consumer validation with repository-owned `git subtree` split checks, exact tree/inventory equivalence, generated split Composer validation, an offline alpha/stable consumer matrix, documented alpha consumer policy, retained internal `^2.0` constraints, Policy PHP 8.4 split-validation enforcement, manual/pre-release consumer validation, and no remote writes, tags or release artifacts. - Added Phase 2.10A deterministic package release validation with an explicit release package map/order, package-local README/licence preparation, and no remote publication or splitting yet. - Added Phase 2.9A supply-chain security foundation with Composer lockfile security audit enforcement, abandoned-package failure, locked production and development licence-policy checks for MIT, BSD-3-Clause and Apache-2.0, repository-owned Dependabot version-update configuration, Policy job enforcement, and documentation of GitHub setting boundaries. - Added Phase 2.8 developer-experience foundation with EditorConfig, VS Code extension recommendations, portable VS Code settings and portable task commands, PHP 8.4 language-analysis targeting, canonical Composer-script reuse, explicit non-mutating quality checks versus the Style Fix mutating task, no local executable paths, and no runtime debugging configuration. diff --git a/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php b/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php index eeb4071..c84213f 100644 --- a/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php +++ b/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php @@ -78,7 +78,10 @@ public function testPolicyJobRunsRootPolicySuitesWithWorkspacePhpUnitOnPhp84(): $this->assertMatchesPattern('/php-version:\s*\'8\.4\'/', $job); $this->assertStringContainsString('composer --working-dir=workspace validate --strict --check-lock', $job); $this->assertStringContainsString('composer --working-dir=workspace install --no-interaction --no-progress --prefer-dist', $job); + $this->assertSame(1, substr_count($job, 'composer --working-dir=workspace supply-chain')); + $this->assertSame(1, substr_count($job, 'composer --working-dir=workspace release:split:validate')); $this->assertStringContainsString('php workspace/vendor/bin/phpunit --configuration phpunit.xml.dist tests/Architecture tests/Documentation', $job); + $this->assertStringNotContainsString('release:consumer:validate', $this->workflow); $this->assertDoesNotMatchPattern('/composer install(?! --working-dir=workspace)|composer --working-dir=\.\s+install/', $job); $this->assertDoesNotMatchPattern('/composer update|--ignore-platform-reqs?|config\.platform\.php/', $job); $this->assertDoesNotMatchPattern('/phpunit.*(?:core|components|helpers|index\.php|route\.php)/i', $job); @@ -104,7 +107,16 @@ public function testWorkflowExcludesReleasePublishingSecretsCachesAndDeployments '/secrets\./', '/deploy(?:ment)?/i', '/publish/i', - '/release/i', + '/gh\s+release/i', + '/actions\/create-release/i', + '/softprops\/action-gh-release/i', + '/ncipollo\/release-action/i', + '/git\s+tag\b/i', + '/git\s+push(?:\s+[^\r\n]*)?\s+--tags\b/i', + '/git\s+push(?:\s+[^\r\n]*)?\s+tags\//i', + '/packagist/i', + '/release_(?:token|key|secret|credential)/i', + '/release-(?:token|key|secret|credential)/i', '/upload-artifact/', '/codecov|coveralls/i', '/actions\/cache/', diff --git a/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php b/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php new file mode 100644 index 0000000..d960390 --- /dev/null +++ b/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php @@ -0,0 +1,207 @@ +root = dirname(__DIR__, 2); + } + + public function testReleaseSplitAndConsumerValidationToolsAreRepositoryOwnedPhpEntrypoints(): void + { + $this->assertFileExists($this->path('workspace/tools/release-validation-common.php')); + + foreach (array( + 'workspace/tools/validate-package-splits.php', + 'workspace/tools/validate-prerelease-consumers.php', + ) as $path) { + $content = $this->readProjectFile($path); + + $this->assertStringStartsWith('assertStringContainsString("require_once __DIR__ . '/release-validation-common.php';", $content); + $this->assertStringNotContainsString('D:\\tools\\composer84\\composer.phar', $content); + $this->assertDoesNotMatchRegularExpression('/\\b(?:curl|gh|git push|remote add)\\b/i', $content); + } + } + + public function testSharedHelperUsesArgumentVectorProcessesAndDoesNotRunOnInclude(): void + { + $content = $this->readProjectFile('workspace/tools/release-validation-common.php'); + + $this->assertStringContainsString('proc_open(', $content); + $this->assertStringContainsString('bypass_shell', $content); + $this->assertStringContainsString('loadReleasePackages', $content); + $this->assertStringContainsString('createTemporaryDirectory', $content); + $this->assertStringContainsString('removeDirectory', $content); + $this->assertDoesNotMatchRegularExpression('/\\b(?:shell_exec|exec|passthru|system)\\s*\\(/', $content); + $this->assertDoesNotMatchRegularExpression('/\\b(?:git push|gh|curl|remote add|config --global)\\b/i', $content); + $this->assertDoesNotMatchRegularExpression('/EvolvePHP .* validation passed/', $content); + } + + public function testSplitValidatorDocumentsDeterministicSplitContract(): void + { + $content = $this->readProjectFile('workspace/tools/validate-package-splits.php'); + + foreach (array( + '--root=', + '--ref=', + '--composer=', + 'git subtree split', + 'first split', + 'second split', + 'deterministic: yes', + 'tree equality: yes', + 'inventory equality: yes', + 'composer validate --strict: pass', + 'history commits:', + 'Source repository state preserved.', + ) as $needle) { + $this->assertStringContainsString($needle, $content); + } + + $this->assertStringNotContainsString('2.0.0-alpha.1', $content); + $this->assertStringNotContainsString('git tag', $content); + } + + public function testConsumerValidatorDocumentsOfflinePrereleaseAndStableMatrix(): void + { + $content = $this->readProjectFile('workspace/tools/validate-prerelease-consumers.php'); + + foreach (array( + 'COMPOSER_DISABLE_NETWORK', + 'packagist.org', + '2.0.0-alpha.1', + '2.0.0', + 'Alpha case A', + 'Alpha case B', + 'Alpha case C', + 'Alpha case D', + 'Full-graph case E', + 'Full-graph case F', + 'Full-graph case G', + 'Stable case H', + 'expected failure', + 'minimum-stability', + 'prefer-stable', + 'Source repository state preserved.', + ) as $needle) { + $this->assertStringContainsString($needle, $content); + } + + $this->assertDoesNotMatchRegularExpression('/\\b(?:curl|gh|git push|remote add|config --global)\\b/i', $content); + } + + public function testWorkspaceComposerExposesReleaseValidationScriptsWithoutPrepareScript(): void + { + $manifest = $this->readJsonFile('workspace/composer.json'); + + $this->assertSame(array('@architecture', '@analyse', '@style:check', '@test'), $manifest['scripts']['quality']); + $this->assertSame(array('@security:audit', '@licenses:check'), $manifest['scripts']['supply-chain']); + $this->assertSame('@php tools/validate-release-packages.php', $manifest['scripts']['release:validate']); + $this->assertSame('@php tools/validate-package-splits.php', $manifest['scripts']['release:split:validate']); + $this->assertSame('@php tools/validate-prerelease-consumers.php', $manifest['scripts']['release:consumer:validate']); + $this->assertArrayNotHasKey('release:prepare', $manifest['scripts']); + } + + public function testPackageManifestsRetainStableInternalConstraintsAndNoStabilityPolicy(): void + { + foreach ($this->releasePackages() as $package) { + $manifest = $this->readJsonFile($package['directory'] . '/composer.json'); + + $this->assertArrayNotHasKey('version', $manifest, $package['name']); + $this->assertArrayNotHasKey('minimum-stability', $manifest, $package['name']); + $this->assertArrayNotHasKey('prefer-stable', $manifest, $package['name']); + $this->assertSame('^8.4', $manifest['require']['php'], $package['name']); + + foreach ($manifest['require'] as $dependency => $constraint) { + if (strpos($dependency, 'evolvephp/') !== 0) { + continue; + } + + $this->assertSame('^2.0', $constraint, $package['name'] . ' internal constraint for ' . $dependency); + $this->assertStringNotContainsString('@alpha', $constraint, $package['name']); + } + } + } + + public function testCiRunsOnlyPackageSplitValidationInExistingPolicyJob(): void + { + $workflow = $this->readProjectFile('.github/workflows/quality.yml'); + + $this->assertSame(1, substr_count($workflow, 'name: Policy (PHP 8.4)')); + $this->assertSame(1, substr_count($workflow, 'name: Workspace quality (PHP ${{ matrix.php }})')); + $this->assertSame(1, substr_count($workflow, 'Run release package split validation')); + $this->assertStringContainsString('composer --working-dir=workspace release:split:validate', $workflow); + $this->assertStringNotContainsString('release:consumer:validate', $workflow); + $this->assertStringContainsString('Run workspace supply-chain checks', $workflow); + $this->assertStringContainsString('Run root policy tests', $workflow); + } + + public function testWorkspaceReadmeDocumentsAlphaConsumerPolicyAndDeferredPublication(): void + { + $content = $this->readProjectFile('workspace/README.md'); + + foreach (array( + 'composer --working-dir=workspace release:split:validate', + 'composer --working-dir=workspace release:consumer:validate', + 'minimum-stability: alpha', + 'prefer-stable: true', + 'Explicit root `@alpha` flags', + 'no package Composer manifest should add `@alpha`, `minimum-stability`, `prefer-stable` or a hard-coded `version` field.', + 'Remote package repositories, remote synchronization, Packagist registration, tags and releases remain deferred.', + ) as $needle) { + $this->assertStringContainsString($needle, $content); + } + } + + public function testChangelogRecordsPhase210BValidationWithoutPublicationClaims(): void + { + $content = $this->readProjectFile('CHANGELOG.md'); + + $this->assertStringContainsString('Phase 2.10B deterministic package split and prerelease consumer validation', $content); + $this->assertStringContainsString('alpha consumer policy', $content); + $this->assertDoesNotMatchRegularExpression('/Phase 2\\.10B.*(?:published|Packagist|GitHub release)/i', $content); + } + + private function readProjectFile($path) + { + $absolute = $this->path($path); + + $this->assertFileExists($absolute, $path . ' must exist.'); + + $content = file_get_contents($absolute); + + $this->assertIsString($content, $path . ' must be readable.'); + + return $content; + } + + private function readJsonFile($path) + { + $decoded = json_decode($this->readProjectFile($path), true); + + $this->assertSame(JSON_ERROR_NONE, json_last_error(), $path . ' must contain valid JSON.'); + $this->assertIsArray($decoded, $path . ' must decode to an array.'); + + return $decoded; + } + + private function releasePackages() + { + $map = $this->readJsonFile('workspace/release-packages.json'); + + $this->assertSame(1, $map['version']); + $this->assertCount(6, $map['packages']); + + return $map['packages']; + } + + private function path($path) + { + return $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); + } +} diff --git a/workspace/README.md b/workspace/README.md index 5504e50..e70c4e4 100644 --- a/workspace/README.md +++ b/workspace/README.md @@ -130,7 +130,44 @@ Phase 2.10A keeps the six packages mapped explicitly in `workspace/release-packa No package is being published by this command. No remote repositories are contacted, no tags/releases are created, and no split repositories are synchronized. Package Composer manifests remain authoritative for package metadata. -`release:validate` is distinct from `quality`. It is also distinct from network-dependent `supply-chain`. Package splitting is Phase 2.10B, and remote synchronization and Packagist publication remain deferred. Prerelease consumer stability still requires separate 2.10B validation. RFC 0003 remains authoritative for release and version policy. +`release:validate` is distinct from `quality`. It is also distinct from network-dependent `supply-chain`. Package splitting is Phase 2.10B validation work, and prerelease consumer stability is validated by the Phase 2.10B consumer matrix. RFC 0003 remains authoritative for release and version policy. + +### Package Split Validation + +Run deterministic package split validation: + +```bash +composer --working-dir=workspace release:split:validate +``` + +`release:split:validate` reads `workspace/release-packages.json`, creates a disposable local clone with `--no-hardlinks`, runs every mapped `git subtree` split twice, validates repeated split SHA equality, validates exact subtree/root tree equality, validates exact inventory equality, validates generated split-root Composer manifests, and confirms package-specific Git history is retained. It creates no remote repository, pushes nothing, creates no source tags, works only on committed Git history/ref and runs in Policy PHP 8.4 CI. + +### Prerelease Consumer Validation + +Run offline prerelease and stable consumer validation: + +```bash +composer --working-dir=workspace release:consumer:validate +``` + +`release:consumer:validate` creates temporary local VCS package repositories from generated split roots, creates disposable alpha/stable tags only, disables Packagist, disables Composer network access, validates expected success and expected failure cases, and uses disposable lockfiles. It is not currently a required CI step and is intended for pre-release/manual validation. + +For an EvolvePHP 2 alpha consumer, the recommended root consumer settings are: + +```json +{ + "minimum-stability": "alpha", + "prefer-stable": true +} +``` + +These are root consumer settings. First-party package manifests must not add alpha stability policy. Existing first-party internal constraints remain `^2.0`; no package Composer manifest should add `@alpha`, `minimum-stability`, `prefer-stable` or a hard-coded `version` field. Top-level `@alpha` on only one package is insufficient for transitive alpha packages. Explicit root `@alpha` flags for every involved EvolvePHP package are a valid but more verbose alternative. Stable 2.0.0 consumers do not require alpha stability settings. + +In prose: set `minimum-stability: alpha` and `prefer-stable: true` only in the root alpha consumer. + +No package is published by `release:validate`, `release:split:validate` or `release:consumer:validate`. Remote package repositories, remote synchronization, Packagist registration, tags and releases remain deferred. + +`release:validate` remains metadata/package-boundary validation. `release:split:validate` validates generated split history/root content. `release:consumer:validate` validates package-resolution semantics. `supply-chain` remains network-dependent security/licence validation. `quality` remains ordinary workspace quality. ## Supply-Chain Security diff --git a/workspace/composer.json b/workspace/composer.json index d660970..0e44c06 100644 --- a/workspace/composer.json +++ b/workspace/composer.json @@ -46,6 +46,8 @@ "@style:check", "@test" ], + "release:consumer:validate": "@php tools/validate-prerelease-consumers.php", + "release:split:validate": "@php tools/validate-package-splits.php", "release:validate": "@php tools/validate-release-packages.php", "security:audit": "@composer audit --locked --abandoned=fail", "style:check": "@php vendor/bin/php-cs-fixer check --config=.php-cs-fixer.dist.php --diff --verbose", diff --git a/workspace/tools/release-validation-common.php b/workspace/tools/release-validation-common.php new file mode 100644 index 0000000..1d2c9a7 --- /dev/null +++ b/workspace/tools/release-validation-common.php @@ -0,0 +1,469 @@ + $command + */ + public function __construct( + public readonly array $command, + public readonly int $exitCode, + public readonly string $stdout, + public readonly string $stderr, + ) { + } + + public function output(): string + { + return trim($this->stdout . "\n" . $this->stderr); + } +} + +final class ReleaseValidationProcessRunner +{ + /** + * @param list $command + * @param array $environment + */ + public function run(array $command, ?string $workingDirectory = null, array $environment = array()): ReleaseValidationProcessResult + { + if ($command === array()) { + throw new ReleaseValidationFailure('Cannot run an empty process command.'); + } + + foreach ($command as $argument) { + if (!is_string($argument) || $argument === '') { + throw new ReleaseValidationFailure('Process commands must contain non-empty string arguments.'); + } + } + + $descriptorSpec = array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ); + + $processEnvironment = null; + + if ($environment !== array()) { + $baseEnvironment = getenv(); + $processEnvironment = array_merge(is_array($baseEnvironment) ? $baseEnvironment : array(), $environment); + } + + $process = proc_open( + $command, + $descriptorSpec, + $pipes, + $workingDirectory, + $processEnvironment, + array('bypass_shell' => true) + ); + + if (!is_resource($process)) { + throw new ReleaseValidationFailure('Unable to start process: ' . describeCommand($command)); + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + return new ReleaseValidationProcessResult( + $command, + $exitCode, + is_string($stdout) ? $stdout : '', + is_string($stderr) ? $stderr : '' + ); + } + + /** + * @param list $command + * @param array $environment + */ + public function mustRun(array $command, ?string $workingDirectory = null, array $environment = array()): ReleaseValidationProcessResult + { + $result = $this->run($command, $workingDirectory, $environment); + + if ($result->exitCode !== 0) { + throw new ReleaseValidationFailure( + 'Process failed with exit code ' . $result->exitCode . ': ' . describeCommand($command) . "\n" . $result->output() + ); + } + + return $result; + } +} + +final class ReleaseValidationTemporaryDirectory +{ + private bool $removed = false; + + public function __construct(public readonly string $path) + { + } + + public function child(string $relativePath): string + { + return joinPaths($this->path, $relativePath); + } + + public function cleanup(): void + { + if ($this->removed) { + return; + } + + removeDirectory($this->path, $this->path); + $this->removed = true; + } +} + +/** + * @return never + */ +function releaseValidationFail(string $message): void +{ + throw new ReleaseValidationFailure($message); +} + +/** + * @param list $command + */ +function describeCommand(array $command): string +{ + return implode(' ', array_map(static function (string $argument): string { + return preg_match('/\s/', $argument) === 1 ? '"' . $argument . '"' : $argument; + }, $command)); +} + +function repositoryRootDefault(): string +{ + $root = realpath(dirname(__DIR__, 2)); + + if ($root === false) { + releaseValidationFail('Unable to determine repository root.'); + } + + return $root; +} + +/** + * @return array{root: string, ref: string, composer: string|null} + */ +function parseReleaseValidationArguments(array $argv, bool $allowRef = true): array +{ + $options = array( + 'root' => repositoryRootDefault(), + 'ref' => 'HEAD', + 'composer' => null, + ); + + foreach (array_slice($argv, 1) as $argument) { + if (str_starts_with($argument, '--root=')) { + $root = realpath(substr($argument, strlen('--root='))); + + if ($root === false || !is_dir($root)) { + releaseValidationFail('Repository root does not exist: ' . substr($argument, strlen('--root='))); + } + + $options['root'] = $root; + continue; + } + + if ($allowRef && str_starts_with($argument, '--ref=')) { + $ref = substr($argument, strlen('--ref=')); + + if ($ref === '') { + releaseValidationFail('--ref must not be empty.'); + } + + $options['ref'] = $ref; + continue; + } + + if (str_starts_with($argument, '--composer=')) { + $composer = substr($argument, strlen('--composer=')); + + if ($composer === '') { + releaseValidationFail('--composer must not be empty.'); + } + + if ($composer !== 'composer') { + $resolvedComposer = realpath($composer); + + if ($resolvedComposer === false || !is_file($resolvedComposer)) { + releaseValidationFail('Composer path does not exist: ' . $composer); + } + + $composer = $resolvedComposer; + } + + $options['composer'] = $composer; + continue; + } + + releaseValidationFail('Unknown CLI option: ' . $argument); + } + + return $options; +} + +function joinPaths(string $base, string $relativePath): string +{ + return rtrim($base, "\\/") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); +} + +function normalizePath(string $path): string +{ + return str_replace('\\', '/', $path); +} + +function normalizeRelativePath(string $path): string +{ + return trim(normalizePath($path), '/'); +} + +/** + * @return mixed + */ +function readJsonFile(string $path, string $label) +{ + if (!is_file($path)) { + releaseValidationFail($label . ' does not exist.'); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + releaseValidationFail($label . ' is not readable.'); + } + + try { + return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + releaseValidationFail($label . ' contains malformed JSON: ' . $exception->getMessage()); + } +} + +/** + * @return list + */ +function loadReleasePackages(string $root): array +{ + $map = readJsonFile(joinPaths($root, 'workspace/release-packages.json'), 'workspace/release-packages.json'); + + if (!is_array($map) || array_keys($map) !== array('version', 'packages')) { + releaseValidationFail('workspace/release-packages.json must contain only version and packages.'); + } + + if (($map['version'] ?? null) !== 1) { + releaseValidationFail('workspace/release-packages.json version must be exactly 1.'); + } + + if (!is_array($map['packages']) || count($map['packages']) !== 6) { + releaseValidationFail('workspace/release-packages.json must contain exactly six package entries.'); + } + + $expected = array( + array('name' => 'evolvephp/contracts', 'directory' => 'packages/contracts'), + array('name' => 'evolvephp/core', 'directory' => 'packages/core'), + array('name' => 'evolvephp/module', 'directory' => 'packages/module'), + array('name' => 'evolvephp/plugin', 'directory' => 'packages/plugin'), + array('name' => 'evolvephp/http', 'directory' => 'packages/http'), + array('name' => 'evolvephp/testing', 'directory' => 'packages/testing'), + ); + + $packages = array(); + $seenNames = array(); + $seenDirectories = array(); + + foreach ($map['packages'] as $index => $package) { + if (!is_array($package) || array_keys($package) !== array('name', 'directory')) { + releaseValidationFail('workspace/release-packages.json package entry ' . ($index + 1) . ' must contain only name and directory.'); + } + + if (!is_string($package['name']) || !is_string($package['directory'])) { + releaseValidationFail('workspace/release-packages.json package entry ' . ($index + 1) . ' must contain string name and directory.'); + } + + $directory = normalizeRelativePath($package['directory']); + + if (preg_match('/^(?:[A-Za-z]:)?[\/\\\\]/', $package['directory']) === 1 || in_array('..', explode('/', $directory), true)) { + releaseValidationFail($package['name'] . ' uses an unsafe package directory.'); + } + + if (isset($seenNames[$package['name']]) || isset($seenDirectories[$directory])) { + releaseValidationFail('workspace/release-packages.json contains duplicate package names or directories.'); + } + + if (!is_file(joinPaths($root, $directory . '/composer.json'))) { + releaseValidationFail($package['name'] . ' mapped directory must contain composer.json.'); + } + + $seenNames[$package['name']] = true; + $seenDirectories[$directory] = true; + $packages[] = array('name' => $package['name'], 'directory' => $directory); + } + + if ($packages !== $expected) { + releaseValidationFail('workspace/release-packages.json must use the canonical Phase 2.10A package order.'); + } + + return $packages; +} + +/** + * @return list + */ +function composerCommand(?string $composer): array +{ + if ($composer !== null) { + if (strtolower(substr($composer, -5)) === '.phar') { + return array(PHP_BINARY, $composer); + } + + return array($composer); + } + + $composerBinary = getenv('COMPOSER_BINARY'); + + if (is_string($composerBinary) && $composerBinary !== '') { + if (strtolower(substr($composerBinary, -5)) === '.phar') { + return array(PHP_BINARY, $composerBinary); + } + + return array($composerBinary); + } + + return array('composer'); +} + +function createTemporaryDirectory(string $prefix = 'evolvephp-release-validation-'): ReleaseValidationTemporaryDirectory +{ + $base = sys_get_temp_dir(); + $candidate = tempnam($base, $prefix); + + if ($candidate === false) { + releaseValidationFail('Unable to allocate temporary directory name.'); + } + + if (is_file($candidate) && !unlink($candidate)) { + releaseValidationFail('Unable to prepare temporary directory: ' . $candidate); + } + + if (!mkdir($candidate, 0777, true)) { + releaseValidationFail('Unable to create temporary directory: ' . $candidate); + } + + $real = realpath($candidate); + + if ($real === false) { + releaseValidationFail('Unable to resolve temporary directory: ' . $candidate); + } + + return new ReleaseValidationTemporaryDirectory($real); +} + +function removeDirectory(string $path, string $ownedRoot): void +{ + $realPath = realpath($path); + $realRoot = realpath($ownedRoot); + + if ($realPath === false) { + return; + } + + if ($realRoot === false || $realPath !== $realRoot && !str_starts_with($realPath, $realRoot . DIRECTORY_SEPARATOR)) { + releaseValidationFail('Refusing to remove path outside validator-owned temporary root: ' . $path); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($realPath, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $entry) { + $entryPath = $entry->getPathname(); + + if ($entry->isDir() && !$entry->isLink()) { + @chmod($entryPath, 0777); + + if (!rmdir($entryPath)) { + releaseValidationFail('Unable to remove temporary directory: ' . $entryPath); + } + + continue; + } + + @chmod($entryPath, 0666); + + if (!unlink($entryPath)) { + releaseValidationFail('Unable to remove temporary file: ' . $entryPath); + } + } + + @chmod($realPath, 0777); + + if (!rmdir($realPath)) { + releaseValidationFail('Unable to remove temporary root: ' . $realPath); + } +} + +/** + * @return array{head: string, refs: string, tags: string, status: string} + */ +function captureSourceState(ReleaseValidationProcessRunner $runner, string $root): array +{ + return array( + 'head' => trim($runner->mustRun(array('git', '-C', $root, 'rev-parse', 'HEAD'))->stdout), + 'refs' => trim($runner->mustRun(array('git', '-C', $root, 'show-ref', '--heads', '--tags'))->stdout), + 'tags' => trim($runner->mustRun(array('git', '-C', $root, 'tag', '--list'))->stdout), + 'status' => trim($runner->mustRun(array('git', '-C', $root, 'status', '--short', '--untracked-files=all'))->stdout), + ); +} + +/** + * @param array{head: string, refs: string, tags: string, status: string} $before + */ +function assertSourceStatePreserved(ReleaseValidationProcessRunner $runner, string $root, array $before): void +{ + $after = captureSourceState($runner, $root); + + foreach ($before as $key => $value) { + if ($after[$key] !== $value) { + releaseValidationFail('Source repository state changed during validation: ' . $key . '.'); + } + } +} + +/** + * @return list + */ +function sortedLines(string $text): array +{ + $lines = array_values(array_filter(preg_split('/\r?\n/', trim($text)) ?: array(), static fn (string $line): bool => $line !== '')); + sort($lines); + + return $lines; +} + +function packageSlug(string $packageName): string +{ + return str_replace('evolvephp/', '', $packageName); +} + +/** + * @param list $command + * @return list + */ +function withComposerCommand(array $command, ?string $composer): array +{ + return array_merge(composerCommand($composer), $command); +} diff --git a/workspace/tools/validate-package-splits.php b/workspace/tools/validate-package-splits.php new file mode 100644 index 0000000..daa5bae --- /dev/null +++ b/workspace/tools/validate-package-splits.php @@ -0,0 +1,180 @@ +runner = new ReleaseValidationProcessRunner(); + } + + /** + * @return list> + */ + public function validate(string $root, string $ref, ?string $composer): array + { + $packages = loadReleasePackages($root); + $sourceState = captureSourceState($this->runner, $root); + $temp = createTemporaryDirectory(); + + try { + $sourceSha = trim($this->runner->mustRun(array('git', '-C', $root, 'rev-parse', $ref))->stdout); + $clone = $temp->child('monorepo'); + + $this->runner->mustRun(array('git', 'clone', '--no-hardlinks', $root, $clone)); + $this->runner->mustRun(array('git', '-C', $clone, 'checkout', '--detach', $sourceSha)); + + $results = array(); + + foreach ($packages as $package) { + $results[] = $this->validatePackage($temp, $clone, $sourceSha, $package, $composer); + } + + assertSourceStatePreserved($this->runner, $root, $sourceState); + + return $results; + } finally { + $temp->cleanup(); + } + } + + /** + * @param array{name: string, directory: string} $package + * @return array + */ + private function validatePackage(ReleaseValidationTemporaryDirectory $temp, string $clone, string $sourceSha, array $package, ?string $composer): array + { + $name = $package['name']; + $directory = $package['directory']; + $slug = packageSlug($name); + + $firstSplit = trim($this->runner->mustRun(array('git', '-C', $clone, 'subtree', 'split', '--prefix=' . $directory, $sourceSha))->stdout); + $secondSplit = trim($this->runner->mustRun(array('git', '-C', $clone, 'subtree', 'split', '--prefix=' . $directory, $sourceSha))->stdout); + + if ($firstSplit !== $secondSplit) { + releaseValidationFail($name . ' split is not deterministic. expected ' . $firstSplit . ', actual ' . $secondSplit . '.'); + } + + $originalTree = trim($this->runner->mustRun(array('git', '-C', $clone, 'rev-parse', $sourceSha . ':' . $directory))->stdout); + $splitTree = trim($this->runner->mustRun(array('git', '-C', $clone, 'rev-parse', $firstSplit . '^{tree}'))->stdout); + + if ($originalTree !== $splitTree) { + releaseValidationFail($name . ' split root tree mismatch. expected ' . $originalTree . ', actual ' . $splitTree . '.'); + } + + $originalInventory = sortedLines(str_replace($directory . '/', '', $this->runner->mustRun(array('git', '-C', $clone, 'ls-tree', '-r', '--name-only', $sourceSha, $directory))->stdout)); + $splitInventory = sortedLines($this->runner->mustRun(array('git', '-C', $clone, 'ls-tree', '-r', '--name-only', $firstSplit))->stdout); + + if ($originalInventory !== $splitInventory) { + releaseValidationFail($name . ' split inventory mismatch.'); + } + + $splitRoot = $temp->child('split-roots/' . $slug); + $this->runner->mustRun(array('git', 'clone', '--no-hardlinks', $clone, $splitRoot)); + $this->runner->mustRun(array('git', '-C', $splitRoot, 'checkout', '--detach', $firstSplit)); + + $this->validateSplitRoot($splitRoot, $package, $composer); + + $historyCount = (int) trim($this->runner->mustRun(array('git', '-C', $clone, 'rev-list', '--count', $firstSplit))->stdout); + + return array( + 'name' => $name, + 'directory' => $directory, + 'firstSplit' => $firstSplit, + 'secondSplit' => $secondSplit, + 'originalTree' => $originalTree, + 'splitTree' => $splitTree, + 'fileCount' => count($splitInventory), + 'historyCount' => $historyCount, + ); + } + + /** + * @param array{name: string, directory: string} $package + */ + private function validateSplitRoot(string $splitRoot, array $package, ?string $composer): void + { + foreach (array('composer.json', 'README.md', 'LICENSE.md', 'src', 'tests') as $requiredPath) { + if (!file_exists(joinPaths($splitRoot, $requiredPath))) { + releaseValidationFail($package['name'] . ' split root is missing ' . $requiredPath . '.'); + } + } + + $forbidden = array('packages', 'workspace', 'docs', '.github', 'CHANGELOG.md'); + + foreach ($forbidden as $path) { + if (file_exists(joinPaths($splitRoot, $path))) { + releaseValidationFail($package['name'] . ' split root unexpectedly contains monorepo-owned ' . $path . '.'); + } + } + + $this->runner->mustRun(withComposerCommand(array('--working-dir=' . $splitRoot, 'validate', '--strict'), $composer)); + + $manifest = readJsonFile(joinPaths($splitRoot, 'composer.json'), $package['name'] . ' generated split composer.json'); + + if (!is_array($manifest)) { + releaseValidationFail($package['name'] . ' generated split composer.json must be a JSON object.'); + } + + if (($manifest['name'] ?? null) !== $package['name']) { + releaseValidationFail($package['name'] . ' split manifest name changed.'); + } + + if (($manifest['license'] ?? null) !== 'BSD-3-Clause') { + releaseValidationFail($package['name'] . ' split manifest license changed.'); + } + + if (($manifest['require']['php'] ?? null) !== '^8.4') { + releaseValidationFail($package['name'] . ' split manifest PHP constraint changed.'); + } + + if (array_key_exists('version', $manifest)) { + releaseValidationFail($package['name'] . ' split manifest must not contain a hard-coded version.'); + } + + foreach (($manifest['require'] ?? array()) as $dependency => $constraint) { + if (str_starts_with((string) $dependency, 'evolvephp/') && $constraint !== '^2.0') { + releaseValidationFail($package['name'] . ' split manifest internal constraint changed for ' . $dependency . '.'); + } + } + } +} + +try { + $options = parseReleaseValidationArguments($argv); + $validator = new PackageSplitValidator(); + $results = $validator->validate($options['root'], $options['ref'], $options['composer']); + $source = trim((new ReleaseValidationProcessRunner())->mustRun(array('git', '-C', $options['root'], 'rev-parse', $options['ref']))->stdout); + + echo 'EvolvePHP package split validation passed.' . PHP_EOL; + echo 'Source: ' . $source . PHP_EOL; + echo 'Packages: ' . count($results) . PHP_EOL . PHP_EOL; + + foreach ($results as $result) { + echo $result['name'] . PHP_EOL; + echo ' first split: ' . $result['firstSplit'] . PHP_EOL; + echo ' second split: ' . $result['secondSplit'] . PHP_EOL; + echo ' deterministic: yes' . PHP_EOL; + echo ' tree equality: yes' . PHP_EOL; + echo ' inventory equality: yes' . PHP_EOL; + echo ' file count: ' . $result['fileCount'] . PHP_EOL; + echo ' composer validate --strict: pass' . PHP_EOL; + echo ' history commits: ' . $result['historyCount'] . PHP_EOL; + } + + echo 'Source repository state preserved.' . PHP_EOL; + exit(0); +} catch (ReleaseValidationFailure $failure) { + fwrite(STDERR, 'EvolvePHP package split validation failed: ' . $failure->getMessage() . PHP_EOL); + exit(1); +} diff --git a/workspace/tools/validate-prerelease-consumers.php b/workspace/tools/validate-prerelease-consumers.php new file mode 100644 index 0000000..47063b3 --- /dev/null +++ b/workspace/tools/validate-prerelease-consumers.php @@ -0,0 +1,332 @@ +runner = new ReleaseValidationProcessRunner(); + } + + /** + * @return list + */ + public function validate(string $root, string $ref, ?string $composer): array + { + $packages = loadReleasePackages($root); + $sourceState = captureSourceState($this->runner, $root); + $temp = createTemporaryDirectory(); + + try { + $sourceSha = trim($this->runner->mustRun(array('git', '-C', $root, 'rev-parse', $ref))->stdout); + $clone = $temp->child('monorepo'); + + $this->runner->mustRun(array('git', 'clone', '--no-hardlinks', $root, $clone)); + $this->runner->mustRun(array('git', '-C', $clone, 'checkout', '--detach', $sourceSha)); + + $splitRoots = $this->materializeSplitRoots($temp, $clone, $sourceSha, $packages); + $alphaRepositories = $this->createTaggedRepositories($temp->child('alpha-repositories'), $splitRoots, self::ALPHA_VERSION); + $stableRepositories = $this->createTaggedRepositories($temp->child('stable-repositories'), $splitRoots, self::STABLE_VERSION); + + $results = array(); + $results[] = $this->runExpectedFailureCase($temp, $composer, 'Alpha case A', $alphaRepositories, array('evolvephp/http' => '2.0.0-alpha.1')); + $results[] = $this->runExpectedFailureCase($temp, $composer, 'Alpha case B', $alphaRepositories, array('evolvephp/http' => '^2.0@alpha')); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Alpha case C', $alphaRepositories, array('evolvephp/http' => '^2.0'), array('minimum-stability' => 'alpha', 'prefer-stable' => true), array( + 'evolvephp/contracts' => self::ALPHA_VERSION, + 'evolvephp/core' => self::ALPHA_VERSION, + 'evolvephp/http' => self::ALPHA_VERSION, + )); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Alpha case D', $alphaRepositories, array( + 'evolvephp/contracts' => '^2.0@alpha', + 'evolvephp/core' => '^2.0@alpha', + 'evolvephp/http' => '^2.0@alpha', + ), array(), array( + 'evolvephp/contracts' => self::ALPHA_VERSION, + 'evolvephp/core' => self::ALPHA_VERSION, + 'evolvephp/http' => self::ALPHA_VERSION, + )); + $results[] = $this->runExpectedFailureCase($temp, $composer, 'Full-graph case E', $alphaRepositories, array('evolvephp/testing' => '^2.0@alpha')); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Full-graph case F', $alphaRepositories, array('evolvephp/testing' => '^2.0'), array('minimum-stability' => 'alpha', 'prefer-stable' => true), $this->expectedVersions($packages, self::ALPHA_VERSION)); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Full-graph case G', $alphaRepositories, $this->explicitAlphaRootRequirements($packages), array(), $this->expectedVersions($packages, self::ALPHA_VERSION)); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Stable case H', $stableRepositories, array('evolvephp/testing' => '^2.0'), array(), $this->expectedVersions($packages, self::STABLE_VERSION)); + + assertSourceStatePreserved($this->runner, $root, $sourceState); + + return $results; + } finally { + $temp->cleanup(); + } + } + + /** + * @param list $packages + * @return array + */ + private function materializeSplitRoots(ReleaseValidationTemporaryDirectory $temp, string $clone, string $sourceSha, array $packages): array + { + $roots = array(); + + foreach ($packages as $package) { + $slug = packageSlug($package['name']); + $split = trim($this->runner->mustRun(array('git', '-C', $clone, 'subtree', 'split', '--prefix=' . $package['directory'], $sourceSha))->stdout); + $root = $temp->child('split-roots/' . $slug); + + $this->runner->mustRun(array('git', 'clone', '--no-hardlinks', $clone, $root)); + $this->runner->mustRun(array('git', '-C', $root, 'checkout', '--detach', $split)); + + $roots[$package['name']] = $root; + } + + return $roots; + } + + /** + * @param array $splitRoots + * @return array + */ + private function createTaggedRepositories(string $repositoryRoot, array $splitRoots, string $tag): array + { + if (!mkdir($repositoryRoot, 0777, true) && !is_dir($repositoryRoot)) { + releaseValidationFail('Unable to create temporary package repository root: ' . $repositoryRoot); + } + + $repositories = array(); + + foreach ($splitRoots as $packageName => $splitRoot) { + $repository = joinPaths($repositoryRoot, packageSlug($packageName)); + + $this->runner->mustRun(array('git', 'clone', '--no-hardlinks', $splitRoot, $repository)); + $this->runner->mustRun(array('git', '-C', $repository, 'tag', $tag)); + $this->runner->mustRun(array('git', '-C', $repository, 'tag', '--list', $tag)); + + $repositories[$packageName] = $repository; + } + + return $repositories; + } + + /** + * @param array $repositories + * @param array $requirements + * @return array{name: string, outcome: string, detail: string} + */ + private function runExpectedFailureCase(ReleaseValidationTemporaryDirectory $temp, ?string $composer, string $caseName, array $repositories, array $requirements): array + { + $consumer = $this->createConsumer($temp, $caseName, $repositories, $requirements, array()); + $result = $this->runComposerUpdate($consumer, $temp, $composer); + $output = $result->output(); + + if ($result->exitCode === 0) { + releaseValidationFail($caseName . ' unexpectedly succeeded.'); + } + + if (!$this->isMinimumStabilityFailure($output)) { + releaseValidationFail($caseName . ' failed, but not as a transitive minimum-stability rejection: ' . $output); + } + + return array('name' => $caseName, 'outcome' => 'expected failure', 'detail' => 'transitive minimum-stability rejection'); + } + + /** + * @param array $repositories + * @param array $requirements + * @param array $consumerOptions + * @param array $expectedVersions + * @return array{name: string, outcome: string, detail: string} + */ + private function runExpectedSuccessCase(ReleaseValidationTemporaryDirectory $temp, ?string $composer, string $caseName, array $repositories, array $requirements, array $consumerOptions, array $expectedVersions): array + { + $consumer = $this->createConsumer($temp, $caseName, $repositories, $requirements, $consumerOptions); + $result = $this->runComposerUpdate($consumer, $temp, $composer); + + if ($result->exitCode !== 0) { + releaseValidationFail($caseName . ' failed unexpectedly: ' . $result->output()); + } + + $actualVersions = $this->readLockedFirstPartyVersions(joinPaths($consumer, 'composer.lock')); + + if ($actualVersions !== $expectedVersions) { + releaseValidationFail($caseName . ' selected unexpected package versions. expected ' . json_encode($expectedVersions) . ', actual ' . json_encode($actualVersions) . '.'); + } + + return array('name' => $caseName, 'outcome' => 'success', 'detail' => $this->formatVersions($actualVersions)); + } + + /** + * @param array $repositories + * @param array $requirements + * @param array $consumerOptions + */ + private function createConsumer(ReleaseValidationTemporaryDirectory $temp, string $caseName, array $repositories, array $requirements, array $consumerOptions): string + { + $directory = $temp->child('consumers/' . strtolower(str_replace(' ', '-', $caseName))); + + if (!mkdir($directory, 0777, true) && !is_dir($directory)) { + releaseValidationFail('Unable to create consumer fixture for ' . $caseName . '.'); + } + + $repositoryEntries = array(array('packagist.org' => false)); + + foreach ($repositories as $repository) { + $repositoryEntries[] = array('type' => 'vcs', 'url' => normalizePath($repository)); + } + + $manifest = array( + 'name' => 'evolvephp/' . strtolower(str_replace(' ', '-', $caseName)), + 'type' => 'project', + 'repositories' => $repositoryEntries, + 'require' => $requirements, + 'config' => array('allow-plugins' => false, 'secure-http' => false), + ); + + foreach ($consumerOptions as $key => $value) { + $manifest[$key] = $value; + } + + $encoded = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if (!is_string($encoded) || file_put_contents(joinPaths($directory, 'composer.json'), $encoded . PHP_EOL) === false) { + releaseValidationFail('Unable to write disposable consumer fixture for ' . $caseName . '.'); + } + + return $directory; + } + + private function runComposerUpdate(string $consumer, ReleaseValidationTemporaryDirectory $temp, ?string $composer): ReleaseValidationProcessResult + { + return $this->runner->run( + withComposerCommand(array( + '--working-dir=' . $consumer, + 'update', + '--no-interaction', + '--no-plugins', + '--no-scripts', + '--no-audit', + '--no-install', + '--no-ansi', + ), $composer), + null, + array( + 'COMPOSER_DISABLE_NETWORK' => '1', + 'COMPOSER_HOME' => $temp->child('composer-home'), + 'COMPOSER_CACHE_DIR' => $temp->child('composer-cache'), + ) + ); + } + + private function isMinimumStabilityFailure(string $output): bool + { + return str_contains($output, 'minimum-stability') + && str_contains($output, 'found evolvephp/') + && str_contains($output, 'but it does not match'); + } + + /** + * @return array + */ + private function readLockedFirstPartyVersions(string $lockfile): array + { + $lock = readJsonFile($lockfile, 'disposable composer.lock'); + + if (!is_array($lock) || !isset($lock['packages']) || !is_array($lock['packages'])) { + releaseValidationFail('Disposable composer.lock must contain packages.'); + } + + $versions = array(); + + foreach ($lock['packages'] as $package) { + if (!is_array($package) || !isset($package['name'], $package['version'])) { + continue; + } + + if (str_starts_with((string) $package['name'], 'evolvephp/')) { + $versions[(string) $package['name']] = (string) $package['version']; + } + } + + ksort($versions); + + return $versions; + } + + /** + * @param list $packages + * @return array + */ + private function expectedVersions(array $packages, string $version): array + { + $versions = array(); + + foreach ($packages as $package) { + $versions[$package['name']] = $version; + } + + ksort($versions); + + return $versions; + } + + /** + * @param list $packages + * @return array + */ + private function explicitAlphaRootRequirements(array $packages): array + { + $requirements = array(); + + foreach ($packages as $package) { + $requirements[$package['name']] = '^2.0@alpha'; + } + + return $requirements; + } + + /** + * @param array $versions + */ + private function formatVersions(array $versions): string + { + $lines = array(); + + foreach ($versions as $name => $version) { + $lines[] = $name . ' ' . $version; + } + + return implode(', ', $lines); + } +} + +try { + $options = parseReleaseValidationArguments($argv); + $validator = new PrereleaseConsumerValidator(); + $results = $validator->validate($options['root'], $options['ref'], $options['composer']); + + echo 'EvolvePHP prerelease consumer validation passed.' . PHP_EOL; + echo 'COMPOSER_DISABLE_NETWORK: enabled' . PHP_EOL; + echo 'Packagist: disabled' . PHP_EOL; + echo 'Alpha tag: 2.0.0-alpha.1' . PHP_EOL; + echo 'Stable tag: 2.0.0' . PHP_EOL; + + foreach ($results as $result) { + echo $result['name'] . ': ' . $result['outcome'] . ' - ' . $result['detail'] . PHP_EOL; + } + + echo '^2.0 remains correct.' . PHP_EOL; + echo 'Default stability alpha failures are expected minimum-stability rejections.' . PHP_EOL; + echo 'Top-level @alpha alone is insufficient for transitive alpha packages.' . PHP_EOL; + echo 'minimum-stability: alpha plus prefer-stable: true succeeds.' . PHP_EOL; + echo 'Explicit root @alpha flags succeed.' . PHP_EOL; + echo 'Stable 2.0.0 succeeds under default stability.' . PHP_EOL; + echo 'No package-manifest change is required.' . PHP_EOL; + echo 'Source repository state preserved.' . PHP_EOL; + exit(0); +} catch (ReleaseValidationFailure $failure) { + fwrite(STDERR, 'EvolvePHP prerelease consumer validation failed: ' . $failure->getMessage() . PHP_EOL); + exit(1); +} From 425207035898818e98a80987ea789c5709a838cf Mon Sep 17 00:00:00 2001 From: Josiah King Date: Mon, 10 Aug 2026 16:53:14 +0100 Subject: [PATCH 2/3] fix: support detached CI release validation --- ...2ReleaseSplitAndConsumerValidationTest.php | 47 +++++++++++++++++++ workspace/tools/release-validation-common.php | 11 ++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php b/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php index d960390..343dd35 100644 --- a/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php +++ b/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php @@ -42,6 +42,53 @@ public function testSharedHelperUsesArgumentVectorProcessesAndDoesNotRunOnInclud $this->assertDoesNotMatchRegularExpression('/EvolvePHP .* validation passed/', $content); } + public function testSourceStateCaptureUsesDeterministicRefEnumerationForDetachedCi(): void + { + $content = $this->readProjectFile('workspace/tools/release-validation-common.php'); + + $this->assertStringContainsString("'for-each-ref'", $content); + $this->assertStringContainsString("'--sort=refname'", $content); + $this->assertStringContainsString("'--format=%(objectname) %(refname)'", $content); + $this->assertStringContainsString("'refs/heads'", $content); + $this->assertStringContainsString("'refs/tags'", $content); + $this->assertStringNotContainsString("'show-ref', '--heads', '--tags'", $content); + $this->assertStringNotContainsString('|| true', $content); + $this->assertStringNotContainsString('2>/dev/null', $content); + } + + public function testSourceStateCaptureSupportsDetachedRepositoriesWithoutLocalHeadsOrTags(): void + { + require_once $this->path('workspace/tools/release-validation-common.php'); + + $runner = new ReleaseValidationProcessRunner(); + $temporary = createTemporaryDirectory('evolvephp-detached-source-state-test-'); + + try { + $runner->mustRun(array('git', 'init'), $temporary->path); + $runner->mustRun(array('git', 'config', 'user.name', 'EvolvePHP Test'), $temporary->path); + $runner->mustRun(array('git', 'config', 'user.email', 'evolvephp-test@example.com'), $temporary->path); + + file_put_contents($temporary->child('README.md'), "detached fixture\n"); + + $runner->mustRun(array('git', 'add', 'README.md'), $temporary->path); + $runner->mustRun(array('git', 'commit', '-m', 'Initial fixture'), $temporary->path); + + $branch = trim($runner->mustRun(array('git', 'symbolic-ref', '--short', 'HEAD'), $temporary->path)->stdout); + $head = trim($runner->mustRun(array('git', 'rev-parse', 'HEAD'), $temporary->path)->stdout); + + $runner->mustRun(array('git', 'checkout', '--detach', $head), $temporary->path); + $runner->mustRun(array('git', 'branch', '-D', $branch), $temporary->path); + + $state = captureSourceState($runner, $temporary->path); + + $this->assertSame($head, $state['head']); + $this->assertSame('', $state['refs']); + $this->assertSame('', $state['tags']); + } finally { + $temporary->cleanup(); + } + } + public function testSplitValidatorDocumentsDeterministicSplitContract(): void { $content = $this->readProjectFile('workspace/tools/validate-package-splits.php'); diff --git a/workspace/tools/release-validation-common.php b/workspace/tools/release-validation-common.php index 1d2c9a7..d913154 100644 --- a/workspace/tools/release-validation-common.php +++ b/workspace/tools/release-validation-common.php @@ -423,7 +423,16 @@ function captureSourceState(ReleaseValidationProcessRunner $runner, string $root { return array( 'head' => trim($runner->mustRun(array('git', '-C', $root, 'rev-parse', 'HEAD'))->stdout), - 'refs' => trim($runner->mustRun(array('git', '-C', $root, 'show-ref', '--heads', '--tags'))->stdout), + 'refs' => trim($runner->mustRun(array( + 'git', + '-C', + $root, + 'for-each-ref', + '--sort=refname', + '--format=%(objectname) %(refname)', + 'refs/heads', + 'refs/tags', + ))->stdout), 'tags' => trim($runner->mustRun(array('git', '-C', $root, 'tag', '--list'))->stdout), 'status' => trim($runner->mustRun(array('git', '-C', $root, 'status', '--short', '--untracked-files=all'))->stdout), ); From 7fca92edf55c071b4ab4359d0869b9c3c601c547 Mon Sep 17 00:00:00 2001 From: Josiah King Date: Mon, 10 Aug 2026 17:19:45 +0100 Subject: [PATCH 3/3] fix: fetch full history for split validation --- .github/workflows/quality.yml | 1 + .../EvolvePhp2ContinuousIntegrationTest.php | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index bf38b0c..04d35f0 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -26,6 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - name: Set up PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 diff --git a/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php b/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php index c84213f..641fa97 100644 --- a/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php +++ b/tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php @@ -87,6 +87,21 @@ public function testPolicyJobRunsRootPolicySuitesWithWorkspacePhpUnitOnPhp84(): $this->assertDoesNotMatchPattern('/phpunit.*(?:core|components|helpers|index\.php|route\.php)/i', $job); } + public function testPolicyCheckoutFetchesCompleteHistoryForReleaseSplitValidation(): void + { + $policyCheckout = $this->extractStep($this->extractJob('policy'), 'Checkout repository'); + $workspaceQualityCheckout = $this->extractStep($this->extractJob('workspace-quality'), 'Checkout repository'); + + $this->assertSame(1, substr_count($this->workflow, 'fetch-depth: 0')); + $this->assertMatchesPattern('/persist-credentials:\s*false/', $policyCheckout); + $this->assertMatchesPattern('/fetch-depth:\s*0/', $policyCheckout); + $this->assertStringContainsString('composer --working-dir=workspace release:split:validate', $this->extractJob('policy')); + + $this->assertMatchesPattern('/persist-credentials:\s*false/', $workspaceQualityCheckout); + $this->assertDoesNotMatchPattern('/fetch-depth:\s*0/', $workspaceQualityCheckout); + $this->assertStringNotContainsString('release:split:validate', $this->extractJob('workspace-quality')); + } + public function testWorkspaceQualityMatrixUsesLockfileInstallAndApprovedAggregateCommand(): void { $job = $this->extractJob('workspace-quality'); @@ -193,6 +208,15 @@ private function extractJob($job) return $matches['job']; } + private function extractStep($job, $stepName) + { + $pattern = '/^\s{6}- name:\s*' . preg_quote($stepName, '/') . '\s*\R(?P.*?)(?=^\s{6}- name:\s*|\z)/ms'; + + $this->assertSame(1, preg_match($pattern, $job, $matches), 'Missing step: ' . $stepName); + + return $matches['step']; + } + private function projectPath($path) { return $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);