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([]);
+ }
+}