From 1187c00c07cacdb11577d9a410558db044563d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bouc=CC=8Cek?= Date: Tue, 13 Oct 2026 12:24:20 +0200 Subject: [PATCH 1/5] docs: draft extension inventory with NEON compatibility findings --- tools/tests/expected-extensions.neon | 90 ++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tools/tests/expected-extensions.neon diff --git a/tools/tests/expected-extensions.neon b/tools/tests/expected-extensions.neon new file mode 100644 index 0000000..ea74c73 --- /dev/null +++ b/tools/tests/expected-extensions.neon @@ -0,0 +1,90 @@ +# DRAFT: not wired into builds. See build-notes.md for dependency blockers. +# Profiles describe image tags, not PHP_SAPI. base means neither debug nor cli. +# mbstring exceptions record observations and require review before adoption. +profiles: + - '8.4' + - 8.4-cli + - 8.4-debug + - '8.5' + - 8.5-cli + - 8.5-debug + - 8.6-rc + - 8.6-rc-cli + - 8.6-rc-debug + +common: + - bcmath + - bz2 + - calendar + - core + - ctype + - curl + - date + - dom + - exif + - fileinfo + - filter + - gd + - gettext + - gmp + - hash + - iconv + - intl + - json + - libxml + - mbstring + - memcached + - mysqli + - mysqlnd + - openssl + - pcntl + - pcre + - pdo + - pdo_mysql + - pdo_sqlite + - phar + - posix + - random + - readline + - reflection + - session + - simplexml + - soap + - sockets + - sodium + - spl + - sqlite3 + - standard + - sysvmsg + - sysvsem + - sysvshm + - tokenizer + - xml + - xmlreader + - xmlwriter + - xsl + - zend opcache + - zip + - zlib + +rules: + - when: ['>=8.5'] + add: [lexbor, uri] + + - when: [+debug] + add: [xdebug, spx] + +# Applied last, only to the exact named profile; never to future stable 8.6. +exceptions: + 8.6-rc: + remove: [mbstring] + reason: Observed missing in local PHP 8.6.0RC3; intent is not yet approved. + removeWhen: Resolve mbstring policy before enabling the inventory check; revisit at 8.6 stable. + 8.6-rc-cli: + remove: [mbstring] + reason: Observed missing in local PHP 8.6.0RC3; intent is not yet approved. + removeWhen: Resolve mbstring policy before enabling the inventory check; revisit at 8.6 stable. + 8.6-rc-debug: + remove: [mbstring] + reason: Observed missing in local PHP 8.6.0RC3; intent is not yet approved. + removeWhen: Resolve mbstring policy before enabling the inventory check; revisit at 8.6 stable. From 5425f28c84b9a59b4c16fd2b06702b79b83d759d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bouc=CC=8Cek?= Date: Tue, 13 Oct 2026 12:36:02 +0200 Subject: [PATCH 2/5] docs: select verified stable NEON and require mbstring in PHP 8.6 --- tools/tests/expected-extensions.neon | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/tools/tests/expected-extensions.neon b/tools/tests/expected-extensions.neon index ea74c73..dfaec25 100644 --- a/tools/tests/expected-extensions.neon +++ b/tools/tests/expected-extensions.neon @@ -1,7 +1,13 @@ -# DRAFT: not wired into builds. See build-notes.md for dependency blockers. +# DRAFT: not wired into builds. Parser choice and validation evidence: build-notes.md. # Profiles describe image tags, not PHP_SAPI. base means neither debug nor cli. -# mbstring exceptions record observations and require review before adoption. +# mbstring is mandatory, including PHP 8.6 RC; current images need repair. profiles: + - '8.2' + - 8.2-cli + - 8.2-debug + - '8.3' + - 8.3-cli + - 8.3-debug - '8.4' - 8.4-cli - 8.4-debug @@ -74,17 +80,5 @@ rules: - when: [+debug] add: [xdebug, spx] -# Applied last, only to the exact named profile; never to future stable 8.6. -exceptions: - 8.6-rc: - remove: [mbstring] - reason: Observed missing in local PHP 8.6.0RC3; intent is not yet approved. - removeWhen: Resolve mbstring policy before enabling the inventory check; revisit at 8.6 stable. - 8.6-rc-cli: - remove: [mbstring] - reason: Observed missing in local PHP 8.6.0RC3; intent is not yet approved. - removeWhen: Resolve mbstring policy before enabling the inventory check; revisit at 8.6 stable. - 8.6-rc-debug: - remove: [mbstring] - reason: Observed missing in local PHP 8.6.0RC3; intent is not yet approved. - removeWhen: Resolve mbstring policy before enabling the inventory check; revisit at 8.6 stable. +# Exact-profile exceptions apply last. Missing mbstring is a build defect, not an exception. +exceptions: {} From 5ba4900e57f5a06db72a4a73de262a4ae0f4448e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bouc=CC=8Cek?= Date: Tue, 13 Oct 2026 17:41:28 +0200 Subject: [PATCH 3/5] tests: enforce PHP extension inventories from NEON rules Use stable NEON 3.4.8 and validate recursive conditions, exact exceptions and rule conflicts. Derive the variant from DEVSTACK_IMAGE and the version from PHP, without a profile allowlist. Report missing and unexpected modules without skipping the inventory check. Validated rule behavior for future versions, nested conditions, exceptions and malformed data. Full suites passed in all 15 maintained image variants; negative container probes rejected wrong variants and missing identity. PhpStorm inspections reported no warnings. --- tools/composer.json | 5 +- tools/src/ExtensionInventory.php | 199 +++++++++++++++++++++++++++ tools/tests/ExtensionsTest.php | 18 +++ tools/tests/Inventory/RulesTest.php | 87 ++++++++++++ tools/tests/bootstrap.php | 1 + tools/tests/expected-extensions.neon | 21 +-- 6 files changed, 309 insertions(+), 22 deletions(-) create mode 100644 tools/src/ExtensionInventory.php create mode 100644 tools/tests/ExtensionsTest.php create mode 100644 tools/tests/Inventory/RulesTest.php diff --git a/tools/composer.json b/tools/composer.json index b18db1e..ec5aa41 100644 --- a/tools/composer.json +++ b/tools/composer.json @@ -12,9 +12,10 @@ "license": "MIT", "type": "project", "require": { - "php": ">=8.1 <=8.5", + "php": ">=8.1", "nette/tester": "^2.5", - "tracy/tracy": "^2" + "tracy/tracy": "^2", + "nette/neon": "3.4.8" }, "config": { "platform": { diff --git a/tools/src/ExtensionInventory.php b/tools/src/ExtensionInventory.php new file mode 100644 index 0000000..be87514 --- /dev/null +++ b/tools/src/ExtensionInventory.php @@ -0,0 +1,199 @@ + $rule) { + self::keys($rule, ['when', 'add', 'remove'], "rules.$i"); + self::condition($rule['when'] ?? null, "rules.$i.when"); + self::changes($rule, "rules.$i"); + } + if (!is_array($config['exceptions'] ?? null)) { + throw new InvalidArgumentException('exceptions must be a map'); + } + foreach ($config['exceptions'] as $profile => $exception) { + if (!is_string($profile) || !preg_match('~^\d+\.\d+(?:-rc)?(?:-(?:debug|cli))?$~D', $profile)) { + throw new InvalidArgumentException('Exception keys must be exact minor profiles, e.g. 8.6-rc-debug'); + } + self::keys($exception, ['add', 'remove', 'reason', 'removeWhen'], "exceptions.$profile"); + self::changes($exception, "exceptions.$profile"); + foreach (['reason', 'removeWhen'] as $key) { + if (!is_string($exception[$key] ?? null) || trim($exception[$key]) === '') { + throw new InvalidArgumentException("exceptions.$profile.$key must explain the exception"); + } + } + } + $this->config = $config; + } + + /** Resolve image identity independently of its loaded extensions and PHP_SAPI. */ + public static function context(string $image, string $phpVersion): array + { + if (!preg_match('~^(\d+)\.(\d+)\.\d+(.*)$~D', $phpVersion, $runtime)) { + throw new InvalidArgumentException('Invalid PHP runtime version'); + } + $minor = $runtime[1] . '.' . $runtime[2]; + $rc = $runtime[3] !== ''; + if (!preg_match('~^[^\s@]+:([^/:]+)$~D', $image, $reference)) { + throw new InvalidArgumentException('DEVSTACK_IMAGE must contain the full image reference with a tag'); + } + $tag = $reference[1]; + if (preg_match('~^(\d+)(?:\.(\d+)(?:\.\d+)?)?(?:(-rc)(?:-(?:alpha|beta|RC)\d+)?)?(?:-(debug|cli))?$~D', $tag, $parts)) { + if ($parts[1] !== $runtime[1] || (isset($parts[2]) && $parts[2] !== '' && $parts[2] !== $runtime[2])) { + throw new InvalidArgumentException("Image tag $tag does not match PHP $phpVersion"); + } + if (($parts[3] ?? '') !== ($rc ? '-rc' : '')) { + throw new InvalidArgumentException("Image tag $tag does not match PHP pre-release status $phpVersion"); + } + $variant = ($parts[4] ?? '') ?: 'base'; + } elseif (in_array($tag, ['latest', 'debug', 'cli'], true)) { + $variant = $tag === 'latest' ? 'base' : $tag; + } else { + throw new InvalidArgumentException("Unsupported image tag $tag; use a version tag with optional -rc and -debug/-cli"); + } + $flags = [$variant]; + if ($rc) { + $flags[] = 'rc'; + } + return [ + 'version' => $minor, + 'flags' => $flags, + 'profile' => $minor . ($rc ? '-rc' : '') . ($variant === 'base' ? '' : '-' . $variant), + ]; + } + + public function expected(array $context): array + { + $add = $remove = []; + foreach ($this->config['rules'] as $rule) { + if (self::matches($rule['when'], $context)) { + $add = array_merge($add, self::extensions($rule['add'] ?? [], 'add')); + $remove = array_merge($remove, self::extensions($rule['remove'] ?? [], 'remove')); + } + } + $conflicts = array_intersect($add, $remove); + if ($conflicts) { + throw new InvalidArgumentException('Conflicting matching rules: ' . implode(', ', array_unique($conflicts))); + } + $expected = array_diff(array_merge(self::extensions($this->config['common'], 'common'), $add), $remove); + $exception = $this->config['exceptions'][$context['profile']] ?? []; + $expected = array_diff( + array_merge($expected, self::extensions($exception['add'] ?? [], 'add')), + self::extensions($exception['remove'] ?? [], 'remove'), + ); + $expected = array_values(array_unique($expected)); + sort($expected); + return $expected; + } + + public function difference(array $context, array $loaded): array + { + $expected = $this->expected($context); + $actual = array_map('strtolower', $loaded); + $missing = array_values(array_diff($expected, $actual)); + $unexpected = array_values(array_unique(array_diff($actual, $expected))); + sort($unexpected); + return ['missing' => $missing, 'unexpected' => $unexpected]; + } + + private static function keys(mixed $value, array $allowed, string $path): void + { + if (!is_array($value) || array_diff(array_keys($value), $allowed)) { + throw new InvalidArgumentException("$path must be a map with only: " . implode(', ', $allowed)); + } + } + + private static function extensions(mixed $value, string $path): array + { + if (!is_array($value) || !array_is_list($value)) { + throw new InvalidArgumentException("$path must be a list of extension names"); + } + $names = []; + foreach ($value as $name) { + if (!is_string($name) || !preg_match('~^[a-zA-Z][a-zA-Z0-9_ ]*$~D', $name) || trim($name) !== $name) { + throw new InvalidArgumentException("$path contains an invalid extension name"); + } + $names[] = strtolower($name); + } + if (count($names) !== count(array_unique($names))) { + throw new InvalidArgumentException("$path contains duplicate extension names"); + } + return $names; + } + + private static function changes(array $value, string $path): void + { + if (!array_key_exists('add', $value) && !array_key_exists('remove', $value)) { + throw new InvalidArgumentException("$path needs add or remove"); + } + $add = self::extensions(array_key_exists('add', $value) ? $value['add'] : [], "$path.add"); + $remove = self::extensions(array_key_exists('remove', $value) ? $value['remove'] : [], "$path.remove"); + if (array_intersect($add, $remove)) { + throw new InvalidArgumentException("$path adds and removes the same extension"); + } + } + + private static function condition(mixed $value, string $path): void + { + if (is_string($value)) { + if (!preg_match('~^(?:[+-](?:base|debug|cli|rc)|(?:>=|<=|!=|=|>|<)\d+(?:\.\d+)?)$~D', $value)) { + throw new InvalidArgumentException("Invalid condition at $path: $value"); + } + return; + } + if (!is_array($value) || !$value) { + throw new InvalidArgumentException("$path must be a condition or a nonempty condition list"); + } + if (!array_is_list($value)) { + if (count($value) !== 1 || !in_array(array_key_first($value), ['all', 'any'], true)) { + throw new InvalidArgumentException("Invalid map at $path; quote version comparisons containing ="); + } + $key = array_key_first($value); + $value = $value[$key]; + $path .= '.' . $key; + if (!is_array($value) || !array_is_list($value) || !$value) { + throw new InvalidArgumentException("$path must be a nonempty list"); + } + } + foreach ($value as $i => $child) { + self::condition($child, "$path.$i"); + } + } + + private static function matches(string|array $condition, array $context): bool + { + if (is_string($condition)) { + if ($condition[0] === '+' || $condition[0] === '-') { + return in_array(substr($condition, 1), $context['flags'], true) === ($condition[0] === '+'); + } + preg_match('~^(>=|<=|!=|=|>|<)(\d+)(?:\.(\d+))?$~D', $condition, $parts); + return version_compare($context['version'], $parts[2] . '.' . ($parts[3] ?? '0'), $parts[1]); + } + $any = array_key_exists('any', $condition); + $children = $condition['any'] ?? $condition['all'] ?? $condition; + foreach ($children as $child) { + $match = self::matches($child, $context); + if ($any && $match) { + return true; + } + if (!$any && !$match) { + return false; + } + } + return !$any; + } +} diff --git a/tools/tests/ExtensionsTest.php b/tools/tests/ExtensionsTest.php new file mode 100644 index 0000000..a23bac6 --- /dev/null +++ b/tools/tests/ExtensionsTest.php @@ -0,0 +1,18 @@ +difference($context, get_loaded_extensions()); +Assert::true($difference === ['missing' => [], 'unexpected' => []], + 'PHP ' . PHP_VERSION . ', profile ' . $context['profile'] + . '; missing: ' . implode(', ', $difference['missing']) + . '; unexpected: ' . implode(', ', $difference['unexpected'])); diff --git a/tools/tests/Inventory/RulesTest.php b/tools/tests/Inventory/RulesTest.php new file mode 100644 index 0000000..faa7eaf --- /dev/null +++ b/tools/tests/Inventory/RulesTest.php @@ -0,0 +1,87 @@ + ['Core'], 'rules' => $rules, 'exceptions' => $exceptions]); +} + +function context(string $tag = '8.6-rc-debug', string $version = '8.6.0RC3'): array +{ + return ExtensionInventory::context('registry.example:5000/dev/php:' . $tag, $version); +} + +$debug = context(); +Assert::same(['debug', 'rc'], $debug['flags']); +Assert::same('8.6-rc-debug', $debug['profile']); +Assert::same(['base'], context('8.5', '8.5.11')['flags']); +Assert::same(['cli'], context('8-cli', '8.5.11')['flags']); +Assert::same('8.6-rc-debug', context('8.6.0-rc-RC3-debug')['profile']); +Assert::same('8.7-debug', context('debug', '8.7.0')['profile']); + +$rules = [ + ['when' => ['>=8.6', ['any' => ['+debug', '+cli']]], 'add' => ['intl']], + ['when' => ['all' => ['+debug', '-cli']], 'add' => ['Xdebug', 'SPX']], + ['when' => ['+base'], 'remove' => ['core']], +]; +$matrix = inventory($rules); +Assert::same(['core', 'intl', 'spx', 'xdebug'], $matrix->expected($debug)); +Assert::same(['core', 'intl'], $matrix->expected(context('8.7-cli', '8.7.0'))); +Assert::same(['core', 'intl', 'spx', 'xdebug'], $matrix->expected(context('8.7-debug', '8.7.0'))); +Assert::same([], $matrix->expected(context('8.5', '8.5.11'))); +Assert::same(['missing' => ['intl'], 'unexpected' => ['extra']], + $matrix->difference($debug, ['Core', 'SPX', 'Xdebug', 'extra'])); +Assert::same(['missing' => [], 'unexpected' => []], + $matrix->difference($debug, ['Xdebug', 'SPX', 'intl', 'Core'])); + +foreach (['=8.4' => true, '!=8.4' => false, '>8.4' => false, '>=8.4' => true, + '<8.4' => false, '<=8.4' => true, '<8' => false, '>8' => true] as $condition => $matches) { + $i = inventory([['when' => [$condition], 'add' => ['intl']]]); + Assert::same($matches ? ['core', 'intl'] : ['core'], $i->expected(context('8.4', '8.4.26'))); +} +Assert::same(['core', 'intl'], inventory([['when' => ['>8.9'], 'add' => ['intl']]]) + ->expected(context('8.10', '8.10.0'))); + +$exception = ['8.6-rc-debug' => [ + 'remove' => ['xdebug'], 'add' => ['json'], + 'reason' => 'Test fixture', 'removeWhen' => 'When the fixture ends', +]]; +Assert::same(['core', 'intl', 'json', 'spx'], inventory($rules, $exception)->expected($debug)); +Assert::same(['core', 'intl', 'spx', 'xdebug'], inventory($rules, $exception) + ->expected(context('8.6-debug', '8.6.0'))); +Assert::exception(fn() => inventory([ + ['when' => ['+debug'], 'add' => ['SPX']], + ['when' => ['>=8'], 'remove' => ['spx']], +])->expected($debug), InvalidArgumentException::class, 'Conflicting matching rules: spx'); + +foreach (['', '8.6-rc-cli-debug', '8.6-rc-dev'] as $tag) { + Assert::exception(fn() => context($tag), InvalidArgumentException::class); +} +Assert::exception(fn() => ExtensionInventory::context('', '8.6.0RC3'), InvalidArgumentException::class); +Assert::exception(fn() => context('8.5-debug'), InvalidArgumentException::class); +Assert::exception(fn() => context('8.6-debug'), InvalidArgumentException::class); + +foreach ([[], 8.6, ['>' => 8.6], ['all' => []], ['any' => ['+debug'], 'all' => ['+cli']], + ['any' => '+debug'], ['+debig'], ['>=8.6.0'], [1 => '+debug'], ['all' => [['wat' => ['+cli']]]]] as $bad) { + Assert::exception(fn() => inventory([['when' => $bad, 'add' => ['intl']]]), InvalidArgumentException::class); +} +$unquoted = Neon::decode("when:\n\t- >=8.6\n\t- -cli"); +Assert::exception(fn() => inventory([['when' => $unquoted['when'], 'add' => ['intl']]]), + InvalidArgumentException::class, 'Invalid map at rules.0.when.0; quote version comparisons containing ='); +foreach ([ + ['common' => ['Core', 'core'], 'rules' => [], 'exceptions' => []], + ['common' => [], 'rules' => [], 'exceptions' => [], 'profiles' => []], + ['common' => [], 'rules' => [['when' => ['+cli'], 'add' => null]], 'exceptions' => []], + ['common' => [], 'rules' => [['when' => ['+cli'], 'add' => ['x'], 'remove' => ['X']]], 'exceptions' => []], + ['common' => [], 'rules' => [], 'exceptions' => ['8.6-rc' => ['remove' => ['x']]]], +] as $bad) { + Assert::exception(fn() => new ExtensionInventory($bad), InvalidArgumentException::class); +} diff --git a/tools/tests/bootstrap.php b/tools/tests/bootstrap.php index 642f038..6c69168 100644 --- a/tools/tests/bootstrap.php +++ b/tools/tests/bootstrap.php @@ -4,4 +4,5 @@ require __DIR__ . '/../vendor/autoload.php'; Tester\Environment::setup(); +Tester\Dumper::$dumpDir = sys_get_temp_dir() . '/devstack-test-output'; date_default_timezone_set('Europe/Prague'); diff --git a/tools/tests/expected-extensions.neon b/tools/tests/expected-extensions.neon index dfaec25..85508fc 100644 --- a/tools/tests/expected-extensions.neon +++ b/tools/tests/expected-extensions.neon @@ -1,23 +1,4 @@ -# DRAFT: not wired into builds. Parser choice and validation evidence: build-notes.md. -# Profiles describe image tags, not PHP_SAPI. base means neither debug nor cli. -# mbstring is mandatory, including PHP 8.6 RC; current images need repair. -profiles: - - '8.2' - - 8.2-cli - - 8.2-debug - - '8.3' - - 8.3-cli - - 8.3-debug - - '8.4' - - 8.4-cli - - 8.4-debug - - '8.5' - - 8.5-cli - - 8.5-debug - - 8.6-rc - - 8.6-rc-cli - - 8.6-rc-debug - +# Format: ../../docs/extension-inventory.md common: - bcmath - bz2 From 0210bb2e1635c9f8062694736adb1e198d296e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bouc=CC=8Cek?= Date: Tue, 13 Oct 2026 17:42:00 +0200 Subject: [PATCH 4/5] build: run image-aware extension checks before publishing Run all configured tags or one requested tag through test-php.sh, passing DEVSTACK_IMAGE and aggregating failures. Invoke the same suite from all maintained PHP build scripts before push, preserving NO_TEST. Mount tooling read-only and document the NEON contract; keep investigation notes ignored. All ten real build test phases passed with NO_PULL=1 NO_BUILD=1 NO_PUSH=1. Mocked Docker checks confirmed that failed tests prevent any push, successful tests precede push, and NO_TEST bypass works. No images were rebuilt or published. --- .gitignore | 1 + CLAUDE.md | 21 +++++++-- docs/extension-inventory.md | 93 +++++++++++++++++++++++++++++++++++++ php/build-php-8.2-cli.sh | 1 + php/build-php-8.2.sh | 2 + php/build-php-8.3-cli.sh | 1 + php/build-php-8.3.sh | 2 + php/build-php-8.4-cli.sh | 1 + php/build-php-8.4.sh | 2 + php/build-php-8.5-cli.sh | 1 + php/build-php-8.5.sh | 2 + php/build-php-8.6-cli.sh | 1 + php/build-php-8.6.sh | 2 + test-php.sh | 53 ++++++++++++++------- 14 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 docs/extension-inventory.md diff --git a/.gitignore b/.gitignore index ab94291..d8ead09 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /php/build /tools/composer.lock /tools/vendor +/docs/agent-proposal/ diff --git a/CLAUDE.md b/CLAUDE.md index 6f83b00..3c9903f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,14 @@ Source for the prebuilt Docker images `jakubboucek/lamp-devstack-php` and `jakub The project is maintained primarily to serve the maintainer's own development workflow and is published because others found it useful. Its feature set is intentionally opinionated: components and extensions are included only when the maintainer actually needs them, and deliberately omitted otherwise. Any apparent imbalance in the package scope is a design choice, not an oversight. Suggestions and critical feedback on the scope are welcome — but never add new components or extensions on your own initiative; always consult the maintainer first. +## Documentation + +Keep tracked documentation focused on the current project and configuration formats. +Store implementation plans, investigation notes and measurement snapshots in the +ignored `docs/agent-proposal/` directory. Record one-off validation results in commit +messages or PR descriptions. The extension inventory declaration format is described +in [docs/extension-inventory.md](docs/extension-inventory.md). + ## Build commands All build scripts follow the same four-phase pattern (pull → build → test → push), each phase skippable via ENV flags: `NO_PULL=1`, `NO_BUILD=1`, `NO_TEST=1`, `NO_PUSH=1`, `NO_ASSETS=1`. @@ -27,14 +35,17 @@ Common examples: `NO_PUSH=1 ./build-php.sh` (local build only), `NO_PULL=1 NO_BU ## Tests ```bash -./test-php.sh # run test suite inside every current PHP image (8.2–8.5 + 8.6 RC, apache + cli, plus 8.6 RC debug) +./test-php.sh # test every tag listed at the top of the script (base, cli, debug) +./test-php.sh 8.6-rc-debug # test a single tag ``` -Tests live in `tools/tests/` (Nette Tester, one `*Test.php` per PHP extension in `tools/tests/Extensions/`). To run in a single image: +Prepare dependencies with `composer -d tools install` before running tests or build +checks. Build scripts run the same suite before pushing, unless `NO_TEST=1`. -```bash -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.5 composer -d tools/ test -``` +Tests live in `tools/tests/` (Nette Tester, one `*Test.php` per PHP extension in +`tools/tests/Extensions/`). The script passes the full image reference through +`DEVSTACK_IMAGE`, runs without an interactive terminal, and returns a nonzero status +if any container fails while continuing through the remaining tags. `tools/compute-minimal-ext-deps.php` is a build helper: run inside an image to compute the minimal runtime package list (`apt-mark` command) that replaces `-dev` packages in Dockerfiles — use it when changing extension deps. diff --git a/docs/extension-inventory.md b/docs/extension-inventory.md new file mode 100644 index 0000000..b80a06d --- /dev/null +++ b/docs/extension-inventory.md @@ -0,0 +1,93 @@ +# Extension inventory declaration + +`tools/tests/expected-extensions.neon` declares the expected PHP extensions for each +image profile. `tools/tests/ExtensionsTest.php` checks this declaration as part of +Nette Tester. Current build scripts invoke the suite before publishing images. + +Prepare tooling with `composer -d tools install`, then use `./test-php.sh` or +`./test-php.sh 8.6-rc-debug`. Dependencies are installed on the host and mounted +read-only into containers; they are not added to published images. Composer resolves +for PHP 8.1 via `config.platform.php`. NEON 3.4.8 is pinned: its declared upper bound +is 8.5, but its runtime behavior has also been verified for this project on PHP 8.6. + +The runner passes `DEVSTACK_IMAGE` with the full image reference. The test reads the +actual PHP version, derives base/CLI/debug from the tag, and checks that numeric tags +match the runtime major/minor and pre-release status. Numeric tags may include patch +versions and RC release labels; aliases `latest`, `debug`, `cli`, and major tags such +as `8-cli` also work. Exact exceptions use the canonical minor profile (e.g. +`8.6-rc-debug`), independent of the patch tag used to launch the container. + +Every pre-release runtime (alpha, beta, RC or dev) receives the `rc` flag. Unknown +future PHP versions use the same rules without registration; unrecognized tag +suffixes and missing image identity are errors. The functional extension tests retain +their skips, but the inventory test never skips missing extensions. + +## Structure + +- `common`: extensions expected in every supported profile. +- `rules`: a list of conditions (`when`) and extension lists to `add` or `remove`. + All matching rules apply. Conflicting additions/removals between matching rules are + errors; rule order does not resolve conflicts. +- `exceptions`: a map keyed by exact profile, applied after ordinary rules. Each entry + has `add` and/or `remove`, a `reason`, and a `removeWhen` condition for retiring it. + Use `{}` when no exceptions are needed. Quote numeric-looking keys such as `'8.5'`. + +Extension names are compared case-insensitively (`SPX` becomes `spx`, `Zend OPcache` +becomes `zend opcache`). Missing and unexpected extensions both fail the check; +`remove` means the extension must be absent, not optional. + +## Conditions + +A plain condition list means AND. Explicit `all` (AND) and `any` (OR) accept lists +of conditions and may be nested. For example, PHP 8.6 or later with debug or CLI: + +```neon +when: + - '>=8.6' + - any: [+debug, +cli] +``` + +PHP 8.6 or later in any non-CLI variant: + +```neon +when: + - '>=8.6' + - -cli +``` + +Version operators are `=`, `!=`, `>`, `>=`, `<`, and `<=`. They compare PHP minor +series: `=8.4` includes every 8.4.x, `>8.4` starts at 8.5, and `<8` means below 8.0. +Pre-release status is a flag and does not affect numeric version comparisons. + +`+flag` requires a flag; `-flag` excludes it. Flags are `debug`, `cli`, `rc`, and +synthetic `base` (neither debug nor CLI): + +| Profile | Flags | +| --- | --- | +| `8.5` | base | +| `8.5-debug` | debug | +| `8.5-cli` | cli | +| `8.6-rc` | base, rc | +| `8.6-rc-debug` | debug, rc | + +Thus `-debug` also matches CLI, while `+base` includes RC base images. Unknown flags +and unsupported combinations such as CLI plus debug are errors. + +Unquoted conditions are allowed when NEON parses them as strings. Operators containing +`=` require quotes because NEON otherwise interprets them as mappings or syntax errors. +Validate recursively: accept string leaves, nonempty lists with consecutive integer +keys starting at zero, and maps containing exactly one `all` or `any` key with a +nonempty condition list. Reject other maps, non-string leaves and empty logical nodes. +Never reconstruct a comparator from a parsed mapping: numeric conversion may have +already changed a version such as `8.10` to `8.1`. + +The configuration validator checks every rule, including inactive rules, and rejects +unknown fields, duplicate extension names and malformed exception entries. String +conditions also work directly in `when`. An exception cannot resolve a conflict between +ordinary matching rules: fix the conflicting rules first. + +A failure reports the runtime PHP version, canonical profile and sorted missing and +unexpected modules. Build scripts honor `NO_TEST=1` as an explicit bypass; otherwise +a failing suite prevents that build script from reaching its push phase. This does +not make a multi-version release atomic. The suite inspects CLI-loaded modules in +each image; HTTP/Apache behavior remains a separate concern. diff --git a/php/build-php-8.2-cli.sh b/php/build-php-8.2-cli.sh index 04471c2..c5f1ad0 100755 --- a/php/build-php-8.2-cli.sh +++ b/php/build-php-8.2-cli.sh @@ -23,6 +23,7 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.2-cli docker run --rm jakubboucek/lamp-devstack-php:8.2-cli php --version docker run --rm jakubboucek/lamp-devstack-php:8.2-cli php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" docker run --rm jakubboucek/lamp-devstack-php:8.2-cli php -r "var_export(gd_info()) . PHP_EOL;" diff --git a/php/build-php-8.2.sh b/php/build-php-8.2.sh index e24510b..ee347d5 100755 --- a/php/build-php-8.2.sh +++ b/php/build-php-8.2.sh @@ -24,6 +24,8 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.2 + ../test-php.sh 8.2-debug docker run --rm jakubboucek/lamp-devstack-php:8.2 php --version docker run --rm jakubboucek/lamp-devstack-php:8.2-debug php --version docker run --rm jakubboucek/lamp-devstack-php:8.2 php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" diff --git a/php/build-php-8.3-cli.sh b/php/build-php-8.3-cli.sh index add935a..406505e 100755 --- a/php/build-php-8.3-cli.sh +++ b/php/build-php-8.3-cli.sh @@ -22,6 +22,7 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.3-cli docker run --rm jakubboucek/lamp-devstack-php:8.3-cli php --version docker run --rm jakubboucek/lamp-devstack-php:8.3-cli php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" docker run --rm jakubboucek/lamp-devstack-php:8.3-cli php -r "var_export(gd_info()) . PHP_EOL;" diff --git a/php/build-php-8.3.sh b/php/build-php-8.3.sh index 2a2681f..409db64 100755 --- a/php/build-php-8.3.sh +++ b/php/build-php-8.3.sh @@ -24,6 +24,8 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.3 + ../test-php.sh 8.3-debug docker run --rm jakubboucek/lamp-devstack-php:8.3 php --version docker run --rm jakubboucek/lamp-devstack-php:8.3-debug php --version docker run --rm jakubboucek/lamp-devstack-php:8.3 php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" diff --git a/php/build-php-8.4-cli.sh b/php/build-php-8.4-cli.sh index 54ad8d7..8b1beca 100755 --- a/php/build-php-8.4-cli.sh +++ b/php/build-php-8.4-cli.sh @@ -22,6 +22,7 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.4-cli docker run --rm jakubboucek/lamp-devstack-php:8.4-cli php --version docker run --rm jakubboucek/lamp-devstack-php:8.4-cli php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" docker run --rm jakubboucek/lamp-devstack-php:8.4-cli php -r "var_export(gd_info()) . PHP_EOL;" diff --git a/php/build-php-8.4.sh b/php/build-php-8.4.sh index 6a77d07..93aa665 100755 --- a/php/build-php-8.4.sh +++ b/php/build-php-8.4.sh @@ -24,6 +24,8 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.4 + ../test-php.sh 8.4-debug docker run --rm jakubboucek/lamp-devstack-php:8.4 php --version docker run --rm jakubboucek/lamp-devstack-php:8.4-debug php --version docker run --rm jakubboucek/lamp-devstack-php:8.4 php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" diff --git a/php/build-php-8.5-cli.sh b/php/build-php-8.5-cli.sh index fa6e9b8..1cffcd0 100755 --- a/php/build-php-8.5-cli.sh +++ b/php/build-php-8.5-cli.sh @@ -24,6 +24,7 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.5-cli docker run --rm jakubboucek/lamp-devstack-php:8.5-cli php --version docker run --rm jakubboucek/lamp-devstack-php:8.5-cli php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" docker run --rm jakubboucek/lamp-devstack-php:8.5-cli php -r "var_export(gd_info()) . PHP_EOL;" diff --git a/php/build-php-8.5.sh b/php/build-php-8.5.sh index 11603e8..5a2e4ba 100755 --- a/php/build-php-8.5.sh +++ b/php/build-php-8.5.sh @@ -28,6 +28,8 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.5 + ../test-php.sh 8.5-debug docker run --rm jakubboucek/lamp-devstack-php:8.5 php --version docker run --rm jakubboucek/lamp-devstack-php:8.5-debug php --version docker run --rm jakubboucek/lamp-devstack-php:8.5 php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" diff --git a/php/build-php-8.6-cli.sh b/php/build-php-8.6-cli.sh index f3c2bcb..dd5a6de 100755 --- a/php/build-php-8.6-cli.sh +++ b/php/build-php-8.6-cli.sh @@ -25,6 +25,7 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.6-rc-cli docker run --rm jakubboucek/lamp-devstack-php:8.6-rc-cli php --version docker run --rm jakubboucek/lamp-devstack-php:8.6-rc-cli php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" docker run --rm jakubboucek/lamp-devstack-php:8.6-rc-cli php -r "var_export(gd_info()) . PHP_EOL;" diff --git a/php/build-php-8.6.sh b/php/build-php-8.6.sh index b6590a6..0fe7769 100755 --- a/php/build-php-8.6.sh +++ b/php/build-php-8.6.sh @@ -27,6 +27,8 @@ if [ "${NO_BUILD:-0}" -ne "1" ]; then fi if [ "${NO_TEST:-0}" -ne "1" ]; then + ../test-php.sh 8.6-rc + ../test-php.sh 8.6-rc-debug docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php --version docker run --rm jakubboucek/lamp-devstack-php:8.6-rc-debug php --version docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" diff --git a/test-php.sh b/test-php.sh index 42ef486..6add738 100755 --- a/test-php.sh +++ b/test-php.sh @@ -1,18 +1,39 @@ #!/bin/bash -# shellcheck disable=SC2086 -set -ux; - -cd "$(dirname $0)"; - -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.2 composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.2-cli composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.3 composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.3-cli composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.4 composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.4-cli composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.5 composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.5-cli composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.6-rc composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.6-rc-cli composer -d tools/ test; -docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.6-rc-debug composer -d tools/ test; +TAGS=( + 8.2 8.2-cli 8.2-debug + 8.3 8.3-cli 8.3-debug + 8.4 8.4-cli 8.4-debug + 8.5 8.5-cli 8.5-debug + 8.6-rc 8.6-rc-cli 8.6-rc-debug +) + +set -ux + +if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ -z "$1" ]; }; then + echo "Usage: $0 [tag]" >&2 + exit 2 +fi + +if [ "$#" -eq 1 ]; then + TAGS=("$1") +fi + +cd "$(dirname "$0")" || exit 1 + +if [ ! -f tools/vendor/autoload.php ]; then + echo "Install test dependencies first: composer -d tools install" >&2 + exit 1 +fi + +status=0 +for tag in "${TAGS[@]}"; do + image="jakubboucek/lamp-devstack-php:$tag" + if ! docker run --rm -v "$(pwd)":/app:ro -w /app \ + -e "DEVSTACK_IMAGE=$image" \ + "$image" composer -d tools/ test; then + status=1 + fi +done + +exit "$status" From f5ab0d146063f97d50016657a33d4803fa92e2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bouc=CC=8Cek?= Date: Tue, 13 Oct 2026 22:42:27 +0200 Subject: [PATCH 5/5] tests: finalize image identity and document inventory examples --- CLAUDE.md | 2 +- docs/extension-inventory.md | 131 ++++++++++++++++++++++++++- test-php.sh | 4 +- tools/src/ExtensionInventory.php | 2 +- tools/tests/ExtensionsTest.php | 6 +- tools/tests/expected-extensions.neon | 21 +++-- 6 files changed, 149 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c9903f..f5e4d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ checks. Build scripts run the same suite before pushing, unless `NO_TEST=1`. Tests live in `tools/tests/` (Nette Tester, one `*Test.php` per PHP extension in `tools/tests/Extensions/`). The script passes the full image reference through -`DEVSTACK_IMAGE`, runs without an interactive terminal, and returns a nonzero status +`IMAGE_NAME`, runs without an interactive terminal, and returns a nonzero status if any container fails while continuing through the remaining tags. `tools/compute-minimal-ext-deps.php` is a build helper: run inside an image to compute the minimal runtime package list (`apt-mark` command) that replaces `-dev` packages in Dockerfiles — use it when changing extension deps. diff --git a/docs/extension-inventory.md b/docs/extension-inventory.md index b80a06d..bc66a78 100644 --- a/docs/extension-inventory.md +++ b/docs/extension-inventory.md @@ -10,7 +10,7 @@ read-only into containers; they are not added to published images. Composer reso for PHP 8.1 via `config.platform.php`. NEON 3.4.8 is pinned: its declared upper bound is 8.5, but its runtime behavior has also been verified for this project on PHP 8.6. -The runner passes `DEVSTACK_IMAGE` with the full image reference. The test reads the +The runner passes `IMAGE_NAME` with the full image reference. The test reads the actual PHP version, derives base/CLI/debug from the tag, and checks that numeric tags match the runtime major/minor and pre-release status. Numeric tags may include patch versions and RC release labels; aliases `latest`, `debug`, `cli`, and major tags such @@ -29,7 +29,7 @@ their skips, but the inventory test never skips missing extensions. All matching rules apply. Conflicting additions/removals between matching rules are errors; rule order does not resolve conflicts. - `exceptions`: a map keyed by exact profile, applied after ordinary rules. Each entry - has `add` and/or `remove`, a `reason`, and a `removeWhen` condition for retiring it. + has `add` and/or `remove`, a `reason`, and a human-readable `removeWhen` note for retiring it. Use `{}` when no exceptions are needed. Quote numeric-looking keys such as `'8.5'`. Extension names are compared case-insensitively (`SPX` becomes `spx`, `Zend OPcache` @@ -91,3 +91,130 @@ unexpected modules. Build scripts honor `NO_TEST=1` as an explicit bypass; other a failing suite prevents that build script from reaching its push phase. This does not make a multi-version release atomic. The suite inspects CLI-loaded modules in each image; HTTP/Apache behavior remains a separate concern. + +## Exceptions + +An exception targets one canonical minor profile, including its variant and release +status. It has no `when`: the map key selects the profile. For example, +`8.6-rc-debug` matches all patch tags for that RC debug profile, but not `8.6-rc`, +`8.6-rc-cli`, or stable `8.6-debug`. A quoted key `'8.5'` targets only the stable +8.5 base variant. Wildcards and version expressions are not supported as keys. + +Each exception needs at least one of `add` or `remove`, plus two nonempty strings: + +- `reason` explains why the normal expectations do not apply. +- `removeWhen` tells the maintainer when to delete the exception. It is **human-readable + documentation**, not an evaluated condition, date, or automatic expiration mechanism. + Even a string such as `'>=8.7'` would only be text here. Delete the entry manually + once its stated condition is satisfied. + +The following complete miniature configuration is illustrative; it does not describe +an actual missing extension in our current images: + +```neon +exceptions: + 8.6-rc-debug: + remove: [spx] + reason: The profiler cannot currently be built for this RC. + removeWhen: Delete after a compatible profiler is installed and verified. + +rules: + - when: +debug + add: [xdebug, spx] + +common: [core, json] +``` + +The debug rule first adds both profilers. For `8.6-rc-debug`, the exception then +removes `spx`: the final expected set is `core`, `json`, `xdebug`. A loaded `spx` +would fail as unexpected, reminding the maintainer to retire the exception after +restoring it. Other debug profiles still require both profilers. + +Exceptions apply last even when their section appears first in the file. An `add` +can restore an extension removed by an ordinary rule; a `remove` can exclude one +from `common` or a matching rule. A single exception cannot both add and remove the +same extension. Conflicting ordinary rules fail before exceptions are applied. +Use `exceptions: []` or `exceptions: {}` when there are no exceptions; both decode +to an empty PHP array. + +## Examples + +These independent snippets illustrate the rule language, not changes to the current +extension policy. When trying a snippet on its own, supply any omitted top-level +sections (`common`, `rules`, `exceptions`) with empty lists/maps. + +### Version range, alternative variants, and stable releases only + +```neon +rules: + - when: + - '>=8.4' + - '<8.7' + - -rc + - any: [+debug, +cli] + add: [intl] +``` + +Every outer list item must match. This adds `intl` for stable PHP 8.4, 8.5 and 8.6 +in either debug or CLI variants. It excludes base variants, every pre-release, +PHP 8.3 and PHP 8.7+. All patch versions of each matching minor series are included. + +### Alternatives with their own combined conditions + +```neon +rules: + - when: + any: + - all: ['>=8.6', +debug] + - all: ['=8.4', +cli, -rc] + add: [sockets] +``` + +This adds `sockets` if either branch matches: debug PHP 8.6 or later (including RC), +or stable CLI PHP 8.4. It does not match CLI PHP 8.6 or debug PHP 8.5. Each inner +`all` applies only to its own branch; the second branch's `-rc` does not restrict +the first branch. + +### Removing a common extension, then restoring it for one exact profile + +```neon +exceptions: + 8.6-rc: + add: [intl] + reason: This specific RC base profile retains intl for compatibility. + removeWhen: Delete when this profile adopts the general base-image policy. + +rules: + - when: ['>=8.6', +base] + remove: [intl] + +common: [core, intl] +``` + +The rule removes `intl` from base PHP 8.6 and later, both stable and RC. The exception +restores it only for `8.6-rc`. Consequently, stable `8.6` and `8.7-rc` require only +`core`, while `8.6-rc` requires both extensions. CLI and debug variants also retain +both because they never match `+base`. Removing an expectation does not make the +extension optional: it must be absent unless subsequently restored by the exception. + +### Accumulating matching rules without relying on their order + +```neon +rules: + - when: +debug + add: [xdebug, spx] + - when: ['>=8.5'] + add: [lexbor, uri] + - when: ['>=8.6', -cli, '!=8.7'] + add: [intl] +``` + +For `8.6-rc-debug`, all three rules match and their additions are combined with +`common`. For `8.7-debug`, only the first two match. For `8.8-cli`, only the second +matches; `-cli` excludes it from the third. For base `8.8`, the second and third +match. None of these rules excludes RC unless `-rc` is explicitly present. + +Repeated additions across matching rules are combined into one expected extension. +However, adding `remove: [spx]` to the third rule would create a conflict for +`8.6-rc-debug`, because the first rule adds `spx`. Moving either rule earlier or later +would not resolve it; use non-overlapping conditions or a justified exact exception. diff --git a/test-php.sh b/test-php.sh index 6add738..a913497 100755 --- a/test-php.sh +++ b/test-php.sh @@ -8,7 +8,7 @@ TAGS=( 8.6-rc 8.6-rc-cli 8.6-rc-debug ) -set -ux +set -u if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ -z "$1" ]; }; then echo "Usage: $0 [tag]" >&2 @@ -30,7 +30,7 @@ status=0 for tag in "${TAGS[@]}"; do image="jakubboucek/lamp-devstack-php:$tag" if ! docker run --rm -v "$(pwd)":/app:ro -w /app \ - -e "DEVSTACK_IMAGE=$image" \ + -e "IMAGE_NAME=$image" \ "$image" composer -d tools/ test; then status=1 fi diff --git a/tools/src/ExtensionInventory.php b/tools/src/ExtensionInventory.php index be87514..71c3ab8 100644 --- a/tools/src/ExtensionInventory.php +++ b/tools/src/ExtensionInventory.php @@ -49,7 +49,7 @@ public static function context(string $image, string $phpVersion): array $minor = $runtime[1] . '.' . $runtime[2]; $rc = $runtime[3] !== ''; if (!preg_match('~^[^\s@]+:([^/:]+)$~D', $image, $reference)) { - throw new InvalidArgumentException('DEVSTACK_IMAGE must contain the full image reference with a tag'); + throw new InvalidArgumentException('IMAGE_NAME must contain the full image reference with a tag'); } $tag = $reference[1]; if (preg_match('~^(\d+)(?:\.(\d+)(?:\.\d+)?)?(?:(-rc)(?:-(?:alpha|beta|RC)\d+)?)?(?:-(debug|cli))?$~D', $tag, $parts)) { diff --git a/tools/tests/ExtensionsTest.php b/tools/tests/ExtensionsTest.php index a23bac6..9aa16f7 100644 --- a/tools/tests/ExtensionsTest.php +++ b/tools/tests/ExtensionsTest.php @@ -9,7 +9,11 @@ require __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/../src/ExtensionInventory.php'; -$context = ExtensionInventory::context(getenv('DEVSTACK_IMAGE') ?: '', PHP_VERSION); +$imageName = getenv('IMAGE_NAME'); + +Assert::type('string', $imageName, 'Docker Image `name:tag` not provided'); + +$context = ExtensionInventory::context(getenv('IMAGE_NAME') ?: '', PHP_VERSION); $inventory = new ExtensionInventory(Neon::decodeFile(__DIR__ . '/expected-extensions.neon')); $difference = $inventory->difference($context, get_loaded_extensions()); Assert::true($difference === ['missing' => [], 'unexpected' => []], diff --git a/tools/tests/expected-extensions.neon b/tools/tests/expected-extensions.neon index 85508fc..d33605c 100644 --- a/tools/tests/expected-extensions.neon +++ b/tools/tests/expected-extensions.neon @@ -1,4 +1,15 @@ # Format: ../../docs/extension-inventory.md + +# Exact-profile exceptions apply last. Missing mbstring is a build defect, not an exception. +exceptions: [] + +rules: + - when: ['>=8.5'] + add: [lexbor, uri] + + - when: [+debug] + add: [xdebug, spx] + common: - bcmath - bz2 @@ -53,13 +64,3 @@ common: - zend opcache - zip - zlib - -rules: - - when: ['>=8.5'] - add: [lexbor, uri] - - - when: [+debug] - add: [xdebug, spx] - -# Exact-profile exceptions apply last. Missing mbstring is a build defect, not an exception. -exceptions: {}