diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index d369097..c8a6cac 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -26,7 +26,7 @@ jobs: with: php-version: 8.4 coverage: none - tools: cs2pr + tools: 'composer-normalize, composer-require-checker, cs2pr' - name: Install Composer Dependencies uses: ramsey/composer-install@v4 @@ -41,10 +41,10 @@ jobs: run: vendor/bin/composer-dependency-analyser - name: Run Composer Require Checker - run: vendor/bin/composer-require-checker + run: composer-require-checker - name: Run Composer Normalize - run: composer normalize --dry-run --indent-size 2 --indent-style space --no-check-lock --no-update-lock + run: composer-normalize --dry-run --indent-size 2 --indent-style space --no-check-lock --no-update-lock - name: Run PHP CS Fixer run: vendor/bin/php-cs-fixer check --using-cache=no --config php-cs-fixer.php.dist diff --git a/.gitignore b/.gitignore index 6a1f401..9c36ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ composer.lock composer.phar vendor/ +/tools/ diff --git a/AGENTS.md b/AGENTS.md index 10a6f37..25b5882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ Guidance for AI agents working in this repository. For install and usage see `RE - Runs PHP commands inside a local Kubernetes pod. - `bin/coverage-check` - Checks the total line coverage of a Cobertura report against a minimum percentage. +- `bin/download-phar` + - Keeps `tools/.phar` on the latest GitHub release of a tool. - `bin/print-section` - Prints a section header, used by the `ci` script to separate the steps. @@ -99,14 +101,20 @@ Run `composer ci` (composer audit, composer-dependency-analyser, composer-requir composer-normalize, cs-fixer, code sniffer, Rector, PHPStan, Psalm, PHPUnit with coverage). `composer ci` needs nothing installed globally: a fresh clone plus `composer install` is enough. -`composer-dependency-analyser` and `composer-require-checker` come from `require-dev` and are run -from `vendor/bin` (Composer puts the bin-dir first on `PATH` for scripts, so the vendored copy wins -over any global one), `composer-normalize` is a `require-dev` plugin providing the `normalize` -subcommand, and `audit` is built into Composer. - -`composer audit` runs with `--abandoned=report`: `maglnet/composer-require-checker` pulls in the -abandoned `azjezz/psl`, which would otherwise fail the step. Security advisories still fail the -build, for production and dev dependencies alike; only the abandoned notice is informational. +`composer-dependency-analyser` comes from `require-dev` and is run from `vendor/bin` (Composer puts +the bin-dir first on `PATH` for scripts, so the vendored copy wins over any global one), and `audit` +is built into Composer. + +`composer-require-checker` and `composer-normalize` are deliberately **not** dependencies: as +`require-dev` entries their own requirements constrain what the package can install, and Composer +Require Checker's upstream recommends the PHAR. The `require-checker` and `normalize-check` scripts +first run `tool:*`, which calls `bin/download-phar` to keep `tools/.phar` (gitignored) on the +latest release, then run the PHAR. CI instead installs both with `setup-php`, like the shared +`backend_code_tools.yml`. Do not add either tool back to `require-dev`. + +`composer audit` runs with `--abandoned=report`: any package we depend on may be marked abandoned +at any time, and that alone should not fail the build. Security advisories still fail it, for +production and dev dependencies alike; only the abandoned notice is informational. Note that `composer ci` calls `@cs-fix`, so it rewrites files rather than only checking them. CI runs `php-cs-fixer check` instead. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d598013..2a2281a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,25 +35,28 @@ composer install #### Scripts ```console -composer ci # Run same checks as in CI (note: applies CS fixes, CI only checks) -composer cs # PHP CS Fixer (kununu code standards) (dry-run) -composer cs-fix # PHP CS Fixer (kununu code standards) (apply-fixes) -composer sniffer # PHP_CodeSniffer (dry-run) -composer sniffer-fix # PHP_CodeSniffer (apply fixes) -composer rector # Rector (dry-run) -composer rector-fix # Rector (apply fixes) -composer phpstan # PHPStan static analysis -composer psalm # Psalm static analysis -composer test # Run the full PHPUnit test suite -composer test-coverage # Run the full PHPUnit test suite with a coverage report -composer coverage-check # Check the line coverage of the last coverage run against the minimum +composer ci # Run same checks as in CI (note: applies CS fixes, CI only checks) +composer cs # PHP CS Fixer (kununu code standards) (dry-run) +composer cs-fix # PHP CS Fixer (kununu code standards) (apply-fixes) +composer sniffer # PHP_CodeSniffer (dry-run) +composer sniffer-fix # PHP_CodeSniffer (apply fixes) +composer rector # Rector (dry-run) +composer rector-fix # Rector (apply fixes) +composer phpstan # PHPStan static analysis +composer psalm # Psalm static analysis +composer test # Run the full PHPUnit test suite +composer test-coverage # Run the full PHPUnit test suite with a coverage report +composer coverage-check # Check the line coverage of the last coverage run against the minimum +composer require-checker # Composer Require Checker (downloads its PHAR into tools/ if needed) +composer normalize-check # Composer Normalize (dry-run, downloads its PHAR into tools/ if needed) +composer normalize-fix # Composer Normalize (apply fixes) ``` All scripts are defined in `composer.json` under `scripts`. The same tools run in CI (`.github/workflows/continuous-integration.yml`), which also checks `composer-audit`, `composer-normalize`, `composer-require-checker`, and `composer-dependency-analyser`. -`composer-dependency-analyser`, `composer-require-checker` and `composer-normalize` are global tools, installed in CI via `setup-php`. Install them locally to run `composer ci` in full. +`composer-require-checker` and `composer-normalize` are not dependencies of the package. Their scripts (`composer require-checker`, `composer normalize-check`) download the latest release PHAR into the gitignored `tools/` directory with [`download-phar`](docs/DownloadPhar/README.md), and only download again when a new release is out. Nothing has to be installed globally. `composer ci` runs the same set of tools as in CI: - Composer Audit diff --git a/README.md b/README.md index e2d5232..fffce90 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Every tool works out of the box. Use [`bin/code-tools`](docs/CodeTools/README.md | [Architecture Sniffer](docs/ArchitectureSniffer/README.md) | Architecture and dependency rules, powered by PHPAT | | [`bin/code-tools`](docs/CodeTools/README.md) | Publishes the configuration templates above into your project | | [`bin/coverage-check`](docs/CoverageCheck/README.md) | Checks the total line coverage of a Cobertura report against a minimum | +| [`bin/download-phar`](docs/DownloadPhar/README.md) | Keeps a tool's PHAR on its latest GitHub release | | [`bin/php-in-k8s`](docs/PHPInK8s/README.md) | Runs PHP commands inside a local Kubernetes pod | | [`bin/print-section`](docs/PrintSection/README.md) | Prints a section header, to separate the steps of a multi-tool script | diff --git a/bin/download-phar b/bin/download-phar new file mode 100755 index 0000000..094862a --- /dev/null +++ b/bin/download-phar @@ -0,0 +1,186 @@ +#!/usr/bin/env php +/.phar on the latest release of a GitHub repository. + * + * Usage: download-phar [directory] + * + * Every run asks GitHub which tag `releases/latest` redirects to (no download), and downloads the asset only when that + * tag is not the one already in the directory. Without network access the PHAR already there is used. + * + * The directory defaults to "tools", relative to the working directory. + */ +final readonly class DownloadPhar +{ + private const string DEFAULT_DIRECTORY = 'tools'; + private const string LATEST_URL = 'https://github.com/%s/releases/latest'; + private const string DOWNLOAD_URL = 'https://github.com/%s/releases/download/%s/%s'; + private const string TAG_PATTERN = '#/releases/tag/([^/]+)$#'; + private const string NAME_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9._-]*$/'; + private const string REPOSITORY_PATTERN = '#^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$#'; + private const int CHECK_TIMEOUT = 10; + private const int DOWNLOAD_TIMEOUT = 60; + + private string $tool; + private string $repository; + private string $asset; + private string $directory; + private string $phar; + private string $versionFile; + + public function __construct(?string $tool, ?string $repository, ?string $asset, ?string $directory) + { + $this->validateRequiredArguments($tool, $repository, $asset); + + $this->tool = $this->getName((string) $tool, 'tool'); + $this->repository = $this->getRepository((string) $repository); + $this->asset = $this->getName((string) $asset, 'release asset'); + $this->directory = $this->getDirectory($directory); + $this->phar = sprintf('%s/%s.phar', $this->directory, $this->tool); + $this->versionFile = sprintf('%s/%s.version', $this->directory, $this->tool); + } + + public function __invoke(): void + { + $tag = $this->getLatestTag(); + + if (null === $tag) { + $this->useExistingPhar(); + + return; + } + + if ($this->isUpToDate($tag)) { + return; + } + + $this->download($tag); + } + + private function validateRequiredArguments(?string $tool, ?string $repository, ?string $asset): void + { + if (null === $tool || null === $repository || null === $asset) { + $this->fail( + sprintf('Usage: %s [directory]', basename(__FILE__)) + ); + } + } + + private function getName(string $name, string $description): string + { + if (1 !== preg_match(self::NAME_PATTERN, $name)) { + $this->fail(sprintf('Invalid %s name "%s".', $description, $name)); + } + + return $name; + } + + private function getRepository(string $repository): string + { + if (1 !== preg_match(self::REPOSITORY_PATTERN, $repository)) { + $this->fail(sprintf('The repository must be in the "owner/repository" format, got "%s".', $repository)); + } + + return $repository; + } + + private function getDirectory(?string $directory): string + { + $directory = rtrim($directory ?? self::DEFAULT_DIRECTORY, '/'); + + if ('' === $directory) { + $this->fail('The directory must not be empty.'); + } + + return $directory; + } + + // `releases/latest` redirects to `releases/tag/`: reading the redirect gives the tag without a download + private function getLatestTag(): ?string + { + $headers = @get_headers( + sprintf(self::LATEST_URL, $this->repository), + true, + stream_context_create( + [ + 'http' => [ + 'method' => 'HEAD', + 'follow_location' => 0, + 'timeout' => self::CHECK_TIMEOUT, + ], + ] + ) + ); + + $location = is_array($headers) ? ($headers['Location'] ?? $headers['location'] ?? null) : null; + $location = is_array($location) ? end($location) : $location; + + return is_string($location) && 1 === preg_match(self::TAG_PATTERN, $location, $matches) + ? rawurldecode($matches[1]) + : null; + } + + private function useExistingPhar(): void + { + if (!is_file($this->phar)) { + $this->fail(sprintf('Could not find the latest %s release.', $this->tool)); + } + + fwrite( + STDERR, + sprintf('Could not check the latest %s release, using %s.%s', $this->tool, $this->phar, PHP_EOL) + ); + } + + private function isUpToDate(string $tag): bool + { + return is_file($this->phar) + && is_file($this->versionFile) + && trim((string) file_get_contents($this->versionFile)) === $tag; + } + + // Download next to the target and rename, so an interrupted download never replaces a working PHAR + private function download(string $tag): void + { + if (!is_dir($this->directory) && !@mkdir($this->directory, 0755, true) && !is_dir($this->directory)) { + $this->fail(sprintf('Could not create the directory "%s".', $this->directory)); + } + + printf('Downloading %s %s%s', $this->tool, $tag, PHP_EOL); + + $url = sprintf(self::DOWNLOAD_URL, $this->repository, rawurlencode($tag), $this->asset); + $temporary = sprintf('%s.tmp', $this->phar); + $context = stream_context_create(['http' => ['timeout' => self::DOWNLOAD_TIMEOUT]]); + + if (!@copy($url, $temporary, $context)) { + @unlink($temporary); + $this->fail(sprintf('Could not download %s', $url)); + } + + if (!@rename($temporary, $this->phar)) { + @unlink($temporary); + $this->fail(sprintf('Could not move the download to %s', $this->phar)); + } + + if (false === @file_put_contents($this->versionFile, sprintf('%s%s', $tag, PHP_EOL))) { + $this->fail(sprintf('Could not write %s', $this->versionFile)); + } + } + + private function fail(string $message): never + { + throw new RuntimeException($message); + } +} + +try { + (new DownloadPhar($argv[1] ?? null, $argv[2] ?? null, $argv[3] ?? null, $argv[4] ?? null))(); + + exit(0); +} catch (RuntimeException $e) { + fwrite(STDERR, sprintf('%s%s', $e->getMessage(), PHP_EOL)); + + exit(1); +} diff --git a/composer.json b/composer.json index 0d7da99..f4de183 100644 --- a/composer.json +++ b/composer.json @@ -28,12 +28,10 @@ "symfony/console": "^6.4 || ^7.4", "symfony/process": "^6.4 || ^7.4", "symfony/yaml": "^6.4 || ^7.4", - "vimeo/psalm": "^6.17" + "vimeo/psalm": "^6.20" }, "require-dev": { - "ergebnis/composer-normalize": "^2.53", - "maglnet/composer-require-checker": "^4.20", - "phpunit/phpunit": "^13.3", + "phpunit/phpunit": "^13.4", "shipmonk/composer-dependency-analyser": "^1.8" }, "conflict": { @@ -69,13 +67,11 @@ "bin": [ "bin/code-tools", "bin/coverage-check", + "bin/download-phar", "bin/php-in-k8s", "bin/print-section" ], "config": { - "allow-plugins": { - "ergebnis/composer-normalize": true - }, "process-timeout": 900, "sort-packages": true }, @@ -91,9 +87,9 @@ "bin/print-section 'Dependency Analyser'", "composer-dependency-analyser", "bin/print-section 'Composer Require Checker'", - "composer-require-checker", + "@require-checker", "bin/print-section 'Composer Normalize'", - "composer normalize --dry-run --indent-size 2 --indent-style space --no-check-lock --no-update-lock", + "@normalize-check", "bin/print-section 'PHP CS Fixer'", "@cs-fix", "bin/print-section 'PHP CodeSniffer'", @@ -113,28 +109,48 @@ "cs": "php-cs-fixer check --config=php-cs-fixer.php.dist", "cs-fix": "php-cs-fixer fix --config=php-cs-fixer.php.dist", "git-hook": "Kununu\\CodeTools\\PHPCSFixer\\CsFixerPlugin::installGitHooks", + "normalize-check": [ + "@tool:composer-normalize", + "@php tools/composer-normalize.phar --dry-run --indent-size 2 --indent-style space --no-check-lock --no-update-lock" + ], + "normalize-fix": [ + "@tool:composer-normalize", + "@php tools/composer-normalize.phar --indent-size 2 --indent-style space --no-check-lock --no-update-lock" + ], "phpstan": "phpstan analyze", "psalm": "psalm", "rector": "rector process --dry-run --config=rector-ci.php src/ tests/", "rector-fix": "rector process --config=rector-ci.php src/ tests/", + "require-checker": [ + "@tool:composer-require-checker", + "@putenv XDEBUG_MODE=off", + "@php tools/composer-require-checker.phar check composer.json" + ], "sniffer": "phpcs --standard=phpcs.xml.dist src/ tests/", "sniffer-fix": "phpcbf --standard=phpcs.xml.dist src/ tests/", "test": "@php -d zend.assertions=1 vendor/bin/phpunit --testsuite Full --testdox --testdox-summary --no-coverage --no-logging", "test-coverage": [ "@putenv XDEBUG_MODE=coverage", "@php -d zend.assertions=1 vendor/bin/phpunit --testsuite Full --testdox --testdox-summary --log-junit tests/.results/tests-junit.xml --coverage-clover tests/.results/tests-clover.xml --coverage-cobertura tests/.results/tests-cobertura.xml --coverage-html tests/.results/html/" - ] + ], + "tool:composer-normalize": "bin/download-phar composer-normalize ergebnis/composer-normalize composer-normalize.phar", + "tool:composer-require-checker": "bin/download-phar composer-require-checker maglnet/ComposerRequireChecker composer-require-checker.phar" }, "scripts-descriptions": { "ci": "Run all CI checks", "coverage-check": "Check that the line coverage of the last coverage run is at least 95%", "cs": "Run PHP CS Fixer", "git-hook": "Install the PHP CS Fixer git pre-commit hook in this repository", + "normalize-check": "Check that composer.json is normalized", + "normalize-fix": "Normalize composer.json", "phpstan": "Run PHPStan", "rector": "Run Rector in dry-run mode with CI rules", "rector-fix": "Run Rector in fix mode with CI rules", + "require-checker": "Check that every used symbol comes from a declared dependency", "sniffer": "Run PHP_CodeSniffer", "test": "Run all tests", - "test-coverage": "Run all tests with coverage report" + "test-coverage": "Run all tests with coverage report", + "tool:composer-normalize": "Download the latest Composer Normalize PHAR into tools/", + "tool:composer-require-checker": "Download the latest Composer Require Checker PHAR into tools/" } } diff --git a/docs/DownloadPhar/README.md b/docs/DownloadPhar/README.md new file mode 100644 index 0000000..468cd5d --- /dev/null +++ b/docs/DownloadPhar/README.md @@ -0,0 +1,122 @@ +# `download-phar` usage + +This script keeps a tool's PHAR on the **latest release** of its GitHub repository, inside your project. + +It is meant for tools that should not be project dependencies. Installed through `require-dev`, a tool's own requirements +constrain what your project can install (for example, a recent Composer Require Checker needs `symfony/console` ^7, +which blocks a Symfony 6.4 test leg). Some tools, Composer Require Checker among them, also recommend the PHAR outright. + +## Usage + +```bash +vendor/bin/download-phar [directory] +``` + +| Argument | Required | Default | Description | +|----------------------|----------|---------|-------------------------------------------------------------------------------| +| `` | yes | | Local name of the tool, used for `.phar` and `.version` | +| `` | yes | | The GitHub repository that publishes the releases | +| `` | yes | | File name of the PHAR attached to each release | +| `[directory]` | no | `tools` | Where to keep the PHAR, relative to the **working directory** unless absolute | + +`` and `` must be plain file names (letters, digits, `.`, `_` and `-`), so they can never point +outside the directory. + +```bash +vendor/bin/download-phar composer-require-checker maglnet/ComposerRequireChecker composer-require-checker.phar +``` + +Composer runs scripts from the project root, so inside a `composer.json` script the default directory is the project's +own `tools/`. Add it to your `.gitignore`: + +```gitignore +/tools/ +``` + +## How it works + +1. It sends a `HEAD` request to `https://github.com//releases/latest`, without following the redirect, + and reads the tag from the `Location` header (`…/releases/tag/`). Nothing is downloaded, and it does not use the + GitHub API, so there is no API rate limit and no token is needed. +2. If `/.version` already holds that tag and `/.phar` exists, it does nothing. +3. Otherwise it downloads `https://github.com//releases/download//` to + `/.phar.tmp`, renames it to `/.phar` and writes the tag to `.version`. + Because of the rename, an interrupted download never replaces a working PHAR. + +The directory is created when needed. Running the script before every use of the tool is cheap: when nothing changed it +costs a single `HEAD` request. + +The script only downloads the PHAR. It does not run it, and it does not verify signatures. + +## Wiring it into `composer.json` + +This is how the package itself runs Composer Require Checker and Composer Normalize. One `tool:*` script keeps each PHAR +current, and the scripts that use the tool call it first: + +```json +{ + "scripts": { + "tool:composer-normalize": "vendor/bin/download-phar composer-normalize ergebnis/composer-normalize composer-normalize.phar", + "tool:composer-require-checker": "vendor/bin/download-phar composer-require-checker maglnet/ComposerRequireChecker composer-require-checker.phar", + "normalize-check": [ + "@tool:composer-normalize", + "@php tools/composer-normalize.phar --dry-run --indent-size 2 --indent-style space --no-check-lock --no-update-lock" + ], + "normalize-fix": [ + "@tool:composer-normalize", + "@php tools/composer-normalize.phar --indent-size 2 --indent-style space --no-check-lock --no-update-lock" + ], + "require-checker": [ + "@tool:composer-require-checker", + "@putenv XDEBUG_MODE=off", + "@php tools/composer-require-checker.phar check composer.json" + ], + "ci": [ + "@require-checker", + "@normalize-check" + ] + } +} +``` + +- `XDEBUG_MODE=off` is what Composer Require Checker's own README recommends. +- The Composer Require Checker PHAR picks up a `composer-require-checker.json` in the working directory on its own. +- Once the tools run as PHARs, remove `maglnet/composer-require-checker` and `ergebnis/composer-normalize` from + `require-dev`, and `ergebnis/composer-normalize` from `config.allow-plugins`. + +See the `scripts` section of this repository's own `composer.json` for the complete version. + +## Output + +Nothing when the PHAR is already on the latest release. When it downloads, a single line on `STDOUT`: + +```console +Downloading composer-require-checker 4.24.0 +``` + +## Offline and errors + +When the latest release cannot be determined (no network, GitHub unreachable, or an unknown repository) and a PHAR is +already there, the script warns on `STDERR` and exits with status `0`, so the tool still runs with the PHAR you have: + +```console +Could not check the latest composer-require-checker release, using tools/composer-require-checker.phar. +``` + +Every other problem is written to `STDERR` and exits with status `1`. A failed download leaves no `.tmp` file behind, +and the PHAR that was there before, if any, is untouched. + +| Message | Cause | +|------------------------------------------------------------------------------|------------------------------------------------------------------------| +| `Usage: download-phar [directory]` | One of the three required arguments is missing | +| `Invalid tool name "…".` / `Invalid release asset name "…".` | The name is not a plain file name | +| `The repository must be in the "owner/repository" format, got "…".` | The second argument is not `owner/repository` | +| `The directory must not be empty.` | An empty directory argument was given | +| `Could not find the latest … release.` | The latest release could not be determined and there is no PHAR yet | +| `Could not create the directory "…".` | The directory does not exist and cannot be created | +| `Could not download …` | The asset does not exist in the latest release, or the download failed | +| `Could not move the download to …` / `Could not write …` | The directory is not writable | + +--- + +[Back to Index](../../README.md) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2b29bae..938211f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ cacheDirectory=".phpunit.cache" colors="true" displayDetailsOnAllIssues="true" - executionOrder="depends,defects" + executionOrder="defects" failOnRisky="true" failOnWarning="true"> @@ -21,5 +21,4 @@ src - diff --git a/tests/PHPCSFixer/Command/CommandTestCase.php b/tests/PHPCSFixer/Command/CommandTestCase.php index 6c1a085..5cd8e1d 100644 --- a/tests/PHPCSFixer/Command/CommandTestCase.php +++ b/tests/PHPCSFixer/Command/CommandTestCase.php @@ -20,7 +20,7 @@ protected function setUp(): void { $application = new Application(); $application->setCatchExceptions(false); - $application->addCommand($this->getCommand()); + $application->addCommands([$this->getCommand()]); $this->tester = new CommandTester($application->find($this->getCommandName())); }