Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
composer.lock
composer.phar
vendor/
/tools/
24 changes: 16 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tool>.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.

Expand Down Expand Up @@ -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/<tool>.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.
Expand Down
29 changes: 16 additions & 13 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
186 changes: 186 additions & 0 deletions bin/download-phar
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);

/**
* Keeps <directory>/<tool>.phar on the latest release of a GitHub repository.
*
* Usage: download-phar <tool> <owner/repository> <release-asset> [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 <tool> <owner/repository> <release-asset> [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/<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);
}
38 changes: 27 additions & 11 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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
},
Expand All @@ -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'",
Expand All @@ -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/"
}
}
Loading
Loading