From 030144339466b52b4bca154b7b3ba6f8377bd19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 29 Apr 2026 14:40:01 -0300 Subject: [PATCH 1/3] Bootstrap GitHub Actions runtime --- .docheader | 13 ++ .editorconfig | 15 +++ .gitattributes | 12 ++ .gitignore | 10 ++ .php-cs-fixer.dist.php | 67 ++++++++++ AGENTS.md | 34 +++++ CHANGELOG.md | 14 ++ LICENSE | 4 +- README.md | 41 +++++- bin/fast-forward-actions | 24 ++++ composer-dependency-analyser.php | 27 ++++ composer.json | 89 +++++++++++++ docs/index.rst | 34 +++++ phpunit.xml.dist | 14 ++ .../ChangelogResolveMergedVersionCommand.php | 118 +++++++++++++++++ src/Command/PhpDetectProjectCommand.php | 104 +++++++++++++++ src/Command/SummaryWriteCommand.php | 123 ++++++++++++++++++ src/Console/ConsoleApplicationFactory.php | 46 +++++++ src/GitHub/GitHubOutputWriter.php | 72 ++++++++++ src/GitHub/StepSummaryWriter.php | 50 +++++++ src/Project/ProjectSurface.php | 95 ++++++++++++++ src/Project/ProjectSurfaceDetector.php | 104 +++++++++++++++ ...angelogResolveMergedVersionCommandTest.php | 83 ++++++++++++ tests/Command/PhpDetectProjectCommandTest.php | 104 +++++++++++++++ tests/Command/SummaryWriteCommandTest.php | 87 +++++++++++++ 25 files changed, 1380 insertions(+), 4 deletions(-) create mode 100644 .docheader create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100755 bin/fast-forward-actions create mode 100644 composer-dependency-analyser.php create mode 100644 composer.json create mode 100644 docs/index.rst create mode 100644 phpunit.xml.dist create mode 100644 src/Command/ChangelogResolveMergedVersionCommand.php create mode 100644 src/Command/PhpDetectProjectCommand.php create mode 100644 src/Command/SummaryWriteCommand.php create mode 100644 src/Console/ConsoleApplicationFactory.php create mode 100644 src/GitHub/GitHubOutputWriter.php create mode 100644 src/GitHub/StepSummaryWriter.php create mode 100644 src/Project/ProjectSurface.php create mode 100644 src/Project/ProjectSurfaceDetector.php create mode 100644 tests/Command/ChangelogResolveMergedVersionCommandTest.php create mode 100644 tests/Command/PhpDetectProjectCommandTest.php create mode 100644 tests/Command/SummaryWriteCommandTest.php diff --git a/.docheader b/.docheader new file mode 100644 index 0000000..41772fc --- /dev/null +++ b/.docheader @@ -0,0 +1,13 @@ +/** + * Symfony Console runtime for Fast Forward shared GitHub Actions automation. + * + * This file is part of fast-forward/github-actions project. + * + * @author Felipe Sayao Lobato Abreu + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..76ebdb1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yml,yaml,json,md,rst}] +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e97a82e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto +/.github/ export-ignore +/docs/ export-ignore +/tests/ export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/composer-dependency-analyser.php export-ignore +/AGENTS.md export-ignore +/phpunit.xml.dist export-ignore +/README.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1447f6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.dev-tools/ +.idea/ +.phpunit.cache/ +.vscode/ +backup/ +tmp/ +vendor/ +*.cache +.DS_Store +composer.lock diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..abe2a90 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,67 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +require __DIR__ . '/vendor/autoload.php'; + +use FastForward\DevTools\Path\WorkingProjectPathResolver; +use PhpCsFixer\Config; +use PhpCsFixer\Finder; + +$rules = [ + 'phpdoc_indent' => true, + 'phpdoc_order' => [ + 'order' => ['param', 'return', 'throws'], + ], + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'phpdoc_to_comment' => false, + 'phpdoc_add_missing_param_annotation' => true, +]; + +$docHeader = __DIR__ . '/.docheader'; + +if (file_exists($docHeader)) { + $header = file_get_contents($docHeader); + + if (is_string($header)) { + $header = preg_replace( + ['!^/\*\*\n!', '! \*/!', '! \* ?!', '!%year%!', '!' . date('Y-Y') . '!'], + [null, null, null, date('Y'), date('Y')], + $header + ); + + $rules['header_comment'] = [ + 'header' => trim((string) $header), + 'comment_type' => 'PHPDoc', + 'location' => 'after_declare_strict', + 'separate' => 'both', + ]; + } +} + +$finder = Finder::create() + ->in([__DIR__]) + ->exclude(WorkingProjectPathResolver::TOOLING_EXCLUDED_DIRECTORIES); + +return (new Config()) + ->setRiskyAllowed(false) + ->setFinder($finder) + ->setRules($rules); diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..051468c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS - Fast Forward GitHub Actions + +This repository contains the Composer-installable command runtime used by Fast +Forward reusable GitHub Actions workflows. + +## Repository Surfaces + +- CLI entrypoint: [`bin/fast-forward-actions`](bin/fast-forward-actions) +- Console wiring: [`src/Console/`](src/Console/) +- Commands: [`src/Command/`](src/Command/) +- GitHub Actions IO helpers: [`src/GitHub/`](src/GitHub/) +- Project detection logic: [`src/Project/`](src/Project/) +- Tests: [`tests/`](tests/) +- Docs: [`docs/`](docs/) +- Release history: [`CHANGELOG.md`](CHANGELOG.md) + +## Setup And Local Workflow + +- Install dependencies with `composer install --no-scripts` while workflow + synchronization is still being externalized. +- Use `composer global require fast-forward/github-actions --no-plugins --no-scripts` + when smoke-testing the runtime as a workflow dependency. +- Run tests with `vendor/bin/phpunit`. +- Validate package metadata with `composer validate --strict`. +- Do not add `.github/workflows` in this initial package unless a task explicitly + asks for workflow publication. + +## Design Notes + +- Keep reusable workflow YAML in `php-fast-forward/.github`. +- Keep this package focused on deterministic commands callable from those + workflows. +- Prefer small services with unit tests over shell fragments embedded in workflow + YAML. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f79513 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Bootstrap the GitHub Actions console runtime with Symfony Console commands for summaries, changelog release branch parsing, and PHP project surface detection (#1) + +[unreleased]: https://github.com/php-fast-forward/github-actions/compare/HEAD diff --git a/LICENSE b/LICENSE index 52773d0..27662c1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Fast Forward Framework: Code Fast, Deploy Faster! +Copyright (c) 2026 Felipe Sayao Lobato Abreu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 0e99c4c..47d4efc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,39 @@ -# github-actions -Symfony Console runtime for Fast Forward shared GitHub Actions automation. +# Fast Forward GitHub Actions + +Symfony Console runtime for Fast Forward reusable GitHub Actions workflows. + +This package is intended to move Fast Forward-owned workflow behavior out of +composite-action scripts and into a Composer-installable, testable PHP +application. + +## Installation + +```bash +composer global require fast-forward/github-actions --no-plugins --no-scripts +``` + +During early repository work, install local dependencies without running scripts: + +```bash +composer install --no-scripts +``` + +The package depends on `fast-forward/dev-tools`, so reusable workflows can install +this runtime globally even when a consumer repository does not require DevTools +directly. The global install command keeps Composer plugins and scripts disabled +so installing the runtime does not trigger DevTools synchronization. +The DevTools Composer plugin is disabled for this repository, keeping workflow +and agent synchronization out of the first package bootstrap. + +## Usage + +```bash +fast-forward-actions list +fast-forward-actions php:detect-project --github-output +fast-forward-actions changelog:resolve-merged-version release/v0.1.0 --github-output +fast-forward-actions summary:write "## Workflow Summary" +``` + +This first version intentionally does not add synchronized workflow wrappers. +The organization `.github` repository remains responsible for reusable workflow +YAML while this package grows the command runtime used by those workflows. diff --git a/bin/fast-forward-actions b/bin/fast-forward-actions new file mode 100755 index 0000000..eae30d2 --- /dev/null +++ b/bin/fast-forward-actions @@ -0,0 +1,24 @@ +#!/usr/bin/env php + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +use FastForward\GitHubActions\Console\ConsoleApplicationFactory; + +require __DIR__ . '/../vendor/autoload.php'; + +exit(ConsoleApplicationFactory::create()->run()); diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php new file mode 100644 index 0000000..8cc5273 --- /dev/null +++ b/composer-dependency-analyser.php @@ -0,0 +1,27 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +use FastForward\DevTools\Config\ComposerDependencyAnalyserConfig; +use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; +use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; + +return ComposerDependencyAnalyserConfig::configure( + static function (Configuration $configuration): void { + $configuration->ignoreErrorsOnPackage('fast-forward/dev-tools', [ErrorType::UNUSED_DEPENDENCY]); + } +); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1f1849f --- /dev/null +++ b/composer.json @@ -0,0 +1,89 @@ +{ + "name": "fast-forward/github-actions", + "description": "Symfony Console runtime for Fast Forward shared GitHub Actions automation.", + "license": "MIT", + "type": "library", + "keywords": [ + "automation", + "cli", + "fast-forward", + "github-actions", + "symfony-console" + ], + "readme": "README.md", + "authors": [ + { + "name": "Felipe Sayao Lobato Abreu", + "email": "github@mentordosnerds.com", + "homepage": "https://github.com/coisa", + "role": "Maintainer" + } + ], + "homepage": "https://github.com/php-fast-forward/github-actions", + "support": { + "issues": "https://github.com/php-fast-forward/github-actions/issues", + "wiki": "https://github.com/php-fast-forward/github-actions/wiki", + "source": "https://github.com/php-fast-forward/github-actions", + "docs": "https://php-fast-forward.github.io/github-actions/" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/php-fast-forward" + }, + { + "type": "custom", + "url": "https://www.paypal.com/donate/?business=JLDAF45XZ8D84" + } + ], + "require": { + "php": "^8.3", + "fast-forward/dev-tools": "^1.24", + "symfony/console": "^7.3 || ^8.0", + "symfony/filesystem": "^7.3 || ^8.0", + "thecodingmachine/safe": "^3.4" + }, + "require-dev": { + "phpunit/phpunit": "^12.0 || ^13.0" + }, + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "FastForward\\GitHubActions\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "FastForward\\GitHubActions\\Tests\\": "tests/" + } + }, + "bin": [ + "bin/fast-forward-actions" + ], + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "fast-forward/dev-tools": false, + "phpdocumentor/shim": true, + "phpro/grumphp-shim": true, + "pyrech/composer-changelogs": true + }, + "platform": { + "php": "8.3.0" + }, + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + }, + "grumphp": { + "config-default-path": "vendor/fast-forward/dev-tools/grumphp.yml" + } + }, + "scripts": { + "dev-tools": "dev-tools", + "dev-tools:fix": "@dev-tools --fix", + "test": "phpunit" + } +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..448f69d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,34 @@ +Fast Forward GitHub Actions +=========================== + +``fast-forward/github-actions`` provides a Symfony Console runtime for Fast +Forward reusable GitHub Actions workflows. + +The package is intentionally small: workflows install it globally, then call +commands that replace Fast Forward-owned composite action scripts over time. + +Installation +------------ + +.. code-block:: bash + + composer global require fast-forward/github-actions --no-plugins --no-scripts + +For local development, install dependencies without running scripts while +workflow synchronization is still being separated: + +.. code-block:: bash + + composer install --no-scripts + +Initial Commands +---------------- + +.. code-block:: bash + + fast-forward-actions php:detect-project --github-output + fast-forward-actions changelog:resolve-merged-version release/v0.1.0 --github-output + fast-forward-actions summary:write "## Workflow Summary" + +The ``.github`` organization repository remains the owner of reusable workflow +YAML. This package owns the command behavior those workflows call. diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e7be158 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + tests + + + diff --git a/src/Command/ChangelogResolveMergedVersionCommand.php b/src/Command/ChangelogResolveMergedVersionCommand.php new file mode 100644 index 0000000..2f5e8fe --- /dev/null +++ b/src/Command/ChangelogResolveMergedVersionCommand.php @@ -0,0 +1,118 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Command; + +use RuntimeException; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand( + name: 'changelog:resolve-merged-version', + description: 'Derive a release version from a merged release branch name.', +)] +final class ChangelogResolveMergedVersionCommand extends Command +{ + /** + * @param GitHubOutputWriter $githubOutputWriter + */ + public function __construct( + private readonly GitHubOutputWriter $githubOutputWriter, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addArgument('head-ref', InputArgument::REQUIRED, 'Merged pull request head ref.') + ->addOption( + 'release-branch-prefix', + null, + InputOption::VALUE_REQUIRED, + 'Release branch prefix.', + 'release/v' + ) + ->addOption('github-output', null, InputOption::VALUE_NONE, 'Write the resolved version to GITHUB_OUTPUT.') + ->addOption('output-file', null, InputOption::VALUE_REQUIRED, 'Override the GitHub output file path.'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $headRef = (string) $input->getArgument('head-ref'); + $releaseBranchPrefix = (string) $input->getOption('release-branch-prefix'); + + if ('' === $releaseBranchPrefix || ! str_starts_with($headRef, $releaseBranchPrefix)) { + $io->error(\sprintf('Failed to derive the release version from "%s".', $headRef)); + + return Command::FAILURE; + } + + $version = substr($headRef, \strlen($releaseBranchPrefix)); + + if ('' === $version) { + $io->error(\sprintf('Release branch "%s" does not contain a version.', $headRef)); + + return Command::FAILURE; + } + + if ($input->getOption('github-output')) { + $this->writeGitHubOutput($input, 'value', $version); + } + + $output->writeln($version); + + return Command::SUCCESS; + } + + /** + * @param InputInterface $input + * @param string $name + * @param string $value + * + * @return void + * + * @throws RuntimeException + */ + private function writeGitHubOutput(InputInterface $input, string $name, string $value): void + { + $path = (string) ($input->getOption('output-file') ?: getenv('GITHUB_OUTPUT') ?: ''); + + if ('' === $path) { + throw new RuntimeException('GITHUB_OUTPUT is not available; pass --output-file to write command outputs.'); + } + + $this->githubOutputWriter->write($path, $name, $value); + } +} diff --git a/src/Command/PhpDetectProjectCommand.php b/src/Command/PhpDetectProjectCommand.php new file mode 100644 index 0000000..3edf947 --- /dev/null +++ b/src/Command/PhpDetectProjectCommand.php @@ -0,0 +1,104 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Command; + +use RuntimeException; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use FastForward\GitHubActions\Project\ProjectSurfaceDetector; +use JsonException; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +use function Safe\getcwd; +use function Safe\json_encode; + +#[AsCommand( + name: 'php:detect-project', + description: 'Detect Composer, PHPUnit, PHP source, test, and documentation surfaces.', +)] +final class PhpDetectProjectCommand extends Command +{ + /** + * @param ProjectSurfaceDetector $detector + * @param GitHubOutputWriter $githubOutputWriter + */ + public function __construct( + private readonly ProjectSurfaceDetector $detector, + private readonly GitHubOutputWriter $githubOutputWriter, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addOption( + 'working-dir', + null, + InputOption::VALUE_REQUIRED, + 'Repository working directory.', + getcwd() ?: '.' + ) + ->addOption('github-output', null, InputOption::VALUE_NONE, 'Write detected values to GITHUB_OUTPUT.') + ->addOption('output-file', null, InputOption::VALUE_REQUIRED, 'Override the GitHub output file path.'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @throws JsonException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $workingDirectory = (string) $input->getOption('working-dir'); + $surface = $this->detector->detect($workingDirectory); + + if ($input->getOption('github-output')) { + $this->writeGitHubOutputs($input, $surface->toGitHubOutputs()); + } + + $output->writeln(json_encode($surface->toArray(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT)); + + return Command::SUCCESS; + } + + /** + * @param array $outputs + * @param InputInterface $input + */ + private function writeGitHubOutputs(InputInterface $input, array $outputs): void + { + $path = (string) ($input->getOption('output-file') ?: getenv('GITHUB_OUTPUT') ?: ''); + + if ('' === $path) { + throw new RuntimeException('GITHUB_OUTPUT is not available; pass --output-file to write command outputs.'); + } + + foreach ($outputs as $name => $value) { + $this->githubOutputWriter->write($path, $name, $value); + } + } +} diff --git a/src/Command/SummaryWriteCommand.php b/src/Command/SummaryWriteCommand.php new file mode 100644 index 0000000..c10680b --- /dev/null +++ b/src/Command/SummaryWriteCommand.php @@ -0,0 +1,123 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Command; + +use RuntimeException; +use FastForward\GitHubActions\GitHub\StepSummaryWriter; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function Safe\file_get_contents; +use function Safe\stream_get_contents; + +#[AsCommand(name: 'summary:write', description: 'Append Markdown to the GitHub Actions step summary.',)] +final class SummaryWriteCommand extends Command +{ + /** + * @param StepSummaryWriter $summaryWriter + */ + public function __construct( + private readonly StepSummaryWriter $summaryWriter, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addArgument('markdown', InputArgument::OPTIONAL, 'Markdown content. Reads STDIN when omitted.') + ->addOption('file', null, InputOption::VALUE_REQUIRED, 'Read Markdown content from a file.') + ->addOption( + 'summary-file', + null, + InputOption::VALUE_REQUIRED, + 'Override the GitHub step summary file path.' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $summaryFile = (string) ($input->getOption('summary-file') ?: getenv('GITHUB_STEP_SUMMARY') ?: ''); + + if ('' === $summaryFile) { + $io->error('GITHUB_STEP_SUMMARY is not available; pass --summary-file to write a summary.'); + + return Command::FAILURE; + } + + $markdown = $this->resolveMarkdown($input); + + if ('' === trim($markdown)) { + $io->note('No summary content supplied.'); + + return Command::SUCCESS; + } + + $this->summaryWriter->append($summaryFile, $markdown); + $io->success(\sprintf('Summary appended to %s.', $summaryFile)); + + return Command::SUCCESS; + } + + /** + * @param InputInterface $input + * + * @return string + * + * @throws RuntimeException + */ + private function resolveMarkdown(InputInterface $input): string + { + $file = (string) ($input->getOption('file') ?: ''); + $markdown = $input->getArgument('markdown'); + + if ('' !== $file) { + $contents = file_get_contents($file); + + if (! \is_string($contents)) { + throw new RuntimeException(\sprintf('Could not read summary file "%s".', $file)); + } + + return $contents; + } + + if (\is_string($markdown)) { + return $markdown; + } + + $stdin = stream_get_contents(\STDIN); + + return \is_string($stdin) ? $stdin : ''; + } +} diff --git a/src/Console/ConsoleApplicationFactory.php b/src/Console/ConsoleApplicationFactory.php new file mode 100644 index 0000000..b8e9fc9 --- /dev/null +++ b/src/Console/ConsoleApplicationFactory.php @@ -0,0 +1,46 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Console; + +use FastForward\GitHubActions\Command\ChangelogResolveMergedVersionCommand; +use FastForward\GitHubActions\Command\PhpDetectProjectCommand; +use FastForward\GitHubActions\Command\SummaryWriteCommand; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use FastForward\GitHubActions\GitHub\StepSummaryWriter; +use FastForward\GitHubActions\Project\ProjectSurfaceDetector; +use Symfony\Component\Console\Application; + +final class ConsoleApplicationFactory +{ + /** + * @return Application + */ + public static function create(): Application + { + $application = new Application('Fast Forward GitHub Actions', '0.1.x-dev'); + + $githubOutputWriter = new GitHubOutputWriter(); + + $application->addCommand(new ChangelogResolveMergedVersionCommand($githubOutputWriter)); + $application->addCommand(new PhpDetectProjectCommand(new ProjectSurfaceDetector(), $githubOutputWriter)); + $application->addCommand(new SummaryWriteCommand(new StepSummaryWriter())); + + return $application; + } +} diff --git a/src/GitHub/GitHubOutputWriter.php b/src/GitHub/GitHubOutputWriter.php new file mode 100644 index 0000000..118c10e --- /dev/null +++ b/src/GitHub/GitHubOutputWriter.php @@ -0,0 +1,72 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\GitHub; + +use InvalidArgumentException; +use Symfony\Component\Filesystem\Filesystem; + +use function Safe\preg_match; +use function Safe\file_put_contents; + +final readonly class GitHubOutputWriter +{ + /** + * @param Filesystem $filesystem + */ + public function __construct( + private Filesystem $filesystem = new Filesystem(), + ) {} + + /** + * @param non-empty-string $name + * @param string $path + * @param string $value + */ + public function write(string $path, string $name, string $value): void + { + if (0 === preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $name)) { + throw new InvalidArgumentException(\sprintf('"%s" is not a valid GitHub Actions output name.', $name)); + } + + $directory = \dirname($path); + + if (! is_dir($directory)) { + $this->filesystem->mkdir($directory); + } + + file_put_contents($path, $this->format($name, $value), \FILE_APPEND | \LOCK_EX); + } + + /** + * @param string $name + * @param string $value + * + * @return string + */ + private function format(string $name, string $value): string + { + if (! str_contains($value, "\n")) { + return \sprintf("%s=%s\n", $name, $value); + } + + $delimiter = \sprintf('FAST_FORWARD_%s', hash('xxh128', $name . "\0" . $value)); + + return \sprintf("%s<<%s\n%s\n%s\n", $name, $delimiter, $value, $delimiter); + } +} diff --git a/src/GitHub/StepSummaryWriter.php b/src/GitHub/StepSummaryWriter.php new file mode 100644 index 0000000..59ffd20 --- /dev/null +++ b/src/GitHub/StepSummaryWriter.php @@ -0,0 +1,50 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\GitHub; + +use Symfony\Component\Filesystem\Filesystem; + +use function Safe\file_put_contents; + +final readonly class StepSummaryWriter +{ + /** + * @param Filesystem $filesystem + */ + public function __construct( + private Filesystem $filesystem = new Filesystem(), + ) {} + + /** + * @param string $path + * @param string $markdown + * + * @return void + */ + public function append(string $path, string $markdown): void + { + $directory = \dirname($path); + + if (! is_dir($directory)) { + $this->filesystem->mkdir($directory); + } + + file_put_contents($path, rtrim($markdown) . "\n", \FILE_APPEND | \LOCK_EX); + } +} diff --git a/src/Project/ProjectSurface.php b/src/Project/ProjectSurface.php new file mode 100644 index 0000000..088b0fd --- /dev/null +++ b/src/Project/ProjectSurface.php @@ -0,0 +1,95 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Project; + +final readonly class ProjectSurface +{ + /** + * @param bool $composerJson + * @param bool $docsSource + * @param bool $phpFiles + * @param bool $phpunitConfig + * @param bool $testFiles + */ + public function __construct( + public bool $composerJson, + public bool $docsSource, + public bool $phpFiles, + public bool $phpunitConfig, + public bool $testFiles, + ) {} + + /** + * @return bool + */ + public function testable(): bool + { + return $this->composerJson && $this->phpunitConfig && $this->testFiles; + } + + /** + * @return bool + */ + public function reportable(): bool + { + return $this->testable() && $this->docsSource && $this->phpFiles; + } + + /** + * @return array + */ + public function toGitHubOutputs(): array + { + return [ + 'composer-json' => $this->format($this->composerJson), + 'docs-source' => $this->format($this->docsSource), + 'php-files' => $this->format($this->phpFiles), + 'phpunit-config' => $this->format($this->phpunitConfig), + 'test-files' => $this->format($this->testFiles), + 'testable' => $this->format($this->testable()), + 'reportable' => $this->format($this->reportable()), + ]; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'composer-json' => $this->composerJson, + 'docs-source' => $this->docsSource, + 'php-files' => $this->phpFiles, + 'phpunit-config' => $this->phpunitConfig, + 'test-files' => $this->testFiles, + 'testable' => $this->testable(), + 'reportable' => $this->reportable(), + ]; + } + + /** + * @param bool $value + * + * @return string + */ + private function format(bool $value): string + { + return $value ? 'true' : 'false'; + } +} diff --git a/src/Project/ProjectSurfaceDetector.php b/src/Project/ProjectSurfaceDetector.php new file mode 100644 index 0000000..58cc1a9 --- /dev/null +++ b/src/Project/ProjectSurfaceDetector.php @@ -0,0 +1,104 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Project; + +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +final class ProjectSurfaceDetector +{ + /** + * @param non-empty-string $workingDirectory + */ + public function detect(string $workingDirectory): ProjectSurface + { + return new ProjectSurface( + composerJson: is_file($workingDirectory . '/composer.json'), + docsSource: $this->hasAnyFile($workingDirectory . '/docs'), + phpFiles: $this->hasPhpFileInAny($workingDirectory, ['app', 'bin', 'config', 'public', 'src', 'tests']), + phpunitConfig: is_file($workingDirectory . '/phpunit.xml') || is_file( + $workingDirectory . '/phpunit.xml.dist' + ), + testFiles: $this->hasPhpFile($workingDirectory . '/tests'), + ); + } + + /** + * @param list $directories + * @param string $workingDirectory + */ + private function hasPhpFileInAny(string $workingDirectory, array $directories): bool + { + foreach ($directories as $directory) { + if ($this->hasPhpFile($workingDirectory . '/' . $directory)) { + return true; + } + } + + return false; + } + + /** + * @param string $directory + * + * @return bool + */ + private function hasPhpFile(string $directory): bool + { + return $this->hasAnyFile($directory, 'php'); + } + + /** + * @param string $directory + * @param string|null $extension + * + * @return bool + */ + private function hasAnyFile(string $directory, ?string $extension = null): bool + { + if (! is_dir($directory)) { + return false; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if (! $file instanceof SplFileInfo) { + continue; + } + + if (! $file->isFile()) { + continue; + } + + if (null === $extension && '.DS_Store' !== $file->getFilename()) { + return true; + } + + if (null !== $extension && $file->getExtension() === $extension) { + return true; + } + } + + return false; + } +} diff --git a/tests/Command/ChangelogResolveMergedVersionCommandTest.php b/tests/Command/ChangelogResolveMergedVersionCommandTest.php new file mode 100644 index 0000000..dd54486 --- /dev/null +++ b/tests/Command/ChangelogResolveMergedVersionCommandTest.php @@ -0,0 +1,83 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Tests\Command; + +use FastForward\GitHubActions\Command\ChangelogResolveMergedVersionCommand; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +use function Safe\file_get_contents; +use function Safe\mkdir; + +#[CoversClass(ChangelogResolveMergedVersionCommand::class)] +#[CoversClass(GitHubOutputWriter::class)] +final class ChangelogResolveMergedVersionCommandTest extends TestCase +{ + /** + * @return void + */ + public function testItResolvesTheVersionFromAReleaseBranch(): void + { + $outputFile = $this->temporaryPath('github-output'); + $command = new ChangelogResolveMergedVersionCommand(new GitHubOutputWriter()); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + 'head-ref' => 'release/v0.1.0', + '--github-output' => true, + '--output-file' => $outputFile, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('0.1.0', $tester->getDisplay()); + self::assertSame("value=0.1.0\n", file_get_contents($outputFile)); + } + + /** + * @return void + */ + public function testItFailsWhenTheHeadRefDoesNotUseTheReleasePrefix(): void + { + $command = new ChangelogResolveMergedVersionCommand(new GitHubOutputWriter()); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + 'head-ref' => 'feature/not-a-release', + ]); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('Failed to derive the release version', $tester->getDisplay()); + } + + /** + * @param string $name + * + * @return string + */ + private function temporaryPath(string $name): string + { + $directory = sys_get_temp_dir() . '/fast-forward-github-actions-' . bin2hex(random_bytes(8)); + + mkdir($directory, 0o777, true); + + return $directory . '/' . $name; + } +} diff --git a/tests/Command/PhpDetectProjectCommandTest.php b/tests/Command/PhpDetectProjectCommandTest.php new file mode 100644 index 0000000..2be753d --- /dev/null +++ b/tests/Command/PhpDetectProjectCommandTest.php @@ -0,0 +1,104 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Tests\Command; + +use FastForward\GitHubActions\Command\PhpDetectProjectCommand; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use FastForward\GitHubActions\Project\ProjectSurface; +use FastForward\GitHubActions\Project\ProjectSurfaceDetector; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +use function Safe\mkdir; +use function Safe\file_put_contents; +use function Safe\file_get_contents; + +#[CoversClass(PhpDetectProjectCommand::class)] +#[CoversClass(GitHubOutputWriter::class)] +#[CoversClass(ProjectSurface::class)] +#[CoversClass(ProjectSurfaceDetector::class)] +final class PhpDetectProjectCommandTest extends TestCase +{ + /** + * @return void + */ + public function testItDetectsATestableAndReportableProject(): void + { + $project = $this->temporaryDirectory(); + $outputFile = $this->temporaryDirectory() . '/github-output'; + + mkdir($project . '/docs', 0o777, true); + mkdir($project . '/src', 0o777, true); + mkdir($project . '/tests', 0o777, true); + file_put_contents($project . '/composer.json', '{}'); + file_put_contents($project . '/phpunit.xml.dist', ''); + file_put_contents($project . '/docs/index.rst', 'Docs'); + file_put_contents($project . '/src/Example.php', 'execute([ + '--working-dir' => $project, + '--github-output' => true, + '--output-file' => $outputFile, + ]); + + self::assertSame(0, $exitCode); + self::assertJson($tester->getDisplay()); + self::assertStringContainsString("composer-json=true\n", file_get_contents($outputFile)); + self::assertStringContainsString("testable=true\n", file_get_contents($outputFile)); + self::assertStringContainsString("reportable=true\n", file_get_contents($outputFile)); + } + + /** + * @return void + */ + public function testItSkipsTestabilityWhenRequiredFilesAreMissing(): void + { + $project = $this->temporaryDirectory(); + file_put_contents($project . '/composer.json', '{}'); + + $command = new PhpDetectProjectCommand(new ProjectSurfaceDetector(), new GitHubOutputWriter()); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + '--working-dir' => $project, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('"composer-json": true', $tester->getDisplay()); + self::assertStringContainsString('"testable": false', $tester->getDisplay()); + self::assertStringContainsString('"reportable": false', $tester->getDisplay()); + } + + /** + * @return string + */ + private function temporaryDirectory(): string + { + $directory = sys_get_temp_dir() . '/fast-forward-github-actions-' . bin2hex(random_bytes(8)); + + mkdir($directory, 0o777, true); + + return $directory; + } +} diff --git a/tests/Command/SummaryWriteCommandTest.php b/tests/Command/SummaryWriteCommandTest.php new file mode 100644 index 0000000..8e6a306 --- /dev/null +++ b/tests/Command/SummaryWriteCommandTest.php @@ -0,0 +1,87 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Tests\Command; + +use FastForward\GitHubActions\Command\SummaryWriteCommand; +use FastForward\GitHubActions\GitHub\StepSummaryWriter; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\mkdir; + +#[CoversClass(SummaryWriteCommand::class)] +#[CoversClass(StepSummaryWriter::class)] +final class SummaryWriteCommandTest extends TestCase +{ + /** + * @return void + */ + public function testItAppendsMarkdownToTheSummaryFile(): void + { + $summaryFile = $this->temporaryPath('summary.md'); + $command = new SummaryWriteCommand(new StepSummaryWriter()); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + 'markdown' => '## Workflow Summary', + '--summary-file' => $summaryFile, + ]); + + self::assertSame(0, $exitCode); + self::assertSame("## Workflow Summary\n", file_get_contents($summaryFile)); + } + + /** + * @return void + */ + public function testItReadsMarkdownFromAFile(): void + { + $markdownFile = $this->temporaryPath('input.md'); + $summaryFile = $this->temporaryPath('summary.md'); + file_put_contents($markdownFile, "## From File\n"); + + $command = new SummaryWriteCommand(new StepSummaryWriter()); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + '--file' => $markdownFile, + '--summary-file' => $summaryFile, + ]); + + self::assertSame(0, $exitCode); + self::assertSame("## From File\n", file_get_contents($summaryFile)); + } + + /** + * @param string $name + * + * @return string + */ + private function temporaryPath(string $name): string + { + $directory = sys_get_temp_dir() . '/fast-forward-github-actions-' . bin2hex(random_bytes(8)); + + mkdir($directory, 0o777, true); + + return $directory . '/' . $name; + } +} From 39b9f026f8b4cce29c12b6abf31321fd7d49583d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 29 Apr 2026 14:47:54 -0300 Subject: [PATCH 2/3] Add PHP version resolver command --- AGENTS.md | 1 + CHANGELOG.md | 1 + README.md | 1 + docs/index.rst | 1 + src/Command/PhpResolveVersionCommand.php | 103 +++++++ src/Console/ConsoleApplicationFactory.php | 3 + src/Project/PhpVersionResolution.php | 74 +++++ src/Project/PhpVersionResolver.php | 283 ++++++++++++++++++ .../Command/PhpResolveVersionCommandTest.php | 132 ++++++++ 9 files changed, 599 insertions(+) create mode 100644 src/Command/PhpResolveVersionCommand.php create mode 100644 src/Project/PhpVersionResolution.php create mode 100644 src/Project/PhpVersionResolver.php create mode 100644 tests/Command/PhpResolveVersionCommandTest.php diff --git a/AGENTS.md b/AGENTS.md index 051468c..8bc6f08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Forward reusable GitHub Actions workflows. - Commands: [`src/Command/`](src/Command/) - GitHub Actions IO helpers: [`src/GitHub/`](src/GitHub/) - Project detection logic: [`src/Project/`](src/Project/) +- PHP version resolution logic: [`src/Project/`](src/Project/) - Tests: [`tests/`](tests/) - Docs: [`docs/`](docs/) - Release history: [`CHANGELOG.md`](CHANGELOG.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f79513..0456f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Bootstrap the GitHub Actions console runtime with Symfony Console commands for summaries, changelog release branch parsing, and PHP project surface detection (#1) +- Add a PHP version resolver command for reusable workflow smoke tests (#1) [unreleased]: https://github.com/php-fast-forward/github-actions/compare/HEAD diff --git a/README.md b/README.md index 47d4efc..2718e37 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ and agent synchronization out of the first package bootstrap. ```bash fast-forward-actions list +fast-forward-actions php:resolve-version --github-output fast-forward-actions php:detect-project --github-output fast-forward-actions changelog:resolve-merged-version release/v0.1.0 --github-output fast-forward-actions summary:write "## Workflow Summary" diff --git a/docs/index.rst b/docs/index.rst index 448f69d..89c4411 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ Initial Commands .. code-block:: bash + fast-forward-actions php:resolve-version --github-output fast-forward-actions php:detect-project --github-output fast-forward-actions changelog:resolve-merged-version release/v0.1.0 --github-output fast-forward-actions summary:write "## Workflow Summary" diff --git a/src/Command/PhpResolveVersionCommand.php b/src/Command/PhpResolveVersionCommand.php new file mode 100644 index 0000000..9eb2724 --- /dev/null +++ b/src/Command/PhpResolveVersionCommand.php @@ -0,0 +1,103 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Command; + +use RuntimeException; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use FastForward\GitHubActions\Project\PhpVersionResolver; +use JsonException; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +use function Safe\getcwd; +use function Safe\json_encode; + +#[AsCommand( + name: 'php:resolve-version', + description: 'Resolve the PHP version and test matrix from Composer metadata.', +)] +final class PhpResolveVersionCommand extends Command +{ + /** + * @param PhpVersionResolver $resolver + * @param GitHubOutputWriter $githubOutputWriter + */ + public function __construct( + private readonly PhpVersionResolver $resolver, + private readonly GitHubOutputWriter $githubOutputWriter, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addOption( + 'working-dir', + null, + InputOption::VALUE_REQUIRED, + 'Repository working directory.', + getcwd() ?: '.' + ) + ->addOption('github-output', null, InputOption::VALUE_NONE, 'Write resolved values to GITHUB_OUTPUT.') + ->addOption('output-file', null, InputOption::VALUE_REQUIRED, 'Override the GitHub output file path.'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @throws JsonException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $resolution = $this->resolver->resolve((string) $input->getOption('working-dir')); + + if ($input->getOption('github-output')) { + $this->writeGitHubOutputs($input, $resolution->toGitHubOutputs()); + } + + $output->writeln(json_encode($resolution->toArray(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT)); + + return Command::SUCCESS; + } + + /** + * @param array $outputs + * @param InputInterface $input + */ + private function writeGitHubOutputs(InputInterface $input, array $outputs): void + { + $path = (string) ($input->getOption('output-file') ?: getenv('GITHUB_OUTPUT') ?: ''); + + if ('' === $path) { + throw new RuntimeException('GITHUB_OUTPUT is not available; pass --output-file to write command outputs.'); + } + + foreach ($outputs as $name => $value) { + $this->githubOutputWriter->write($path, $name, $value); + } + } +} diff --git a/src/Console/ConsoleApplicationFactory.php b/src/Console/ConsoleApplicationFactory.php index b8e9fc9..0a31d0b 100644 --- a/src/Console/ConsoleApplicationFactory.php +++ b/src/Console/ConsoleApplicationFactory.php @@ -20,10 +20,12 @@ use FastForward\GitHubActions\Command\ChangelogResolveMergedVersionCommand; use FastForward\GitHubActions\Command\PhpDetectProjectCommand; +use FastForward\GitHubActions\Command\PhpResolveVersionCommand; use FastForward\GitHubActions\Command\SummaryWriteCommand; use FastForward\GitHubActions\GitHub\GitHubOutputWriter; use FastForward\GitHubActions\GitHub\StepSummaryWriter; use FastForward\GitHubActions\Project\ProjectSurfaceDetector; +use FastForward\GitHubActions\Project\PhpVersionResolver; use Symfony\Component\Console\Application; final class ConsoleApplicationFactory @@ -39,6 +41,7 @@ public static function create(): Application $application->addCommand(new ChangelogResolveMergedVersionCommand($githubOutputWriter)); $application->addCommand(new PhpDetectProjectCommand(new ProjectSurfaceDetector(), $githubOutputWriter)); + $application->addCommand(new PhpResolveVersionCommand(new PhpVersionResolver(), $githubOutputWriter)); $application->addCommand(new SummaryWriteCommand(new StepSummaryWriter())); return $application; diff --git a/src/Project/PhpVersionResolution.php b/src/Project/PhpVersionResolution.php new file mode 100644 index 0000000..07993c9 --- /dev/null +++ b/src/Project/PhpVersionResolution.php @@ -0,0 +1,74 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Project; + +final readonly class PhpVersionResolution +{ + /** + * @param non-empty-string $phpVersion + * @param non-empty-string $source + * @param list $testMatrix + * @param string $warning + */ + public function __construct( + public string $phpVersion, + public string $source, + public string $warning, + public array $testMatrix, + ) {} + + /** + * @return array + */ + public function toGitHubOutputs(): array + { + return [ + 'php-version' => $this->phpVersion, + 'php-version-source' => $this->source, + 'test-matrix' => $this->formatTestMatrix(), + 'warning' => $this->warning, + ]; + } + + /** + * @return array{php-version: string, php-version-source: string, test-matrix: array{php-version: list}, warning: string} + */ + public function toArray(): array + { + return [ + 'php-version' => $this->phpVersion, + 'php-version-source' => $this->source, + 'test-matrix' => [ + 'php-version' => $this->testMatrix, + ], + 'warning' => $this->warning, + ]; + } + + /** + * @return string + */ + private function formatTestMatrix(): string + { + return \sprintf('{"php-version":[%s]}', implode(',', array_map( + static fn(string $version): string => \sprintf('"%s"', $version), + $this->testMatrix, + ))); + } +} diff --git a/src/Project/PhpVersionResolver.php b/src/Project/PhpVersionResolver.php new file mode 100644 index 0000000..e7cb5be --- /dev/null +++ b/src/Project/PhpVersionResolver.php @@ -0,0 +1,283 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Project; + +use JsonException; +use Throwable; + +use function Safe\file_get_contents; +use function Safe\json_decode; +use function Safe\preg_match; +use function Safe\preg_match_all; + +final class PhpVersionResolver +{ + private const string DEFAULT_PHP_VERSION = '8.3'; + + /** + * @var list + */ + private const array SUPPORTED_MINORS = ['8.3', '8.4', '8.5']; + + /** + * @param non-empty-string $workingDirectory + */ + public function resolve(string $workingDirectory): PhpVersionResolution + { + [$resolved, $source] = $this->resolveFromLock($workingDirectory . '/composer.lock'); + + if (null === $resolved) { + [$resolved, $source] = $this->resolveFromJson($workingDirectory . '/composer.json'); + } + + if (null === $resolved) { + return $this->fallback('No reliable PHP version source was found. Falling back to 8.3.'); + } + + if (! \in_array($resolved, self::SUPPORTED_MINORS, true)) { + return $this->fallback(\sprintf( + 'Resolved PHP version %s from %s is outside the supported CI policy. Falling back to 8.3.', + $resolved, + $source ?? 'fallback', + )); + } + + return new PhpVersionResolution( + phpVersion: $resolved, + source: $source ?? 'fallback', + warning: '', + testMatrix: $this->matrixFrom($resolved), + ); + } + + /** + * @param string $composerLock + * + * @return array{0: string|null, 1: string|null} + */ + private function resolveFromLock(string $composerLock): array + { + if (! is_file($composerLock)) { + return [null, null]; + } + + try { + $payload = json_decode(file_get_contents($composerLock), true, 512, \JSON_THROW_ON_ERROR); + } catch (Throwable) { + return [null, 'composer.lock exists but could not be parsed']; + } + + if (! \is_array($payload)) { + return [null, 'composer.lock exists but could not be parsed']; + } + + $platformOverrides = $payload['platform-overrides'] ?? []; + + if (! \is_array($platformOverrides) || ! \is_string($platformOverrides['php'] ?? null)) { + return [null, null]; + } + + $resolved = $this->normalizeMinor($platformOverrides['php']); + + if (null !== $resolved) { + return [$resolved, 'composer.lock platform-overrides.php']; + } + + return [null, 'composer.lock platform-overrides.php is not a supported PHP version']; + } + + /** + * @param string $composerJson + * + * @return array{0: string|null, 1: string|null} + */ + private function resolveFromJson(string $composerJson): array + { + if (! is_file($composerJson)) { + return [null, 'composer.json does not exist']; + } + + try { + $payload = json_decode(file_get_contents($composerJson), true, 512, \JSON_THROW_ON_ERROR); + } catch (JsonException) { + return [null, 'composer.json could not be parsed']; + } + + if (! \is_array($payload)) { + return [null, 'composer.json could not be parsed']; + } + + $configPlatformPhp = $payload['config']['platform']['php'] ?? null; + + if (\is_string($configPlatformPhp)) { + $resolved = $this->normalizeMinor($configPlatformPhp); + + if (null !== $resolved) { + return [$resolved, 'composer.json config.platform.php']; + } + + return [null, 'composer.json config.platform.php is not a supported PHP version']; + } + + $requirePhp = $payload['require']['php'] ?? null; + + if (\is_string($requirePhp)) { + $resolved = $this->inferMinimumSupportedMinor($requirePhp); + + if (null !== $resolved) { + return [$resolved, 'composer.json require.php']; + } + + return [null, 'composer.json require.php could not be resolved safely']; + } + + return [null, null]; + } + + /** + * @param string $requirement + * + * @return string|null + */ + private function inferMinimumSupportedMinor(string $requirement): ?string + { + $lowerBounds = []; + + foreach (explode('||', $requirement) as $clause) { + $clauseLowerBound = $this->inferClauseLowerBound(trim($clause)); + + if (null !== $clauseLowerBound) { + $lowerBounds[] = $clauseLowerBound; + } + } + + if ([] === $lowerBounds) { + return null; + } + + usort($lowerBounds, version_compare(...)); + + return $lowerBounds[0]; + } + + /** + * @param string $clause + * + * @return string|null + */ + private function inferClauseLowerBound(string $clause): ?string + { + preg_match_all('/(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)/', $clause, $matches, \PREG_SET_ORDER); + + $lowerBounds = []; + + foreach ($matches as $match) { + $operator = $match[1] ?? ''; + $normalized = $this->normalizeMinor($match[2]); + + if (null === $normalized) { + continue; + } + + if (\in_array($operator, ['', '=', '==', '^', '~', '>='], true)) { + $lowerBounds[] = $normalized; + + continue; + } + + if ('>' === $operator && null !== ($nextMinor = $this->nextSupportedMinor($normalized))) { + $lowerBounds[] = $nextMinor; + } + } + + if ([] === $lowerBounds) { + return null; + } + + usort($lowerBounds, version_compare(...)); + + return $lowerBounds[array_key_last($lowerBounds)]; + } + + /** + * @param string $version + * + * @return string|null + */ + private function normalizeMinor(string $version): ?string + { + if (1 !== preg_match('/^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$/', $version, $matches)) { + return null; + } + + return \sprintf('%s.%s', $matches[1], $matches[2]); + } + + /** + * @param string $version + * + * @return string|null + */ + private function nextSupportedMinor(string $version): ?string + { + $index = array_search($version, self::SUPPORTED_MINORS, true); + + if (false === $index) { + return null; + } + + $nextIndex = $index + 1; + + if (isset(self::SUPPORTED_MINORS[$nextIndex])) { + return self::SUPPORTED_MINORS[$nextIndex]; + } + + [$major, $minor] = array_map(intval(...), explode('.', $version)); + + return \sprintf('%d.%d', $major, $minor + 1); + } + + /** + * @param string $resolved + * + * @return list + */ + private function matrixFrom(string $resolved): array + { + return array_values(array_filter( + self::SUPPORTED_MINORS, + static fn(string $version): bool => version_compare($version, $resolved, '>='), + )); + } + + /** + * @param string $warning + * + * @return PhpVersionResolution + */ + private function fallback(string $warning): PhpVersionResolution + { + return new PhpVersionResolution( + phpVersion: self::DEFAULT_PHP_VERSION, + source: 'fallback', + warning: $warning, + testMatrix: $this->matrixFrom(self::DEFAULT_PHP_VERSION), + ); + } +} diff --git a/tests/Command/PhpResolveVersionCommandTest.php b/tests/Command/PhpResolveVersionCommandTest.php new file mode 100644 index 0000000..f844810 --- /dev/null +++ b/tests/Command/PhpResolveVersionCommandTest.php @@ -0,0 +1,132 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/github-actions + * @see https://github.com/php-fast-forward/github-actions/issues + * @see https://php-fast-forward.github.io/github-actions/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\GitHubActions\Tests\Command; + +use FastForward\GitHubActions\Command\PhpResolveVersionCommand; +use FastForward\GitHubActions\GitHub\GitHubOutputWriter; +use FastForward\GitHubActions\Project\PhpVersionResolution; +use FastForward\GitHubActions\Project\PhpVersionResolver; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\mkdir; + +#[CoversClass(PhpResolveVersionCommand::class)] +#[CoversClass(GitHubOutputWriter::class)] +#[CoversClass(PhpVersionResolution::class)] +#[CoversClass(PhpVersionResolver::class)] +final class PhpResolveVersionCommandTest extends TestCase +{ + /** + * @return void + */ + public function testItResolvesThePhpVersionFromComposerLockPlatformOverrides(): void + { + $project = $this->temporaryDirectory(); + $outputFile = $this->temporaryDirectory() . '/github-output'; + + file_put_contents($project . '/composer.lock', <<<'JSON' + { + "platform-overrides": { + "php": "8.4.12" + } + } + JSON); + + $tester = new CommandTester(new PhpResolveVersionCommand(new PhpVersionResolver(), new GitHubOutputWriter())); + + $exitCode = $tester->execute([ + '--working-dir' => $project, + '--github-output' => true, + '--output-file' => $outputFile, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('"php-version": "8.4"', $tester->getDisplay()); + self::assertStringContainsString("php-version=8.4\n", file_get_contents($outputFile)); + self::assertStringContainsString( + "php-version-source=composer.lock platform-overrides.php\n", + file_get_contents($outputFile) + ); + self::assertStringContainsString( + "test-matrix={\"php-version\":[\"8.4\",\"8.5\"]}\n", + file_get_contents($outputFile) + ); + } + + /** + * @return void + */ + public function testItResolvesThePhpVersionFromComposerJsonRequirement(): void + { + $project = $this->temporaryDirectory(); + + file_put_contents($project . '/composer.json', <<<'JSON' + { + "require": { + "php": "^8.3 || ^8.4" + } + } + JSON); + + $tester = new CommandTester(new PhpResolveVersionCommand(new PhpVersionResolver(), new GitHubOutputWriter())); + + $exitCode = $tester->execute([ + '--working-dir' => $project, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('"php-version": "8.3"', $tester->getDisplay()); + self::assertStringContainsString('"php-version-source": "composer.json require.php"', $tester->getDisplay()); + } + + /** + * @return void + */ + public function testItFallsBackWhenComposerMetadataIsMissing(): void + { + $project = $this->temporaryDirectory(); + + $tester = new CommandTester(new PhpResolveVersionCommand(new PhpVersionResolver(), new GitHubOutputWriter())); + + $exitCode = $tester->execute([ + '--working-dir' => $project, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('"php-version": "8.3"', $tester->getDisplay()); + self::assertStringContainsString('"php-version-source": "fallback"', $tester->getDisplay()); + self::assertStringContainsString('No reliable PHP version source was found.', $tester->getDisplay()); + } + + /** + * @return string + */ + private function temporaryDirectory(): string + { + $directory = sys_get_temp_dir() . '/fast-forward-github-actions-' . bin2hex(random_bytes(8)); + + mkdir($directory, 0o777, true); + + return $directory; + } +} From 28da75de2b349d4c01809994d1661062f7daf0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 29 Apr 2026 14:52:27 -0300 Subject: [PATCH 3/3] Load Composer root autoload from installed binary --- bin/fast-forward-actions | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/bin/fast-forward-actions b/bin/fast-forward-actions index eae30d2..6d9ff77 100755 --- a/bin/fast-forward-actions +++ b/bin/fast-forward-actions @@ -19,6 +19,19 @@ declare(strict_types=1); use FastForward\GitHubActions\Console\ConsoleApplicationFactory; -require __DIR__ . '/../vendor/autoload.php'; +$autoloadCandidates = [ + __DIR__ . '/../vendor/autoload.php', + __DIR__ . '/../../../autoload.php', +]; -exit(ConsoleApplicationFactory::create()->run()); +foreach ($autoloadCandidates as $autoloadCandidate) { + if (is_file($autoloadCandidate)) { + require $autoloadCandidate; + + exit(ConsoleApplicationFactory::create()->run()); + } +} + +fwrite(STDERR, "Could not locate Composer autoload.php for fast-forward/github-actions.\n"); + +exit(1);