Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +41,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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 37 additions & 1 deletion tests/Architecture/EvolvePhp2ContinuousIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,30 @@ 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);
}

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');
Expand All @@ -104,7 +122,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/',
Expand Down Expand Up @@ -181,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<step>.*?)(?=^\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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
<?php

use PHPUnit\Framework\TestCase;

final class EvolvePhp2ReleaseSplitAndConsumerValidationTest extends TestCase
{
private $root;

protected function setUp(): void
{
$this->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('<?php declare(strict_types=1);', $content);
$this->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 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');

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);
}
}
Loading