diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f26e88c..5717157 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,3 +30,16 @@ variables: _PHPUNIT_CONCURRENT: 0 _CSPELL_WORDS: 'cloudflared, cleanstring, ctools, dpagini, ffpu, multiext, oleh, origname, pagini, vehera, voleger, ั‚ะตัั‚' OPT_IN_TEST_PREVIOUS_MAJOR: '1' + +# The module requires PHP 8.2. Test Drupal 10 on PHP 8.2, not on 8.1. +# CORE_PREVIOUS_PHP_MIN is a CI/CD settings variable. A settings variable +# wins over any value in this file, so set the PHP version on the job. +composer (previous major): + variables: + PHP_VERSION: '8.2' + +# Drupal 10's test bootstrap changes directory into web/. PHPUnit 9 then +# reads the configuration file again from there, so a relative path fails. +phpunit (previous major): + variables: + _PHPUNIT_EXTRA: '--no-coverage --configuration=$CI_PROJECT_DIR/phpunit.gitlab-ci.xml' diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a7c2a..240abf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ This file records the changes in each release of File (Field) Paths. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Releases follow the Drupal.org `8.x-1.x` contrib versioning scheme. +## Unreleased + +### Changed + +- [#3622262](https://www.drupal.org/i/3622262): Declared PHP 8.2 as the + minimum version. `8.x-1.0-rc2` already needed it, because it declares five + classes `readonly`, but it did not say so and failed with a parse error on + PHP 8.1. Composer and Drupal now refuse PHP 8.1. Sites on PHP 8.1 should + pin `8.x-1.0-rc1`, because Composer there still resolves to `8.x-1.0-rc2`. + ## 8.x-1.0-rc2 - 2026-09-07 ### Added diff --git a/README.md b/README.md index 233b360..087a36a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Submit bug reports and feature suggestions, or track changes in the ## Requirements -This module requires the following modules: +This module requires PHP 8.2 or later and the following modules: - [Pathauto](https://www.drupal.org/project/pathauto) - [Redirect](https://www.drupal.org/project/redirect) diff --git a/composer.json b/composer.json index 3580524..4dc3f6d 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,9 @@ "keywords": ["file", "field", "path", "token", "drupal"], "type": "drupal-module", "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.2" + }, "require-dev": { "drupal/ctools": "*", "drupal/pathauto": "*", diff --git a/filefield_paths.info.yml b/filefield_paths.info.yml index 442ac99..56d7829 100644 --- a/filefield_paths.info.yml +++ b/filefield_paths.info.yml @@ -3,6 +3,7 @@ description: 'Adds improved Token based file sorting and renaming functionalitie package: Fields configure: filefield_paths.admin_settings core_version_requirement: ^10.3 || ^11 +php: 8.2 type: module dependencies: - drupal:file diff --git a/tests/src/Unit/PhpRequirementTest.php b/tests/src/Unit/PhpRequirementTest.php new file mode 100644 index 0000000..1f21c26 --- /dev/null +++ b/tests/src/Unit/PhpRequirementTest.php @@ -0,0 +1,175 @@ +infoFilePhp(); + $required = $this->requiredPhp(); + + // Drupal compares this value with version_compare() too. + $this->assertTrue( + version_compare($php, $required, '>='), + sprintf('filefield_paths.info.yml declares PHP %s, but the code needs PHP %s.', $php, $required), + ); + } + + /** + * Tests that composer.json rejects a PHP version the code cannot run on. + */ + public function testComposerRejectsOlderPhp(): void { + $constraint = $this->composerPhp(); + $required = $this->requiredPhp(); + $below = $this->versionBelow($required); + + $this->assertFalse( + Semver::satisfies($below, $constraint), + sprintf('composer.json allows PHP %s with "%s", but the code needs PHP %s.', $below, $constraint, $required), + ); + } + + /** + * Tests that composer.json and the info file declare the same minimum. + */ + public function testComposerMatchesInfoFile(): void { + $constraint = $this->composerPhp(); + $php = $this->infoFilePhp(); + + $this->assertTrue( + Semver::satisfies($php . '.0', $constraint), + sprintf('composer.json "%s" rejects PHP %s, the minimum in the info file.', $constraint, $php), + ); + $this->assertFalse( + Semver::satisfies($this->versionBelow($php), $constraint), + sprintf('composer.json "%s" allows a PHP older than %s, the minimum in the info file.', $constraint, $php), + ); + } + + /** + * Returns the minimum PHP version in the info file. + * + * @return string + * The value of the php key, for example "8.2". + */ + private function infoFilePhp(): string { + $info = Yaml::decode($this->readModuleFile('filefield_paths.info.yml')); + $this->assertIsArray($info); + $php = $info['php'] ?? NULL; + $this->assertIsScalar($php, 'filefield_paths.info.yml does not declare a minimum PHP version.'); + + return (string) $php; + } + + /** + * Returns the PHP constraint in composer.json. + * + * @return string + * The constraint, for example ">=8.2". + */ + private function composerPhp(): string { + $composer = json_decode($this->readModuleFile('composer.json'), TRUE); + $this->assertIsArray($composer); + $require = $composer['require'] ?? []; + $this->assertIsArray($require); + $constraint = $require['php'] ?? NULL; + $this->assertIsString($constraint, 'composer.json does not require a minimum PHP version.'); + + return $constraint; + } + + /** + * Returns the minimum PHP version that the code in src/ needs. + * + * @return string + * The PHP version, for example "8.2". + */ + private function requiredPhp(): string { + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->moduleRoot() . '/src', \FilesystemIterator::SKIP_DOTS), + ); + foreach ($files as $file) { + if (!$file instanceof \SplFileInfo || $file->getExtension() !== 'php') { + continue; + } + $code = (string) file_get_contents($file->getPathname()); + if (preg_match('/^\s*(?:(?:final|abstract)\s+)*readonly\s+(?:(?:final|abstract)\s+)*class\s/m', $code) === 1) { + return self::READONLY_CLASS_PHP; + } + } + + return self::DRUPAL_10_PHP; + } + + /** + * Returns the last patch release before a minor version. + * + * @param string $version + * A minor version, for example "8.2". + * + * @return string + * The release before it, for example "8.1.99". + */ + private function versionBelow(string $version): string { + [$major, $minor] = array_map(intval(...), explode('.', $version)); + + return $minor > 0 ? sprintf('%d.%d.99', $major, $minor - 1) : sprintf('%d.99.99', $major - 1); + } + + /** + * Reads a file from the module root. + * + * @param string $name + * The file name, relative to the module root. + * + * @return string + * The file contents. + */ + private function readModuleFile(string $name): string { + $contents = file_get_contents($this->moduleRoot() . '/' . $name); + $this->assertIsString($contents, sprintf('Cannot read %s.', $name)); + + return $contents; + } + + /** + * Returns the module root directory. + * + * @return string + * The absolute path of the directory that holds the info file. + */ + private function moduleRoot(): string { + return dirname(__DIR__, 3); + } + +}