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 2081782..f5e4d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,20 @@ 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`. ```bash -./build-php.sh # build all current PHP versions (8.2–8.5, apache + cli variants) +./build-php.sh # build all current PHP versions (8.2–8.5 + 8.6 RC, apache + cli variants) ./php/build-php-8.5.sh # build a single PHP version (apache + debug variants) ./php/build-php-8.5-cli.sh # build a single CLI variant ./php/legacy/build-php-legacy.sh # legacy PHP versions (many disabled — Debian repos shut down) @@ -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, apache + cli) +./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 +`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. @@ -58,6 +69,7 @@ docker run --rm -it -v "$(pwd)":/app -w /app jakubboucek/lamp-devstack-php:8.5 c - PHP: the `-rc` suffix is hard-coded in the script, combined with the pre-release label (e.g. alpha/RC name) which can be read programmatically from the PHP binary itself. - MariaDB: RC builds have no additional sub-versions. Based on upstream's observed release mechanism, when a release channel goes stable the patch version is bumped to `2` and the `-rc` suffix is dropped. - During a PHP RC phase, components without upstream support for the new version (typically Xdebug → the `-debug` variant, PIE, individual extensions) are temporarily left out or commented in the Dockerfiles. They are re-enabled as soon as a *functional* upstream version exists — a working dev/beta/dev-master is enough, don't wait for a final release. Track the remaining work in a GitHub issue cross-linked with the version's PR. +- For PHP 8.6 build prerequisites, see [build-notes.md](build-notes.md#php-86-rc); the RC-to-stable checklist is maintained in [#100](https://github.com/jakubboucek/docker-lamp-devstack/issues/100). - All images are `linux/amd64` only. Building for ARM would require hardware the maintainer doesn't have (and renting a VPS for it is not planned); this will change only if the maintainer's own environment moves to ARM. ## Defaults baked into images diff --git a/README.md b/README.md index 8e12091..b788383 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ of base images designed to cover most standard development workflows — with no - unsupported versions of **MariaDB** 12.2, 12.1, 12.0, 11.7, 11.6, 11.5, 11.3, 11.2, 11.1, 11.0, 10.10, 10.9, 10.8, 10.7, 10.5, 10.4 and 10.3 (unmaintained) - current version of **Apache** 2.4 (in non-CLI images) -- current version of **Xdebug** 3.5 (in debug images) +- **Xdebug** 3.5 in stable debug images; 3.6 alpha in PHP 8.6 RC debug images - current version of [**SPX Profiler**](https://github.com/NoiseByNorthwest/php-spx) (in debug images) - extra PHP extensions: [`bmath`](https://www.php.net/manual/en/book.bc.php), @@ -161,9 +161,10 @@ All PHP images also have alternative CLI variants, use `-cli` tag suffix, exampl The pre-release of PHP 8.6 images have the `-rc` suffix, example: - `jakubboucek/lamp-devstack-php:8.6-rc` - `jakubboucek/lamp-devstack-php:8.6-rc-cli` +- `jakubboucek/lamp-devstack-php:8.6-rc-debug` -> Note: The 8.6 pre-release images don't contain the `memcached` extension yet and have no `-debug` variant — -> upstream support (PIE, Xdebug) for PHP 8.6 is not available yet. +> The 8.6 pre-release images include `memcached` from its development branch, installed with PIE 1.5 or newer. +> The `-debug` variant includes Xdebug 3.6 alpha and SPX from its `v0.5` development branch. ### Using MariaDB MariaDB server starts at the same time as the web server. diff --git a/build-notes.md b/build-notes.md index 48d0576..8746147 100644 --- a/build-notes.md +++ b/build-notes.md @@ -11,6 +11,15 @@ To build only specific PHP version run only the target script on [`php` folder]( To build older PHP versions run [`php/legacy/build-php-legacy.sh`](php/legacy/build-php-legacy.sh). +### PHP 8.6 RC + +PHP 8.6 builds require PIE 1.5 or newer. When reusing assets with `NO_PULL=1`, +run `./prepare-assets.sh` first if the cached `php/build/pie.phar` is older. +Build the Apache image before debug; the debug Dockerfile uses the bundled frontend +and the locally built base. Package selectors and SPX compatibility flags are defined +and explained in the Dockerfiles. The [stable-transition checklist](https://github.com/jakubboucek/docker-lamp-devstack/issues/100) +is maintained in #100. + ## MariaDB Run [`build-mariadb.sh`](build-mariadb.sh) script to build all of MariaDB versions. diff --git a/docs/extension-inventory.md b/docs/extension-inventory.md new file mode 100644 index 0000000..bc66a78 --- /dev/null +++ b/docs/extension-inventory.md @@ -0,0 +1,220 @@ +# 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 `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 +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 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` +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. + +## 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/php/Dockerfile-8.6 b/php/Dockerfile-8.6 index d9ad91b..e296952 100644 --- a/php/Dockerfile-8.6 +++ b/php/Dockerfile-8.6 @@ -25,8 +25,8 @@ ARG EXTENSION_DEV_DEPS=" \ libgmp-dev \ libicu-dev \ libjpeg62-turbo-dev \ -# libmemcached-dev \ libonig-dev \ + libmemcached-dev \ libpng-dev \ libsodium-dev \ libssl-dev \ @@ -50,8 +50,8 @@ ARG EXTENSION_RUNTIME_DEPS=" \ libicu76 \ libjpeg62-turbo \ liblzma5 \ -# libmemcached11t64 \ libonig5 \ + libmemcached11t64 \ libpng16-16t64 \ librav1e0.7 \ libsharpyuv0 \ @@ -69,6 +69,7 @@ ARG EXTENSION_RUNTIME_DEPS=" \ zlib1g \ " +# PHP 8.6 API compatibility fixes are currently available in memcached dev-master only. # OS binaries install && update critical binaries RUN set -eux; \ apt-get update; \ @@ -104,8 +105,7 @@ RUN set -eux; \ sysvshm \ xsl \ zip; \ -# Memcached disabled: PIE does not support PHP 8.6 yet (requires PHP <= 8.5) -# pie install php-memcached/php-memcached; \ + pie install php-memcached/php-memcached:dev-master; \ a2enmod \ expires \ headers \ diff --git a/php/Dockerfile-8.6-cli b/php/Dockerfile-8.6-cli index 5179739..f7eb12b 100644 --- a/php/Dockerfile-8.6-cli +++ b/php/Dockerfile-8.6-cli @@ -25,8 +25,8 @@ ARG EXTENSION_DEV_DEPS=" \ libgmp-dev \ libicu-dev \ libjpeg62-turbo-dev \ -# libmemcached-dev \ libonig-dev \ + libmemcached-dev \ libpng-dev \ libsodium-dev \ libssl-dev \ @@ -50,8 +50,8 @@ ARG EXTENSION_RUNTIME_DEPS=" \ libicu76 \ libjpeg62-turbo \ liblzma5 \ -# libmemcached11t64 \ libonig5 \ + libmemcached11t64 \ libpng16-16t64 \ librav1e0.7 \ libsharpyuv0 \ @@ -69,6 +69,7 @@ ARG EXTENSION_RUNTIME_DEPS=" \ zlib1g \ " +# PHP 8.6 API compatibility fixes are currently available in memcached dev-master only. # OS binaries install && update critical binaries RUN set -eux; \ apt-get update; \ @@ -104,8 +105,7 @@ RUN set -eux; \ sysvshm \ xsl \ zip; \ -# Memcached disabled: PIE does not support PHP 8.6 yet (requires PHP <= 8.5) -# pie install php-memcached/php-memcached; \ + pie install php-memcached/php-memcached:dev-master; \ apt-mark manual ${EXTENSION_RUNTIME_DEPS}; \ apt-get purge -y --auto-remove ${EXTENSION_DEV_DEPS}; \ apt-get clean -y && \ diff --git a/php/Dockerfile-8.6-debug b/php/Dockerfile-8.6-debug new file mode 100644 index 0000000..b7a9765 --- /dev/null +++ b/php/Dockerfile-8.6-debug @@ -0,0 +1,37 @@ +# check=skip=SecretsUsedInArgOrEnv + +FROM jakubboucek/lamp-devstack-php:8.6-rc + +LABEL org.label-schema.name="PHP 8.6 (Pre-release, Apache module + Xdebug)" + +# Configure Xdebug +COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini + +# Setup PIE working directory +ARG PIE_WORKING_DIRECTORY=/tmp/.pie + +# Prevent interactive block +ARG DEBIAN_FRONTEND=noninteractive + +# PHP 8.6 support requires Xdebug 3.6 alpha and the SPX v0.5 development branch. +# SPX v0.5 uses an old-style TLS declaration under ZTS; keep the GCC warning non-fatal. +# PHP 8.6 headers use typeof; SPX forces strict C11, so use the equivalent GNU spelling. +# CI=true keeps SPX portable by disabling its -march=native optimization. +# Install Xdebug and SPX +RUN set -eux; \ + apt-get update; \ + apt-get install --no-install-recommends -y zlib1g-dev; \ + pie install xdebug/xdebug:^3.6@alpha; \ + CFLAGS="${CFLAGS:-} -Wno-error=old-style-declaration -Dtypeof=__typeof__" CI=true pie install noisebynorthwest/php-spx:0.5.x-dev; \ + apt-get remove --purge -y zlib1g-dev; \ + apt-get clean -y && \ + apt-get autoclean -y && \ + apt-get autoremove -y && \ + rm -rf /var/cache/* /var/lib/apt/lists/* /var/lib/log/* /tmp/* /var/tmp/* ~/.composer/cache/*; + +# Configure SPX +ENV PHP_SPX_ENABLED=0 +ENV PHP_SPX_HTTP_KEY="dev" +ENV PHP_SPX_HTTP_IP_WHITELIST="*" + +COPY spx.ini /usr/local/etc/php/conf.d/spx.ini 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 bdd504c..0fe7769 100755 --- a/php/build-php-8.6.sh +++ b/php/build-php-8.6.sh @@ -17,15 +17,22 @@ fi if [ "${NO_BUILD:-0}" -ne "1" ]; then docker build --progress plain -f ./Dockerfile-8.6 -t jakubboucek/lamp-devstack-php:8.6-rc ./ + docker build --progress plain -f ./Dockerfile-8.6-debug -t jakubboucek/lamp-devstack-php:8.6-rc-debug ./ PHP_RELEASE=$(docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php -r "echo PHP_RELEASE_VERSION;") PHP_RELEASE_EXTRA=$(docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php -r "echo PHP_EXTRA_VERSION;") docker tag jakubboucek/lamp-devstack-php:8.6-rc jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc + docker tag jakubboucek/lamp-devstack-php:8.6-rc-debug jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc-debug docker tag jakubboucek/lamp-devstack-php:8.6-rc jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc-${PHP_RELEASE_EXTRA} + docker tag jakubboucek/lamp-devstack-php:8.6-rc-debug jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc-${PHP_RELEASE_EXTRA}-debug 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;" + docker run --rm jakubboucek/lamp-devstack-php:8.6-rc-debug php -r "echo implode(', ', get_loaded_extensions()) . PHP_EOL;" docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php -r "var_export(gd_info()) . PHP_EOL;" fi @@ -33,6 +40,9 @@ if [ "${NO_PUSH:-0}" -ne "1" ]; then PHP_RELEASE=$(docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php -r "echo PHP_RELEASE_VERSION;") PHP_RELEASE_EXTRA=$(docker run --rm jakubboucek/lamp-devstack-php:8.6-rc php -r "echo PHP_EXTRA_VERSION;") docker push jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc-${PHP_RELEASE_EXTRA} + docker push jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc-${PHP_RELEASE_EXTRA}-debug docker push jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc + docker push jakubboucek/lamp-devstack-php:8.6.${PHP_RELEASE}-rc-debug docker push jakubboucek/lamp-devstack-php:8.6-rc + docker push jakubboucek/lamp-devstack-php:8.6-rc-debug fi diff --git a/test-php.sh b/test-php.sh index fa38079..a913497 100755 --- a/test-php.sh +++ b/test-php.sh @@ -1,17 +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; +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 -u + +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 "IMAGE_NAME=$image" \ + "$image" composer -d tools/ test; then + status=1 + fi +done + +exit "$status" 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..71c3ab8 --- /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('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)) { + 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..9aa16f7 --- /dev/null +++ b/tools/tests/ExtensionsTest.php @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..d33605c --- /dev/null +++ b/tools/tests/expected-extensions.neon @@ -0,0 +1,66 @@ +# 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 + - 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