From 7f0a717014764a5538d700bfd9ea3ea07b50edfd Mon Sep 17 00:00:00 2001 From: Yostin Vargas Date: Mon, 24 Aug 2026 11:00:56 -0400 Subject: [PATCH 1/2] feat: pick the PHP versions you install, like the databases All three ran whether you wanted them or not: 5.7 GB of images and three containers for what is usually one version plus an occasional second. Databases already work the other way round, so versions become profiles too -- php83, php84, php85 -- and the installer asks which to install and which of them is the default. Compose refuses to parse a project whose dependency sits behind a disabled profile, so `depends_on: php${PHP_VERSION}dev` had to go from both web servers. It only ordered startup; the backend is resolved per request in resolve_docroot.lua. Keeping it would have meant one bad edit of .env bricking every compose command, forge included. The invariant: COMPOSE_PROFILES always carries the default version. Zero php entries can then only mean a .env written before this change, which is what ensure_php_profiles keys off -- it keeps every version that .env used to run and says so once. forge php off refuses the default, forge use installs what it switches to. Asking for a version that is not installed used to proxy to a host that does not resolve: a bare 503, same as a typo. Both servers now get COMPOSE_PROFILES as ENABLED_PROFILES and answer with a page naming the command to add it. It is written from the translate-name hook, not from fixups: "/" reaches PHP through DirectoryIndex, and the internal redirect throws away a body written that late -- the request ends as an empty 200 on a directory. Verified against a running stack on Apache and nginx, with the pty driver answering the two new questions. Both new CI steps were falsified once: the page by blanking ENABLED_PROFILES, the migration by removing the call from need_env. --- .env.example | 12 ++- .../__pycache__/drive-install.cpython-314.pyc | Bin 5841 -> 5885 bytes .../__pycache__/run-step.cpython-314.pyc | Bin 3741 -> 3741 bytes .github/scripts/drive-install.py | 5 +- .github/workflows/ci.yml | 93 +++++++++++++++++- CLAUDE.md | 2 +- README.es.md | 43 ++++++-- README.md | 43 ++++++-- TASKS.md | 77 +++++++++++++++ bin/forge | 83 +++++++++++++++- docker-compose.yml | 16 ++- .../httpd/config_files/resolve_docroot.lua | 70 +++++++++++++ .../common_server_config.conf.tpl | 2 + .../nginx/config_files/docker-entrypoint.sh | 4 +- .../nginx/config_files/resolve_docroot.lua | 54 ++++++++++ install.sh | 75 +++++++++++--- lib/compose.sh | 21 +++- 17 files changed, 553 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 1fdc072..120606c 100644 --- a/.env.example +++ b/.env.example @@ -22,11 +22,15 @@ USERNAME=${USER} USER_DEV=php-devforge PASSWD_DEV=php-devforge01 -##### DATABASES AND EXTRAS ##### +##### DATABASES, PHP VERSIONS AND EXTRAS ##### # Comma separated. Nothing here starts unless listed. Manage with: # forge db list forge db on pg18 forge mail on -# Available: pg16 pg17 pg18 mariadb11 mariadb12 mail search nginx -COMPOSE_PROFILES= +# forge php list forge php on 8.3 +# Available: php83 php84 php85 pg16 pg17 pg18 mariadb11 mariadb12 mail search nginx +# +# The default version below must be listed here, or nothing answers a plain +# host name. `forge php off` refuses to remove it for that reason. +COMPOSE_PROFILES=php84 ##### LOCAL DNS PORT ##### # Port on 127.0.0.1 where dnsmasq listens. Not 53: it is usually taken @@ -34,6 +38,8 @@ COMPOSE_PROFILES= DNS_PORT=5354 ##### DEFAULT PHP VERSION ##### +# Answers host names with no --pNN suffix. Change it with `forge use 8.5`, +# which also installs that version if you do not have it yet. PHP_VERSION=84 ##### HOST USER ##### diff --git a/.github/scripts/__pycache__/drive-install.cpython-314.pyc b/.github/scripts/__pycache__/drive-install.cpython-314.pyc index 734ab74cb1fe230853e8e30125b622b44ee051a3..61ce32ac2ac6b95310b252fbffa091f8b3d85bb0 100644 GIT binary patch delta 245 zcmcbp`&U<8n~#@^0SLIGda~XKFfcp@abSQA%J_V1qI!=Zn;t_DpFg89QxJcUKoENn zM-ZnTlQEMXO9@X9mpDTSZxA<-TKfyt}H)B!7~JsJQ2 delta 203 zcmeyXdr?mcLyyUrNsqaN zCx}y=p@cVx3rO+>aRW*ISb-p(iBA+6`8TsM8Zd2kVgAd+sIb|I?I1Iw(PkDdeNIM~ z%_hPXOj1l$!Y-+4iKRIu3IQGg3T3H9#hLkex0ozUCtnop;)(`Z%m~EA?UPf*yk)r< lSe0(Dh}~cnnNT*P?1Gru1s3%W>@1v24XmH|CSMa%2LQXZFf0H7 diff --git a/.github/scripts/__pycache__/run-step.cpython-314.pyc b/.github/scripts/__pycache__/run-step.cpython-314.pyc index a39bdbf206f977caad33b1c8c2c357e3d28afe69..81fb71124b14d8eab53e50919d0df1cd58372fd9 100644 GIT binary patch delta 19 ZcmbO$J6D!Vn~#@^0SJ8OY~-5E2LLCb1QY-O delta 19 ZcmbO$J6D!Vn~#@^0SIo)*vK`R4*)991e*W= diff --git a/.github/scripts/drive-install.py b/.github/scripts/drive-install.py index 1251892..0ffa39b 100644 --- a/.github/scripts/drive-install.py +++ b/.github/scripts/drive-install.py @@ -22,8 +22,11 @@ # What to answer, in order: (text that means the menu is up, what to pick). # A single-choice menu takes one name; a multi-choice menu takes a list. +# 84 comes preselected, so naming it here toggles it off: the run installs 8.3 +# and 8.5 only, which is the point of picking versions at all. STEPS = [ - ("Default PHP version", "85"), + ("PHP versions", ["84", "83", "85"]), + ("Default version", "85"), ("Images", "pull"), ("space toggles", ["pg18", "mail"]), ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f44e250..0bd38e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,9 @@ jobs: ./install.sh --skip-cert --skip-dns --skip-link --domain=phpforge.dev grep -q '^PHP_VERSION=85$' .env # moved off the default 8.4 grep -q '^IMAGE_MODE=missing$' .env # kept 'pull' - grep -q '^COMPOSE_PROFILES=pg18,mail$' .env + # 8.4 was toggled off and 8.3/8.5 on, so the versions are a choice and + # the second question decided which of them answers a plain host name. + grep -q '^COMPOSE_PROFILES=php83,php85,pg18,mail$' .env rm -f .env .env.backup echo "the menus work end to end" @@ -137,7 +139,20 @@ jobs: # --skip-dns: the runner's resolver is left alone; curl --resolve is used # instead. Certificates are not skipped, so real TLS is exercised. - name: run the installer - run: ./install.sh --yes --skip-dns + run: ./install.sh --yes --skip-dns --php=84 + + # Every version used to be installed whether or not you wanted it: 1.9 GB + # each. What is not picked must not reach the default set at all. + - name: only the PHP versions you picked are installed + run: | + grep -q '^COMPOSE_PROFILES=php84$' .env + for s in php83dev php85dev; do + if docker compose config --services | grep -qx "$s"; then + echo "$s starts by default and should not"; exit 1 + fi + done + docker compose config --services | grep -qx php84dev + echo "one version picked, one version installed" - name: .env was written run: | @@ -152,7 +167,7 @@ jobs: test -L "$HOME/.local/bin/forge" || { echo "installer did not link forge"; exit 1; } export PATH="$HOME/.local/bin:$PATH" cd /tmp # it must work from anywhere, not just the checkout - forge help | grep -q "8.3 8.4 8.5" || { echo "versions not discovered"; exit 1; } + forge help | grep -q "out of 8.3, 8.4, 8.5" || { echo "versions not discovered"; exit 1; } forge status | grep -q "PHP DevForge" forge images status # unknown versions must be refused, not passed to docker @@ -164,6 +179,21 @@ jobs: # built-in and docker-php-ext-install started failing on it. run: docker compose build php83dev php84dev php85dev apachedev + # The rest of the run needs all three. Adding them through forge rather than + # by naming services proves the command people are told to use, and keeps + # .env and the containers agreeing -- Apache reads COMPOSE_PROFILES to know + # what exists. + - name: forge php on adds a version + run: | + export PATH="$HOME/.local/bin:$PATH" + forge php on 8.3 + forge php on 8.5 + for v in 83 84 85; do + grep -q "^COMPOSE_PROFILES=.*php$v" .env || { grep '^COMPOSE_PROFILES=' .env; exit 1; } + done + forge php list | grep -qE '^\s+8\.4\s+ON\s+default' + echo "versions added" + - name: start the stack run: | docker compose up -d apachedev php83dev php84dev php85dev @@ -255,6 +285,63 @@ jobs: || { echo "expected PHP ${v:0:1}.${v:1:1}"; exit 1; } done + # A version that is not installed used to proxy to a host that does not + # resolve: a bare 503, indistinguishable from a typo. The trap puts 8.3 back + # however this ends, because every step after this one uses it. + - name: an uninstalled version says so instead of failing blankly + run: | + export PATH="$HOME/.local/bin:$PATH" + trap 'forge php on 8.3 >/dev/null 2>&1 || true' EXIT + + # the default cannot be removed: nothing would answer a plain host name + out="$(forge php off 8.4 2>&1)" && { echo "removing the default was allowed"; exit 1; } + printf '%s\n' "$out" | grep -q 'forge use' || { echo "$out"; exit 1; } + + forge php off 8.3 + docker compose ps --services | grep -qx php83dev \ + && { echo "php83dev still running"; exit 1; } + + host=my-app--sites--p83.phpforge.dev + code="$(curl -sk -o /tmp/gone.html -w '%{http_code}' \ + --resolve "$host:443:127.0.0.1" "https://$host/")" + test "$code" = 503 || { echo "expected 503, got $code"; exit 1; } + # "/" reaches PHP through DirectoryIndex, which is where an earlier + # version of this page was swallowed and served as an empty 200. + grep -q 'PHP 8.3 is not installed' /tmp/gone.html || { cat /tmp/gone.html; exit 1; } + grep -q 'forge php on 8.3' /tmp/gone.html || { cat /tmp/gone.html; exit 1; } + echo "it explains itself" + + forge php on 8.3 + # -k here only: TLS trust is what several other steps are for, and this + # way the step also runs on a machine that skipped ./install_cert.sh. + for i in $(seq 1 30); do + curl -fsSk --max-time 5 --resolve "$host:443:127.0.0.1" "https://$host/" \ + | grep -q 'PHP 8.3' && { echo "8.3 serves again after ${i}s"; exit 0; } + sleep 1 + done + echo "8.3 never came back"; exit 1 + + # Upgrading is a git pull, and a .env written before task 41 lists no php + # profile at all. Dropping to one version would silently break every --pNN + # host that person had. + - name: an older .env keeps the versions it had + run: | + export PATH="$HOME/.local/bin:$PATH" + cp .env /tmp/env.keep + trap 'cp /tmp/env.keep .env' EXIT + sed -i 's/^COMPOSE_PROFILES=.*/COMPOSE_PROFILES=/' .env + + out="$(forge status)" + printf '%s\n' "$out" | grep -q 'PHP versions are now picked' \ + || { echo "$out"; echo "no notice printed"; exit 1; } + grep -q '^COMPOSE_PROFILES=php83,php84,php85$' .env \ + || { grep '^COMPOSE_PROFILES=' .env; exit 1; } + + # and it says it once: the write is the migration + forge status | grep -q 'PHP versions are now picked' \ + && { echo "the notice repeats"; exit 1; } + echo "older .env kept its versions" + # Neither is installed by us any more: the base images already provide them. # If that ever stops being true, this catches it instead of a user finding out. - name: opcache and mbstring are present in every version diff --git a/CLAUDE.md b/CLAUDE.md index d62de44..87ef415 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ This is the core mechanism and it spans three files. 1. **DNS** — `dnsmasq` resolves `*.${DEV_DOMAIN}` (default `phpforge.dev`) to `127.0.0.1`. `setup-local-dns.sh` inserts `127.0.0.1` as the system's first nameserver (NetworkManager → systemd-resolved → raw `resolv.conf`, in that order of preference). 2. **Docroot resolution** — `docker-library/httpd/config_files/resolve_docroot.lua` runs as `LuaHookTranslateName`. It strips the `--pNN` suffix and the base domain from the Host header, splits the remainder on `--` (or on `.`), **reverses the segments**, and joins with `/` under `/home/php-devforge/public_html`. So `public--site--laravel.phpforge.dev` → `/home/php-devforge/public_html/laravel/site/public`. -3. **PHP version routing** — `devlocal.conf` / `devlocal_https.conf` set `PHP_VERSION` from the env (via `PassEnv`), then override it per-request with `SetEnvIf Host ".*--p83.*"`. A `` block picks `proxy:fcgi://php83dev:9000` or `php84dev:9000` accordingly. Both PHP containers are always running; the hostname decides which one handles the request. +3. **PHP version routing** — `resolve_docroot.lua` (`set_php_handler`, a `LuaHookFixups`) picks `proxy:fcgi://phpNNdev:9000` from the `--pNN` suffix, falling back to `PHP_VERSION`. Each version is a compose profile (`php83`, `php84`, `php85`) listed in `COMPOSE_PROFILES`, so only the installed ones run; the same variable reaches Apache and nginx as `ENABLED_PROFILES`, and the Lua answers a request for a version that is not installed with a page instead of a bare 503. `COMPOSE_PROFILES` always contains the default version — `forge php off` refuses to remove it, and a `.env` with no `php*` entry is migrated by `forge`. `docker-library/httpd/config_files/php-fpm.conf` is a legacy/unused fallback — the active handler selection lives in the two vhost files. Both vhosts are duplicates apart from the `SSLEngine` block; **changes to routing must be applied to both**. diff --git a/README.es.md b/README.es.md index a7d2d1a..6767119 100644 --- a/README.es.md +++ b/README.es.md @@ -60,8 +60,8 @@ https://mi-app--p83.phpforge.dev # esta petición en PHP 8.3 https://mi-app--p85.phpforge.dev # esta petición en PHP 8.5 ``` -El mismo código en tres versiones, sin reiniciar y sin cambiar nada. Útil para -comprobar una actualización antes de comprometerte con ella. +El mismo código en las versiones que instalaste, sin reiniciar y sin cambiar nada. +Útil para comprobar una actualización antes de comprometerte con ella. Todo se sirve por **HTTPS real**, con un certificado en el que tu equipo confía. @@ -83,7 +83,7 @@ pensado para tener muchos proyectos sobre un mismo entorno, sin configurar ningu ## 📖 Qué incluye -- **Apache + PHP-FPM 8.3, 8.4 y 8.5**, los tres activos, elegidos por petición +- **Apache + PHP-FPM 8.3, 8.4 y 8.5** — instalas las que quieras, elegidas por petición - **HTTPS automático** con una CA local de confianza - **DNS local con comodín** que solo afecta a tu dominio de desarrollo - **Edición en vivo** — los archivos están montados, no hay que sincronizar nada @@ -124,14 +124,16 @@ Y abre **https://welcome.phpforge.dev**. ```bash ./install.sh ``` - Pregunta por tu dominio, dónde vivirán tus proyectos y la versión de PHP por - defecto; busca un puerto DNS libre, detecta tu usuario, escribe `.env`, crea la - carpeta de proyectos, y ofrece generar los certificados y configurar el DNS. + Pregunta por tu dominio, dónde vivirán tus proyectos, qué versiones de PHP + instalar y cuál de ellas es la de por defecto; busca un puerto DNS libre, detecta + tu usuario, escribe `.env`, crea la carpeta de proyectos, y ofrece generar los + certificados y configurar el DNS. Se puede volver a ejecutar sin miedo: tu `.env` actual da los valores por defecto. Para uso desatendido: ```bash ./install.sh --yes --domain=midominio.dev --projects-dir=~/code + ./install.sh --yes --php=84,83 # dos versiones, 8.4 la de por defecto ./install.sh --yes --profiles=pg18,mail # bases de datos y correo, desatendido ./install.sh --help # todas las opciones ``` @@ -161,7 +163,10 @@ forge restart forge status # qué corre y cómo está configurado forge link ~/code/app/public # publicar un proyecto en app. -forge use 8.5 # cambiar la versión por defecto + +forge php list # versiones de PHP, y cuáles están instaladas +forge php on|off 8.3 # instalar una, o liberar los ~2 GB que ocupa +forge use 8.5 # cambiar la versión por defecto (la instala si falta) forge shell 8.4 # entrar a un contenedor forge logs 8.4 # seguir sus logs @@ -242,6 +247,25 @@ ln -s ../projects/mi-app/public ~/php-devforge/sites/mi-app El enlace debe apuntar a la carpeta que contiene el `index.php` (el `public/` del framework). El `.htaccess` funciona: `AllowOverride All` está activado. +### 🐘 Versiones de PHP + +Se eligen al instalar, igual que las bases de datos, porque cada una es una imagen +aparte de unos 2 GB: + +```bash +forge php list # 8.3 off / 8.4 ON default / 8.5 off +forge php on 8.3 # descargarla y arrancarla +forge php off 8.3 # pararla; la imagen sigue en disco hasta que la borres +``` + +Una de ellas es la **de por defecto**: responde a todos los nombres de host sin +sufijo `--pNN`. `forge use 8.5` la cambia, instalando esa versión antes si no la +tienes, y `forge php off` se niega a quitar la de por defecto — si no, ningún +nombre de host normal tendría quién lo atienda. + +Pedir una versión que no instalaste devuelve una página que lo explica y dice el +comando para añadirla, en vez de un 503 pelado. + ### 🔄 Día a día - Edita el código en tu editor — los archivos están montados, los cambios son inmediatos @@ -407,7 +431,8 @@ las imágenes que arranca ese perfil. | Perfil | Servicios | Notas | |---|---|---| -| *(ninguno)* | apachedev, php83dev, php84dev, php85dev, dnsmasq | los levanta `forge start` | +| *(ninguno)* | apachedev, dnsmasq | los levanta `forge start` | +| `php83` `php84` `php85` | php83dev, php84dev, php85dev | se eligen al instalar, o `forge php on 8.3` | | `pg16` `pg17` `pg18` | postgres | ver la sección Bases de datos y correo | | `mariadb11` `mariadb12` | mariadb | igual | | `mail` | mailpit | interfaz en `https://mail.` | @@ -471,6 +496,8 @@ dentro de un contenedor; tu editor debe escuchar en el puerto **9003**. adoptan esos ids al arrancar, así que lo que escriben es tuyo. - **No cambia la versión de PHP**: revisa `PHP_VERSION` en `.env`, o añade `--p83`/`--p84`/`--p85` al nombre del host. `forge status` muestra la de por defecto. +- **"PHP 8.3 is not installed"**: esa versión no se eligió al instalar. + `forge php on 8.3` la añade; `forge php list` muestra las que tienes. - **`npm` muestra un aviso sobre pnpm**: es a propósito. npm sigue funcionando; aquí se prefiere pnpm. - **Un `.ini` de `custom/php.d/` parece ignorado**: recrea el contenedor con diff --git a/README.md b/README.md index 229f8a6..c783a03 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ https://my-app--p83.phpforge.dev # this request on PHP 8.3 https://my-app--p85.phpforge.dev # this request on PHP 8.5 ``` -Same code, three PHP versions, no restart and no switching. Useful for checking an -upgrade before committing to it. +Same code, any version you installed, no restart and no switching. Useful for +checking an upgrade before committing to it. Everything is served over **real HTTPS** with a locally trusted certificate. @@ -83,7 +83,7 @@ for keeping many projects on one shared environment with no per-project setup. ## 📖 What you get -- **Apache + PHP-FPM 8.3, 8.4 and 8.5**, all running, chosen per request +- **Apache + PHP-FPM 8.3, 8.4 and 8.5** — install the ones you want, chosen per request - **Automatic HTTPS** with a locally trusted CA - **Wildcard local DNS** that only touches your dev domain - **Live editing** — files are mounted, nothing to sync @@ -124,13 +124,15 @@ Then open **https://welcome.phpforge.dev**. ```bash ./install.sh ``` - It asks for your domain, where your projects should live and the default PHP - version, finds a free DNS port, detects your user id, writes `.env`, creates the - projects folder, and offers to generate the certificates and configure local DNS. + It asks for your domain, where your projects should live, which PHP versions to + install and which of them is the default, finds a free DNS port, detects your user + id, writes `.env`, creates the projects folder, and offers to generate the + certificates and configure local DNS. Safe to re-run: your current `.env` supplies the defaults. For unattended use: ```bash ./install.sh --yes --domain=mydomain.dev --projects-dir=~/code + ./install.sh --yes --php=84,83 # two versions, 8.4 the default ./install.sh --yes --profiles=pg18,mail # databases and mail, unattended ./install.sh --help # every option ``` @@ -160,7 +162,10 @@ forge restart forge status # what is running, and how it is configured forge link ~/code/app/public # publish a project at app. -forge use 8.5 # set the default PHP version + +forge php list # PHP versions, and which are installed +forge php on|off 8.3 # install one, or free the ~2 GB it uses +forge use 8.5 # set the default version (installs it if needed) forge shell 8.4 # open a shell in a container forge logs 8.4 # follow its logs @@ -246,6 +251,25 @@ port 80 — are right without you remembering why. The backend is derived from the host name, so adding a PHP version needs no configuration change — only a service in `docker-compose.yml`. +### 🐘 PHP versions + +You pick them at install time, the same way you pick databases, because each one +is a separate image of about 2 GB: + +```bash +forge php list # 8.3 off / 8.4 ON default / 8.5 off +forge php on 8.3 # pull it and start it +forge php off 8.3 # stop it; the image stays on disk until you prune it +``` + +One of them is the **default**: it answers every host name without a `--pNN` +suffix. `forge use 8.5` moves it, installing that version first if you do not have +it, and `forge php off` refuses to remove the default — otherwise nothing would +answer a plain host name. + +Asking for a version you did not install returns a page that says so and names the +command to add it, rather than a bare 503. + ### 🔄 Development Workflow - Edit code in your editor — the files are volume-mounted, so changes are live @@ -415,7 +439,8 @@ the images that profile starts. | Profile | Services | Notes | |---|---|---| -| *(none)* | apachedev, php83dev, php84dev, php85dev, dnsmasq | started by `forge start` | +| *(none)* | apachedev, dnsmasq | started by `forge start` | +| `php83` `php84` `php85` | php83dev, php84dev, php85dev | pick them at install, or `forge php on 8.3` | | `pg16` `pg17` `pg18` | postgres | see the Databases and Mail section | | `mariadb11` `mariadb12` | mariadb | same | | `mail` | mailpit | UI at `https://mail.` | @@ -479,6 +504,8 @@ container; your IDE should listen on port **9003**. startup, so files they write belong to you. - **PHP version not switching**: check `PHP_VERSION` in `.env`, or append `--p83`/`--p84`/`--p85` to the host name. `forge status` prints the default. +- **"PHP 8.3 is not installed"**: that version was not picked at install time. + `forge php on 8.3` adds it; `forge php list` shows what you have. - **`npm` prints a notice about pnpm**: deliberate. npm still works; pnpm is preferred here. - **An `.ini` in `custom/php.d/` seems ignored**: recreate the container with diff --git a/TASKS.md b/TASKS.md index 1e474da..96184e8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1418,3 +1418,80 @@ reasoning about them. gitignored `docker-compose.local.yml`, so the copied `.env` has to drop it from `COMPOSE_FILE`; and `forge status | grep -q` dies of SIGPIPE under `pipefail`, the same trap as `forge profile on tools | grep`. + +--- + +## 💡 Proposed while using it + +- [x] **41. Every PHP version is installed whether you want it or not** — FIXED + `php83dev`, `php84dev` and `php85dev` are in the default set, so a plain + install pulls **5.7 GB of PHP images** (1.9 GB each) and keeps three + containers running, when most people work on one version and reach for a + second occasionally. + + Databases and mail already work the other way round — nothing starts unless + you pick it — and the machinery for that is built: compose profiles, + `COMPOSE_PROFILES`, `forge profile on|off`, and a multi-select in the + installer. PHP versions should be picked the same way, plus a second + question the databases do not need: **which one is the default**, since + that is what a host name without a `--pNN` suffix gets. + + **The obstacle, verified rather than assumed.** `apachedev` and `nginxdev` + declare `depends_on: php${PHP_VERSION}dev`. Compose refuses to parse a + project whose dependency sits behind a disabled profile: + + ``` + service "web" depends on undefined service "php84": invalid compose project + ``` + + That is not a warning — every compose command fails, so `forge` and the + installer go blind with it. Keeping `depends_on` would mean the default + version's profile must never be absent from `COMPOSE_PROFILES`, and one bad + edit of `.env` bricks the tooling. Dropping `depends_on` costs only startup + ordering, which Apache does not need: the FPM backend is resolved per + request in `resolve_docroot.lua:108`. + + Two more facts that shape it, both tested: `COMPOSE_PROFILES='*'` still + enumerates every service, so `php_versions()` can keep offering versions + that are not installed; and naming a profiled service starts it + (`docker compose up -d php83dev`), which a later plain `up -d` leaves + running. + + **Decided with the user.** Versions are ordinary profiles and appear in + `forge profile list` next to `pg18`, with a `forge php list|on|off` wrapper + like `forge db`. `--php=84,83` installs both and the first is the default, so + `--php=84` keeps meaning what it meant. A request for a version that is not + installed gets a page that names the command to add it. An existing `.env` + keeps every version it had, announced once. + + The invariant that makes it safe: `COMPOSE_PROFILES` always contains the + default version's profile. Zero `php[0-9][0-9]` entries therefore means one + thing only — a `.env` written before this change — which is what + `ensure_php_profiles()` in `bin/forge` keys off. `forge php off` refuses the + default, `forge use` installs what it switches to. + + **Three things the local run caught before CI.** + + The page cannot be written from the fixups hook where the backend is chosen. + A `/` reaches PHP through mod_dir's DirectoryIndex, and the internal redirect + throws the body away: the request ends as an empty `200` on a directory, while + `/index.php` was correct. It moved to `LuaHookTranslateName`, which runs before + mod_dir, so both paths answer the same. + + Lua locals are only visible to functions defined after them. The helpers had + been inserted between `silly_mapper` and `set_php_handler`, so every request + died with `attempt to call a nil value (global 'installed_versions')` — a 500 + on the whole site, not a subtle bug. + + `paste -sd', '` alternates the two delimiters, so a third item joins with a + space: `8.3,8.4 8.5`. That was pre-existing in `profile_images`; three PHP + versions made it visible. A `commas()` helper replaced every call. + + Also fixed on the way: answering "mail" to the databases prompt and yes to the + mail catcher wrote `mail` twice into `COMPOSE_PROFILES`. + + Verified against a real stack, both servers: Apache and the nginx variant serve + the page for an uninstalled version and for a typo (`--p99`), the installed + ones keep serving, and the pty driver answers the two new questions. Each new + CI assertion was falsified once — the page step by blanking `ENABLED_PROFILES`, + the migration step by removing the call from `need_env`. diff --git a/bin/forge b/bin/forge index fae3047..55dc0b6 100755 --- a/bin/forge +++ b/bin/forge @@ -38,6 +38,33 @@ image_mode() { need_env() { [ -f .env ] || { err "No .env here. Run ./install.sh first."; exit 1; } + ensure_php_profiles +} + +# PHP versions became profiles in task 41. Before that every version ran, so a +# COMPOSE_PROFILES with no php entry can only be an older .env: keep everything +# it used to have rather than silently dropping two versions. The write makes +# this a one-time notice. +ensure_php_profiles() { + enabled_profiles | grep -qE '^php[0-9][0-9]$' && return 0 + local versions keep add + versions="$(php_versions)" + [ -n "$versions" ] || return 0 + keep="$(enabled_profiles)" + add="$(printf 'php%s\n' $versions)" + write_profiles $keep $add + warn "PHP versions are now picked, like the databases." + echo " Kept the ones you had: $(printf '%s\n' "$versions" | pretty | commas)" + echo " Drop the ones you do not use: forge php list" +} + +# A version is installed when its profile is on. Anything else is a 2 GB image +# that was never pulled. +need_php() { + is_on "php$1" && return 0 + err "PHP $(printf '%s' "$1" | pretty) is not installed." + echo " Install it: forge php on $(printf '%s' "$1" | pretty)" >&2 + exit 1 } # Every checkout claims the same compose project, so containers started from @@ -92,7 +119,7 @@ cmd_status() { need_env echo -e "${BLUE}PHP DevForge${NC}" echo " domain: *.$(sed -ne 's/^DEV_DOMAIN=//p' .env)" - echo " default: PHP $(current_version | pretty)" + echo " php: $(installed_versions | pretty | commas) (default $(current_version | pretty))" echo " images: $(image_mode)" echo " projects: $(sed -ne 's/^PROJECTS_DIR=//p' .env)" local owner; owner="$(active_dir)" @@ -107,6 +134,11 @@ cmd_use() { need_env [ $# -ge 1 ] || { err "Which version? e.g. forge use 8.5"; exit 1; } local v; v="$(normalise "$1")" + if ! is_on "php$v"; then + info "PHP $(echo "$v" | pretty) is not installed yet; adding it" + local keep; keep="$(enabled_profiles)" + write_profiles $keep "php$v" + fi sed -i "s/^PHP_VERSION=.*/PHP_VERSION=${v}/" .env info "Default is now PHP $(echo "$v" | pretty)" compose_up @@ -115,6 +147,7 @@ cmd_use() { cmd_shell() { need_env local v; v="$(normalise "${1:-$(current_version)}")" + need_php "$v" # Keep your current directory when you are inside the projects folder. local wd="/home/php-devforge" case "$PWD" in *public_html*|"$(sed -ne 's/^PROJECTS_DIR=//p' .env)"*) wd="$PWD" ;; esac @@ -130,7 +163,9 @@ cmd_logs() { if [ -z "$arg" ]; then docker compose logs -f elif printf '%s' "$arg" | grep -qE '^[0-9]+\.?[0-9]+$'; then - docker compose logs -f "php$(normalise "$arg")dev" + local v; v="$(normalise "$arg")" + need_php "$v" + docker compose logs -f "php${v}dev" else # Plain compose here, not compose_with_env: that one clears # COMPOSE_PROFILES, which would hide mailpit and the databases. @@ -184,6 +219,10 @@ profile_list() { db_profiles() { profile_list | grep -E '^(pg[0-9]+|mariadb[0-9]+)$' || true; } +# The versions actually installed, as 83/84 -- php_versions() lists every one the +# compose files define, installed or not. +installed_versions() { enabled_profiles | sed -n 's/^php\([0-9][0-9]\)$/\1/p' | sort; } + is_on() { enabled_profiles | grep -qx "$1"; } # Two profiles are not plain add-ons, and saying so beats parsing YAML for it: @@ -193,6 +232,7 @@ profile_note() { case "$1" in nginx) echo "replaces apachedev" ;; tools) echo "used by forge certs" ;; + "php$(current_version)") echo "default: answers host names with no --pNN" ;; esac } @@ -244,7 +284,7 @@ cmd_profile() { warn "apachedev goes down: nginx wants the same ports" fi compose_up - info "$name on -> $(profile_services "$name" | paste -sd', ' -)" + info "$name on -> $(profile_services "$name" | commas)" else write_profiles $keep profile_services "$name" | while read -r svc; do @@ -281,6 +321,36 @@ cmd_db() { esac } +# Versions are profiles too, so this reads like `forge db`. The extra rule is the +# default: something has to answer a host name with no --pNN suffix. +cmd_php() { + need_env + local action="${1:-list}" name="${2:-}" v def + def="$(current_version)" + case "$action" in + list) + php_versions | while read -r p; do + [ -n "$p" ] || continue + if [ "$p" = "$def" ]; then printf ' %-5s ON default\n' "$(printf '%s' "$p" | pretty)" + elif is_on "php$p"; then printf ' %-5s ON\n' "$(printf '%s' "$p" | pretty)" + else printf ' %-5s off about 2 GB to install\n' "$(printf '%s' "$p" | pretty)" + fi + done + ;; + on|off) + [ -n "$name" ] || { err "which version? try: forge php list"; exit 1; } + v="$(normalise "$name")" + if [ "$action" = off ] && [ "$v" = "$def" ]; then + err "PHP $(printf '%s' "$v" | pretty) is the default; nothing would answer a plain host name." + echo " Point the default elsewhere first: forge use " >&2 + exit 1 + fi + cmd_profile "$action" "php$v" + ;; + *) err "forge php [list|on |off ]"; exit 1 ;; + esac +} + # Publishing a project is making a symlink under sites/. Doing it by hand means # getting a relative path right from the wrong directory, which is easy to botch. cmd_link() { @@ -342,7 +412,9 @@ PHP DevForge forge restart [service...] recreate the containers forge status what is running, and how it is configured - forge use set the default PHP version + forge php list the PHP versions, and which are installed + forge php on|off install one, or free the disk it uses + forge use set the default version (installs it if needed) forge shell [version] open a shell in a PHP container forge logs [version|service] follow the logs; everything if you name nothing @@ -359,7 +431,7 @@ PHP DevForge forge certs regenerate the HTTPS certificates forge dns [setup|status|test|remove] -Versions available here: $(php_versions | pretty | tr '\n' ' ') +PHP versions: $(installed_versions | pretty | commas) installed, out of $(php_versions | pretty | commas) Version arguments accept either form: 8.5 or 85. EOF } @@ -373,6 +445,7 @@ case "${1:-help}" in stop) shift; cmd_stop "$@" ;; restart) shift; cmd_restart "$@" ;; status) shift; cmd_status ;; + php) shift; cmd_php "$@" ;; use) shift; cmd_use "$@" ;; shell) shift; cmd_shell "$@" ;; logs) shift; cmd_logs "$@" ;; diff --git a/docker-compose.yml b/docker-compose.yml index edf0cfd..abff6e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,7 +69,6 @@ services: - 80:80 - 443:443 depends_on: - - php${PHP_VERSION}dev - dnsmasq volumes: - dataphp-devforge:/home/php-devforge:ro @@ -80,6 +79,9 @@ services: environment: - PHP_VERSION=${PHP_VERSION} - DEV_DOMAIN=${DEV_DOMAIN} + # The setting itself, not a copy of it: resolve_docroot.lua answers a request + # for a version that is not installed, and a second list would drift. + - ENABLED_PROFILES=${COMPOSE_PROFILES:-} logging: *json-logging healthcheck: test: curl --fail http://localhost || exit 1 @@ -115,8 +117,8 @@ services: environment: - DEV_DOMAIN=${DEV_DOMAIN} - PHP_VERSION=${PHP_VERSION} + - ENABLED_PROFILES=${COMPOSE_PROFILES:-} depends_on: - - php${PHP_VERSION}dev - dnsmasq logging: *json-logging healthcheck: @@ -126,7 +128,15 @@ services: start_period: 10s timeout: 10s + # One profile per version: each image is about 2 GB, so nothing is installed + # unless it is picked. COMPOSE_PROFILES always carries the default version -- + # install.sh writes it, `forge php off` refuses to remove it. + # + # No `depends_on` on the web servers: compose refuses to parse a project whose + # dependency sits behind a disabled profile, and the backend is resolved per + # request anyway (resolve_docroot.lua). php83dev: + profiles: ["php83"] image: ghcr.io/zelti/php-devforge/php:8.3-dev pull_policy: ${IMAGE_MODE:-missing} container_name: php83dev @@ -139,6 +149,7 @@ services: <<: *phpconfig_default php84dev: + profiles: ["php84"] image: ghcr.io/zelti/php-devforge/php:8.4-dev pull_policy: ${IMAGE_MODE:-missing} container_name: php84dev @@ -153,6 +164,7 @@ services: # Adding a version is this block and nothing else: one Dockerfile serves them # all, and the vhosts pick the backend from the host name. php85dev: + profiles: ["php85"] image: ghcr.io/zelti/php-devforge/php:8.5-dev pull_policy: ${IMAGE_MODE:-missing} container_name: php85dev diff --git a/docker-library/httpd/config_files/resolve_docroot.lua b/docker-library/httpd/config_files/resolve_docroot.lua index 3943c47..8308473 100644 --- a/docker-library/httpd/config_files/resolve_docroot.lua +++ b/docker-library/httpd/config_files/resolve_docroot.lua @@ -14,6 +14,65 @@ local function is_dir(path) end +-- 83 -> 8.3 +local function pretty(v) + return v:sub(1, 1) .. "." .. v:sub(2) +end + +-- ENABLED_PROFILES is the COMPOSE_PROFILES line from .env, handed over by +-- docker-compose.yml. Versions are installed one profile at a time, so a version +-- that is not listed there has no container to proxy to. +-- +-- No php entry at all means we cannot tell -- someone running compose by hand -- +-- and then it is better to proxy and fail as before than to refuse everything. +local function installed_versions() + local enabled = os.getenv("ENABLED_PROFILES") or "" + if not enabled:find("php%d%d") then return nil end + local list = {} + for v in enabled:gmatch("php(%d%d)") do list[#list + 1] = v end + table.sort(list) + return list +end + +local function has(list, ver) + for _, v in ipairs(list) do if v == ver then return true end end + return false +end + +-- Answered from the translate-name hook, not from set_php_handler: a "/" reaches +-- PHP through mod_dir's DirectoryIndex, whose internal redirect throws away a body +-- written that late -- the request ends as an empty 200 on a directory. Translate +-- name runs before mod_dir, so this covers "/" and /index.php alike. +-- +-- Without it the request proxies to a host that does not resolve and Apache +-- answers a bare 503, which says nothing about the one thing that is wrong. +local function not_installed_page(r, ver, list) + local default = os.getenv("PHP_VERSION") or "" + local have = {} + for _, v in ipairs(list) do + have[#have + 1] = pretty(v) .. (v == default and " (default)" or "") + end + + r.status = 503 + r.content_type = "text/html; charset=utf-8" + r:puts([[ +PHP ]] .. pretty(ver) .. [[ is not installed + +

PHP ]] .. pretty(ver) .. [[ is not installed

+

]] .. (r.hostname or "") .. [[ asks for PHP ]] .. pretty(ver) .. [[, +but this environment serves ]] .. table.concat(have, ", ") .. [[.

+

Add it:

+
forge php on ]] .. pretty(ver) .. [[
+

forge php list shows every version this project can install.

+]]) + r:info("PHP " .. pretty(ver) .. " requested but not installed: " .. (r.hostname or "")) + return apache2.DONE +end + function silly_mapper(r) local dev_domain = os.getenv("DEV_DOMAIN") local host = r.hostname @@ -25,6 +84,17 @@ function silly_mapper(r) r:err("ERROR: DEV_DOMAIN environment variable not set") return apache2.DECLINED end + + -- The whole host is unusable when its PHP version is not installed, static + -- files included, so this is decided once here rather than per file type. + local installed = installed_versions() + if installed then + local ver = host:match("%-%-p([0-9][0-9])%." .. dev_domain:gsub("%.", "%%.") .. "$") + or os.getenv("PHP_VERSION") + if ver and ver:match("^[0-9][0-9]$") and not has(installed, ver) then + return not_installed_page(r, ver, installed) + end + end local path_only = nil diff --git a/docker-library/nginx/config_files/common_server_config.conf.tpl b/docker-library/nginx/config_files/common_server_config.conf.tpl index 6f84659..eadbdf1 100644 --- a/docker-library/nginx/config_files/common_server_config.conf.tpl +++ b/docker-library/nginx/config_files/common_server_config.conf.tpl @@ -1,5 +1,7 @@ set $dev_domain "${DEV_DOMAIN}"; set $php_version "${PHP_VERSION}"; +# The COMPOSE_PROFILES line, so the Lua can tell an uninstalled version from a typo. +set $enabled_profiles "${ENABLED_PROFILES}"; # Both are filled in by the Lua below. The placeholder values matter: if the Lua # ever failed to run, a .php would be served as a static file, which is how the diff --git a/docker-library/nginx/config_files/docker-entrypoint.sh b/docker-library/nginx/config_files/docker-entrypoint.sh index 3b2aaae..6d957a2 100644 --- a/docker-library/nginx/config_files/docker-entrypoint.sh +++ b/docker-library/nginx/config_files/docker-entrypoint.sh @@ -40,9 +40,9 @@ else fi # The variable list is explicit so nginx's own $mail and $subdomains survive. -envsubst '$DEV_DOMAIN $PHP_VERSION $SSL_CERT $SSL_KEY' \ +envsubst '$DEV_DOMAIN $PHP_VERSION $ENABLED_PROFILES $SSL_CERT $SSL_KEY' \ < /etc/nginx/conf.d/site.conf.tpl > /etc/nginx/conf.d/site.conf -envsubst '$DEV_DOMAIN $PHP_VERSION $SSL_CERT $SSL_KEY' \ +envsubst '$DEV_DOMAIN $PHP_VERSION $ENABLED_PROFILES $SSL_CERT $SSL_KEY' \ < /etc/nginx/conf.d/common_server_config.conf.tpl > /etc/nginx/snippets/common_server_config.conf exec "$@" diff --git a/docker-library/nginx/config_files/resolve_docroot.lua b/docker-library/nginx/config_files/resolve_docroot.lua index f1125c5..95d92dc 100644 --- a/docker-library/nginx/config_files/resolve_docroot.lua +++ b/docker-library/nginx/config_files/resolve_docroot.lua @@ -23,6 +23,54 @@ local function is_dir(path) end +-- 83 -> 8.3 +local function pretty(v) + return v:sub(1, 1) .. "." .. v:sub(2) +end + +-- Mirrors the Apache side: ENABLED_PROFILES is the COMPOSE_PROFILES line, so a +-- version missing from it has no container to reach. No php entry at all means +-- we cannot tell, and then serving as before beats refusing everything. +local function installed_versions() + local enabled = ngx.var.enabled_profiles or "" + if not enabled:find("php%d%d") then return nil end + local list = {} + for v in enabled:gmatch("php(%d%d)") do list[#list + 1] = v end + table.sort(list) + return list +end + +local function has(list, ver) + for _, v in ipairs(list) do if v == ver then return true end end + return false +end + +local function not_installed_page(ver, list, default) + local have = {} + for _, v in ipairs(list) do + have[#have + 1] = pretty(v) .. (v == default and " (default)" or "") + end + + ngx.status = 503 + ngx.header.content_type = "text/html; charset=utf-8" + ngx.say([[ +PHP ]] .. pretty(ver) .. [[ is not installed + +

PHP ]] .. pretty(ver) .. [[ is not installed

+

]] .. (ngx.var.host or "") .. [[ asks for PHP ]] .. pretty(ver) .. [[, +but this environment serves ]] .. table.concat(have, ", ") .. [[.

+

Add it:

+
forge php on ]] .. pretty(ver) .. [[
+

forge php list shows every version this project can install.

+]]) + return ngx.exit(ngx.HTTP_OK) +end + + local host = ngx.var.host local dev_domain = ngx.var.dev_domain local default_version = ngx.var.php_version @@ -48,6 +96,12 @@ end version = version or default_version if version and version:match("^[0-9][0-9]$") then + -- Only for .php: a static file needs no backend, and "/" reaches here again + -- as /index.php through the index directive, which is where Apache checks too. + local installed = installed_versions() + if installed and ngx.var.uri:match("%.php$") and not has(installed, version) then + return not_installed_page(version, installed, default_version) + end ngx.var.php_backend = "php" .. version .. "dev" end diff --git a/install.sh b/install.sh index 00f95bd..d5df353 100755 --- a/install.sh +++ b/install.sh @@ -31,7 +31,8 @@ Usage: $0 [OPTIONS] --domain=NAME development domain (default: phpforge.dev) --projects-dir=PATH where your projects live (default: ~/php-devforge) - --php=83|84 default PHP version (default: 84) + --php=84[,83,...] PHP versions to install; the first is the default + (default: 84 -- each version is about 2 GB) --dns-port=N port for the local DNS (default: first free one) --images=pull|build use the published images, or build your own (default: pull) --profiles=a,b databases and extras to enable, e.g. pg18,mariadb12,mail @@ -153,6 +154,12 @@ PHP_LIST="$(php_versions)" || { exit 1 } +# Versions already installed, so re-running the installer preselects them. Each +# is a compose profile: php84 in COMPOSE_PROFILES means PHP 8.4 is installed. +DEF_PHPS="$(printf '%s\n' "${COMPOSE_PROFILES:-}" | tr ',' '\n' \ + | sed -n 's/^php\([0-9][0-9]\)$/\1/p' | paste -sd, - || true)" +[ -n "$DEF_PHPS" ] || DEF_PHPS="$DEF_PHP" + opts=() while read -r v; do [ -n "$v" ] || continue @@ -161,15 +168,34 @@ done <&2 +PHP_PROFILES="" +for v in $PHPS; do + v="${v//./}" + printf '%s\n' "$PHP_LIST" | grep -qx "$v" || { + err "Unknown PHP version: $v" + echo " available: $(printf '%s\n' "$PHP_LIST" | paste -sd' ' -)" >&2 + exit 1 + } + PHP_PROFILES="${PHP_PROFILES:+$PHP_PROFILES,}php$v" +done +[ -n "$PHP_PROFILES" ] || { err "Pick at least one PHP version."; exit 1; } + +# Nothing answers a host name without a --pNN suffix otherwise. +printf '%s\n' $PHPS | tr -d . | grep -qx "$PHPV" || { + err "The default (${PHPV}) is not among the versions you picked: $PHPS" exit 1 } case "$IMAGES" in @@ -234,6 +272,11 @@ ALL_PROFILES="$(all_profiles)" || { DB_PROFILES="$(printf '%s\n' "$ALL_PROFILES" | grep -E '^(pg|mariadb)[0-9]+$' || true)" +# The PHP versions live on the same COMPOSE_PROFILES line but were asked about +# above, so they must not come back as a default here. +DEF_EXTRAS="$(printf '%s\n' "${COMPOSE_PROFILES:-}" | tr ',' '\n' \ + | grep -vE '^php[0-9][0-9]$' | grep -v '^$' | paste -sd, - || true)" + echo " Nothing is started unless you pick it. You can change this later with" echo " 'forge db on|off ' and 'forge mail on|off'." echo "" @@ -257,7 +300,7 @@ elif [ "$ASSUME_YES" -eq 1 ] || ! menu_available; then [ -n "$names" ] && printf ' %-12s %s\n' "${engine%%:*}" "$names" done echo "" - DBS="$(ask "Which databases? (space separated, or none)" "${COMPOSE_PROFILES:-none}")" + DBS="$(ask "Which databases? (space separated, or none)" "${DEF_EXTRAS:-none}")" case "$DBS" in none|"") DBS="" ;; esac for d in ${DBS//,/ }; do [ -z "$d" ] && continue @@ -286,9 +329,15 @@ EOF opts+=("mail $(profile_images mail) -> mail.${DOMAIN}") fi - PROFILES="$(menu_many "Optional services" "${COMPOSE_PROFILES:-}" "${opts[@]}")" + PROFILES="$(menu_many "Optional services" "$DEF_EXTRAS" "${opts[@]}")" fi +# The PHP versions lead the line, then the extras. Deduplicated because the two +# questions can overlap -- answering "mail" to the databases prompt and yes to the +# mail catcher used to write it twice. +PROFILES_LINE="$(printf '%s\n' "${PHP_PROFILES}${PROFILES:+,$PROFILES}" \ + | tr ',' '\n' | grep -v '^$' | awk '!seen[$0]++' | paste -sd, - || true)" + # ---------- write .env ---------- title "Writing configuration" [ -f .env ] && cp .env ".env.backup" && warn "Previous .env saved as .env.backup" @@ -300,7 +349,7 @@ sed -e "s|^DEV_DOMAIN=.*|DEV_DOMAIN=${DOMAIN}|" \ -e "s|^PUID=.*|PUID=${PUID_V}|" \ -e "s|^PGID=.*|PGID=${PGID_V}|" \ -e "s|^IMAGE_MODE=.*|IMAGE_MODE=${IMAGE_MODE_V}|" \ - -e "s|^COMPOSE_PROFILES=.*|COMPOSE_PROFILES=${PROFILES}|" \ + -e "s|^COMPOSE_PROFILES=.*|COMPOSE_PROFILES=${PROFILES_LINE}|" \ .env.example > .env info ".env written" @@ -331,7 +380,7 @@ mkdir -p custom/php.d if [ ! -f custom/php.d/README.md ]; then cat > custom/php.d/README.md <<'INIEOF' Any `.ini` file here is loaded by every PHP container. No rebuild needed: edit, -then `docker compose up -d --force-recreate php84dev`. +then `forge restart`. ; custom/php.d/99-mine.ini memory_limit = 512M @@ -407,6 +456,10 @@ cat </dev/null)" [ -n "$out" ] && { printf '%s\n' "$out"; return 0; } done return 1 } +# `paste -d', '` alternates the two delimiters, so a third item joins with a +# space instead of a comma: 8.3,8.4 8.5. +commas() { paste -sd, - | sed 's/,/, /g'; } + all_profiles() { compose_with_env config --profiles | sort; } +# Every version the compose files define, installed or not: '*' enables all +# profiles for this read only. The installer offers these, and `forge php on` +# accepts them. php_versions() { - compose_with_env config --services | sed -n 's/^php\([0-9][0-9]\)dev$/\1/p' | sort + local out + _CW_PROFILES='*' + out="$(compose_with_env config --services)" + _CW_PROFILES="" + printf '%s\n' "$out" | sed -n 's/^php\([0-9][0-9]\)dev$/\1/p' | sort } # Fills the base sets once. Callers that loop should call this first: a command @@ -58,7 +73,7 @@ _profile_extra() { # images|services, profile # pg18 -> postgres:18 ; search -> elasticsearch:8.14.2, kibana:8.14.2 # The registry and namespace are trimmed: the tag carries the version, which is # the part worth reading, and a full ghcr.io/... path wrecks a table column. -profile_images() { _profile_extra images "$1" | sed 's|.*/||' | paste -sd', ' -; } +profile_images() { _profile_extra images "$1" | sed 's|.*/||' | commas; } # pg18 -> postgres18dev ; search -> es8143dev, kibana (one per line) profile_services() { _profile_extra services "$1"; } From 702d6f6cedc2a02fb1a30137aae2caef63c50695 Mon Sep 17 00:00:00 2001 From: Yostin Vargas Date: Mon, 24 Aug 2026 11:05:09 -0400 Subject: [PATCH 2/2] ci: the profiles line now leads with the PHP version Two steps asserted COMPOSE_PROFILES exactly and were written before versions lived on that line. The first install of a scripted run also accepts the mail catcher, so the light-install check compares what is there rather than the whole line. Both were run verbatim from a clean copy this time -- the gap that let them through was checking only the steps the running stack could reach. --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd38e0..49bce5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,8 @@ jobs: # reproduce the bug: --profiles bypassed validation entirely, so this # passed even while every typed name was being rejected. ./install.sh --yes --skip-dns --skip-cert --skip-link --profiles=pg18,mail >/dev/null - grep -q '^COMPOSE_PROFILES=pg18,mail$' .env + # The default PHP version leads the line: versions are profiles too. + grep -q '^COMPOSE_PROFILES=php84,pg18,mail$' .env rm -f .env .env.backup # a name that does not must stop a scripted install, not write a dead profile @@ -145,7 +146,13 @@ jobs: # each. What is not picked must not reach the default set at all. - name: only the PHP versions you picked are installed run: | - grep -q '^COMPOSE_PROFILES=php84$' .env + # Not an exact match on the line: --yes also accepts the mail catcher. + grep -qE '^COMPOSE_PROFILES=php84(,|$)' .env \ + || { grep '^COMPOSE_PROFILES=' .env; exit 1; } + if grep '^COMPOSE_PROFILES=' .env | grep -qE 'php(83|85)'; then + echo "a version nobody asked for was enabled" + grep '^COMPOSE_PROFILES=' .env; exit 1 + fi for s in php83dev php85dev; do if docker compose config --services | grep -qx "$s"; then echo "$s starts by default and should not"; exit 1