Skip to content
Open
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
125 changes: 118 additions & 7 deletions bin/devkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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('<info>All workflows updated successfully.</info>');

return 0;
}

private function updateWorkflowFile(
string $filePath,
array $versions,
string $minVersion,
OutputInterface $output,
bool $onlyMinVersion = false
): void {
if (!\file_exists($filePath)) {
$output->writeln(sprintf('<error>Workflow file not found: %s</error>', $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('<info>Updated %s</info>', basename($filePath)));
}
}
);
$application->run();
85 changes: 85 additions & 0 deletions src/Json/PhpVersionsParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php declare(strict_types=1);

namespace Zalas\Toolbox\Json;

use InvalidArgumentException;
use RuntimeException;

final class PhpVersionsParser
{
/**
* Parses PHP versions from a composer.json file.
*
* @param string $composerJsonPath Path to composer.json
* @return array<string> 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<string> 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<string> $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];
}
}
147 changes: 147 additions & 0 deletions tests/Json/PhpVersionsParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php declare(strict_types=1);

namespace Zalas\Toolbox\Tests\Json;

use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Zalas\Toolbox\Json\PhpVersionsParser;

class PhpVersionsParserTest extends TestCase
{
public function test_it_parses_tilde_constraints()
{
$versions = PhpVersionsParser::fromConstraint('~8.2.0 || ~8.3.0 || ~8.4.0');

$this->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([]);
}
}
Loading