|
| 1 | +name: CI |
| 2 | + |
| 3 | +on: |
| 4 | + push: |
| 5 | + branches: ["**"] |
| 6 | + paths-ignore: ["**.md", "LICENSE", "logo.png"] |
| 7 | + pull_request: |
| 8 | + paths-ignore: ["**.md", "LICENSE", "logo.png"] |
| 9 | + workflow_dispatch: |
| 10 | + |
| 11 | +jobs: |
| 12 | + lint: |
| 13 | + name: Shell and Dockerfile lint |
| 14 | + runs-on: ubuntu-latest |
| 15 | + steps: |
| 16 | + - uses: actions/checkout@v5 |
| 17 | + |
| 18 | + - name: shellcheck |
| 19 | + run: | |
| 20 | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck |
| 21 | + shellcheck --severity=warning \ |
| 22 | + install.sh install_cert.sh setup-local-dns.sh aliases.bash \ |
| 23 | + docker-library/php/config_files/docker-php-entrypoint \ |
| 24 | + docker-library/php/config_files/xdebug \ |
| 25 | + docker-library/httpd/config_files/docker-entrypoint.sh |
| 26 | +
|
| 27 | + - name: hadolint |
| 28 | + uses: hadolint/hadolint-action@v3.1.0 |
| 29 | + with: |
| 30 | + dockerfile: docker-library/php/Dockerfile |
| 31 | + failure-threshold: error |
| 32 | + |
| 33 | + install: |
| 34 | + name: Install and serve on a clean machine |
| 35 | + runs-on: ubuntu-latest |
| 36 | + timeout-minutes: 40 |
| 37 | + steps: |
| 38 | + - uses: actions/checkout@v5 |
| 39 | + |
| 40 | + - name: compose file parses |
| 41 | + run: | |
| 42 | + cp .env.example .env |
| 43 | + docker compose config >/dev/null |
| 44 | + # Optional services live behind profiles instead of being commented |
| 45 | + # out, so they stay parsed and cannot rot unnoticed the way the old |
| 46 | + # nginx block did. |
| 47 | + docker compose --profile search --profile tools config >/dev/null |
| 48 | + echo "default services:" |
| 49 | + docker compose config --services |
| 50 | + echo "with every profile:" |
| 51 | + docker compose --profile search --profile tools config --services |
| 52 | +
|
| 53 | + - name: profiles do not leak into the default set |
| 54 | + run: | |
| 55 | + for s in es8143dev kibana mkcert; do |
| 56 | + if docker compose config --services | grep -qx "$s"; then |
| 57 | + echo "$s starts by default and should not"; exit 1 |
| 58 | + fi |
| 59 | + done |
| 60 | + echo "optional services stay optional" |
| 61 | +
|
| 62 | + # --skip-dns: the runner's resolver is left alone; curl --resolve is used |
| 63 | + # instead. Certificates are not skipped, so real TLS is exercised. |
| 64 | + - name: run the installer |
| 65 | + run: ./install.sh --yes --skip-dns |
| 66 | + |
| 67 | + - name: .env was written |
| 68 | + run: | |
| 69 | + grep -q '^PROJECTS_DIR=' .env |
| 70 | + grep -q "^PUID=$(id -u)$" .env |
| 71 | + grep -q "^PGID=$(id -g)$" .env |
| 72 | +
|
| 73 | + - name: build images |
| 74 | + # Every PHP version is built. 8.5 broke silently once: opcache became |
| 75 | + # built-in and docker-php-ext-install started failing on it. |
| 76 | + run: docker compose build php83dev php84dev php85dev apachedev |
| 77 | + |
| 78 | + - name: start the stack |
| 79 | + run: | |
| 80 | + docker compose up -d apachedev php83dev php84dev php85dev |
| 81 | + docker compose ps |
| 82 | + # "running" only means the process started. The PHP entrypoints still |
| 83 | + # have setup to finish, and Apache serves in parallel, so wait for an |
| 84 | + # actual answer instead. |
| 85 | + for i in $(seq 1 60); do |
| 86 | + if curl -fsS --max-time 5 --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ |
| 87 | + https://welcome--sites.phpforge.dev/ >/dev/null 2>&1; then |
| 88 | + echo "ready after ${i}s"; exit 0 |
| 89 | + fi |
| 90 | + sleep 1 |
| 91 | + done |
| 92 | + echo "still not serving after 60s" |
| 93 | + docker compose logs --tail=30 |
| 94 | + exit 1 |
| 95 | +
|
| 96 | + - name: create a project with a hyphen in its name |
| 97 | + run: | |
| 98 | + DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" |
| 99 | + mkdir -p "$DIR/projects/my-app/public" |
| 100 | + printf '<?php echo "my-app on PHP ".PHP_VERSION."\\n";' \ |
| 101 | + > "$DIR/projects/my-app/public/index.php" |
| 102 | + ln -sfn ../projects/my-app/public "$DIR/sites/my-app" |
| 103 | +
|
| 104 | + - name: diagnostics |
| 105 | + if: failure() |
| 106 | + run: | |
| 107 | + echo "--- PROJECTS_DIR in .env ---" |
| 108 | + grep '^PROJECTS_DIR=' .env |
| 109 | + echo "--- mount as compose resolves it ---" |
| 110 | + docker compose config | grep -A3 'public_html' | head -12 |
| 111 | + echo "--- on the host ---" |
| 112 | + DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" |
| 113 | + ls -la "$DIR" "$DIR/sites" "$DIR/sites/welcome" 2>&1 | head -30 |
| 114 | + echo "--- inside apachedev ---" |
| 115 | + docker compose exec -T apachedev ls -la /home/php-devforge/ /home/php-devforge/public_html/ 2>&1 | head -20 |
| 116 | + echo "--- DEV_DOMAIN inside apachedev ---" |
| 117 | + docker compose exec -T apachedev printenv DEV_DOMAIN PHP_VERSION |
| 118 | + echo "--- what the response actually is ---" |
| 119 | + curl -sv --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ |
| 120 | + https://welcome--sites.phpforge.dev/ 2>&1 | tail -25 |
| 121 | +
|
| 122 | + - name: the welcome page is served over HTTPS |
| 123 | + run: | |
| 124 | + curl -fsS --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ |
| 125 | + https://welcome--sites.phpforge.dev/ | tee /tmp/welcome.txt |
| 126 | + grep -q "PHP DevForge is running" /tmp/welcome.txt |
| 127 | +
|
| 128 | + # Hyphens in project names were broken for a long time: mi-app--sites |
| 129 | + # resolved to sites/app/mi. |
| 130 | + - name: a hyphenated project name resolves |
| 131 | + run: | |
| 132 | + curl -fsS --resolve my-app--sites.phpforge.dev:443:127.0.0.1 \ |
| 133 | + https://my-app--sites.phpforge.dev/ | tee /tmp/app.txt |
| 134 | + grep -q "my-app on PHP 8.4" /tmp/app.txt |
| 135 | +
|
| 136 | + - name: the URL suffix selects the PHP version |
| 137 | + run: | |
| 138 | + for v in 83 84 85; do |
| 139 | + host="my-app--sites--p${v}.phpforge.dev" |
| 140 | + got="$(curl -fsS --resolve "$host:443:127.0.0.1" "https://$host/")" |
| 141 | + echo "$got" |
| 142 | + printf '%s' "$got" | grep -q "PHP ${v:0:1}.${v:1:1}" \ |
| 143 | + || { echo "expected PHP ${v:0:1}.${v:1:1}"; exit 1; } |
| 144 | + done |
| 145 | +
|
| 146 | + - name: opcache is present in every version |
| 147 | + run: | |
| 148 | + for s in php83dev php84dev php85dev; do |
| 149 | + got="$(docker compose exec -T "$s" php -r 'echo extension_loaded("Zend OPcache")?"yes":"no";')" |
| 150 | + test "$got" = "yes" || { echo "$s: opcache missing"; exit 1; } |
| 151 | + echo "$s ok" |
| 152 | + done |
| 153 | +
|
| 154 | + # This one leaked source for every request that did not go through |
| 155 | + # DirectoryIndex. Guard it so it cannot come back. |
| 156 | + - name: PHP source is never served as text |
| 157 | + run: | |
| 158 | + for host in my-app--sites my-app--sites--p83 my-app--sites--p99; do |
| 159 | + body="$(curl -sk --resolve $host.phpforge.dev:443:127.0.0.1 \ |
| 160 | + https://$host.phpforge.dev/index.php || true)" |
| 161 | + if printf '%s' "$body" | grep -q '<?php'; then |
| 162 | + echo "SOURCE LEAK on $host"; exit 1 |
| 163 | + fi |
| 164 | + done |
| 165 | + body="$(curl -s http://127.0.0.1/index.php -H 'Host: my-app--sites.phpforge.dev' || true)" |
| 166 | + printf '%s' "$body" | grep -q '<?php' && { echo "SOURCE LEAK over plain HTTP"; exit 1; } |
| 167 | + echo "no leaks" |
| 168 | +
|
| 169 | + # Files written by the container must belong to the host user, or |
| 170 | + # node_modules cannot be deleted without sudo. |
| 171 | + - name: files created in the container belong to the host user |
| 172 | + run: | |
| 173 | + DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" |
| 174 | + printf '<?php file_put_contents(__DIR__."/written.txt","x");' \ |
| 175 | + > "$DIR/projects/my-app/public/write.php" |
| 176 | + curl -fsS --resolve my-app--sites.phpforge.dev:443:127.0.0.1 \ |
| 177 | + https://my-app--sites.phpforge.dev/write.php >/dev/null |
| 178 | + owner="$(stat -c %u "$DIR/projects/my-app/public/written.txt")" |
| 179 | + test "$owner" = "$(id -u)" || { echo "owned by $owner, expected $(id -u)"; exit 1; } |
| 180 | + rm -f "$DIR/projects/my-app/public/written.txt" |
| 181 | +
|
| 182 | + # Apache does not forward the Authorization header to FastCGI by itself; |
| 183 | + # php-fpm.conf adds it back. Removing that line breaks every Bearer token |
| 184 | + # and JWT silently, with nothing else looking wrong. |
| 185 | + - name: the Authorization header reaches PHP |
| 186 | + run: | |
| 187 | + DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" |
| 188 | + printf '<?php echo $_SERVER["HTTP_AUTHORIZATION"] ?? "MISSING";' \ |
| 189 | + > "$DIR/sites/welcome/auth.php" |
| 190 | + got="$(curl -fsS -H 'Authorization: Bearer ci-token' \ |
| 191 | + --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ |
| 192 | + https://welcome--sites.phpforge.dev/auth.php)" |
| 193 | + test "$got" = "Bearer ci-token" || { echo "got '$got'"; exit 1; } |
| 194 | +
|
| 195 | + - name: hidden .php files are not served |
| 196 | + run: | |
| 197 | + DIR="$(grep '^PROJECTS_DIR=' .env | cut -d= -f2-)" |
| 198 | + printf '<?php echo "should not be reachable";' > "$DIR/sites/welcome/.php" |
| 199 | + code="$(curl -sk -o /dev/null -w '%{http_code}' \ |
| 200 | + --resolve welcome--sites.phpforge.dev:443:127.0.0.1 \ |
| 201 | + https://welcome--sites.phpforge.dev/.php)" |
| 202 | + rm -f "$DIR/sites/welcome/.php" |
| 203 | + test "$code" = "403" || { echo "expected 403, got $code"; exit 1; } |
| 204 | +
|
| 205 | + # Everything above proves the stack works, using curl --resolve. From here |
| 206 | + # the system resolver is configured for real, so a failure below points at |
| 207 | + # setup-local-dns.sh and nothing else. |
| 208 | + - name: configure the system DNS |
| 209 | + run: | |
| 210 | + ./setup-local-dns.sh |
| 211 | + ./setup-local-dns.sh --status |
| 212 | +
|
| 213 | + - name: the domain resolves through the system resolver |
| 214 | + run: | |
| 215 | + got="$(getent hosts welcome--sites.phpforge.dev | awk '{print $1}')" |
| 216 | + test "$got" = "127.0.0.1" || { echo "got '$got', expected 127.0.0.1"; exit 1; } |
| 217 | + # A wildcard, so any subdomain must work without being listed anywhere. |
| 218 | + got="$(getent hosts anything--at--all.phpforge.dev | awk '{print $1}')" |
| 219 | + test "$got" = "127.0.0.1" || { echo "wildcard failed: '$got'"; exit 1; } |
| 220 | +
|
| 221 | + - name: the rest of DNS is untouched |
| 222 | + run: | |
| 223 | + for h in github.com debian.org; do |
| 224 | + got="$(getent hosts $h | awk '{print $1; exit}')" |
| 225 | + test -n "$got" || { echo "$h does not resolve"; exit 1; } |
| 226 | + echo "$h -> $got" |
| 227 | + done |
| 228 | +
|
| 229 | + # The old script wrote Domains=~., sending every query on the machine |
| 230 | + # through the container. Stopping the stack then killed all DNS. |
| 231 | + - name: internet still works with dnsmasq stopped |
| 232 | + run: | |
| 233 | + docker compose stop dnsmasq |
| 234 | + sudo resolvectl flush-caches || true |
| 235 | + for h in github.com debian.org; do |
| 236 | + got="$(getent hosts $h | awk '{print $1; exit}')" |
| 237 | + test -n "$got" || { echo "FAIL: $h stopped resolving"; exit 1; } |
| 238 | + done |
| 239 | + echo "internet survived" |
| 240 | + docker compose start dnsmasq |
| 241 | + sleep 3 |
| 242 | +
|
| 243 | + # No --resolve here: this is the path a browser actually takes. |
| 244 | + - name: serve over the real DNS path |
| 245 | + run: | |
| 246 | + for i in $(seq 1 20); do |
| 247 | + curl -fsS --max-time 5 https://welcome--sites.phpforge.dev/ >/dev/null 2>&1 && break |
| 248 | + sleep 1 |
| 249 | + done |
| 250 | + curl -fsS https://welcome--sites.phpforge.dev/ | grep -q "PHP DevForge is running" |
| 251 | + curl -fsS https://my-app--sites.phpforge.dev/ | grep -q "my-app on PHP 8.4" |
| 252 | + curl -fsS https://my-app--sites--p83.phpforge.dev/ | grep -q "PHP 8.3" |
| 253 | +
|
| 254 | + - name: --remove undoes it |
| 255 | + run: | |
| 256 | + ./setup-local-dns.sh --remove |
| 257 | + test ! -f /etc/systemd/resolved.conf.d/50-php-devforge.conf |
| 258 | + getent hosts github.com >/dev/null || { echo "DNS broken after removal"; exit 1; } |
| 259 | + echo "clean" |
| 260 | +
|
| 261 | + - name: container logs on failure |
| 262 | + if: failure() |
| 263 | + run: docker compose logs --tail=50 |
0 commit comments