Skip to content

Pick the PHP versions you install, like the databases #74

Pick the PHP versions you install, like the databases

Pick the PHP versions you install, like the databases #74

Workflow file for this run

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