diff --git a/.github/workflows/build-unix.yml b/.github/workflows/build-unix.yml index ea9fb6ee..3cc8beaf 100644 --- a/.github/workflows/build-unix.yml +++ b/.github/workflows/build-unix.yml @@ -331,5 +331,4 @@ jobs: with: name: build-meta-${{ inputs.php-version }}-${{ inputs.os }} path: | - buildroot/build-extensions.json - buildroot/build-libraries.json + buildroot/build-manifest.json diff --git a/.github/workflows/build-windows-x86_64.yml b/.github/workflows/build-windows-x86_64.yml index d9cece6a..6eb48d7f 100644 --- a/.github/workflows/build-windows-x86_64.yml +++ b/.github/workflows/build-windows-x86_64.yml @@ -115,5 +115,4 @@ jobs: with: name: build-meta path: | - buildroot/build-extensions.json - buildroot/build-libraries.json + buildroot/build-manifest.json diff --git a/src/Package/Target/php.php b/src/Package/Target/php.php index 9efd5867..c9d31eaf 100644 --- a/src/Package/Target/php.php +++ b/src/Package/Target/php.php @@ -328,6 +328,49 @@ class php extends TargetPackage return $info; } + public function getBuildManifestData(PackageInstaller $installer): array + { + if ($this->getName() !== 'php') { + return []; + } + + $all_extensions = []; + $static_extensions = []; + $shared_extensions = []; + foreach ($installer->getResolvedPackages(PhpExtensionPackage::class) as $extension) { + $extension_name = $extension->getExtensionName(); + if ($extension->isBuildStatic() || $extension->isBuildShared()) { + $all_extensions[] = $extension_name; + } + if ($extension->isBuildStatic()) { + $static_extensions[] = $extension_name; + } + if ($extension->isBuildShared()) { + $shared_extensions[] = $extension_name; + } + } + + $sapis = array_values(array_filter([ + $installer->isPackageResolved('php-cli') ? 'cli' : null, + $installer->isPackageResolved('php-fpm') ? 'fpm' : null, + $installer->isPackageResolved('php-micro') ? 'micro' : null, + $installer->isPackageResolved('php-cgi') ? 'cgi' : null, + $installer->isPackageResolved('php-embed') ? 'embed' : null, + $installer->isPackageResolved('frankenphp') ? 'frankenphp' : null, + ])); + + return [ + 'version' => static::getPHPVersion(return_null_if_failed: true), + 'thread-safety' => $this->getBuildOption('enable-zts', false) ? 'zts' : 'nts', + 'sapis' => $sapis, + 'extensions' => [ + 'all' => $all_extensions, + 'static' => $static_extensions, + 'shared' => $shared_extensions, + ], + ]; + } + #[BeforeStage('php', 'build')] public function beforeBuild(PackageBuilder $builder, Package $package): void { diff --git a/src/StaticPHP/Package/PackageInstaller.php b/src/StaticPHP/Package/PackageInstaller.php index cf93368d..4032d33f 100644 --- a/src/StaticPHP/Package/PackageInstaller.php +++ b/src/StaticPHP/Package/PackageInstaller.php @@ -15,6 +15,7 @@ use StaticPHP\Exception\EnvironmentException; use StaticPHP\Exception\WrongUsageException; use StaticPHP\Registry\PackageLoader; use StaticPHP\Runtime\SystemTarget; +use StaticPHP\Util\BuildManifestDumper; use StaticPHP\Util\BuildRootTracker; use StaticPHP\Util\DependencyResolver; use StaticPHP\Util\DirDiff; @@ -282,6 +283,11 @@ class PackageInstaller // pipeline above (they were merged into $this->packages in resolvePackages()). This only // throws if a tool genuinely failed to become available (e.g. no artifact for this platform). $this->ensureRequiredTools(); + + // Pure install operations must not overwrite the manifest of an existing build. + if ($this->build_packages !== []) { + new BuildManifestDumper()->dump($this->packages, $this->build_packages, $this->install_packages, $this); + } } public function isBuildPackage(Package|string $package): bool diff --git a/src/StaticPHP/Package/TargetPackage.php b/src/StaticPHP/Package/TargetPackage.php index 9ff5c7de..18524920 100644 --- a/src/StaticPHP/Package/TargetPackage.php +++ b/src/StaticPHP/Package/TargetPackage.php @@ -117,6 +117,19 @@ class TargetPackage extends LibraryPackage return null; } + /** + * Get target-specific structured data for the build manifest. + * + * Target implementations may override this method to expose build facts that + * cannot be represented by the generic resolved package list. + * + * @return array + */ + public function getBuildManifestData(PackageInstaller $installer): array + { + return []; + } + /** * Gets all build options for the target package. * diff --git a/src/StaticPHP/Util/BuildManifestDumper.php b/src/StaticPHP/Util/BuildManifestDumper.php new file mode 100644 index 00000000..c47569b7 --- /dev/null +++ b/src/StaticPHP/Util/BuildManifestDumper.php @@ -0,0 +1,82 @@ + $packages Resolved packages in dependency order + * @param array $buildPackages Packages explicitly scheduled for building + * @param array $installPackages Packages explicitly scheduled for installation + */ + public function dump( + array $packages, + array $buildPackages, + array $installPackages, + PackageInstaller $installer, + string $outputPath = BUILD_ROOT_PATH . '/build-manifest.json', + ): void { + $targets = []; + foreach ($packages as $package) { + if (!$package instanceof TargetPackage) { + continue; + } + + $data = $package->getBuildManifestData($installer); + if ($data !== []) { + $targets[$package->getName()] = $data; + } + } + + $manifest = [ + 'schema-version' => self::SCHEMA_VERSION, + 'spc-version' => ConsoleApplication::VERSION, + 'target' => [ + 'platform' => SystemTarget::getCurrentPlatformString(), + 'os' => SystemTarget::getTargetOS(), + 'architecture' => SystemTarget::getTargetArch(), + 'libc' => SystemTarget::getLibc(), + 'libc-version' => SystemTarget::getLibcVersion(), + 'spc-target' => getenv('SPC_TARGET') ?: null, + 'toolchain' => getenv('SPC_TOOLCHAIN') ?: null, + ], + 'requested-packages' => [ + 'build' => array_keys($buildPackages), + 'install' => array_keys($installPackages), + ], + 'packages' => array_values(array_map( + static function (Package $package): array { + $artifact = $package->getArtifact(); + return [ + 'name' => $package->getName(), + 'type' => $package->getType(), + 'artifact' => $artifact?->getName(), + ]; + }, + $packages, + )), + 'targets' => $targets, + ]; + + $json = json_encode( + $manifest, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + if (FileSystem::writeFile($outputPath, $json . PHP_EOL) === false) { + throw new SPCInternalException("Failed to write build manifest: {$outputPath}"); + } + + logger()->info('Generated build manifest with ' . count($packages) . " package(s): {$outputPath}"); + } +} diff --git a/tests/StaticPHP/Util/BuildManifestDumperTest.php b/tests/StaticPHP/Util/BuildManifestDumperTest.php new file mode 100644 index 00000000..f52a589a --- /dev/null +++ b/tests/StaticPHP/Util/BuildManifestDumperTest.php @@ -0,0 +1,187 @@ +tempDir = sys_get_temp_dir() . '/build_manifest_dumper_test_' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + } + + public function testDumpWritesResolvedPackagesAndBuildContext(): void + { + $phpArtifact = new Artifact('php-src', ['source' => ['type' => 'url', 'url' => 'https://example.com/php.tar.gz']]); + $phpManifestData = [ + 'thread-safety' => 'nts', + 'extensions' => ['all' => ['curl'], 'static' => ['curl'], 'shared' => []], + ]; + $php = $this->createTargetPackage('php', $phpArtifact, $phpManifestData); + $openssl = $this->createPackage('openssl', 'library'); + $curl = $this->createPackage('ext-curl', 'php-extension'); + $cli = $this->createPackage('php-cli', 'target'); + $installer = $this->createMock(PackageInstaller::class); + + $outputPath = $this->tempDir . '/nested/build-manifest.json'; + (new BuildManifestDumper())->dump( + [ + 'openssl' => $openssl, + 'ext-curl' => $curl, + 'php' => $php, + 'php-cli' => $cli, + ], + ['php' => $php], + ['php-cli' => $cli], + $installer, + $outputPath, + ); + + $this->assertFileExists($outputPath); + $manifest = json_decode((string) file_get_contents($outputPath), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(BuildManifestDumper::SCHEMA_VERSION, $manifest['schema-version']); + $this->assertSame(ConsoleApplication::VERSION, $manifest['spc-version']); + $this->assertSame(SystemTarget::getCurrentPlatformString(), $manifest['target']['platform']); + $this->assertSame(SystemTarget::getTargetOS(), $manifest['target']['os']); + $this->assertSame(SystemTarget::getTargetArch(), $manifest['target']['architecture']); + $this->assertSame(['php'], $manifest['requested-packages']['build']); + $this->assertSame(['php-cli'], $manifest['requested-packages']['install']); + $this->assertSame([ + ['name' => 'openssl', 'type' => 'library', 'artifact' => null], + ['name' => 'ext-curl', 'type' => 'php-extension', 'artifact' => null], + ['name' => 'php', 'type' => 'target', 'artifact' => 'php-src'], + ['name' => 'php-cli', 'type' => 'target', 'artifact' => null], + ], $manifest['packages']); + $this->assertSame(['php' => $phpManifestData], $manifest['targets']); + } + + public function testPhpTargetExportsPhpSpecificBuildFacts(): void + { + $curl = new PhpExtensionPackage('curl', extension_config: ['arg-type' => 'with']); + $curl->setBuildStatic(); + $pdo = new PhpExtensionPackage('pdo', extension_config: ['arg-type' => 'enable']); + $pdo->setBuildStatic(); + $pdo->setBuildShared(); + $xdebug = new PhpExtensionPackage('xdebug', extension_config: ['arg-type' => 'enable']); + $xdebug->setBuildShared(); + $unused = new PhpExtensionPackage('unused', extension_config: ['arg-type' => 'enable']); + + $installer = $this->createMock(PackageInstaller::class); + $installer->method('getResolvedPackages')->willReturn([$curl, $pdo, $xdebug, $unused]); + $installer->method('isPackageResolved')->willReturnCallback( + static fn (string $name): bool => in_array($name, ['php-cli', 'php-micro', 'php-embed'], true), + ); + + $php = new class('php', 'target') extends PhpTargetPackage { + public static function getPHPVersion(?string $from_custom_source = null, bool $return_null_if_failed = false): ?string + { + return '8.4.10'; + } + + public function getBuildOption(string $key, mixed $default = null): mixed + { + return $key === 'enable-zts' ? true : $default; + } + }; + + $data = $php->getBuildManifestData($installer); + + $this->assertSame('8.4.10', $data['version']); + $this->assertSame('zts', $data['thread-safety']); + $this->assertSame(['cli', 'micro', 'embed'], $data['sapis']); + $this->assertSame([ + 'all' => ['curl', 'pdo', 'xdebug'], + 'static' => ['curl', 'pdo'], + 'shared' => ['pdo', 'xdebug'], + ], $data['extensions']); + } + + private function createPackage(string $name, string $type, ?Artifact $artifact = null): Package + { + return new class($name, $type, $artifact) extends Package { + public function __construct(string $name, string $type, private readonly ?Artifact $artifact) + { + parent::__construct($name, $type); + } + + public function getArtifact(): ?Artifact + { + return $this->artifact; + } + }; + } + + /** @param array $manifestData */ + private function createTargetPackage(string $name, ?Artifact $artifact, array $manifestData): TargetPackage + { + return new class($name, 'target', $artifact, $manifestData) extends TargetPackage { + /** @param array $manifestData */ + public function __construct( + string $name, + string $type, + private readonly ?Artifact $artifact, + private readonly array $manifestData, + ) { + parent::__construct($name, $type); + } + + public function getArtifact(): ?Artifact + { + return $this->artifact; + } + + public function getBuildManifestData(PackageInstaller $installer): array + { + return $this->manifestData; + } + }; + } + + private function removeDirectory(string $directory): void + { + $items = scandir($directory); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $directory . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + rmdir($directory); + } +}