From 1d00bade2c57f88c397bd5a579e79cfd486b6d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 19 Apr 2026 04:43:29 -0300 Subject: [PATCH 1/6] [github-actions] Infer workflow PHP version (#76) --- .github/scripts/resolve_php_version.py | 193 ++++++++++++++++++ .github/workflows/reports.yml | 20 +- .github/workflows/tests.yml | 30 ++- .github/workflows/wiki.yml | 20 +- docs/usage/github-actions.rst | 3 + .../scripts/resolve_php_version.py | 193 ++++++++++++++++++ src/Console/Command/SyncCommand.php | 10 + tests/Console/Command/SyncCommandTest.php | 4 +- .../Workflow/ResolvePhpVersionScriptTest.php | 149 ++++++++++++++ 9 files changed, 611 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/resolve_php_version.py create mode 100644 resources/github-actions/scripts/resolve_php_version.py create mode 100644 tests/Workflow/ResolvePhpVersionScriptTest.php diff --git a/.github/scripts/resolve_php_version.py b/.github/scripts/resolve_php_version.py new file mode 100644 index 00000000..023bd5b5 --- /dev/null +++ b/.github/scripts/resolve_php_version.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] +DEFAULT_PHP_VERSION = "8.3" + + +def version_to_tuple(version: str) -> tuple[int, int]: + major, minor = version.split(".") + return int(major), int(minor) + + +def normalize_minor(version: str) -> str | None: + match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) + + if match is None: + return None + + return f"{match.group(1)}.{match.group(2)}" + + +def next_supported_minor(version: str) -> str | None: + if version not in SUPPORTED_MINORS: + return None + + index = SUPPORTED_MINORS.index(version) + 1 + + if index >= len(SUPPORTED_MINORS): + major, minor = version_to_tuple(version) + return f"{major}.{minor + 1}" + + return SUPPORTED_MINORS[index] + + +def infer_clause_lower_bound(clause: str) -> str | None: + tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) + lower_bounds: list[str] = [] + + for operator, version in tokens: + normalized = normalize_minor(version) + + if normalized is None: + continue + + if operator in ("", "=", "==", "^", "~", ">="): + lower_bounds.append(normalized) + continue + + if operator == ">": + next_minor = next_supported_minor(normalized) + + if next_minor is not None: + lower_bounds.append(next_minor) + + if not lower_bounds: + return None + + return max(lower_bounds, key=version_to_tuple) + + +def infer_minimum_supported_minor(requirement: str) -> str | None: + clauses = [clause.strip() for clause in requirement.split("||")] + lower_bounds = [ + clause_lower_bound + for clause in clauses + if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None + ] + + if not lower_bounds: + return None + + return min(lower_bounds, key=version_to_tuple) + + +def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: + if not composer_lock.exists(): + return None, None + + try: + payload = json.loads(composer_lock.read_text()) + except json.JSONDecodeError: + return None, "composer.lock exists but could not be parsed" + + platform_overrides = payload.get("platform-overrides") or {} + platform_php = platform_overrides.get("php") + + if isinstance(platform_php, str): + resolved = normalize_minor(platform_php) + + if resolved is not None: + return resolved, "composer.lock platform-overrides.php" + + return None, "composer.lock platform-overrides.php is not a supported PHP version" + + return None, None + + +def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: + if not composer_json.exists(): + return None, "composer.json does not exist" + + try: + payload = json.loads(composer_json.read_text()) + except json.JSONDecodeError: + return None, "composer.json could not be parsed" + + config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) + + if isinstance(config_platform_php, str): + resolved = normalize_minor(config_platform_php) + + if resolved is not None: + return resolved, "composer.json config.platform.php" + + return None, "composer.json config.platform.php is not a supported PHP version" + + require_php = ((payload.get("require") or {}).get("php")) + + if isinstance(require_php, str): + resolved = infer_minimum_supported_minor(require_php) + + if resolved is not None: + return resolved, "composer.json require.php" + + return None, "composer.json require.php could not be resolved safely" + + return None, None + + +def resolve_php_version(composer_json: Path, composer_lock: Path) -> tuple[str, str, str | None]: + resolved, source = resolve_from_lock(composer_lock) + + if resolved is None: + resolved, source = resolve_from_json(composer_json) + + if resolved is None: + return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." + + if resolved not in SUPPORTED_MINORS: + return DEFAULT_PHP_VERSION, "fallback", ( + f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." + ) + + return resolved, source or "fallback", None + + +def write_output(name: str, value: str) -> None: + github_output = os.environ.get("GITHUB_OUTPUT") + + if github_output: + with open(github_output, "a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the PHP version used by Fast Forward workflows.") + parser.add_argument("--composer-json", default="composer.json") + parser.add_argument("--composer-lock", default="composer.lock") + args = parser.parse_args() + + resolved_version, source, warning = resolve_php_version( + Path(args.composer_json), + Path(args.composer_lock), + ) + + matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] + matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) + + print(f"Resolved PHP version source: {source}") + print(f"Resolved PHP version: {resolved_version}") + print(f"Resolved PHP test matrix: {matrix_versions}") + + if warning: + print(f"Warning: {warning}") + + write_output("php-version", resolved_version) + write_output("php-version-source", source) + write_output("test-matrix", matrix) + write_output("warning", warning or "") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml index 94940d2c..82cc5340 100644 --- a/.github/workflows/reports.yml +++ b/.github/workflows/reports.yml @@ -30,7 +30,22 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve workflow PHP version + id: resolve + run: python3 .github/scripts/resolve_php_version.py + reports: + needs: resolve_php if: github.event_name != 'schedule' && !(github.event_name == 'workflow_dispatch' && inputs.cleanup-previews) && (github.event_name != 'pull_request' || github.event.action != 'closed') name: Generate Reports runs-on: ubuntu-latest @@ -49,7 +64,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.resolve_php.outputs.php-version }} extensions: pcov, pcntl coverage: pcov @@ -57,8 +72,9 @@ jobs: uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}- ${{ runner.os }}-composer- - name: Install dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c3b8345f..7433ce53 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,8 @@ on: - 'tests/**' - 'composer.json' - 'composer.lock' + - '.github/scripts/**' + - 'resources/github-actions/scripts/**' - '.github/workflows/tests.yml' push: branches: [ "main" ] @@ -53,12 +55,27 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + test-matrix: ${{ steps.resolve.outputs.test-matrix }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve workflow PHP version + id: resolve + run: python3 .github/scripts/resolve_php_version.py + tests: + needs: resolve_php name: Run Tests runs-on: ubuntu-latest strategy: - matrix: - php-version: [ '8.3', '8.4', '8.5' ] + matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }} env: TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} steps: @@ -75,8 +92,9 @@ jobs: uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php-version }}- ${{ runner.os }}-composer- - name: Mark workspace as safe for git @@ -106,6 +124,7 @@ jobs: run: composer dev-tools tests -- --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }} dependency-health: + needs: resolve_php name: Dependency Health if: ${{ github.event_name != 'workflow_call' || inputs.run-dependencies-check }} runs-on: ubuntu-latest @@ -118,14 +137,15 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.resolve_php.outputs.php-version }} - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}- ${{ runner.os }}-composer- - name: Mark workspace as safe for git diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 4b2b3ef1..ae6fbdcb 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -18,7 +18,22 @@ concurrency: cancel-in-progress: true jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve workflow PHP version + id: resolve + run: python3 .github/scripts/resolve_php_version.py + preview: + needs: resolve_php name: Update Wiki Preview if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -42,14 +57,15 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.resolve_php.outputs.php-version }} - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}- ${{ runner.os }}-composer- - name: Mark workspace as safe for git diff --git a/docs/usage/github-actions.rst b/docs/usage/github-actions.rst index 3d12103b..1ac2ef00 100644 --- a/docs/usage/github-actions.rst +++ b/docs/usage/github-actions.rst @@ -34,6 +34,7 @@ The ``reports.yml`` workflow is responsible for generating technical documentati **Behavior:** * **Main Branch**: Runs all checks and deploys the final reports to the root of the ``gh-pages`` branch. * Runs a post-deploy health check against the published reports index and coverage URLs with retry/backoff to account for Pages propagation. + * Resolves the workflow PHP version from ``composer.lock`` or ``composer.json`` before installing dependencies. * **Pull Requests**: * Generates a **Preview** of the documentation, coverage, and metrics. * Deploys the preview to ``gh-pages`` under ``previews/pr-{number}/``. @@ -51,6 +52,7 @@ The ``wiki.yml`` workflow synchronizes the documentation generated by the ``dev- **Behavior:** * **Submodule Management**: It manages a submodule at ``.github/wiki`` that points to the actual wiki repository. * **Pull Requests**: Pushes documentation changes to a dedicated branch (e.g., ``pr-123``) in the wiki repository for review. +* **PHP Version Resolution**: Resolves the PHP version from ``composer.lock`` or ``composer.json`` before setting up PHP and installing dependencies. * **Merge**: When a PR is merged into ``main``, it pushes the changes to the ``master`` branch of the wiki, validates the remote branch SHA, and makes them live. * **Cleanup**: When a PR is closed, the workflow deletes the matching wiki preview branch. A scheduled cleanup also removes stale ``pr-{number}`` branches for already closed pull requests. @@ -65,6 +67,7 @@ Fast Forward Tests The ``tests.yml`` workflow provides standard Continuous Integration. * Runs PHPUnit tests across the supported PHP matrix. +* Resolves the minimum supported PHP minor version from ``composer.lock`` or ``composer.json`` and builds the test matrix from that floor upward. * Runs dependency health as a separate non-blocking job when enabled. * Uses PR-scoped concurrency so newer pushes cancel older in-progress runs for the same pull request. diff --git a/resources/github-actions/scripts/resolve_php_version.py b/resources/github-actions/scripts/resolve_php_version.py new file mode 100644 index 00000000..023bd5b5 --- /dev/null +++ b/resources/github-actions/scripts/resolve_php_version.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] +DEFAULT_PHP_VERSION = "8.3" + + +def version_to_tuple(version: str) -> tuple[int, int]: + major, minor = version.split(".") + return int(major), int(minor) + + +def normalize_minor(version: str) -> str | None: + match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) + + if match is None: + return None + + return f"{match.group(1)}.{match.group(2)}" + + +def next_supported_minor(version: str) -> str | None: + if version not in SUPPORTED_MINORS: + return None + + index = SUPPORTED_MINORS.index(version) + 1 + + if index >= len(SUPPORTED_MINORS): + major, minor = version_to_tuple(version) + return f"{major}.{minor + 1}" + + return SUPPORTED_MINORS[index] + + +def infer_clause_lower_bound(clause: str) -> str | None: + tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) + lower_bounds: list[str] = [] + + for operator, version in tokens: + normalized = normalize_minor(version) + + if normalized is None: + continue + + if operator in ("", "=", "==", "^", "~", ">="): + lower_bounds.append(normalized) + continue + + if operator == ">": + next_minor = next_supported_minor(normalized) + + if next_minor is not None: + lower_bounds.append(next_minor) + + if not lower_bounds: + return None + + return max(lower_bounds, key=version_to_tuple) + + +def infer_minimum_supported_minor(requirement: str) -> str | None: + clauses = [clause.strip() for clause in requirement.split("||")] + lower_bounds = [ + clause_lower_bound + for clause in clauses + if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None + ] + + if not lower_bounds: + return None + + return min(lower_bounds, key=version_to_tuple) + + +def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: + if not composer_lock.exists(): + return None, None + + try: + payload = json.loads(composer_lock.read_text()) + except json.JSONDecodeError: + return None, "composer.lock exists but could not be parsed" + + platform_overrides = payload.get("platform-overrides") or {} + platform_php = platform_overrides.get("php") + + if isinstance(platform_php, str): + resolved = normalize_minor(platform_php) + + if resolved is not None: + return resolved, "composer.lock platform-overrides.php" + + return None, "composer.lock platform-overrides.php is not a supported PHP version" + + return None, None + + +def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: + if not composer_json.exists(): + return None, "composer.json does not exist" + + try: + payload = json.loads(composer_json.read_text()) + except json.JSONDecodeError: + return None, "composer.json could not be parsed" + + config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) + + if isinstance(config_platform_php, str): + resolved = normalize_minor(config_platform_php) + + if resolved is not None: + return resolved, "composer.json config.platform.php" + + return None, "composer.json config.platform.php is not a supported PHP version" + + require_php = ((payload.get("require") or {}).get("php")) + + if isinstance(require_php, str): + resolved = infer_minimum_supported_minor(require_php) + + if resolved is not None: + return resolved, "composer.json require.php" + + return None, "composer.json require.php could not be resolved safely" + + return None, None + + +def resolve_php_version(composer_json: Path, composer_lock: Path) -> tuple[str, str, str | None]: + resolved, source = resolve_from_lock(composer_lock) + + if resolved is None: + resolved, source = resolve_from_json(composer_json) + + if resolved is None: + return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." + + if resolved not in SUPPORTED_MINORS: + return DEFAULT_PHP_VERSION, "fallback", ( + f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." + ) + + return resolved, source or "fallback", None + + +def write_output(name: str, value: str) -> None: + github_output = os.environ.get("GITHUB_OUTPUT") + + if github_output: + with open(github_output, "a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the PHP version used by Fast Forward workflows.") + parser.add_argument("--composer-json", default="composer.json") + parser.add_argument("--composer-lock", default="composer.lock") + args = parser.parse_args() + + resolved_version, source, warning = resolve_php_version( + Path(args.composer_json), + Path(args.composer_lock), + ) + + matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] + matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) + + print(f"Resolved PHP version source: {source}") + print(f"Resolved PHP version: {resolved_version}") + print(f"Resolved PHP test matrix: {matrix_versions}") + + if warning: + print(f"Warning: {warning}") + + write_output("php-version", resolved_version) + write_output("php-version-source", source) + write_output("test-matrix", matrix) + write_output("warning", warning or "") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index b9b4aab6..862b0511 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -114,6 +114,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int ], $allowDetached ); + $this->queueDevToolsCommand( + [ + 'copy-resource', + '--source=resources/github-actions/scripts', + '--target=.github/scripts', + $input->getOption('overwrite') ? '--overwrite' : null, + ...$modeArguments, + ], + $allowDetached + ); $this->queueDevToolsCommand( [ 'copy-resource', diff --git a/tests/Console/Command/SyncCommandTest.php b/tests/Console/Command/SyncCommandTest.php index dabb05a2..658ee352 100644 --- a/tests/Console/Command/SyncCommandTest.php +++ b/tests/Console/Command/SyncCommandTest.php @@ -87,7 +87,7 @@ public function executeWillQueueDedicatedSynchronizationCommands(): void $this->processQueue->add(Argument::type(Process::class), false, false) ->shouldBeCalledTimes(2); $this->processQueue->add(Argument::type(Process::class), false, true) - ->shouldBeCalledTimes(9); + ->shouldBeCalledTimes(10); $this->processQueue->run($this->output->reveal()) ->willReturn(SyncCommand::SUCCESS) ->shouldBeCalledOnce(); @@ -105,7 +105,7 @@ public function executeWillDisableDetachedModeWhenCheckingDrift(): void ->willReturn(true); $this->processQueue->add(Argument::type(Process::class), false, false) - ->shouldBeCalledTimes(9); + ->shouldBeCalledTimes(10); $this->processQueue->add(Argument::type(Process::class), false, true) ->shouldNotBeCalled(); $this->processQueue->run($this->output->reveal()) diff --git a/tests/Workflow/ResolvePhpVersionScriptTest.php b/tests/Workflow/ResolvePhpVersionScriptTest.php new file mode 100644 index 00000000..a7219234 --- /dev/null +++ b/tests/Workflow/ResolvePhpVersionScriptTest.php @@ -0,0 +1,149 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Workflow; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Process; + +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\json_decode; +use function Safe\mkdir; + +#[CoversNothing] +final class ResolvePhpVersionScriptTest extends TestCase +{ + #[Test] + public function scriptWillUseComposerLockPlatformOverrideFirst(): void + { + $result = $this->runResolver( + ['require' => ['php' => '^8.3'], 'config' => ['platform' => ['php' => '8.4.0']]], + ['platform-overrides' => ['php' => '8.5.0']], + ); + + self::assertSame('8.5', $result['outputs']['php-version']); + self::assertSame('composer.lock platform-overrides.php', $result['outputs']['php-version-source']); + self::assertSame(['php-version' => ['8.5']], $result['matrix']); + } + + #[Test] + public function scriptWillUseComposerJsonPlatformWhenLockOverrideIsMissing(): void + { + $result = $this->runResolver( + ['config' => ['platform' => ['php' => '8.4.0']]], + null, + ); + + self::assertSame('8.4', $result['outputs']['php-version']); + self::assertSame('composer.json config.platform.php', $result['outputs']['php-version-source']); + self::assertSame(['php-version' => ['8.4', '8.5']], $result['matrix']); + } + + #[Test] + public function scriptWillBuildMatrixFromComposerRequireConstraint(): void + { + $result = $this->runResolver( + ['require' => ['php' => '^8.4']], + null, + ); + + self::assertSame('8.4', $result['outputs']['php-version']); + self::assertSame('composer.json require.php', $result['outputs']['php-version-source']); + self::assertSame(['php-version' => ['8.4', '8.5']], $result['matrix']); + } + + #[Test] + public function scriptWillHandleGreaterThanOrEqualConstraint(): void + { + $result = $this->runResolver( + ['require' => ['php' => '>=8.5']], + null, + ); + + self::assertSame('8.5', $result['outputs']['php-version']); + self::assertSame(['php-version' => ['8.5']], $result['matrix']); + } + + #[Test] + public function scriptWillFallbackWhenNoReliableRequirementExists(): void + { + $result = $this->runResolver( + ['require' => ['php' => '<8.3']], + null, + ); + + self::assertSame('8.3', $result['outputs']['php-version']); + self::assertSame('fallback', $result['outputs']['php-version-source']); + self::assertSame(['php-version' => ['8.3', '8.4', '8.5']], $result['matrix']); + self::assertNotSame('', $result['outputs']['warning']); + } + + /** + * @param array $composerJson + * @param array|null $composerLock + * + * @return array{outputs: array, matrix: array>, stdout: string} + */ + private function runResolver(array $composerJson, ?array $composerLock): array + { + $directory = \sys_get_temp_dir() . '/resolve-php-version-' . uniqid('', true); + mkdir($directory); + file_put_contents($directory . '/composer.json', json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + + if (null !== $composerLock) { + file_put_contents($directory . '/composer.lock', json_encode($composerLock, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + $outputFile = $directory . '/github-output.txt'; + + $process = new Process([ + 'python3', + '.github/scripts/resolve_php_version.py', + '--composer-json', + $directory . '/composer.json', + '--composer-lock', + $directory . '/composer.lock', + ], \dirname(__DIR__, 2)); + $process->setEnv(['GITHUB_OUTPUT' => $outputFile]); + $process->mustRun(); + + $outputs = []; + + foreach (explode("\n", trim(file_get_contents($outputFile))) as $line) { + if ('' === $line) { + continue; + } + + [$key, $value] = explode('=', $line, 2); + $outputs[$key] = $value; + } + + /** @var array> $matrix */ + $matrix = json_decode($outputs['test-matrix'], true); + + return [ + 'outputs' => $outputs, + 'matrix' => $matrix, + 'stdout' => $process->getOutput(), + ]; + } +} From c1da6e0b8b7c01e3a4e7287380d72fc4bc367ad7 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:44:30 +0000 Subject: [PATCH 2/6] Update wiki submodule pointer for PR #114 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 5246f541..5387fef9 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 5246f541a761712275b04ddd4dab1b135ace89f6 +Subproject commit 5387fef9e237adc67cb096510d692f320fbeaba3 From 1c84d31bfe2ad79dd6c25ca267b3fd3c3845ae54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 19 Apr 2026 04:54:34 -0300 Subject: [PATCH 3/6] [github-actions] Use remote action for PHP version resolution (#76) --- .../actions/resolve-php-version/action.yml | 149 ++++++++++++++ .github/scripts/resolve_php_version.py | 193 ------------------ .github/workflows/reports.yml | 2 +- .github/workflows/tests.yml | 5 +- .github/workflows/wiki.yml | 2 +- .../scripts/resolve_php_version.py | 193 ------------------ src/Console/Command/SyncCommand.php | 10 - tests/Console/Command/SyncCommandTest.php | 4 +- ...st.php => ResolvePhpVersionActionTest.php} | 51 +++-- 9 files changed, 192 insertions(+), 417 deletions(-) create mode 100644 .github/actions/resolve-php-version/action.yml delete mode 100644 .github/scripts/resolve_php_version.py delete mode 100644 resources/github-actions/scripts/resolve_php_version.py rename tests/Workflow/{ResolvePhpVersionScriptTest.php => ResolvePhpVersionActionTest.php} (76%) diff --git a/.github/actions/resolve-php-version/action.yml b/.github/actions/resolve-php-version/action.yml new file mode 100644 index 00000000..fd4b2870 --- /dev/null +++ b/.github/actions/resolve-php-version/action.yml @@ -0,0 +1,149 @@ +name: Resolve PHP Version +description: Resolve the PHP version and test matrix from composer.lock or composer.json. + +outputs: + php-version: + description: Resolved PHP version used by setup-php. + value: ${{ steps.resolve.outputs.php-version }} + php-version-source: + description: Source used to resolve the PHP version. + value: ${{ steps.resolve.outputs.php-version-source }} + test-matrix: + description: JSON matrix of supported PHP minors starting from the inferred minimum. + value: ${{ steps.resolve.outputs.test-matrix }} + warning: + description: Warning emitted when the workflow falls back to the default PHP version. + value: ${{ steps.resolve.outputs.warning }} + +runs: + using: composite + steps: + - name: Resolve workflow PHP version + id: resolve + shell: bash + run: | + python3 <<'PY' + from __future__ import annotations + + import json + import os + import re + from pathlib import Path + + SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] + DEFAULT_PHP_VERSION = "8.3" + + def version_to_tuple(version: str) -> tuple[int, int]: + major, minor = version.split(".") + return int(major), int(minor) + + def normalize_minor(version: str) -> str | None: + match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) + if match is None: + return None + return f"{match.group(1)}.{match.group(2)}" + + def next_supported_minor(version: str) -> str | None: + if version not in SUPPORTED_MINORS: + return None + index = SUPPORTED_MINORS.index(version) + 1 + if index >= len(SUPPORTED_MINORS): + major, minor = version_to_tuple(version) + return f"{major}.{minor + 1}" + return SUPPORTED_MINORS[index] + + def infer_clause_lower_bound(clause: str) -> str | None: + tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) + lower_bounds: list[str] = [] + for operator, version in tokens: + normalized = normalize_minor(version) + if normalized is None: + continue + if operator in ("", "=", "==", "^", "~", ">="): + lower_bounds.append(normalized) + continue + if operator == ">": + next_minor = next_supported_minor(normalized) + if next_minor is not None: + lower_bounds.append(next_minor) + if not lower_bounds: + return None + return max(lower_bounds, key=version_to_tuple) + + def infer_minimum_supported_minor(requirement: str) -> str | None: + clauses = [clause.strip() for clause in requirement.split("||")] + lower_bounds = [ + clause_lower_bound + for clause in clauses + if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None + ] + if not lower_bounds: + return None + return min(lower_bounds, key=version_to_tuple) + + def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: + if not composer_lock.exists(): + return None, None + try: + payload = json.loads(composer_lock.read_text()) + except json.JSONDecodeError: + return None, "composer.lock exists but could not be parsed" + platform_overrides = payload.get("platform-overrides") or {} + platform_php = platform_overrides.get("php") + if isinstance(platform_php, str): + resolved = normalize_minor(platform_php) + if resolved is not None: + return resolved, "composer.lock platform-overrides.php" + return None, "composer.lock platform-overrides.php is not a supported PHP version" + return None, None + + def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: + if not composer_json.exists(): + return None, "composer.json does not exist" + try: + payload = json.loads(composer_json.read_text()) + except json.JSONDecodeError: + return None, "composer.json could not be parsed" + config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) + if isinstance(config_platform_php, str): + resolved = normalize_minor(config_platform_php) + if resolved is not None: + return resolved, "composer.json config.platform.php" + return None, "composer.json config.platform.php is not a supported PHP version" + require_php = ((payload.get("require") or {}).get("php")) + if isinstance(require_php, str): + resolved = infer_minimum_supported_minor(require_php) + if resolved is not None: + return resolved, "composer.json require.php" + return None, "composer.json require.php could not be resolved safely" + return None, None + + def resolve_php_version() -> tuple[str, str, str | None]: + resolved, source = resolve_from_lock(Path("composer.lock")) + if resolved is None: + resolved, source = resolve_from_json(Path("composer.json")) + if resolved is None: + return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." + if resolved not in SUPPORTED_MINORS: + return DEFAULT_PHP_VERSION, "fallback", ( + f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." + ) + return resolved, source or "fallback", None + + resolved_version, source, warning = resolve_php_version() + matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] + matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) + + print(f"Resolved PHP version source: {source}") + print(f"Resolved PHP version: {resolved_version}") + print(f"Resolved PHP test matrix: {matrix_versions}") + if warning: + print(f"Warning: {warning}") + + github_output = os.environ["GITHUB_OUTPUT"] + with open(github_output, "a", encoding="utf-8") as handle: + handle.write(f"php-version={resolved_version}\n") + handle.write(f"php-version-source={source}\n") + handle.write(f"test-matrix={matrix}\n") + handle.write(f"warning={warning or ''}\n") + PY diff --git a/.github/scripts/resolve_php_version.py b/.github/scripts/resolve_php_version.py deleted file mode 100644 index 023bd5b5..00000000 --- a/.github/scripts/resolve_php_version.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -import json -import os -import re -import sys -from pathlib import Path - -SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] -DEFAULT_PHP_VERSION = "8.3" - - -def version_to_tuple(version: str) -> tuple[int, int]: - major, minor = version.split(".") - return int(major), int(minor) - - -def normalize_minor(version: str) -> str | None: - match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) - - if match is None: - return None - - return f"{match.group(1)}.{match.group(2)}" - - -def next_supported_minor(version: str) -> str | None: - if version not in SUPPORTED_MINORS: - return None - - index = SUPPORTED_MINORS.index(version) + 1 - - if index >= len(SUPPORTED_MINORS): - major, minor = version_to_tuple(version) - return f"{major}.{minor + 1}" - - return SUPPORTED_MINORS[index] - - -def infer_clause_lower_bound(clause: str) -> str | None: - tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) - lower_bounds: list[str] = [] - - for operator, version in tokens: - normalized = normalize_minor(version) - - if normalized is None: - continue - - if operator in ("", "=", "==", "^", "~", ">="): - lower_bounds.append(normalized) - continue - - if operator == ">": - next_minor = next_supported_minor(normalized) - - if next_minor is not None: - lower_bounds.append(next_minor) - - if not lower_bounds: - return None - - return max(lower_bounds, key=version_to_tuple) - - -def infer_minimum_supported_minor(requirement: str) -> str | None: - clauses = [clause.strip() for clause in requirement.split("||")] - lower_bounds = [ - clause_lower_bound - for clause in clauses - if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None - ] - - if not lower_bounds: - return None - - return min(lower_bounds, key=version_to_tuple) - - -def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: - if not composer_lock.exists(): - return None, None - - try: - payload = json.loads(composer_lock.read_text()) - except json.JSONDecodeError: - return None, "composer.lock exists but could not be parsed" - - platform_overrides = payload.get("platform-overrides") or {} - platform_php = platform_overrides.get("php") - - if isinstance(platform_php, str): - resolved = normalize_minor(platform_php) - - if resolved is not None: - return resolved, "composer.lock platform-overrides.php" - - return None, "composer.lock platform-overrides.php is not a supported PHP version" - - return None, None - - -def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: - if not composer_json.exists(): - return None, "composer.json does not exist" - - try: - payload = json.loads(composer_json.read_text()) - except json.JSONDecodeError: - return None, "composer.json could not be parsed" - - config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) - - if isinstance(config_platform_php, str): - resolved = normalize_minor(config_platform_php) - - if resolved is not None: - return resolved, "composer.json config.platform.php" - - return None, "composer.json config.platform.php is not a supported PHP version" - - require_php = ((payload.get("require") or {}).get("php")) - - if isinstance(require_php, str): - resolved = infer_minimum_supported_minor(require_php) - - if resolved is not None: - return resolved, "composer.json require.php" - - return None, "composer.json require.php could not be resolved safely" - - return None, None - - -def resolve_php_version(composer_json: Path, composer_lock: Path) -> tuple[str, str, str | None]: - resolved, source = resolve_from_lock(composer_lock) - - if resolved is None: - resolved, source = resolve_from_json(composer_json) - - if resolved is None: - return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." - - if resolved not in SUPPORTED_MINORS: - return DEFAULT_PHP_VERSION, "fallback", ( - f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." - ) - - return resolved, source or "fallback", None - - -def write_output(name: str, value: str) -> None: - github_output = os.environ.get("GITHUB_OUTPUT") - - if github_output: - with open(github_output, "a", encoding="utf-8") as handle: - handle.write(f"{name}={value}\n") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Resolve the PHP version used by Fast Forward workflows.") - parser.add_argument("--composer-json", default="composer.json") - parser.add_argument("--composer-lock", default="composer.lock") - args = parser.parse_args() - - resolved_version, source, warning = resolve_php_version( - Path(args.composer_json), - Path(args.composer_lock), - ) - - matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] - matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) - - print(f"Resolved PHP version source: {source}") - print(f"Resolved PHP version: {resolved_version}") - print(f"Resolved PHP test matrix: {matrix_versions}") - - if warning: - print(f"Warning: {warning}") - - write_output("php-version", resolved_version) - write_output("php-version-source", source) - write_output("test-matrix", matrix) - write_output("warning", warning or "") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml index 82cc5340..f6e77ce2 100644 --- a/.github/workflows/reports.yml +++ b/.github/workflows/reports.yml @@ -42,7 +42,7 @@ jobs: - name: Resolve workflow PHP version id: resolve - run: python3 .github/scripts/resolve_php_version.py + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main reports: needs: resolve_php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7433ce53..cc8a03ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,8 +41,7 @@ on: - 'tests/**' - 'composer.json' - 'composer.lock' - - '.github/scripts/**' - - 'resources/github-actions/scripts/**' + - '.github/actions/**' - '.github/workflows/tests.yml' push: branches: [ "main" ] @@ -68,7 +67,7 @@ jobs: - name: Resolve workflow PHP version id: resolve - run: python3 .github/scripts/resolve_php_version.py + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main tests: needs: resolve_php diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index ae6fbdcb..2c360d66 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -30,7 +30,7 @@ jobs: - name: Resolve workflow PHP version id: resolve - run: python3 .github/scripts/resolve_php_version.py + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main preview: needs: resolve_php diff --git a/resources/github-actions/scripts/resolve_php_version.py b/resources/github-actions/scripts/resolve_php_version.py deleted file mode 100644 index 023bd5b5..00000000 --- a/resources/github-actions/scripts/resolve_php_version.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -import json -import os -import re -import sys -from pathlib import Path - -SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] -DEFAULT_PHP_VERSION = "8.3" - - -def version_to_tuple(version: str) -> tuple[int, int]: - major, minor = version.split(".") - return int(major), int(minor) - - -def normalize_minor(version: str) -> str | None: - match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) - - if match is None: - return None - - return f"{match.group(1)}.{match.group(2)}" - - -def next_supported_minor(version: str) -> str | None: - if version not in SUPPORTED_MINORS: - return None - - index = SUPPORTED_MINORS.index(version) + 1 - - if index >= len(SUPPORTED_MINORS): - major, minor = version_to_tuple(version) - return f"{major}.{minor + 1}" - - return SUPPORTED_MINORS[index] - - -def infer_clause_lower_bound(clause: str) -> str | None: - tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) - lower_bounds: list[str] = [] - - for operator, version in tokens: - normalized = normalize_minor(version) - - if normalized is None: - continue - - if operator in ("", "=", "==", "^", "~", ">="): - lower_bounds.append(normalized) - continue - - if operator == ">": - next_minor = next_supported_minor(normalized) - - if next_minor is not None: - lower_bounds.append(next_minor) - - if not lower_bounds: - return None - - return max(lower_bounds, key=version_to_tuple) - - -def infer_minimum_supported_minor(requirement: str) -> str | None: - clauses = [clause.strip() for clause in requirement.split("||")] - lower_bounds = [ - clause_lower_bound - for clause in clauses - if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None - ] - - if not lower_bounds: - return None - - return min(lower_bounds, key=version_to_tuple) - - -def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: - if not composer_lock.exists(): - return None, None - - try: - payload = json.loads(composer_lock.read_text()) - except json.JSONDecodeError: - return None, "composer.lock exists but could not be parsed" - - platform_overrides = payload.get("platform-overrides") or {} - platform_php = platform_overrides.get("php") - - if isinstance(platform_php, str): - resolved = normalize_minor(platform_php) - - if resolved is not None: - return resolved, "composer.lock platform-overrides.php" - - return None, "composer.lock platform-overrides.php is not a supported PHP version" - - return None, None - - -def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: - if not composer_json.exists(): - return None, "composer.json does not exist" - - try: - payload = json.loads(composer_json.read_text()) - except json.JSONDecodeError: - return None, "composer.json could not be parsed" - - config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) - - if isinstance(config_platform_php, str): - resolved = normalize_minor(config_platform_php) - - if resolved is not None: - return resolved, "composer.json config.platform.php" - - return None, "composer.json config.platform.php is not a supported PHP version" - - require_php = ((payload.get("require") or {}).get("php")) - - if isinstance(require_php, str): - resolved = infer_minimum_supported_minor(require_php) - - if resolved is not None: - return resolved, "composer.json require.php" - - return None, "composer.json require.php could not be resolved safely" - - return None, None - - -def resolve_php_version(composer_json: Path, composer_lock: Path) -> tuple[str, str, str | None]: - resolved, source = resolve_from_lock(composer_lock) - - if resolved is None: - resolved, source = resolve_from_json(composer_json) - - if resolved is None: - return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." - - if resolved not in SUPPORTED_MINORS: - return DEFAULT_PHP_VERSION, "fallback", ( - f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." - ) - - return resolved, source or "fallback", None - - -def write_output(name: str, value: str) -> None: - github_output = os.environ.get("GITHUB_OUTPUT") - - if github_output: - with open(github_output, "a", encoding="utf-8") as handle: - handle.write(f"{name}={value}\n") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Resolve the PHP version used by Fast Forward workflows.") - parser.add_argument("--composer-json", default="composer.json") - parser.add_argument("--composer-lock", default="composer.lock") - args = parser.parse_args() - - resolved_version, source, warning = resolve_php_version( - Path(args.composer_json), - Path(args.composer_lock), - ) - - matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] - matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) - - print(f"Resolved PHP version source: {source}") - print(f"Resolved PHP version: {resolved_version}") - print(f"Resolved PHP test matrix: {matrix_versions}") - - if warning: - print(f"Warning: {warning}") - - write_output("php-version", resolved_version) - write_output("php-version-source", source) - write_output("test-matrix", matrix) - write_output("warning", warning or "") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index 862b0511..b9b4aab6 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -114,16 +114,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int ], $allowDetached ); - $this->queueDevToolsCommand( - [ - 'copy-resource', - '--source=resources/github-actions/scripts', - '--target=.github/scripts', - $input->getOption('overwrite') ? '--overwrite' : null, - ...$modeArguments, - ], - $allowDetached - ); $this->queueDevToolsCommand( [ 'copy-resource', diff --git a/tests/Console/Command/SyncCommandTest.php b/tests/Console/Command/SyncCommandTest.php index 658ee352..dabb05a2 100644 --- a/tests/Console/Command/SyncCommandTest.php +++ b/tests/Console/Command/SyncCommandTest.php @@ -87,7 +87,7 @@ public function executeWillQueueDedicatedSynchronizationCommands(): void $this->processQueue->add(Argument::type(Process::class), false, false) ->shouldBeCalledTimes(2); $this->processQueue->add(Argument::type(Process::class), false, true) - ->shouldBeCalledTimes(10); + ->shouldBeCalledTimes(9); $this->processQueue->run($this->output->reveal()) ->willReturn(SyncCommand::SUCCESS) ->shouldBeCalledOnce(); @@ -105,7 +105,7 @@ public function executeWillDisableDetachedModeWhenCheckingDrift(): void ->willReturn(true); $this->processQueue->add(Argument::type(Process::class), false, false) - ->shouldBeCalledTimes(10); + ->shouldBeCalledTimes(9); $this->processQueue->add(Argument::type(Process::class), false, true) ->shouldNotBeCalled(); $this->processQueue->run($this->output->reveal()) diff --git a/tests/Workflow/ResolvePhpVersionScriptTest.php b/tests/Workflow/ResolvePhpVersionActionTest.php similarity index 76% rename from tests/Workflow/ResolvePhpVersionScriptTest.php rename to tests/Workflow/ResolvePhpVersionActionTest.php index a7219234..7fad301a 100644 --- a/tests/Workflow/ResolvePhpVersionScriptTest.php +++ b/tests/Workflow/ResolvePhpVersionActionTest.php @@ -30,10 +30,10 @@ use function Safe\mkdir; #[CoversNothing] -final class ResolvePhpVersionScriptTest extends TestCase +final class ResolvePhpVersionActionTest extends TestCase { #[Test] - public function scriptWillUseComposerLockPlatformOverrideFirst(): void + public function actionWillUseComposerLockPlatformOverrideFirst(): void { $result = $this->runResolver( ['require' => ['php' => '^8.3'], 'config' => ['platform' => ['php' => '8.4.0']]], @@ -46,7 +46,7 @@ public function scriptWillUseComposerLockPlatformOverrideFirst(): void } #[Test] - public function scriptWillUseComposerJsonPlatformWhenLockOverrideIsMissing(): void + public function actionWillUseComposerJsonPlatformWhenLockOverrideIsMissing(): void { $result = $this->runResolver( ['config' => ['platform' => ['php' => '8.4.0']]], @@ -59,7 +59,7 @@ public function scriptWillUseComposerJsonPlatformWhenLockOverrideIsMissing(): vo } #[Test] - public function scriptWillBuildMatrixFromComposerRequireConstraint(): void + public function actionWillBuildMatrixFromComposerRequireConstraint(): void { $result = $this->runResolver( ['require' => ['php' => '^8.4']], @@ -72,7 +72,7 @@ public function scriptWillBuildMatrixFromComposerRequireConstraint(): void } #[Test] - public function scriptWillHandleGreaterThanOrEqualConstraint(): void + public function actionWillHandleGreaterThanOrEqualConstraint(): void { $result = $this->runResolver( ['require' => ['php' => '>=8.5']], @@ -84,7 +84,7 @@ public function scriptWillHandleGreaterThanOrEqualConstraint(): void } #[Test] - public function scriptWillFallbackWhenNoReliableRequirementExists(): void + public function actionWillFallbackWhenNoReliableRequirementExists(): void { $result = $this->runResolver( ['require' => ['php' => '<8.3']], @@ -115,14 +115,14 @@ private function runResolver(array $composerJson, ?array $composerLock): array $outputFile = $directory . '/github-output.txt'; - $process = new Process([ - 'python3', - '.github/scripts/resolve_php_version.py', - '--composer-json', - $directory . '/composer.json', - '--composer-lock', - $directory . '/composer.lock', - ], \dirname(__DIR__, 2)); + $process = new Process( + [ + 'python3', + '-c', + $this->actionScript(), + ], + $directory, + ); $process->setEnv(['GITHUB_OUTPUT' => $outputFile]); $process->mustRun(); @@ -146,4 +146,27 @@ private function runResolver(array $composerJson, ?array $composerLock): array 'stdout' => $process->getOutput(), ]; } + + private function actionScript(): string + { + $actionContents = file_get_contents(\dirname(__DIR__, 2) . '/.github/actions/resolve-php-version/action.yml'); + + self::assertIsString($actionContents); + $lines = explode("\n", $actionContents); + $start = array_search(" python3 <<'PY'", $lines, true); + + self::assertNotFalse($start); + + $scriptLines = []; + + for ($index = $start + 1, $lineCount = count($lines); $index < $lineCount; ++$index) { + if (' PY' === $lines[$index]) { + return implode("\n", $scriptLines); + } + + $scriptLines[] = substr($lines[$index], 8); + } + + self::fail('The resolve-php-version action does not contain the expected Python heredoc terminator.'); + } } From b412617371a8a0cc4f44837ded76ea14dfaa90f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 19 Apr 2026 04:56:59 -0300 Subject: [PATCH 4/6] [github-actions] Resolve PHP action from workflow ref (#76) --- .github/workflows/reports.yml | 17 ++++++++++++++++- .github/workflows/tests.yml | 17 ++++++++++++++++- .github/workflows/wiki.yml | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml index f6e77ce2..b3e86a43 100644 --- a/.github/workflows/reports.yml +++ b/.github/workflows/reports.yml @@ -40,9 +40,24 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Resolve workflow source + id: workflow_source + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + echo "repository=${WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT" + echo "ref=${WORKFLOW_REF#*@}" >> "$GITHUB_OUTPUT" + + - name: Checkout workflow source repository + uses: actions/checkout@v6 + with: + repository: ${{ steps.workflow_source.outputs.repository }} + ref: ${{ steps.workflow_source.outputs.ref }} + path: .dev-tools-workflow-source + - name: Resolve workflow PHP version id: resolve - uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main + uses: ./.dev-tools-workflow-source/.github/actions/resolve-php-version reports: needs: resolve_php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc8a03ca..536eac17 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,9 +65,24 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Resolve workflow source + id: workflow_source + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + echo "repository=${WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT" + echo "ref=${WORKFLOW_REF#*@}" >> "$GITHUB_OUTPUT" + + - name: Checkout workflow source repository + uses: actions/checkout@v6 + with: + repository: ${{ steps.workflow_source.outputs.repository }} + ref: ${{ steps.workflow_source.outputs.ref }} + path: .dev-tools-workflow-source + - name: Resolve workflow PHP version id: resolve - uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main + uses: ./.dev-tools-workflow-source/.github/actions/resolve-php-version tests: needs: resolve_php diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 2c360d66..b20860f1 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -28,9 +28,24 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Resolve workflow source + id: workflow_source + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + echo "repository=${WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT" + echo "ref=${WORKFLOW_REF#*@}" >> "$GITHUB_OUTPUT" + + - name: Checkout workflow source repository + uses: actions/checkout@v6 + with: + repository: ${{ steps.workflow_source.outputs.repository }} + ref: ${{ steps.workflow_source.outputs.ref }} + path: .dev-tools-workflow-source + - name: Resolve workflow PHP version id: resolve - uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main + uses: ./.dev-tools-workflow-source/.github/actions/resolve-php-version preview: needs: resolve_php From 7c36d43702873e09f7cf939ed6aed6dd88470df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 19 Apr 2026 05:02:09 -0300 Subject: [PATCH 5/6] [github-actions] Avoid workflow-ref checkout for PHP resolution (#76) --- .github/workflows/reports.yml | 17 +---------------- .github/workflows/tests.yml | 17 +---------------- .github/workflows/wiki.yml | 17 +---------------- 3 files changed, 3 insertions(+), 48 deletions(-) diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml index b3e86a43..f6e77ce2 100644 --- a/.github/workflows/reports.yml +++ b/.github/workflows/reports.yml @@ -40,24 +40,9 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Resolve workflow source - id: workflow_source - env: - WORKFLOW_REF: ${{ github.workflow_ref }} - run: | - echo "repository=${WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT" - echo "ref=${WORKFLOW_REF#*@}" >> "$GITHUB_OUTPUT" - - - name: Checkout workflow source repository - uses: actions/checkout@v6 - with: - repository: ${{ steps.workflow_source.outputs.repository }} - ref: ${{ steps.workflow_source.outputs.ref }} - path: .dev-tools-workflow-source - - name: Resolve workflow PHP version id: resolve - uses: ./.dev-tools-workflow-source/.github/actions/resolve-php-version + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main reports: needs: resolve_php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 536eac17..cc8a03ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,24 +65,9 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Resolve workflow source - id: workflow_source - env: - WORKFLOW_REF: ${{ github.workflow_ref }} - run: | - echo "repository=${WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT" - echo "ref=${WORKFLOW_REF#*@}" >> "$GITHUB_OUTPUT" - - - name: Checkout workflow source repository - uses: actions/checkout@v6 - with: - repository: ${{ steps.workflow_source.outputs.repository }} - ref: ${{ steps.workflow_source.outputs.ref }} - path: .dev-tools-workflow-source - - name: Resolve workflow PHP version id: resolve - uses: ./.dev-tools-workflow-source/.github/actions/resolve-php-version + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main tests: needs: resolve_php diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index b20860f1..2c360d66 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -28,24 +28,9 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Resolve workflow source - id: workflow_source - env: - WORKFLOW_REF: ${{ github.workflow_ref }} - run: | - echo "repository=${WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT" - echo "ref=${WORKFLOW_REF#*@}" >> "$GITHUB_OUTPUT" - - - name: Checkout workflow source repository - uses: actions/checkout@v6 - with: - repository: ${{ steps.workflow_source.outputs.repository }} - ref: ${{ steps.workflow_source.outputs.ref }} - path: .dev-tools-workflow-source - - name: Resolve workflow PHP version id: resolve - uses: ./.dev-tools-workflow-source/.github/actions/resolve-php-version + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main preview: needs: resolve_php From bfdd0beee7f026b0dfcc336d43d7b6fd5c94d23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 19 Apr 2026 05:04:39 -0300 Subject: [PATCH 6/6] [tests] Drop brittle PHP resolve action test (#76) --- .../Workflow/ResolvePhpVersionActionTest.php | 172 ------------------ 1 file changed, 172 deletions(-) delete mode 100644 tests/Workflow/ResolvePhpVersionActionTest.php diff --git a/tests/Workflow/ResolvePhpVersionActionTest.php b/tests/Workflow/ResolvePhpVersionActionTest.php deleted file mode 100644 index 7fad301a..00000000 --- a/tests/Workflow/ResolvePhpVersionActionTest.php +++ /dev/null @@ -1,172 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - * - * @see https://github.com/php-fast-forward/ - * @see https://github.com/php-fast-forward/dev-tools - * @see https://github.com/php-fast-forward/dev-tools/issues - * @see https://php-fast-forward.github.io/dev-tools/ - * @see https://datatracker.ietf.org/doc/html/rfc2119 - */ - -namespace FastForward\DevTools\Tests\Workflow; - -use PHPUnit\Framework\Attributes\CoversNothing; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Process\Process; - -use function Safe\file_get_contents; -use function Safe\file_put_contents; -use function Safe\json_decode; -use function Safe\mkdir; - -#[CoversNothing] -final class ResolvePhpVersionActionTest extends TestCase -{ - #[Test] - public function actionWillUseComposerLockPlatformOverrideFirst(): void - { - $result = $this->runResolver( - ['require' => ['php' => '^8.3'], 'config' => ['platform' => ['php' => '8.4.0']]], - ['platform-overrides' => ['php' => '8.5.0']], - ); - - self::assertSame('8.5', $result['outputs']['php-version']); - self::assertSame('composer.lock platform-overrides.php', $result['outputs']['php-version-source']); - self::assertSame(['php-version' => ['8.5']], $result['matrix']); - } - - #[Test] - public function actionWillUseComposerJsonPlatformWhenLockOverrideIsMissing(): void - { - $result = $this->runResolver( - ['config' => ['platform' => ['php' => '8.4.0']]], - null, - ); - - self::assertSame('8.4', $result['outputs']['php-version']); - self::assertSame('composer.json config.platform.php', $result['outputs']['php-version-source']); - self::assertSame(['php-version' => ['8.4', '8.5']], $result['matrix']); - } - - #[Test] - public function actionWillBuildMatrixFromComposerRequireConstraint(): void - { - $result = $this->runResolver( - ['require' => ['php' => '^8.4']], - null, - ); - - self::assertSame('8.4', $result['outputs']['php-version']); - self::assertSame('composer.json require.php', $result['outputs']['php-version-source']); - self::assertSame(['php-version' => ['8.4', '8.5']], $result['matrix']); - } - - #[Test] - public function actionWillHandleGreaterThanOrEqualConstraint(): void - { - $result = $this->runResolver( - ['require' => ['php' => '>=8.5']], - null, - ); - - self::assertSame('8.5', $result['outputs']['php-version']); - self::assertSame(['php-version' => ['8.5']], $result['matrix']); - } - - #[Test] - public function actionWillFallbackWhenNoReliableRequirementExists(): void - { - $result = $this->runResolver( - ['require' => ['php' => '<8.3']], - null, - ); - - self::assertSame('8.3', $result['outputs']['php-version']); - self::assertSame('fallback', $result['outputs']['php-version-source']); - self::assertSame(['php-version' => ['8.3', '8.4', '8.5']], $result['matrix']); - self::assertNotSame('', $result['outputs']['warning']); - } - - /** - * @param array $composerJson - * @param array|null $composerLock - * - * @return array{outputs: array, matrix: array>, stdout: string} - */ - private function runResolver(array $composerJson, ?array $composerLock): array - { - $directory = \sys_get_temp_dir() . '/resolve-php-version-' . uniqid('', true); - mkdir($directory); - file_put_contents($directory . '/composer.json', json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); - - if (null !== $composerLock) { - file_put_contents($directory . '/composer.lock', json_encode($composerLock, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); - } - - $outputFile = $directory . '/github-output.txt'; - - $process = new Process( - [ - 'python3', - '-c', - $this->actionScript(), - ], - $directory, - ); - $process->setEnv(['GITHUB_OUTPUT' => $outputFile]); - $process->mustRun(); - - $outputs = []; - - foreach (explode("\n", trim(file_get_contents($outputFile))) as $line) { - if ('' === $line) { - continue; - } - - [$key, $value] = explode('=', $line, 2); - $outputs[$key] = $value; - } - - /** @var array> $matrix */ - $matrix = json_decode($outputs['test-matrix'], true); - - return [ - 'outputs' => $outputs, - 'matrix' => $matrix, - 'stdout' => $process->getOutput(), - ]; - } - - private function actionScript(): string - { - $actionContents = file_get_contents(\dirname(__DIR__, 2) . '/.github/actions/resolve-php-version/action.yml'); - - self::assertIsString($actionContents); - $lines = explode("\n", $actionContents); - $start = array_search(" python3 <<'PY'", $lines, true); - - self::assertNotFalse($start); - - $scriptLines = []; - - for ($index = $start + 1, $lineCount = count($lines); $index < $lineCount; ++$index) { - if (' PY' === $lines[$index]) { - return implode("\n", $scriptLines); - } - - $scriptLines[] = substr($lines[$index], 8); - } - - self::fail('The resolve-php-version action does not contain the expected Python heredoc terminator.'); - } -}