diff --git a/CHANGELOG.md b/CHANGELOG.md index 036e749..5d53829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Documentation and governance +- 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. - Added Phase 2.7B repository governance evidence finalization: changed the GitHub default branch to `2.x`, activated repository rulesets for `master` and `2.x`, preserved `master` as the EvolvePHP 1 legacy line, required PR-based change on both branches, blocked deletion and force pushes, enforced strict/up-to-date required status checks on `2.x` for `Policy (PHP 8.4)`, `Workspace quality (PHP 8.4)` and `Workspace quality (PHP 8.5)`, and made no branch rename or deletion. diff --git a/packages/contracts/LICENSE.md b/packages/contracts/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/contracts/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000..c7c5024 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,29 @@ +# EvolvePHP Contracts + +Foundational public contracts for EvolvePHP 2. + +## Package + +`evolvephp/contracts` + +## Requirements + +PHP `^8.4` + +## Dependencies + +None. + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/core/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..7ecc771 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,29 @@ +# EvolvePHP Core + +Application kernel and runtime-neutral orchestration for EvolvePHP 2. + +## Package + +`evolvephp/core` + +## Requirements + +PHP `^8.4` + +## Dependencies + +`evolvephp/contracts` + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/packages/http/LICENSE.md b/packages/http/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/http/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/http/README.md b/packages/http/README.md new file mode 100644 index 0000000..5f376a2 --- /dev/null +++ b/packages/http/README.md @@ -0,0 +1,29 @@ +# EvolvePHP HTTP + +HTTP lifecycle, routing and middleware foundations for EvolvePHP 2. + +## Package + +`evolvephp/http` + +## Requirements + +PHP `^8.4` + +## Dependencies + +`evolvephp/contracts`, `evolvephp/core` + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/packages/module/LICENSE.md b/packages/module/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/module/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/module/README.md b/packages/module/README.md new file mode 100644 index 0000000..92b4a38 --- /dev/null +++ b/packages/module/README.md @@ -0,0 +1,29 @@ +# EvolvePHP Module + +Application module SDK and lifecycle support for EvolvePHP 2. + +## Package + +`evolvephp/module` + +## Requirements + +PHP `^8.4` + +## Dependencies + +`evolvephp/contracts` + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/packages/plugin/LICENSE.md b/packages/plugin/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/plugin/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 0000000..750b5fa --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,29 @@ +# EvolvePHP Plugin + +Framework plugin SDK and lifecycle support for EvolvePHP 2. + +## Package + +`evolvephp/plugin` + +## Requirements + +PHP `^8.4` + +## Dependencies + +`evolvephp/contracts` + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/packages/testing/LICENSE.md b/packages/testing/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/testing/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/testing/README.md b/packages/testing/README.md new file mode 100644 index 0000000..44313fd --- /dev/null +++ b/packages/testing/README.md @@ -0,0 +1,29 @@ +# EvolvePHP Testing + +Testing utilities for EvolvePHP 2 packages and applications. + +## Package + +`evolvephp/testing` + +## Requirements + +PHP `^8.4` + +## Dependencies + +`evolvephp/contracts`, `evolvephp/core`, `evolvephp/http`, `evolvephp/module`, `evolvephp/plugin` + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php b/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php index 75eb8d0..18522c3 100644 --- a/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php +++ b/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php @@ -18,6 +18,12 @@ public function testTrackedReadmeInventoryIsTheExpectedCanonicalSet(): void 'README.md', 'docs/rfcs/README.md', 'packages/README.md', + 'packages/contracts/README.md', + 'packages/core/README.md', + 'packages/http/README.md', + 'packages/module/README.md', + 'packages/plugin/README.md', + 'packages/testing/README.md', 'workspace/README.md', ), $this->trackedReadmes() diff --git a/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php b/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php new file mode 100644 index 0000000..e00038e --- /dev/null +++ b/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php @@ -0,0 +1,247 @@ +root = dirname(__DIR__, 2); + } + + public function testReleasePackageMapDefinesCanonicalDependencyCompatibleOrder(): void + { + $map = $this->readJsonFile('workspace/release-packages.json'); + + $this->assertSame(array('version', 'packages'), array_keys($map)); + $this->assertSame(1, $map['version']); + $this->assertSame( + 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'), + ), + $map['packages'] + ); + + foreach ($map['packages'] as $package) { + $this->assertSame(array('name', 'directory'), array_keys($package)); + $this->assertDoesNotMatchPattern('/^(?:[A-Za-z]:)?[\/\\\\]/', $package['directory']); + $this->assertStringNotContainsString('..', $package['directory']); + + foreach (array('url', 'repository', 'packagist', 'tag', 'version', 'branch', 'token', 'secret', 'password', 'status') as $forbidden) { + $this->assertArrayNotHasKey($forbidden, $package); + } + } + } + + public function testPackageReadmesDocumentPublicationStatusWithoutInventingRemoteRepositories(): void + { + foreach ($this->packages() as $package) { + $content = $this->readProjectFile($package['directory'] . '/README.md'); + + $this->assertStringContainsString('# ' . $package['human'], $content); + $this->assertStringContainsString('`' . $package['name'] . '`', $content); + $this->assertStringContainsString($package['responsibility'], $content); + $this->assertStringContainsString('PHP `^8.4`', $content); + $this->assertMatchesPattern('/EvolvePHP 2 is pre-release/i', $content); + $this->assertMatchesPattern('/not yet independently published/i', $content); + $this->assertMatchesPattern('/canonical source.*EvolvePHP monorepo/i', $content); + $this->assertStringContainsString('https://github.com/josiahking/evolvephp', $content); + $this->assertStringContainsString($package['dependencies'], $content); + $this->assertStringContainsString('BSD-3-Clause', $content); + $this->assertStringContainsString('`LICENSE.md`', $content); + $this->assertDoesNotMatchPattern('/composer require/i', $content); + $this->assertDoesNotMatchPattern('/github\.com\/josiahking\/evolvephp[-\/](?:contracts|core|http|module|plugin|testing)/i', $content); + } + } + + public function testPackageLicenceFilesMatchRootLicenceByteForByte(): void + { + $rootLicence = $this->readProjectFile('LICENSE.md'); + + foreach ($this->packages() as $package) { + $this->assertSame( + $rootLicence, + $this->readProjectFile($package['directory'] . '/LICENSE.md'), + $package['directory'] . '/LICENSE.md should match root LICENSE.md byte-for-byte.' + ); + } + } + + public function testReleaseValidatorIsReadOnlyNetworkFreeAndPortable(): void + { + $content = $this->readProjectFile('workspace/tools/validate-release-packages.php'); + + $this->assertMatchesPattern('/^<\?php\s+declare\(strict_types=1\);/s', $content); + $this->assertStringContainsString('--root=', $content); + $this->assertStringContainsString('workspace/release-packages.json', $content); + $this->assertStringContainsString('DIRECTORY_SEPARATOR', $content); + + foreach (array( + 'curl_', + 'file_get_contents(\'http', + 'file_get_contents("http', + 'github api', + 'packagist', + 'token', + 'secret', + 'password', + 'git push', + 'git tag', + 'gh release', + 'file_put_contents', + 'unlink', + 'rename', + 'mkdir', + 'exec(', + 'shell_exec', + 'system(', + 'passthru(', + ) as $forbidden) { + $this->assertStringNotContainsString($forbidden, strtolower($content)); + } + } + + public function testWorkspaceComposerExposesReleaseValidationWithoutChangingQualityOrSupplyChain(): void + { + $manifest = $this->readJsonFile('workspace/composer.json'); + $scripts = $manifest['scripts']; + + $this->assertArrayHasKey('release:validate', $scripts); + $this->assertSame('@php tools/validate-release-packages.php', $scripts['release:validate']); + $this->assertSame(array('@architecture', '@analyse', '@style:check', '@test'), $scripts['quality']); + $this->assertSame(array('@security:audit', '@licenses:check'), $scripts['supply-chain']); + $this->assertNotContains('@release:validate', $scripts['quality']); + $this->assertNotContains('@release:validate', $scripts['supply-chain']); + } + + public function testWorkspaceReadmeDocumentsReleaseValidationBoundaries(): void + { + $content = $this->readProjectFile('workspace/README.md'); + + foreach (array( + '/## Release Validation/', + '/composer --working-dir=workspace release:validate/', + '/deterministic\/offline|offline.*deterministic/i', + '/six packages.*mapped explicitly|mapped explicitly.*six packages/i', + '/dependency-compatible/i', + '/package-local README/i', + '/package-local.*licen[cs]es/i', + '/identical to root `LICENSE\.md`/i', + '/no package is being published/i', + '/no remote repositories are contacted/i', + '/no tags\/releases are created/i', + '/package Composer manifests remain authoritative/i', + '/distinct from `quality`/i', + '/distinct from.*`supply-chain`/i', + '/package splitting is Phase 2\.10B/i', + '/remote synchronization.*Packagist.*deferred/i', + '/prerelease consumer stability.*2\.10B/i', + '/RFC 0003 remains authoritative/i', + ) as $pattern) { + $this->assertMatchesPattern($pattern, $content); + } + } + + public function testChangelogRecordsPhase210AReleaseReadinessFoundation(): void + { + $content = $this->readProjectFile('CHANGELOG.md'); + + $this->assertMatchesPattern('/Phase 2\.10A/i', $content); + $this->assertMatchesPattern('/deterministic package release validation/i', $content); + $this->assertMatchesPattern('/explicit release package map/i', $content); + $this->assertMatchesPattern('/package-local README.*licen[cs]e/i', $content); + $this->assertMatchesPattern('/no remote publication or splitting/i', $content); + } + + private function packages() + { + return array( + array( + 'name' => 'evolvephp/contracts', + 'directory' => 'packages/contracts', + 'human' => 'EvolvePHP Contracts', + 'responsibility' => 'Foundational public contracts for EvolvePHP 2.', + 'dependencies' => 'None.', + ), + array( + 'name' => 'evolvephp/core', + 'directory' => 'packages/core', + 'human' => 'EvolvePHP Core', + 'responsibility' => 'Application kernel and runtime-neutral orchestration for EvolvePHP 2.', + 'dependencies' => '`evolvephp/contracts`', + ), + array( + 'name' => 'evolvephp/module', + 'directory' => 'packages/module', + 'human' => 'EvolvePHP Module', + 'responsibility' => 'Application module SDK and lifecycle support for EvolvePHP 2.', + 'dependencies' => '`evolvephp/contracts`', + ), + array( + 'name' => 'evolvephp/plugin', + 'directory' => 'packages/plugin', + 'human' => 'EvolvePHP Plugin', + 'responsibility' => 'Framework plugin SDK and lifecycle support for EvolvePHP 2.', + 'dependencies' => '`evolvephp/contracts`', + ), + array( + 'name' => 'evolvephp/http', + 'directory' => 'packages/http', + 'human' => 'EvolvePHP HTTP', + 'responsibility' => 'HTTP lifecycle, routing and middleware foundations for EvolvePHP 2.', + 'dependencies' => '`evolvephp/contracts`, `evolvephp/core`', + ), + array( + 'name' => 'evolvephp/testing', + 'directory' => 'packages/testing', + 'human' => 'EvolvePHP Testing', + 'responsibility' => 'Testing utilities for EvolvePHP 2 packages and applications.', + 'dependencies' => '`evolvephp/contracts`, `evolvephp/core`, `evolvephp/http`, `evolvephp/module`, `evolvephp/plugin`', + ), + ); + } + + private function projectPath($path) + { + return $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + private function readProjectFile($path) + { + $fullPath = $this->projectPath($path); + $this->assertFileExists($fullPath, $path . ' should exist before it is read.'); + + $content = file_get_contents($fullPath); + $this->assertNotFalse($content, $path . ' should be readable.'); + + return $content; + } + + private function readJsonFile($path) + { + $content = $this->readProjectFile($path); + $decoded = json_decode($content, true); + + $this->assertSame(JSON_ERROR_NONE, json_last_error(), $path . ' should contain valid JSON: ' . json_last_error_msg()); + $this->assertIsArray($decoded); + + return $decoded; + } + + private function assertMatchesPattern($pattern, $content) + { + $this->assertSame(1, preg_match($pattern, $content), 'Failed asserting that content matches ' . $pattern); + } + + private function assertDoesNotMatchPattern($pattern, $content) + { + $this->assertSame(0, preg_match($pattern, $content), 'Failed asserting that content does not match ' . $pattern); + } +} diff --git a/workspace/README.md b/workspace/README.md index 7466100..5504e50 100644 --- a/workspace/README.md +++ b/workspace/README.md @@ -118,6 +118,20 @@ composer --working-dir=workspace quality `quality` runs `architecture`, `analyse`, `style:check` and `test`, in that order. `style:fix` remains separate because it is mutating. +## Release Validation + +Run deterministic/offline package release-readiness validation: + +```bash +composer --working-dir=workspace release:validate +``` + +Phase 2.10A keeps the six packages mapped explicitly in `workspace/release-packages.json`. The processing order is dependency-compatible: contracts, core, module, plugin, http and testing. Package-local README and licence files exist so future split roots carry consumer documentation and legal text naturally. Package licences must remain identical to root `LICENSE.md`. + +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. + ## Supply-Chain Security Run the Composer lockfile security audit: diff --git a/workspace/composer.json b/workspace/composer.json index a26683f..d660970 100644 --- a/workspace/composer.json +++ b/workspace/composer.json @@ -46,6 +46,7 @@ "@style:check", "@test" ], + "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", "style:fix": "@php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --diff --verbose", diff --git a/workspace/release-packages.json b/workspace/release-packages.json new file mode 100644 index 0000000..7e2a1be --- /dev/null +++ b/workspace/release-packages.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "packages": [ + { + "name": "evolvephp/contracts", + "directory": "packages/contracts" + }, + { + "name": "evolvephp/core", + "directory": "packages/core" + }, + { + "name": "evolvephp/module", + "directory": "packages/module" + }, + { + "name": "evolvephp/plugin", + "directory": "packages/plugin" + }, + { + "name": "evolvephp/http", + "directory": "packages/http" + }, + { + "name": "evolvephp/testing", + "directory": "packages/testing" + } + ] +} diff --git a/workspace/tools/validate-release-packages.php b/workspace/tools/validate-release-packages.php new file mode 100644 index 0000000..ded5059 --- /dev/null +++ b/workspace/tools/validate-release-packages.php @@ -0,0 +1,553 @@ + $resolvedRoot); +} + +function pathFor(string $root, string $relativePath): string +{ + return $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); +} + +function normalizeRelativePath(string $path): string +{ + return str_replace('\\', '/', trim($path, "/\\")); +} + +/** + * @return mixed + */ +function readJson(string $path, string $label) +{ + if (!is_file($path)) { + fail($label . ' does not exist.'); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + fail($label . ' is not readable.'); + } + + $decoded = json_decode($contents, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + fail($label . ' contains malformed JSON: ' . json_last_error_msg() . '.'); + } + + return $decoded; +} + +/** + * @return array + */ +function expectedPackages(): array +{ + return array( + 'evolvephp/contracts' => array('name' => 'evolvephp/contracts', 'directory' => 'packages/contracts'), + 'evolvephp/core' => array('name' => 'evolvephp/core', 'directory' => 'packages/core'), + 'evolvephp/module' => array('name' => 'evolvephp/module', 'directory' => 'packages/module'), + 'evolvephp/plugin' => array('name' => 'evolvephp/plugin', 'directory' => 'packages/plugin'), + 'evolvephp/http' => array('name' => 'evolvephp/http', 'directory' => 'packages/http'), + 'evolvephp/testing' => array('name' => 'evolvephp/testing', 'directory' => 'packages/testing'), + ); +} + +/** + * @return array + */ +function expectedNamespaces(): array +{ + return array( + 'evolvephp/contracts' => 'Evolve\\Contracts\\', + 'evolvephp/core' => 'Evolve\\Core\\', + 'evolvephp/module' => 'Evolve\\Module\\', + 'evolvephp/plugin' => 'Evolve\\Plugin\\', + 'evolvephp/http' => 'Evolve\\Http\\', + 'evolvephp/testing' => 'Evolve\\Testing\\', + ); +} + +/** + * @return array> + */ +function expectedGraph(): array +{ + return array( + 'evolvephp/contracts' => array(), + 'evolvephp/core' => array('evolvephp/contracts'), + 'evolvephp/module' => array('evolvephp/contracts'), + 'evolvephp/plugin' => array('evolvephp/contracts'), + 'evolvephp/http' => array('evolvephp/contracts', 'evolvephp/core'), + 'evolvephp/testing' => array('evolvephp/contracts', 'evolvephp/core', 'evolvephp/http', 'evolvephp/module', 'evolvephp/plugin'), + ); +} + +/** + * @return list + */ +function validateMap(string $root): array +{ + $mapPath = pathFor($root, 'workspace/release-packages.json'); + $map = readJson($mapPath, 'workspace/release-packages.json'); + + if (!is_array($map)) { + fail('workspace/release-packages.json must decode to a JSON object.'); + } + + if (array_keys($map) !== array('version', 'packages')) { + fail('workspace/release-packages.json must contain only version and packages.'); + } + + if ($map['version'] !== 1) { + fail('workspace/release-packages.json version must be exactly 1.'); + } + + if (!is_array($map['packages']) || count($map['packages']) !== 6) { + fail('workspace/release-packages.json must contain exactly six package entries.'); + } + + $expectedPackages = array_values(expectedPackages()); + $names = array(); + $directories = array(); + $packages = array(); + + foreach ($map['packages'] as $index => $package) { + $label = 'workspace/release-packages.json package entry ' . ($index + 1); + + if (!is_array($package)) { + fail($label . ' must be a JSON object.'); + } + + if (array_keys($package) !== array('name', 'directory')) { + fail($label . ' must contain only name and directory.'); + } + + if (!is_string($package['name']) || $package['name'] === '') { + fail($label . ' must contain a package name.'); + } + + if (!is_string($package['directory']) || $package['directory'] === '') { + fail($label . ' must contain a package directory.'); + } + + if (isset($names[$package['name']])) { + fail($label . ' duplicates package name ' . $package['name'] . '.'); + } + + if (isset($directories[$package['directory']])) { + fail($label . ' duplicates package directory ' . $package['directory'] . '.'); + } + + if (preg_match('/^(?:[A-Za-z]:)?[\/\\\\]/', $package['directory']) === 1) { + fail($label . ' uses an absolute package directory.'); + } + + $normalizedDirectory = normalizeRelativePath($package['directory']); + $parts = explode('/', $normalizedDirectory); + + if (in_array('..', $parts, true)) { + fail($label . ' uses parent-directory traversal.'); + } + + if (!is_file(pathFor($root, $normalizedDirectory . '/composer.json'))) { + fail($package['name'] . ' mapped directory must contain composer.json.'); + } + + $names[$package['name']] = true; + $directories[$package['directory']] = true; + $packages[] = array('name' => $package['name'], 'directory' => $normalizedDirectory); + } + + $expectedByName = expectedPackages(); + + foreach ($packages as $package) { + if (!isset($expectedByName[$package['name']])) { + fail('workspace/release-packages.json maps unknown package ' . $package['name'] . '.'); + } + } + + if ($packages !== $expectedPackages) { + fail('workspace/release-packages.json must use the canonical Phase 2.10A package order.'); + } + + validatePackageManifestCoverage($root, $packages); + + return $packages; +} + +/** + * @param list $packages + */ +function validatePackageManifestCoverage(string $root, array $packages): void +{ + $mappedByDirectory = array(); + $mappedByName = array(); + + foreach ($packages as $package) { + $mappedByDirectory[$package['directory']] = true; + $mappedByName[$package['name']] = true; + } + + $manifests = glob(pathFor($root, 'packages/*/composer.json')); + + if ($manifests === false) { + fail('Unable to scan direct package composer manifests.'); + } + + foreach ($manifests as $manifestPath) { + $directory = normalizeRelativePath(substr(dirname($manifestPath), strlen($root) + 1)); + $manifest = readJson($manifestPath, $directory . '/composer.json'); + + if (!is_array($manifest) || !isset($manifest['name']) || !is_string($manifest['name'])) { + fail($directory . '/composer.json must contain a package name.'); + } + + if (!isset($mappedByDirectory[$directory])) { + fail($directory . ' contains composer.json but is missing from workspace/release-packages.json.'); + } + + if (!isset($mappedByName[$manifest['name']])) { + fail($manifest['name'] . ' is not represented in workspace/release-packages.json.'); + } + } +} + +/** + * @param list $packages + */ +function validatePackages(string $root, array $packages): void +{ + $graph = array(); + $mappedNames = array(); + + foreach ($packages as $package) { + $mappedNames[$package['name']] = true; + } + + foreach ($packages as $package) { + $manifestPath = pathFor($root, $package['directory'] . '/composer.json'); + $manifest = readJson($manifestPath, $package['directory'] . '/composer.json'); + + if (!is_array($manifest)) { + fail($package['directory'] . '/composer.json must decode to a JSON object.'); + } + + validateManifest($package, $manifest, $mappedNames); + validatePackageFiles($root, $package); + + $graph[$package['name']] = internalDependencies($manifest); + } + + validateExactGraph($graph); + validateAcyclicGraph($graph); + validateTopologicalOrder($packages, $graph); +} + +/** + * @param array{name: string, directory: string} $package + * @param array $manifest + * @param array $mappedNames + */ +function validateManifest(array $package, array $manifest, array $mappedNames): void +{ + $label = $package['directory'] . '/composer.json'; + + if (($manifest['name'] ?? null) !== $package['name']) { + fail($label . ' package name must match release map entry.'); + } + + if (($manifest['type'] ?? null) !== 'library') { + fail($label . ' type must be library.'); + } + + if (($manifest['license'] ?? null) !== 'BSD-3-Clause') { + fail($label . ' license must be BSD-3-Clause.'); + } + + if (!isset($manifest['require']) || !is_array($manifest['require'])) { + fail($label . ' must contain require.'); + } + + if (($manifest['require']['php'] ?? null) !== '^8.4') { + fail($label . ' must require PHP ^8.4.'); + } + + if (array_key_exists('version', $manifest)) { + fail($label . ' must not contain a version property.'); + } + + if (!isset($manifest['description']) || !is_string($manifest['description']) || trim($manifest['description']) === '') { + fail($label . ' must contain a non-empty description.'); + } + + $namespace = expectedNamespaces()[$package['name']]; + + if (($manifest['autoload']['psr-4'] ?? null) !== array($namespace => 'src/')) { + fail($label . ' must map ' . $namespace . ' to src/.'); + } + + foreach ($manifest['require'] as $dependency => $constraint) { + if (strpos((string) $dependency, 'evolvephp/') !== 0) { + continue; + } + + if (!isset($mappedNames[$dependency])) { + fail($label . ' references unmapped internal dependency ' . $dependency . '.'); + } + + if ($constraint !== '^2.0') { + fail($label . ' requires ' . $dependency . ' with ' . $constraint . '; expected ^2.0.'); + } + + if ($package['name'] !== 'evolvephp/testing' && $dependency === 'evolvephp/testing') { + fail($label . ' production packages must not require evolvephp/testing.'); + } + } +} + +/** + * @param array $manifest + * @return list + */ +function internalDependencies(array $manifest): array +{ + $dependencies = array(); + + foreach ($manifest['require'] as $dependency => $constraint) { + if ($dependency !== 'php' && strpos((string) $dependency, 'evolvephp/') === 0) { + $dependencies[] = (string) $dependency; + } + } + + sort($dependencies); + + return $dependencies; +} + +/** + * @param array> $graph + */ +function validateExactGraph(array $graph): void +{ + $expectedGraph = expectedGraph(); + + foreach ($expectedGraph as $package => $dependencies) { + $actual = $graph[$package] ?? null; + sort($dependencies); + + if ($actual !== $dependencies) { + fail($package . ' internal dependency graph does not match Phase 2.10A policy.'); + } + } +} + +/** + * @param array> $graph + */ +function validateAcyclicGraph(array $graph): void +{ + $visiting = array(); + $visited = array(); + + foreach (array_keys($graph) as $package) { + visitPackage($package, $graph, $visiting, $visited); + } +} + +/** + * @param array> $graph + * @param array $visiting + * @param array $visited + */ +function visitPackage(string $package, array $graph, array &$visiting, array &$visited): void +{ + if (isset($visited[$package])) { + return; + } + + if (isset($visiting[$package])) { + fail('Package dependency graph contains a cycle at ' . $package . '.'); + } + + $visiting[$package] = true; + + foreach ($graph[$package] as $dependency) { + visitPackage($dependency, $graph, $visiting, $visited); + } + + unset($visiting[$package]); + $visited[$package] = true; +} + +/** + * @param list $packages + * @param array> $graph + */ +function validateTopologicalOrder(array $packages, array $graph): void +{ + $seen = array(); + + foreach ($packages as $package) { + foreach ($graph[$package['name']] as $dependency) { + if (!isset($seen[$dependency])) { + fail($package['name'] . ' appears before dependency ' . $dependency . ' in release package order.'); + } + } + + $seen[$package['name']] = true; + } +} + +/** + * @param array{name: string, directory: string} $package + */ +function validatePackageFiles(string $root, array $package): void +{ + $directory = pathFor($root, $package['directory']); + + foreach (array('README.md', 'LICENSE.md') as $file) { + if (!is_file($directory . DIRECTORY_SEPARATOR . $file)) { + fail($package['name'] . ' must contain ' . $file . '.'); + } + } + + foreach (array('src', 'tests') as $subdirectory) { + if (!is_dir($directory . DIRECTORY_SEPARATOR . $subdirectory)) { + fail($package['name'] . ' must contain ' . $subdirectory . '/.'); + } + } + + $rootLicence = file_get_contents(pathFor($root, 'LICENSE.md')); + $packageLicence = file_get_contents($directory . DIRECTORY_SEPARATOR . 'LICENSE.md'); + + if ($rootLicence === false) { + fail('Root LICENSE.md is not readable.'); + } + + if ($packageLicence === false || $packageLicence !== $rootLicence) { + fail($package['name'] . ' LICENSE.md must match root LICENSE.md byte-for-byte.'); + } + + validateReadme($package, $directory . DIRECTORY_SEPARATOR . 'README.md'); + validateGeneratedFiles($package, $directory); +} + +/** + * @param array{name: string, directory: string} $package + */ +function validateReadme(array $package, string $readmePath): void +{ + $content = file_get_contents($readmePath); + + if ($content === false) { + fail($package['name'] . ' README.md is not readable.'); + } + + if (strpos($content, $package['name']) === false) { + fail($package['name'] . ' README.md must contain the package identifier.'); + } + + if (strpos($content, 'PHP `^8.4`') === false && strpos($content, 'PHP ^8.4') === false) { + fail($package['name'] . ' README.md must document PHP ^8.4.'); + } + + if (preg_match('/not yet independently published/i', $content) !== 1) { + fail($package['name'] . ' README.md must state that independent publication has not begun yet.'); + } + + if (preg_match('/github\.com\/josiahking\/evolvephp[-\/](?:contracts|core|http|module|plugin|testing)/i', $content) === 1) { + fail($package['name'] . ' README.md must not claim a split repository URL.'); + } + + if (preg_match('/composer require/i', $content) === 1) { + fail($package['name'] . ' README.md must not present a public install command.'); + } +} + +/** + * @param array{name: string, directory: string} $package + */ +function validateGeneratedFiles(array $package, string $directory): void +{ + $forbiddenNames = array( + 'vendor', + '.phpunit.cache', + '.phpstan-cache', + '.php-cs-fixer.cache', + '.deptrac.cache', + 'split', + 'split-tree', + 'dist', + ); + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $name = $file->getFilename(); + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + + if (in_array($name, $forbiddenNames, true) || in_array($extension, array('zip', 'tar', 'gz', 'bz2'), true)) { + fail($package['name'] . ' contains generated publication artifact ' . $name . '.'); + } + } +} + +try { + $arguments = parseArguments($argv); + $packages = validateMap($arguments['root']); + validatePackages($arguments['root'], $packages); + + echo 'EvolvePHP release package validation passed.' . PHP_EOL; + echo 'Packages: ' . count($packages) . PHP_EOL; + echo 'Release order:' . PHP_EOL; + + foreach ($packages as $index => $package) { + echo ($index + 1) . '. ' . $package['name'] . PHP_EOL; + } + + exit(0); +} catch (ValidationFailure $failure) { + fwrite(STDERR, 'EvolvePHP release package validation failed: ' . $failure->getMessage() . PHP_EOL); + exit(1); +}