Repository navigation
Pick the PHP versions you install, like the databases #74
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: ["**"] | |
| paths-ignore: ["**.md", "LICENSE", "logo.png"] | |
| pull_request: | |
| paths-ignore: ["**.md", "LICENSE", "logo.png"] | |
| workflow_dispatch: | |
| jobs: | |
| lint: | |
| name: Shell and Dockerfile lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: shellcheck | |
| run: | | |
| sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck | |
| shellcheck --severity=warning \ | |
| install.sh install_cert.sh setup-local-dns.sh aliases.bash \ | |
| lib/menu.sh lib/compose.sh bin/forge \ | |
| docker-library/nginx/config_files/docker-entrypoint.sh \ | |
| docker-library/php/config_files/docker-php-entrypoint \ | |
| docker-library/php/config_files/xdebug \ | |
| docker-library/httpd/config_files/docker-entrypoint.sh | |
| # `forge` exists so the common commands are one word; a README that teaches | |
| # `docker compose up` instead is how it stayed undiscovered until someone | |
| # installed the project and never found `forge help`. Prose about what CI | |
| # validates may still say `docker compose config` -- that is not an instruction. | |
| - name: the READMEs do not teach docker compose | |
| run: | | |
| fail=0 | |
| for pattern in 'docker compose up' 'docker compose logs' \ | |
| 'docker compose stop' 'docker compose --profile' \ | |
| 'docker compose build'; do | |
| if grep -n "$pattern" README.md README.es.md; then | |
| echo "^ use the forge command instead"; fail=1 | |
| fi | |
| done | |
| exit $fail | |
| - name: the helper scripts compile | |
| run: | | |
| python3 -m py_compile .github/scripts/drive-install.py | |
| python3 -m py_compile .github/scripts/run-step.py | |
| - name: hadolint | |
| uses: hadolint/hadolint-action@v3.1.0 | |
| with: | |
| dockerfile: docker-library/php/Dockerfile | |
| failure-threshold: error | |
| install: | |
| name: Install and serve on a clean machine | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 40 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| # The installer used to offer databases it could not name: the list came from | |
| # `docker compose config --profiles`, which cannot parse the files before .env | |
| # exists. The same command validated the answer, so a correct name was dropped | |
| # too. Runs before the real install, in the no-.env state the bug needed. | |
| - name: the installer can name the databases it offers | |
| run: | | |
| set -euo pipefail | |
| # Must run before 'compose file parses' does cp .env.example .env: | |
| # the bug needs the fresh-clone state, with no .env at all. | |
| if [ -f .env ]; then | |
| echo "this step has to run before anything creates .env"; exit 1 | |
| fi | |
| # The regression test. An empty list is what broke both halves: nothing | |
| # to read, and nothing to validate against. --yes answers `none`, so the | |
| # typed path itself needs a tty and stays uncovered until task 33 | |
| # reworks the prompts. | |
| ./install.sh --yes --skip-dns --skip-cert --skip-link | tee /tmp/install.log | |
| grep -q 'pg18' /tmp/install.log | |
| grep -q 'mariadb12' /tmp/install.log | |
| rm -f .env | |
| # Guards the new validation against becoming too strict. It does not | |
| # 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 | |
| # 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 | |
| if ./install.sh --yes --skip-dns --skip-cert --skip-link --profiles=nope >/dev/null 2>&1; then | |
| echo "unknown profile was accepted"; exit 1 | |
| fi | |
| test ! -f .env | |
| echo "the offered list is real and answerable" | |
| # Task 32 shipped a broken prompt because CI only ever ran --yes, where the | |
| # questions are never asked. This presses the keys a person presses: a pty, | |
| # arrow/digit keys, space to toggle. The driver finds each option by reading | |
| # the drawn menu, so adding a database cannot quietly break it. | |
| - name: the menus answer real keystrokes | |
| run: | | |
| set -euo pipefail | |
| rm -f .env .env.backup | |
| python3 .github/scripts/drive-install.py \ | |
| ./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' | |
| # 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" | |
| - name: compose file parses | |
| run: | | |
| cp .env.example .env | |
| docker compose config >/dev/null | |
| # Optional services live behind profiles instead of being commented | |
| # out, so they stay parsed and cannot rot unnoticed the way the old | |
| # nginx block did. | |
| docker compose --profile search --profile tools config >/dev/null | |
| echo "default services:" | |
| docker compose config --services | |
| echo "with every profile:" | |
| docker compose --profile search --profile tools config --services | |
| - name: profiles do not leak into the default set | |
| run: | | |
| for s in es8143dev kibana mkcert; do | |
| if docker compose config --services | grep -qx "$s"; then | |
| echo "$s starts by default and should not"; exit 1 | |
| fi | |
| done | |
| echo "optional services stay optional" | |
| # --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 --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: | | |
| # 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 | |
| fi | |
| done | |
| docker compose config --services | grep -qx php84dev | |
| echo "one version picked, one version installed" | |
| - name: .env was written | |
| run: | | |
| grep -q '^PROJECTS_DIR=' .env | |
| grep -q "^PUID=$(id -u)$" .env | |
| grep -q "^PGID=$(id -g)$" .env | |
| # forge is the documented entry point now, so it gets exercised rather than | |
| # only its README examples. | |
| - name: the forge command works | |
| run: | | |
| 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 "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 | |
| if forge use 9.9 2>/dev/null; then echo "9.9 should have been rejected"; exit 1; fi | |
| - name: build images | |
| # Every PHP version is built. 8.5 broke silently once: opcache became | |
| # 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 | |
| docker compose ps | |
| # "running" only means the process started. The PHP entrypoints still | |
| # have setup to finish, and Apache serves in parallel, so wait for an | |
| # actual answer instead. | |
| for i in $(seq 1 60); do | |
| if curl -fsS --max-time 5 --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome--sites.phpforge.dev/ >/dev/null 2>&1; then | |
| echo "ready after ${i}s"; exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "still not serving after 60s" | |
| docker compose logs --tail=30 | |
| exit 1 | |
| - name: create a project with a hyphen in its name | |
| run: | | |
| DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" | |
| mkdir -p "$DIR/projects/my-app/public" | |
| printf '<?php echo "my-app on PHP ".PHP_VERSION."\\n";' \ | |
| > "$DIR/projects/my-app/public/index.php" | |
| ln -sfn ../projects/my-app/public "$DIR/sites/my-app" | |
| - name: diagnostics | |
| if: failure() | |
| run: | | |
| echo "--- PROJECTS_DIR in .env ---" | |
| grep '^PROJECTS_DIR=' .env | |
| echo "--- mount as compose resolves it ---" | |
| docker compose config | grep -A3 'public_html' | head -12 | |
| echo "--- on the host ---" | |
| DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" | |
| ls -la "$DIR" "$DIR/sites" "$DIR/sites/welcome" 2>&1 | head -30 | |
| echo "--- inside apachedev ---" | |
| docker compose exec -T apachedev ls -la /home/php-devforge/ /home/php-devforge/public_html/ 2>&1 | head -20 | |
| echo "--- DEV_DOMAIN inside apachedev ---" | |
| docker compose exec -T apachedev printenv DEV_DOMAIN PHP_VERSION | |
| echo "--- what the response actually is ---" | |
| curl -sv --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome--sites.phpforge.dev/ 2>&1 | tail -25 | |
| - name: the welcome page is served over HTTPS | |
| run: | | |
| curl -fsS --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome--sites.phpforge.dev/ | tee /tmp/welcome.txt | |
| grep -q "PHP DevForge is running" /tmp/welcome.txt | |
| # Hyphens in project names were broken for a long time: mi-app--sites | |
| # resolved to sites/app/mi. | |
| # Needs running containers, so it sits after the page is confirmed served | |
| # rather than with the other forge checks, which run before the stack is up. | |
| - name: forge logs reaches every container | |
| run: | | |
| export PATH="$HOME/.local/bin:$PATH" | |
| # Counting distinct services, not looking for one name: compose prints | |
| # logs for stopped containers too, so "apachedev appears" would pass even | |
| # if the command had regressed to following a single container. | |
| named() { grep -oE '^[a-z0-9]+ *\|' "$1" | tr -d ' |' | sort -u; } | |
| timeout 5 forge logs > /tmp/all.log 2>&1 || true | |
| all="$(named /tmp/all.log | wc -l)" | |
| test "$all" -ge 3 || { echo "forge logs named $all services, expected the stack"; exit 1; } | |
| # naming one narrows it to exactly that -- a service, not just a PHP | |
| # version, which is what sent the README to `docker compose logs` | |
| timeout 5 forge logs apachedev > /tmp/one.log 2>&1 || true | |
| test "$(named /tmp/one.log)" = apachedev || { | |
| echo "forge logs apachedev named: $(named /tmp/one.log | tr '\n' ' ')"; exit 1; } | |
| if forge logs nope 2>/dev/null; then echo "unknown service accepted"; exit 1; fi | |
| echo "forge logs: $all services with no argument, one when named" | |
| - name: a hyphenated project name resolves | |
| run: | | |
| curl -fsS --resolve my-app--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://my-app--sites.phpforge.dev/ | tee /tmp/app.txt | |
| grep -q "my-app on PHP 8.4" /tmp/app.txt | |
| - name: the URL suffix selects the PHP version | |
| run: | | |
| for v in 83 84 85; do | |
| host="my-app--sites--p${v}.phpforge.dev" | |
| got="$(curl -fsS --resolve "$host:443:127.0.0.1" "https://$host/")" | |
| echo "$got" | |
| printf '%s' "$got" | grep -q "PHP ${v:0:1}.${v:1:1}" \ | |
| || { 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 | |
| run: | | |
| for s in php83dev php84dev php85dev; do | |
| got="$(docker compose exec -T "$s" php -r 'echo extension_loaded("Zend OPcache") && extension_loaded("mbstring") ? "yes" : "no";')" | |
| test "$got" = "yes" || { echo "$s: opcache or mbstring missing"; exit 1; } | |
| len="$(docker compose exec -T "$s" php -r 'echo mb_strlen("ñandú");')" | |
| test "$len" = "5" || { echo "$s: mbstring not working (got $len)"; exit 1; } | |
| echo "$s ok" | |
| done | |
| # This one leaked source for every request that did not go through | |
| # DirectoryIndex. Guard it so it cannot come back. | |
| - name: PHP source is never served as text | |
| run: | | |
| for host in my-app--sites my-app--sites--p83 my-app--sites--p99; do | |
| body="$(curl -sk --resolve $host.phpforge.dev:443:127.0.0.1 \ | |
| https://$host.phpforge.dev/index.php || true)" | |
| if printf '%s' "$body" | grep -q '<?php'; then | |
| echo "SOURCE LEAK on $host"; exit 1 | |
| fi | |
| done | |
| body="$(curl -s http://127.0.0.1/index.php -H 'Host: my-app--sites.phpforge.dev' || true)" | |
| printf '%s' "$body" | grep -q '<?php' && { echo "SOURCE LEAK over plain HTTP"; exit 1; } | |
| echo "no leaks" | |
| # Files written by the container must belong to the host user, or | |
| # node_modules cannot be deleted without sudo. | |
| - name: files created in the container belong to the host user | |
| run: | | |
| DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" | |
| printf '<?php file_put_contents(__DIR__."/written.txt","x");' \ | |
| > "$DIR/projects/my-app/public/write.php" | |
| curl -fsS --resolve my-app--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://my-app--sites.phpforge.dev/write.php >/dev/null | |
| owner="$(stat -c %u "$DIR/projects/my-app/public/written.txt")" | |
| test "$owner" = "$(id -u)" || { echo "owned by $owner, expected $(id -u)"; exit 1; } | |
| rm -f "$DIR/projects/my-app/public/written.txt" | |
| # sites/ is a shortcut tried before the full path, and both must keep working. | |
| - name: short and full paths both resolve | |
| run: | | |
| export PATH="$HOME/.local/bin:$PATH" | |
| DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" | |
| forge link "$DIR/projects/my-app/public" | |
| mkdir -p "$DIR/sites/api/v2" | |
| printf '<?php echo "nested ok";' > "$DIR/sites/api/v2/index.php" | |
| get() { curl -fsS -k --resolve "$1.phpforge.dev:443:127.0.0.1" "https://$1.phpforge.dev/"; } | |
| # The README now sends new users to the short welcome URL, so it is no | |
| # longer only the long form that has to answer. | |
| get welcome | grep -q "PHP DevForge is running" | |
| get my-app | grep -q "my-app on PHP" # sites/ shortcut | |
| get v2--api | grep -q "nested ok" # nested under sites/ | |
| get public--my-app--projects | grep -q "my-app on PHP" # full path still works | |
| get my-app--sites | grep -q "my-app on PHP" # long form still works | |
| # forge link must produce a relative link: an absolute one resolves on the | |
| # runner but points at nothing inside the containers. | |
| case "$(readlink "$DIR/sites/my-app")" in | |
| /*) echo "link is absolute; it will 404 inside the containers"; exit 1 ;; | |
| esac | |
| # and it must refuse a folder the containers cannot see | |
| if forge link /tmp 2>/dev/null; then echo "should have refused /tmp"; exit 1; fi | |
| # Apache does not forward the Authorization header to FastCGI by itself; | |
| # php-fpm.conf adds it back. Removing that line breaks every Bearer token | |
| # and JWT silently, with nothing else looking wrong. | |
| - name: the Authorization header reaches PHP | |
| run: | | |
| DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" | |
| printf '<?php echo $_SERVER["HTTP_AUTHORIZATION"] ?? "MISSING";' \ | |
| > "$DIR/sites/welcome/auth.php" | |
| got="$(curl -fsS -H 'Authorization: Bearer ci-token' \ | |
| --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome--sites.phpforge.dev/auth.php)" | |
| test "$got" = "Bearer ci-token" || { echo "got '$got'"; exit 1; } | |
| - name: hidden .php files are not served | |
| run: | | |
| DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" | |
| printf '<?php echo "should not be reachable";' > "$DIR/sites/welcome/.php" | |
| code="$(curl -sk -o /dev/null -w '%{http_code}' \ | |
| --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome--sites.phpforge.dev/.php)" | |
| rm -f "$DIR/sites/welcome/.php" | |
| test "$code" = "403" || { echo "expected 403, got $code"; exit 1; } | |
| # Everything above proves the stack works, using curl --resolve. From here | |
| # the system resolver is configured for real, so a failure below points at | |
| # setup-local-dns.sh and nothing else. | |
| - name: configure the system DNS | |
| run: | | |
| ./setup-local-dns.sh | |
| ./setup-local-dns.sh --status | |
| - name: the domain resolves through the system resolver | |
| run: | | |
| got="$(getent hosts welcome--sites.phpforge.dev | awk '{print $1}')" | |
| test "$got" = "127.0.0.1" || { echo "got '$got', expected 127.0.0.1"; exit 1; } | |
| # A wildcard, so any subdomain must work without being listed anywhere. | |
| got="$(getent hosts anything--at--all.phpforge.dev | awk '{print $1}')" | |
| test "$got" = "127.0.0.1" || { echo "wildcard failed: '$got'"; exit 1; } | |
| - name: the rest of DNS is untouched | |
| run: | | |
| for h in github.com debian.org; do | |
| got="$(getent hosts $h | awk '{print $1; exit}')" | |
| test -n "$got" || { echo "$h does not resolve"; exit 1; } | |
| echo "$h -> $got" | |
| done | |
| # The old script wrote Domains=~., sending every query on the machine | |
| # through the container. Stopping the stack then killed all DNS. | |
| - name: internet still works with dnsmasq stopped | |
| run: | | |
| docker compose stop dnsmasq | |
| sudo resolvectl flush-caches || true | |
| for h in github.com debian.org; do | |
| got="$(getent hosts $h | awk '{print $1; exit}')" | |
| test -n "$got" || { echo "FAIL: $h stopped resolving"; exit 1; } | |
| done | |
| echo "internet survived" | |
| docker compose start dnsmasq | |
| sleep 3 | |
| # No --resolve here: this is the path a browser actually takes. | |
| - name: serve over the real DNS path | |
| run: | | |
| for i in $(seq 1 20); do | |
| curl -fsS --max-time 5 https://welcome--sites.phpforge.dev/ >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| curl -fsS https://welcome--sites.phpforge.dev/ | grep -q "PHP DevForge is running" | |
| curl -fsS https://my-app--sites.phpforge.dev/ | grep -q "my-app on PHP 8.4" | |
| curl -fsS https://my-app--sites--p83.phpforge.dev/ | grep -q "PHP 8.3" | |
| - name: --remove undoes it, with .env already gone | |
| run: | | |
| # The cleanup order people actually use: delete .env, then undo the DNS | |
| # change. Removal must not need config that is already gone. | |
| mv .env /tmp/env.bak | |
| ./setup-local-dns.sh --remove | |
| mv /tmp/env.bak .env | |
| test ! -f /etc/systemd/resolved.conf.d/50-php-devforge.conf | |
| getent hosts github.com >/dev/null || { echo "DNS broken after removal"; exit 1; } | |
| echo "clean" | |
| # Databases and mail are only useful if PHP can actually reach them, which is | |
| # a shared-network question rather than a compose-syntax one. | |
| - name: databases and mail are reachable from PHP | |
| run: | | |
| export PATH="$HOME/.local/bin:$PATH" | |
| forge db on pg18 | |
| forge db on mariadb12 | |
| forge mail on | |
| # Wait for the thing we actually need -- a connection from PHP with the real | |
| # credentials -- not for a readiness proxy. `mariadb-admin ping` answers on | |
| # the local socket seconds before the server accepts TCP connections and | |
| # before the init scripts have created the user, so it reports "alive" while | |
| # PDO still gets ECONNREFUSED. Same trap as "container running" earlier. | |
| for i in $(seq 1 60); do | |
| if docker compose exec -T php84dev php -r ' | |
| $u = getenv("USER_DEV") ?: "php-devforge"; $p = "php-devforge01"; | |
| new PDO("pgsql:host=postgres18dev;port=5432;dbname=$u", $u, $p); | |
| new PDO("mysql:host=mariadb12dev;port=3306;dbname=$u", $u, $p);' 2>/dev/null; then | |
| echo "both databases reachable after ${i}s" | |
| break | |
| fi | |
| [ "$i" = 60 ] && { echo "databases never became reachable"; docker compose logs --tail=40 mariadb12dev postgres18dev; exit 1; } | |
| sleep 2 | |
| done | |
| # Mail: sent over SMTP from PHP, read back through the web UI. | |
| docker compose exec -T php84dev php -r ' | |
| $s = fsockopen("mailpit", 1025, $e, $es, 5) or exit(1); | |
| fgets($s); | |
| foreach (["EHLO ci","MAIL FROM:<a@ci.test>","RCPT TO:<b@ci.test>","DATA"] as $c) { | |
| fwrite($s, "$c\r\n"); fgets($s); | |
| } | |
| fwrite($s, "Subject: ci-probe\r\n\r\nbody\r\n.\r\n"); fgets($s); | |
| fwrite($s, "QUIT\r\n");' | |
| sleep 3 | |
| curl -fsS -k --resolve mail.phpforge.dev:443:127.0.0.1 \ | |
| "https://mail.phpforge.dev/api/v1/messages?limit=5" | grep -q ci-probe | |
| echo "mail captured and served at mail.<domain>" | |
| # nginx sat commented out and broken since the first commit precisely because | |
| # The reported gap: no way to see what can be turned on without opening | |
| # docker-compose.override.yml. Elasticsearch is listed but deliberately not | |
| # started -- the task is discoverability, and a 700MB pull buys no signal. | |
| - name: forge can see and switch every profile | |
| run: | | |
| set -euo pipefail | |
| export PATH="$HOME/.local/bin:$PATH" | |
| forge profile list | tee /tmp/profiles.txt | |
| for p in mail mariadb11 mariadb12 nginx pg16 pg17 pg18 search tools; do | |
| grep -qE "^ $p +" /tmp/profiles.txt || { echo "$p is missing"; exit 1; } | |
| done | |
| # The ON column must BE COMPOSE_PROFILES, not merely contain a name we | |
| # guessed: hardcoding one coupled this to where the step sits in the job. | |
| listed="$(awk '$2=="ON"{print $1}' /tmp/profiles.txt | sort | paste -sd, -)" | |
| declared="$(sed -ne 's/^COMPOSE_PROFILES=//p' .env | tr ',' '\n' \ | |
| | grep -v '^$' | sort | paste -sd, -)" | |
| test "$listed" = "$declared" || { | |
| echo "listed ON: $listed .env says: $declared"; exit 1; } | |
| grep -qE '^ pg16 +off ' /tmp/profiles.txt | |
| grep -q 'replaces apachedev' /tmp/profiles.txt # the two exceptions are stated | |
| grep -q 'used by forge certs' /tmp/profiles.txt | |
| grep -qE '^ search +off +elasticsearch:[0-9]' /tmp/profiles.txt | |
| test "$(forge profile list maria | wc -l)" -eq 2 | |
| # off/on through the general command, against a container that is really | |
| # running by this point, so stopping it is a real assertion. | |
| # postgres18dev is derived from compose now, not from a pg* -> postgres*dev rule. | |
| forge profile off pg18 | |
| if docker compose ps --services | grep -qx postgres18dev; then | |
| echo "postgres18dev did not stop"; exit 1 | |
| fi | |
| forge profile on pg18 | |
| docker compose ps --services | grep -qx postgres18dev | |
| # Captured, not piped: forge exits 1 here by design, and under pipefail | |
| # a pipeline reports the leftmost failure -- so `forge ... | grep` fails | |
| # even when grep finds what it was looking for. | |
| out="$(forge profile on tools 2>&1 || true)" | |
| printf '%s\n' "$out" | grep -q 'forge certs' | |
| if forge profile on tools >/dev/null 2>&1; then | |
| echo "a one-shot profile was accepted"; exit 1 | |
| fi | |
| if forge profile on nope; then echo "unknown profile accepted"; exit 1; fi | |
| forge db list | grep -q pg18 # the wrapper still works | |
| echo "every profile is visible and switchable" | |
| # nothing exercised it. It is an alternative to Apache, not an addition: both | |
| # want ports 80 and 443. | |
| # Driven through forge rather than by hand: `profile on nginx` has to stop | |
| # apachedev, since both publish 80/443, and `off` has to bring it back. That | |
| # is the one case where turning something on turns something else off. | |
| - name: the nginx variant serves the same sites | |
| run: | | |
| export PATH="$HOME/.local/bin:$PATH" | |
| docker compose build nginxdev | |
| forge profile on nginx | |
| docker compose ps --services | grep -qx nginxdev | |
| if docker compose ps --services | grep -qx apachedev; then | |
| echo "apachedev is still up; nginx cannot have the ports"; exit 1 | |
| fi | |
| for i in $(seq 1 30); do | |
| curl -fsS --max-time 5 -k --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome--sites.phpforge.dev/ >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| get() { curl -fsS -k --resolve "$1.phpforge.dev:443:127.0.0.1" "https://$1.phpforge.dev/"; } | |
| get my-app--sites | grep -q "PHP 8.4" | |
| get my-app--sites--p83 | grep -q "PHP 8.3" | |
| get my-app--sites--p85 | grep -q "PHP 8.5" | |
| # The Lua sets an unroutable backend when it cannot work out a version, so a | |
| # .php fails closed instead of being served as a static file. | |
| for h in my-app--sites my-app--sites--p99; do | |
| body="$(curl -sk --resolve "$h.phpforge.dev:443:127.0.0.1" "https://$h.phpforge.dev/index.php" || true)" | |
| printf '%s' "$body" | grep -q '<?php' && { echo "SOURCE LEAK on $h"; exit 1; } | |
| done | |
| echo "nginx ok" | |
| forge profile off nginx | |
| docker compose ps --services | grep -qx apachedev | |
| # Neither fallback had ever been exercised: CI runs install_cert.sh first, so | |
| # the certificate always existed. nginx died outright without one, and | |
| # Apache's safety net -- written in task 7 -- had never been proven to work. | |
| - name: a missing certificate degrades instead of killing the server | |
| run: | | |
| set -euo pipefail | |
| export PATH="$HOME/.local/bin:$PATH" | |
| mkdir -p /tmp/certs-aside | |
| mv certificates/php-devforge.pem certificates/php-devforge.key /tmp/certs-aside/ | |
| # Unconditional: a failure halfway through would otherwise leave the | |
| # certificates aside, and every later step -- including the log dump on | |
| # failure -- would be looking at a degraded stack. | |
| trap 'mv /tmp/certs-aside/* certificates/ 2>/dev/null || true' EXIT | |
| # `curl -k` accepts any certificate, so it cannot tell a generated | |
| # fallback from a stale file. Comparing issuer to subject can: a | |
| # self-signed certificate signs itself, mkcert's does not. Comparing the | |
| # two beats grepping for a name -- OpenSSL prints `CN = x` on some builds | |
| # and `CN=x` on others, which is what broke the first version of this. | |
| cert_field() { | |
| echo | openssl s_client -connect 127.0.0.1:443 -servername welcome.phpforge.dev 2>/dev/null \ | |
| | openssl x509 -noout "-$1" | sed "s/^$1=//" | tr -d ' ' | |
| } | |
| self_signed() { [ "$(cert_field issuer)" = "$(cert_field subject)" ]; } | |
| docker compose up -d --force-recreate apachedev | |
| for i in $(seq 1 30); do | |
| curl -fsS -k --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome.phpforge.dev/ >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| curl -fsS -k --resolve welcome.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome.phpforge.dev/ | grep -q "PHP DevForge is running" | |
| self_signed || { | |
| echo "apache is not serving a generated certificate: $(cert_field issuer)"; exit 1; } | |
| docker compose logs apachedev | grep -qi 'self-signed' | |
| echo "apache degrades" | |
| forge profile on nginx | |
| for i in $(seq 1 30); do | |
| curl -fsS -k --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome.phpforge.dev/ >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| docker compose ps --services | grep -qx nginxdev || { | |
| echo "nginxdev is not running"; docker compose logs --tail=20 nginxdev; exit 1; } | |
| curl -fsS -k --resolve welcome.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome.phpforge.dev/ | grep -q "PHP DevForge is running" | |
| self_signed || { | |
| echo "nginx is not serving a generated certificate: $(cert_field issuer)"; exit 1; } | |
| echo "nginx degrades too" | |
| # Put it back, or later steps would be testing the wrong certificate. | |
| forge profile off nginx | |
| mv /tmp/certs-aside/php-devforge.pem /tmp/certs-aside/php-devforge.key certificates/ | |
| docker compose up -d --force-recreate apachedev | |
| for i in $(seq 1 30); do | |
| curl -fsS --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \ | |
| https://welcome.phpforge.dev/ >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| if self_signed; then echo "the real certificate did not come back"; exit 1; fi | |
| cert_field issuer | grep -qi mkcert || { | |
| echo "unexpected issuer after restore: $(cert_field issuer)"; exit 1; } | |
| echo "and the real one comes back" | |
| # Every checkout claims the same compose project, so a second one silently | |
| # adopted and reconfigured the first one's containers. Reproduced rather than | |
| # described: a real second directory, started from, then taken back. | |
| - name: a second checkout cannot take over silently | |
| run: | | |
| set -euo pipefail | |
| export PATH="$HOME/.local/bin:$PATH" | |
| HERE="$(pwd)" | |
| # However this ends, the stack goes back to belonging here: the step | |
| # after it would otherwise be testing containers from /tmp. | |
| trap 'cd "$HERE" && forge --force start >/dev/null 2>&1 || true' EXIT | |
| rm -rf /tmp/second-checkout | |
| git ls-files | tar -cf - -T - | (mkdir -p /tmp/second-checkout && tar -xf - -C /tmp/second-checkout) | |
| # A real second clone has only the tracked compose files: any | |
| # docker-compose.local.yml is gitignored and did not come with it. | |
| sed -e 's|^PROJECTS_DIR=.*|PROJECTS_DIR=/tmp/second-projects|' \ | |
| -e 's|^COMPOSE_FILE=.*|COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml|' \ | |
| .env > /tmp/second-checkout/.env | |
| mkdir -p /tmp/second-projects/sites | |
| (cd /tmp/second-checkout && docker compose up -d) | |
| owner() { | |
| docker inspect apachedev \ | |
| --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' | |
| } | |
| test "$(owner)" = /tmp/second-checkout || { | |
| echo "the second checkout did not take the containers: $(owner)"; exit 1; } | |
| # no terminal, no --force: it must refuse and say how | |
| if forge start < /dev/null > /tmp/refused.log 2>&1; then | |
| echo "forge took over without being asked"; exit 1 | |
| fi | |
| grep -q /tmp/second-checkout /tmp/refused.log | |
| grep -q -- --force /tmp/refused.log | |
| test "$(owner)" = /tmp/second-checkout || { echo "it took over anyway"; exit 1; } | |
| # status has to be able to answer "who owns these". Captured, not piped: | |
| # `grep -q` closes the pipe early, forge dies of SIGPIPE, and pipefail | |
| # reports 141 -- the same trap as `forge profile on tools | grep`. | |
| forge status > /tmp/status.log 2>&1 | |
| grep -q /tmp/second-checkout /tmp/status.log | |
| # stop is not destructive, but it is just as surprising | |
| if forge stop < /dev/null >/dev/null 2>&1; then | |
| echo "forge stop took over without being asked"; exit 1 | |
| fi | |
| # and taking over is still one command | |
| forge --force start < /dev/null > /dev/null 2>&1 | |
| test "$(owner)" = "$HERE" || { echo "--force did not take over: $(owner)"; exit 1; } | |
| # with no conflict it must say nothing at all | |
| forge start < /dev/null > /tmp/quiet.log 2>&1 | |
| if grep -q 'started from' /tmp/quiet.log; then | |
| echo "warned when there was nothing to warn about"; exit 1 | |
| fi | |
| rm -rf /tmp/second-checkout /tmp/second-projects | |
| echo "a second checkout announces itself" | |
| - name: container logs on failure | |
| if: failure() | |
| run: docker compose logs --tail=50 |