From d7843425ba8a9c2ca78391993704cafad197a5ca Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 14 Dec 2025 11:34:08 +0100 Subject: [PATCH] Add dynamic PHP version handling from composer.json in tools table and update workflows --- bin/devkit.php | 125 +++++++++++++++++++++-- src/Json/PhpVersionsParser.php | 85 ++++++++++++++++ tests/Json/PhpVersionsParserTest.php | 147 +++++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 src/Json/PhpVersionsParser.php create mode 100644 tests/Json/PhpVersionsParserTest.php diff --git a/bin/devkit.php b/bin/devkit.php index 8201017e..8ed9a0de 100755 --- a/bin/devkit.php +++ b/bin/devkit.php @@ -8,8 +8,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Yaml\Yaml; use Zalas\Toolbox\Runner\PassthruRunner; use Zalas\Toolbox\Json\JsonTools; +use Zalas\Toolbox\Json\PhpVersionsParser; use Zalas\Toolbox\Tool\Collection; use Zalas\Toolbox\Tool\Command; use Zalas\Toolbox\Tool\Command\ShCommand; @@ -70,19 +72,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int $readmePath = $input->getOption('readme'); $tools = $this->loadTools($jsonPath); - $toolsList = '| Name | Description | PHP 8.2 | PHP 8.3 | PHP 8.4 |' . PHP_EOL; - $toolsList .= '| :--- | :---------- | :------ | :------ | :------ |' . PHP_EOL; + $versions = PhpVersionsParser::fromComposerFile(__DIR__ . '/../composer.json'); + + // Generate dynamic table header + $headers = array_merge(['Name', 'Description'], array_map(fn($v) => "PHP $v", $versions)); + $toolsList = '| ' . implode(' | ', $headers) . ' |' . PHP_EOL; + + // Generate dynamic separator + $separators = array_merge([':---', ':----------'], array_fill(0, count($versions), ':------')); + $toolsList .= '| ' . implode(' | ', $separators) . ' |' . PHP_EOL; + + // Generate tool rows with dynamic version checks $toolsList .= $tools->sort(function (Tool $left, Tool $right) { return strcasecmp($left->name(), $right->name()); - })->reduce('', function ($acc, Tool $tool) { + })->reduce('', function ($acc, Tool $tool) use ($versions) { + $versionCols = array_map(function($version) use ($tool) { + $tag = "exclude-php:{$version}"; + return in_array($tag, $tool->tags(), true) ? '❌' : '✅'; + }, $versions); - return $acc . sprintf('| %s | [%s](%s) | %s | %s | %s |', + return $acc . sprintf('| %s | [%s](%s) | %s |', $tool->name(), $tool->summary(), $tool->website(), - in_array('exclude-php:8.2', $tool->tags(), true) ? '❌' : '✅', - in_array('exclude-php:8.3', $tool->tags(), true) ? '❌' : '✅', - in_array('exclude-php:8.4', $tool->tags(), true) ? '❌' : '✅', + implode(' | ', $versionCols) ) . PHP_EOL; }); @@ -296,4 +309,102 @@ function ($htmls) { } } ); +$application->add( + new class extends CliCommand + { + protected function configure(): void + { + $this->setName('update:workflows'); + $this->setDescription('Updates GitHub Actions workflows with PHP versions from composer.json'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $composerPath = __DIR__ . '/../composer.json'; + $versions = PhpVersionsParser::fromComposerFile($composerPath); + $minVersion = PhpVersionsParser::getMinimumVersion($versions); + + // Update build.yml + $this->updateWorkflowFile( + __DIR__ . '/../.github/workflows/build.yml', + $versions, + $minVersion, + $output + ); + + // Update publish-website.yml + $this->updateWorkflowFile( + __DIR__ . '/../.github/workflows/publish-website.yml', + $versions, + $minVersion, + $output, + true // only update minimum version + ); + + // Update update-phars.yml + $this->updateWorkflowFile( + __DIR__ . '/../.github/workflows/update-phars.yml', + $versions, + $minVersion, + $output, + true // only update minimum version + ); + + $output->writeln('All workflows updated successfully.'); + + return 0; + } + + private function updateWorkflowFile( + string $filePath, + array $versions, + string $minVersion, + OutputInterface $output, + bool $onlyMinVersion = false + ): void { + if (!\file_exists($filePath)) { + $output->writeln(sprintf('Workflow file not found: %s', $filePath)); + return; + } + + $content = Yaml::parseFile($filePath); + + if ($onlyMinVersion) { + // For publish-website.yml and update-phars.yml, only update php-version + if (isset($content['jobs'])) { + foreach ($content['jobs'] as &$job) { + if (isset($job['steps'])) { + foreach ($job['steps'] as &$step) { + if (isset($step['uses']) && str_contains($step['uses'], 'setup-php@')) { + if (isset($step['with']['php-version'])) { + $step['with']['php-version'] = $minVersion; + } + } + } + } + } + } + } else { + // For build.yml, update matrix strategy + if (isset($content['jobs'])) { + foreach ($content['jobs'] as &$job) { + if (isset($job['strategy']['matrix']['php'])) { + $job['strategy']['matrix']['php'] = $versions; + } + if (isset($job['strategy']['matrix']['include'])) { + foreach ($job['strategy']['matrix']['include'] as &$include) { + if (isset($include['php'])) { + $include['php'] = $minVersion; + } + } + } + } + } + } + + \file_put_contents($filePath, Yaml::dump($content, 10, 4, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $output->writeln(sprintf('Updated %s', basename($filePath))); + } + } +); $application->run(); diff --git a/src/Json/PhpVersionsParser.php b/src/Json/PhpVersionsParser.php new file mode 100644 index 00000000..706573e1 --- /dev/null +++ b/src/Json/PhpVersionsParser.php @@ -0,0 +1,85 @@ + Array of PHP versions (e.g., ['8.2', '8.3', '8.4']) + * @throws RuntimeException If file cannot be read or parsed + * @throws InvalidArgumentException If PHP constraint is missing or invalid + */ + public static function fromComposerFile(string $composerJsonPath): array + { + if (!\file_exists($composerJsonPath)) { + throw new RuntimeException(\sprintf('Composer file not found: "%s"', $composerJsonPath)); + } + + $content = \file_get_contents($composerJsonPath); + if ($content === false) { + throw new RuntimeException(\sprintf('Failed to read composer file: "%s"', $composerJsonPath)); + } + + $json = \json_decode($content, true); + if ($json === null) { + throw new RuntimeException(\sprintf('Failed to parse composer file as JSON: "%s"', $composerJsonPath)); + } + + if (!isset($json['require']['php'])) { + throw new InvalidArgumentException(\sprintf('No "require.php" constraint found in: "%s"', $composerJsonPath)); + } + + return self::fromConstraint($json['require']['php']); + } + + /** + * Parses PHP versions from a composer constraint string. + * + * @param string $constraint PHP version constraint (e.g., "~8.2.0 || ~8.3.0 || ~8.4.0") + * @return array Array of PHP versions (e.g., ['8.2', '8.3', '8.4']) + * @throws InvalidArgumentException If constraint format is invalid + */ + public static function fromConstraint(string $constraint): array + { + // Match tilde constraints like ~8.2.0, ~8.3.0, etc. + // Also support caret (^8.2), >=8.2.0, 8.2.*, etc. + $pattern = '/(?:~|\^|>=?)?\s*(\d+\.\d+)(?:\.\d+)?(?:\s*\.\*)?/'; + + \preg_match_all($pattern, $constraint, $matches); + + if (empty($matches[1]) || empty(\array_filter($matches[1]))) { + throw new InvalidArgumentException(\sprintf('No valid PHP versions found in constraint: "%s"', $constraint)); + } + + // Extract unique versions and sort them + $versions = \array_unique($matches[1]); + \usort($versions, 'version_compare'); + + return \array_values($versions); + } + + /** + * Gets the minimum (lowest) version from an array of versions. + * + * @param array $versions Array of version strings + * @return string Minimum version + * @throws InvalidArgumentException If versions array is empty + */ + public static function getMinimumVersion(array $versions): string + { + if (empty($versions)) { + throw new InvalidArgumentException('Versions array cannot be empty'); + } + + $sorted = $versions; + \usort($sorted, 'version_compare'); + + return $sorted[0]; + } +} diff --git a/tests/Json/PhpVersionsParserTest.php b/tests/Json/PhpVersionsParserTest.php new file mode 100644 index 00000000..d0066567 --- /dev/null +++ b/tests/Json/PhpVersionsParserTest.php @@ -0,0 +1,147 @@ +assertSame(['8.2', '8.3', '8.4'], $versions); + } + + public function test_it_parses_caret_constraints() + { + $versions = PhpVersionsParser::fromConstraint('^8.2 || ^8.3'); + + $this->assertSame(['8.2', '8.3'], $versions); + } + + public function test_it_parses_comparison_constraints() + { + $versions = PhpVersionsParser::fromConstraint('>=8.2.0'); + + $this->assertSame(['8.2'], $versions); + } + + public function test_it_parses_wildcard_constraints() + { + $versions = PhpVersionsParser::fromConstraint('8.2.* || 8.3.*'); + + $this->assertSame(['8.2', '8.3'], $versions); + } + + public function test_it_parses_single_version() + { + $versions = PhpVersionsParser::fromConstraint('~8.4.0'); + + $this->assertSame(['8.4'], $versions); + } + + public function test_it_sorts_versions() + { + $versions = PhpVersionsParser::fromConstraint('~8.4.0 || ~8.2.0 || ~8.3.0'); + + $this->assertSame(['8.2', '8.3', '8.4'], $versions); + } + + public function test_it_removes_duplicate_versions() + { + $versions = PhpVersionsParser::fromConstraint('~8.2.0 || ^8.2 || >=8.2.0'); + + $this->assertSame(['8.2'], $versions); + } + + public function test_it_parses_mixed_constraint_formats() + { + $versions = PhpVersionsParser::fromConstraint('~7.4.0 || ^8.0 || >=8.1.0 || 8.2.*'); + + $this->assertSame(['7.4', '8.0', '8.1', '8.2'], $versions); + } + + public function test_it_throws_exception_for_invalid_constraint() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No valid PHP versions found in constraint'); + + PhpVersionsParser::fromConstraint('invalid constraint'); + } + + public function test_it_parses_from_composer_file() + { + $tempFile = \tempnam(\sys_get_temp_dir(), 'composer'); + \file_put_contents($tempFile, \json_encode([ + 'require' => [ + 'php' => '~8.2.0 || ~8.3.0 || ~8.4.0' + ] + ])); + + $versions = PhpVersionsParser::fromComposerFile($tempFile); + + $this->assertSame(['8.2', '8.3', '8.4'], $versions); + + \unlink($tempFile); + } + + public function test_it_throws_exception_when_composer_file_not_found() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Composer file not found'); + + PhpVersionsParser::fromComposerFile('/nonexistent/composer.json'); + } + + public function test_it_throws_exception_when_composer_file_is_invalid_json() + { + $tempFile = \tempnam(\sys_get_temp_dir(), 'composer'); + \file_put_contents($tempFile, 'not valid json'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to parse composer file as JSON'); + + PhpVersionsParser::fromComposerFile($tempFile); + + \unlink($tempFile); + } + + public function test_it_throws_exception_when_php_constraint_is_missing() + { + $tempFile = \tempnam(\sys_get_temp_dir(), 'composer'); + \file_put_contents($tempFile, \json_encode([ + 'require' => [ + 'symfony/console' => '^6.0' + ] + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No "require.php" constraint found'); + + PhpVersionsParser::fromComposerFile($tempFile); + + \unlink($tempFile); + } + + public function test_it_gets_minimum_version() + { + $versions = ['8.3', '8.2', '8.4']; + + $minVersion = PhpVersionsParser::getMinimumVersion($versions); + + $this->assertSame('8.2', $minVersion); + } + + public function test_it_throws_exception_when_getting_minimum_from_empty_array() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Versions array cannot be empty'); + + PhpVersionsParser::getMinimumVersion([]); + } +}