From 7d3c56a3315f731e1213e8e71c2451461b960605 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 18:21:20 +0000 Subject: [PATCH 01/13] Fix helper scripts printing shell options to stdout `set -oe` without an option name makes /bin/sh print every shell option to stdout. Anything reading a helper's output got that dump first, such as the watcher URL lookup and the php.net checksum lookup. Use `set -e` (and `set -eu` in the s6 installer). Thanks to @tomschlick for spotting this in #726. Co-authored-by: Tom Schlick --- .../usr/local/bin/docker-php-serversideup-dep-install-alpine | 2 +- .../usr/local/bin/docker-php-serversideup-dep-install-debian | 2 +- src/common/usr/local/bin/docker-php-serversideup-download | 2 +- .../local/bin/docker-php-serversideup-install-php-ext-installer | 2 +- .../local/bin/docker-php-serversideup-install-php-extensions | 2 +- src/s6/usr/local/bin/docker-php-serversideup-s6-install | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/common/usr/local/bin/docker-php-serversideup-dep-install-alpine b/src/common/usr/local/bin/docker-php-serversideup-dep-install-alpine index 620cb91b..7101dda4 100644 --- a/src/common/usr/local/bin/docker-php-serversideup-dep-install-alpine +++ b/src/common/usr/local/bin/docker-php-serversideup-dep-install-alpine @@ -1,5 +1,5 @@ #!/bin/sh -set -oe +set -e ################################################### # Usage: docker-php-serversideup-dep-install-alpine [alpine-packages] diff --git a/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian b/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian index 967cf91f..90287ad3 100644 --- a/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian +++ b/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian @@ -1,5 +1,5 @@ #!/bin/sh -set -oe +set -e ################################################### # Usage: docker-php-serversideup-dep-install-debian [debian-packages] diff --git a/src/common/usr/local/bin/docker-php-serversideup-download b/src/common/usr/local/bin/docker-php-serversideup-download index e587bc8f..38a180ce 100755 --- a/src/common/usr/local/bin/docker-php-serversideup-download +++ b/src/common/usr/local/bin/docker-php-serversideup-download @@ -1,5 +1,5 @@ #!/bin/sh -set -oe +set -e ################################################### # Usage: docker-php-serversideup-download [output-file] diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer index ad37020e..cd494a0c 100644 --- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer +++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer @@ -1,5 +1,5 @@ #!/bin/sh -set -oe +set -e ################################################### # Usage: docker-php-serversideup-install-php-ext-installer [version] diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions b/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions index b27199ba..82110b86 100644 --- a/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions +++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions @@ -1,5 +1,5 @@ #!/bin/sh -set -oe +set -e ################################################### # Usage: docker-php-serversideup-install-php-extensions [extensions] [overrides] diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-install b/src/s6/usr/local/bin/docker-php-serversideup-s6-install index cc38728f..7e8c7a51 100644 --- a/src/s6/usr/local/bin/docker-php-serversideup-s6-install +++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-install @@ -1,5 +1,5 @@ #!/bin/sh -set -oue +set -eu ############################################### # Usage: docker-php-serversideup-s6-install From 8a7f7d19d3bccd95621dd0432318ab6d5b80d693 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 18:28:36 +0000 Subject: [PATCH 02/13] Pin every image dependency in one config file Move each version that ships in an image (s6-overlay, php-fpm-healthcheck, the extension installer, FrankenPHP, Go, watcher, phpredis) into a `dependencies` block in php-versions-base-config.yml. CI and dev.sh pass the block as build args of the same name, and the Dockerfiles declare them without defaults so a missing pin fails the build. Build redis from its GitHub source, with its liblzf submodule fetched separately, so no default build step depends on PECL. Pin watcher instead of downloading the latest release. --- .github/workflows/service_build-images.yml | 13 ++++++++ AGENTS.md | 3 +- .../docs/8.reference/2.command-reference.md | 4 ++- scripts/conf/php-versions-base-config.yml | 24 ++++++++++++++ scripts/dev.sh | 7 ++++- scripts/get-php-versions.sh | 1 + ...php-serversideup-install-php-ext-installer | 19 ++++++------ ...er-php-serversideup-install-php-extensions | 31 +++++++++++++++++++ .../bin/docker-php-serversideup-s6-install | 10 +++--- src/variations/cli/Dockerfile | 3 ++ src/variations/fpm-apache/Dockerfile | 5 +++ src/variations/fpm-nginx/Dockerfile | 5 +++ src/variations/fpm/Dockerfile | 6 +++- src/variations/frankenphp/Dockerfile | 15 +++++---- 14 files changed, 120 insertions(+), 26 deletions(-) diff --git a/.github/workflows/service_build-images.yml b/.github/workflows/service_build-images.yml index fa382312..e6315edb 100644 --- a/.github/workflows/service_build-images.yml +++ b/.github/workflows/service_build-images.yml @@ -124,6 +124,18 @@ jobs: fi echo "nginx_arg=NGINX_VERSION=$VERSION" >> "$GITHUB_OUTPUT" + # Every pinned dependency version from the base config, as build args of the same name. + - name: Collect build args + id: build_args + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} + run: | + { + echo "args< --jq .sha + PHPREDIS_LIBLZF_REF: fb25820c3c0aeafd127956ae6c115063b47e459a + # renovate: datasource=github-releases depName=mlocati/docker-php-extension-installer + PHP_EXT_INSTALLER_VERSION: 2.12.0 + # renovate: datasource=github-releases depName=just-containers/s6-overlay versioning=loose + S6_VERSION: v3.2.3.2 + # renovate: datasource=github-releases depName=renatomefi/php-fpm-healthcheck + PHP_FPM_HEALTHCHECK_VERSION: v0.6.0 + # renovate: datasource=github-releases depName=php/frankenphp + FRANKENPHP_VERSION: 1.13.4 + # renovate: datasource=docker depName=golang + GOLANG_VERSION: "1.27" + # renovate: datasource=github-releases depName=e-dant/watcher + WATCHER_VERSION: 0.14.5 + php_variations: - name: cli default: true diff --git a/scripts/dev.sh b/scripts/dev.sh index 9a2a3d40..824827f6 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -4,7 +4,7 @@ set -oe pipefail ########################################################################## -# Usage: build.sh --variation --version --os +# Usage: dev.sh --variation --version --os ########################################################################## # This script is used to build a Docker image for a specific PHP version # and variation. It is intended to be used for local development. You may @@ -110,6 +110,11 @@ build_docker_image() { build_args+=(--build-arg PHP_VERSION="$PHP_BUILD_VERSION") build_args+=(--build-arg BASE_OS_VERSION="$PHP_BUILD_BASE_OS") + # Every pinned dependency version, the same way CI passes them + while IFS= read -r dependency; do + build_args+=(--build-arg "$dependency") + done < <(yq -r '.dependencies | to_entries[] | "\(.key)=\(.value)"' "$BASE_PHP_VERSIONS_CONFIG_FILE") + if [ -n "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then build_args+=(--build-arg "NGINX_VERSION=$NGINX_VERSION") fi diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh index 5808058f..3fddfca1 100755 --- a/scripts/get-php-versions.sh +++ b/scripts/get-php-versions.sh @@ -434,6 +434,7 @@ if [ "$SKIP_DOWNLOAD" = false ]; then # Use 'echo' to pass the JSON data to 'jq' merged_json=$(jq -s ' { + dependencies: (.[1].dependencies // {}), php_variations: (.[1].php_variations // []), php_versions: ( .[0].php_versions + .[1].php_versions diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer index cd494a0c..a57c5d24 100644 --- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer +++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer @@ -6,23 +6,22 @@ set -e ################################################### # This script installs the "install-php-extensions" script from # https://github.com/mlocati/docker-php-extension-installer +# Our images pass the PHP_EXT_INSTALLER_VERSION build arg from +# scripts/conf/php-versions-base-config.yml. Without a version, the latest release is used. script_name="docker-php-serversideup-install-php-ext-installer" -############ -# Environment variables -############ -PHP_EXT_INSTALLER_VERSION="2.12.0" - ############ # Main ############ -if [ -n "$1" ]; then - PHP_EXT_INSTALLER_VERSION="$1" +PHP_EXT_INSTALLER_VERSION="${1:-${PHP_EXT_INSTALLER_VERSION:-latest}}" + +if [ "$PHP_EXT_INSTALLER_VERSION" = "latest" ]; then + download_url="https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions" +else + download_url="https://github.com/mlocati/docker-php-extension-installer/releases/download/$PHP_EXT_INSTALLER_VERSION/install-php-extensions" fi -docker-php-serversideup-download \ - "https://github.com/mlocati/docker-php-extension-installer/releases/download/$PHP_EXT_INSTALLER_VERSION/install-php-extensions" \ - /usr/local/bin/install-php-extensions +docker-php-serversideup-download "$download_url" /usr/local/bin/install-php-extensions chmod +x /usr/local/bin/install-php-extensions echo "$script_name: ⚡️ Installed $PHP_EXT_INSTALLER_VERSION of \"install-php-extensions\"" \ No newline at end of file diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions b/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions index 82110b86..ed0c1fa2 100644 --- a/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions +++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions @@ -8,6 +8,11 @@ set -e # swapping an extension for a different source. Overrides are space separated # "name=source" pairs, where source is any value install-php-extensions accepts # (for example "redis=phpredis/phpredis@develop"). +# +# When PHPREDIS_VERSION and PHPREDIS_LIBLZF_REF are set (our images pass them as build +# args from scripts/conf/php-versions-base-config.yml), redis is built from its GitHub +# source instead of PECL, so a PECL outage cannot block a release. An override such as +# "redis=phpredis/phpredis@develop" builds that branch, tag, or commit the same way. script_name="docker-php-serversideup-install-php-extensions" @@ -19,9 +24,22 @@ if [ -z "$extensions" ]; then exit 0 fi +phpredis_source_dir="/tmp/phpredis" + ############ # Functions ############ +# GitHub tarballs leave out git submodules, so phpredis's bundled liblzf is downloaded +# separately. The directory is then installed like any local source. +download_phpredis_source() { + mkdir -p "$phpredis_source_dir/liblzf" + docker-php-serversideup-download "https://codeload.github.com/phpredis/phpredis/tar.gz/$1" /tmp/phpredis.tar.gz + tar -xzf /tmp/phpredis.tar.gz -C "$phpredis_source_dir" --strip-components=1 + docker-php-serversideup-download "https://codeload.github.com/nemequ/liblzf/tar.gz/$PHPREDIS_LIBLZF_REF" /tmp/liblzf.tar.gz + tar -xzf /tmp/liblzf.tar.gz -C "$phpredis_source_dir/liblzf" --strip-components=1 + rm -f /tmp/phpredis.tar.gz /tmp/liblzf.tar.gz +} + resolve_extension_source() { resolved_extension="$1" for override in $overrides; do @@ -29,6 +47,18 @@ resolve_extension_source() { resolved_extension="${override#*=}" fi done + if [ -n "${PHPREDIS_VERSION:-}" ] && [ -n "${PHPREDIS_LIBLZF_REF:-}" ]; then + case "$resolved_extension" in + redis) + download_phpredis_source "$PHPREDIS_VERSION" + resolved_extension="$phpredis_source_dir" + ;; + phpredis/phpredis@*) + download_phpredis_source "${resolved_extension#*@}" + resolved_extension="$phpredis_source_dir" + ;; + esac + fi echo "$resolved_extension" } @@ -42,5 +72,6 @@ done echo "🤖 Installing PHP extensions:$resolved_extensions" install-php-extensions $resolved_extensions +rm -rf "$phpredis_source_dir" echo "⚡️ Completed installation of PHP extensions:$resolved_extensions" diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-install b/src/s6/usr/local/bin/docker-php-serversideup-s6-install index 7e8c7a51..26804450 100644 --- a/src/s6/usr/local/bin/docker-php-serversideup-s6-install +++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-install @@ -6,10 +6,12 @@ set -eu ############################################### # This script is used to install S6 Overlay. It is # intended to be used during the build process only. -# Be sure to set the S6_SRC_URL, S6_SRC_DEP, and S6_DIR -# environment variables before running this script. +# Be sure to set the S6_SRC_URL, S6_DIR, S6_VERSION, and +# PHP_FPM_HEALTHCHECK_VERSION environment variables before running +# this script. The versions come from scripts/conf/php-versions-base-config.yml. -S6_VERSION=v3.2.3.2 +: "${S6_VERSION:?S6_VERSION must be set}" +: "${PHP_FPM_HEALTHCHECK_VERSION:?PHP_FPM_HEALTHCHECK_VERSION must be set}" mkdir -p $S6_DIR SYS_ARCH=$(uname -m) export SYS_ARCH @@ -38,5 +40,5 @@ untar ${S6_SRC_URL}/${S6_VERSION}/s6-overlay-${S6_ARCH}.tar.xz # Ensure "php-fpm-healthcheck" is installed echo "⬇️ Downloading php-fpm-healthcheck..." -docker-php-serversideup-download https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck +docker-php-serversideup-download https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/${PHP_FPM_HEALTHCHECK_VERSION}/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck chmod +x /usr/local/bin/php-fpm-healthcheck \ No newline at end of file diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile index afbed7ec..b1386d3e 100644 --- a/src/variations/cli/Dockerfile +++ b/src/variations/cli/Dockerfile @@ -17,6 +17,9 @@ ARG DEPENDENCY_PACKAGES_DEBIAN="\ zip" ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip' ARG PHP_EXTENSION_OVERRIDES='' +ARG PHP_EXT_INSTALLER_VERSION +ARG PHPREDIS_VERSION +ARG PHPREDIS_LIBLZF_REF ARG REPOSITORY_BUILD_VERSION='dev' LABEL org.opencontainers.image.title="serversideup/php (cli)" \ diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile index d6783957..3de22648 100644 --- a/src/variations/fpm-apache/Dockerfile +++ b/src/variations/fpm-apache/Dockerfile @@ -11,6 +11,8 @@ FROM ${BASE_IMAGE} AS s6-build ARG S6_DIR='/opt/s6/' ARG S6_SRC_URL="https://github.com/just-containers/s6-overlay/releases/download" +ARG S6_VERSION +ARG PHP_FPM_HEALTHCHECK_VERSION # copy our scripts COPY --chmod=755 src/common/ / @@ -36,6 +38,9 @@ ARG DEPENDENCY_PACKAGES_DEBIAN="\ zip" ARG DEPENDENCY_PHP_EXTENSIONS='mysqli opcache pcntl pdo_mysql pdo_pgsql redis zip' ARG PHP_EXTENSION_OVERRIDES='' +ARG PHP_EXT_INSTALLER_VERSION +ARG PHPREDIS_VERSION +ARG PHPREDIS_LIBLZF_REF ARG REPOSITORY_BUILD_VERSION='dev' LABEL org.opencontainers.image.title="serversideup/php (fpm-apache)" \ diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile index 70021770..43427cb4 100644 --- a/src/variations/fpm-nginx/Dockerfile +++ b/src/variations/fpm-nginx/Dockerfile @@ -10,6 +10,8 @@ FROM ${BASE_IMAGE} AS s6-build ARG S6_DIR='/opt/s6/' ARG S6_SRC_URL="https://github.com/just-containers/s6-overlay/releases/download" +ARG S6_VERSION +ARG PHP_FPM_HEALTHCHECK_VERSION # copy our scripts COPY --chmod=755 src/common/ / @@ -111,6 +113,9 @@ ARG DEPENDENCY_PACKAGES_DEBIAN="\ zip" ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip' ARG PHP_EXTENSION_OVERRIDES='' +ARG PHP_EXT_INSTALLER_VERSION +ARG PHPREDIS_VERSION +ARG PHPREDIS_LIBLZF_REF ARG REPOSITORY_BUILD_VERSION='dev' ARG NGINX_VERSION='1.28.0-1' diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile index 6976a67d..ab10376c 100644 --- a/src/variations/fpm/Dockerfile +++ b/src/variations/fpm/Dockerfile @@ -19,6 +19,10 @@ ARG DEPENDENCY_PACKAGES_DEBIAN="\ zip" ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip' ARG PHP_EXTENSION_OVERRIDES='' +ARG PHP_FPM_HEALTHCHECK_VERSION +ARG PHP_EXT_INSTALLER_VERSION +ARG PHPREDIS_VERSION +ARG PHPREDIS_LIBLZF_REF ARG REPOSITORY_BUILD_VERSION='dev' LABEL org.opencontainers.image.title="serversideup/php (fpm)" \ @@ -107,7 +111,7 @@ RUN rm -rf /usr/local/etc/php-fpm.d/*.conf && \ \ # Ensure "php-fpm-healthcheck" is installed echo "⬇️ Downloading php-fpm-healthcheck..." && \ - docker-php-serversideup-download https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck && \ + docker-php-serversideup-download https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/${PHP_FPM_HEALTHCHECK_VERSION}/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck && \ chmod +x /usr/local/bin/php-fpm-healthcheck && \ \ # Install default PHP extensions diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile index a9ca1079..af21cb07 100644 --- a/src/variations/frankenphp/Dockerfile +++ b/src/variations/frankenphp/Dockerfile @@ -2,8 +2,8 @@ ARG BASE_OS_VERSION='trixie' ARG PHP_VERSION='8.5' ARG BASE_IMAGE="php:${PHP_VERSION}-zts-${BASE_OS_VERSION}" -ARG FRANKENPHP_VERSION='1.13.4' -ARG GOLANG_VERSION='1.27' +ARG FRANKENPHP_VERSION +ARG GOLANG_VERSION ######################## # Common @@ -53,6 +53,7 @@ FROM golang:${GOLANG_VERSION} AS golang-image FROM common AS frankenphp-build ARG FRANKENPHP_VERSION ARG GOLANG_VERSION +ARG WATCHER_VERSION ARG BUILD_DEPENDENCY_PACKAGES_ALPINE="\ argon2-dev \ bash \ @@ -117,12 +118,7 @@ RUN if ! pkg-config --atleast-version=1.1 libbrotlienc; then \ # Install e-dant/watcher (necessary for file watching) WORKDIR /usr/local/src/watcher -RUN WATCHER_TARBALL_URL=$(docker-php-serversideup-download https://api.github.com/repos/e-dant/watcher/releases/latest | \ - grep tarball_url | \ - awk '{ print $2 }' | \ - sed 's/,$//' | \ - sed 's/"//g') && \ - docker-php-serversideup-download "$WATCHER_TARBALL_URL" /tmp/watcher.tar.gz && \ +RUN docker-php-serversideup-download "https://codeload.github.com/e-dant/watcher/tar.gz/refs/tags/${WATCHER_VERSION}" /tmp/watcher.tar.gz && \ tar xz --strip-components 1 -f /tmp/watcher.tar.gz && \ rm -f /tmp/watcher.tar.gz && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ @@ -174,6 +170,9 @@ ARG DEPENDENCY_PACKAGES_DEBIAN="\ zip" ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip' ARG PHP_EXTENSION_OVERRIDES='' +ARG PHP_EXT_INSTALLER_VERSION +ARG PHPREDIS_VERSION +ARG PHPREDIS_LIBLZF_REF ARG REPOSITORY_BUILD_VERSION='dev' LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \ From b6e645a06415c31b7d4338f47fe751dc58615a72 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 18:30:14 +0000 Subject: [PATCH 03/13] Compile PHP from docker-library's Dockerfiles Stop waiting for Docker Hub's php images. A new build-php-base phase runs prepare-php-base.sh, which takes docker-library/php at a pinned commit, sets the PHP version, URL, and php.net SHA256, and removes `pecl update-channels`. Everything else matches the official images. Each base compiles once without the build cache, so OS security fixes ship on the next run, and is saved to the Depot Registry. The variations build on it through BASE_IMAGE. get-php-versions.sh no longer checks Docker Hub, and a pinned patch_versions now replaces php.net's. Release candidates such as 8.6-rc build from php.net's release-candidates feed, and php_base_overrides lets a minor version change a variation's upstream variant. PHP 8.1 builds from the last docker-library commit that had it. --- .../workflows/action_publish-images-beta.yml | 14 +- .../action_publish-images-production.yml | 14 +- .../workflows/action_publish-images-prs.yml | 33 +- .github/workflows/service_build-images.yml | 22 +- .github/workflows/service_build-php-base.yml | 71 +++++ .github/workflows/service_setup-matrix.yml | 14 +- .gitignore | 3 +- .oss-scanner/threat_model.md | 4 +- AGENTS.md | 17 +- SECURITY.md | 4 +- .../docs/1.getting-started/99.contributing.md | 30 +- scripts/conf/php-versions-base-config.yml | 30 +- scripts/dev.sh | 58 +++- scripts/generate-matrix.sh | 13 +- scripts/get-php-versions.sh | 284 ++---------------- scripts/prepare-php-base.sh | 77 +++++ scripts/test-image.sh | 4 + scripts/tests/run.sh | 38 +++ src/variations/cli/Dockerfile | 3 +- src/variations/fpm-nginx/Dockerfile | 2 +- src/variations/fpm/Dockerfile | 3 +- 21 files changed, 423 insertions(+), 315 deletions(-) create mode 100644 .github/workflows/service_build-php-base.yml create mode 100755 scripts/prepare-php-base.sh diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml index 79fb16ff..d741829d 100644 --- a/.github/workflows/action_publish-images-beta.yml +++ b/.github/workflows/action_publish-images-beta.yml @@ -55,8 +55,18 @@ jobs: with: ref: ${{ needs.release.outputs.tag }} - build: + base: needs: [release, setup] + uses: ./.github/workflows/service_build-php-base.yml + permissions: + contents: read + id-token: write + with: + matrix: ${{ needs.setup.outputs.base-matrix }} + ref: ${{ needs.release.outputs.tag }} + + build: + needs: [release, setup, base] name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -76,7 +86,7 @@ jobs: secrets: inherit test: - needs: [release, setup, build] + needs: [release, setup, base, build] if: always() && needs.setup.result == 'success' uses: ./.github/workflows/service_test-images.yml permissions: diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml index e411c535..df154724 100644 --- a/.github/workflows/action_publish-images-production.yml +++ b/.github/workflows/action_publish-images-production.yml @@ -55,8 +55,18 @@ jobs: with: ref: ${{ needs.release.outputs.tag }} - build: + base: needs: [release, setup] + uses: ./.github/workflows/service_build-php-base.yml + permissions: + contents: read + id-token: write + with: + matrix: ${{ needs.setup.outputs.base-matrix }} + ref: ${{ needs.release.outputs.tag }} + + build: + needs: [release, setup, base] name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -76,7 +86,7 @@ jobs: secrets: inherit test: - needs: [release, setup, build] + needs: [release, setup, base, build] if: always() && needs.setup.result == 'success' uses: ./.github/workflows/service_test-images.yml permissions: diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml index 35b5f765..9e4d1ca7 100644 --- a/.github/workflows/action_publish-images-prs.yml +++ b/.github/workflows/action_publish-images-prs.yml @@ -38,8 +38,29 @@ jobs: with: ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} - build: + # Pull requests from forks skip the base phase and build on the official images. + base: needs: [setup] + uses: ./.github/workflows/service_build-php-base.yml + permissions: + contents: read + id-token: write + with: + matrix: ${{ needs.setup.outputs.base-matrix }} + ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} + publish: >- + ${{ + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + }} + + build: + needs: [setup, base] + # Forks skip every job in the base phase. Build anyway unless a base failed. + if: ${{ !cancelled() && needs.setup.result == 'success' && needs.base.result != 'failure' }} name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -70,7 +91,7 @@ jobs: secrets: inherit test: - needs: [setup, build] + needs: [setup, base, build] if: always() && needs.setup.result == 'success' uses: ./.github/workflows/service_test-images.yml permissions: @@ -104,7 +125,7 @@ jobs: ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} comment: - needs: [build, test, publish, report] + needs: [base, build, test, publish, report] if: >- always() && ( @@ -122,6 +143,7 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }} + BASE_RESULT: ${{ needs.base.result }} BUILD_RESULT: ${{ needs.build.result }} TEST_RESULT: ${{ needs.test.result }} PUBLISH_RESULT: ${{ needs.publish.result }} @@ -141,6 +163,11 @@ jobs: failure) TEST_CELL="❌ Failed" ;; *) TEST_CELL="⏭️ Skipped" ;; esac + # A failed base skips every build, which leaves the test phase nothing to test. + if [ "$BASE_RESULT" = "failure" ]; then + BUILD_CELL="❌ PHP base images failed to compile" + TEST_CELL="⏭️ Skipped" + fi case "$PUBLISH_RESULT" in success) PUBLISH_CELL="✅ Published to serversideup/php-dev" ;; failure) PUBLISH_CELL="❌ Failed" ;; diff --git a/.github/workflows/service_build-images.yml b/.github/workflows/service_build-images.yml index e6315edb..aae69514 100644 --- a/.github/workflows/service_build-images.yml +++ b/.github/workflows/service_build-images.yml @@ -1,11 +1,12 @@ name: Build Images -# Phase 1 of 3: build every image for one variation. Nothing is pushed to a public -# registry here. When publishing is requested, each image is saved to the Depot -# Registry so the image tests can run against it and, if they pass, -# service_publish-images.yml promotes the exact same image to Docker Hub and GHCR. -# When it is not (pull requests from forks), the amd64 image is loaded onto the -# runner and tested right here instead, so a green build still means a tested image. +# Phase 1 of 3: build every image for one variation on the PHP base images that +# service_build-php-base.yml compiled. Nothing is pushed to a public registry here. +# When publishing is requested, each image is saved to the Depot Registry so the +# image tests can run against it and, if they pass, service_publish-images.yml +# promotes the exact same image to Docker Hub and GHCR. When it is not (pull requests +# from forks), the amd64 image is loaded onto the runner and tested right here +# instead, so a green build still means a tested image. on: workflow_call: inputs: @@ -124,15 +125,22 @@ jobs: fi echo "nginx_arg=NGINX_VERSION=$VERSION" >> "$GITHUB_OUTPUT" - # Every pinned dependency version from the base config, as build args of the same name. + # Every pinned dependency version from the base config, plus the PHP base image that + # service_build-php-base.yml compiled for this image. Builds that are not saved (forks) + # have no compiled base and use the Dockerfile's default, the official image. - name: Collect build args id: build_args env: + PUBLISH: ${{ inputs.publish }} PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} + PHP_BASE_TAG: ${{ matrix.php_base_tag }} run: | { echo "args<> "$GITHUB_OUTPUT" - echo "variations=${VARIATIONS}" >> "$GITHUB_OUTPUT" + { + echo "matrix=${MATRIX_BY_VARIATION}" + echo "variations=${VARIATIONS}" + echo "base-matrix=${BASE_MATRIX}" + } >> "$GITHUB_OUTPUT" echo "$MATRIX_BY_VARIATION" | jq '.' { - echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations" + echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations, on $(echo "$BASE_MATRIX" | jq '.include | length') PHP base images" echo "" echo "| PHP | Resolved patch version | Base OS |" echo "|---|---|---|" diff --git a/.gitignore b/.gitignore index 310c9d1b..8f973cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules php-versions.yml *.tmp /docs/_OLD_ -.playwright-mcp \ No newline at end of file +.playwright-mcp +.php-base/ diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index 2d1b8143..adf2719f 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -1,12 +1,12 @@ # Threat model ## What this project does and where untrusted input enters -serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp`) on Debian and Alpine. We build on the official `php` images and add entrypoint scripts, S6 Overlay services, and web server configs. All of it is configured through environment variables. The images run as `www-data` and listen on 8080 and 8443. See `SECURITY.md` for our full scope. +serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp`) on Debian and Alpine. We compile PHP with the official docker-library/php Dockerfiles at a pinned commit and add entrypoint scripts, S6 Overlay services, and web server configs. All of it is configured through environment variables. The images run as `www-data` and listen on 8080 and 8443. See `SECURITY.md` for our full scope. Untrusted input: - **HTTP requests** to the NGINX, Apache, and Caddy (FrankenPHP) configs we ship. Assume any client on the network can send them. This includes headers that reach PHP or our real-IP handling (`TRUSTED_PROXY`), requests for `/healthcheck`, PHP-FPM status paths, `/storage/*.php`, dotfiles such as `.env` and `.git`, path info after `index.php`, and plain-HTTP requests when `SSL_MODE` is `mixed` or `full`. - **Pull requests from forks** against `.github/workflows/`. A fork can control the PR title, branch name, and file contents. It must never reach repository secrets, the Depot OIDC trust, or any publish to Docker Hub or GHCR. -- **Downloads during image builds** (s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures. +- **Downloads during image builds** (the PHP tarball from php.net, the docker-library/php Dockerfiles, phpredis and liblzf source, s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures. The PHP tarball is checked against php.net's SHA256 and its GPG signature, docker-library/php and liblzf are pinned to commits, and the other downloads are pinned to release tags in `scripts/conf/php-versions-base-config.yml`. Trusted (operators control these): - Environment variables diff --git a/AGENTS.md b/AGENTS.md index b7f1c25a..722e9fd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ src/ scripts/ # Build tooling (Bash, not POSIX) dev.sh # Local image builds conf/ # PHP version matrix, base config, and every pinned dependency version (YAML) + prepare-php-base.sh # Prepares docker-library/php's Dockerfile to compile a PHP patch generate-matrix.sh # Generates CI build matrix from YAML config assemble-docker-tags.sh docs/ # Nuxt 4 documentation site (see docs/AGENTS.md for docs-specific guidelines) @@ -56,6 +57,7 @@ Each variation Dockerfile uses multi-stage builds: ### Build args used across Dockerfiles - `PHP_VERSION`, `BASE_OS_VERSION`, `PHP_VARIATION` -- set by CI matrix or `scripts/dev.sh` +- `BASE_IMAGE` -- the compiled PHP base from the Depot Registry in CI. Defaults to the official `php:` image, which local builds and fork PRs use. - Every key under `dependencies` in `scripts/conf/php-versions-base-config.yml` (`S6_VERSION`, `PHPREDIS_VERSION`, `FRANKENPHP_VERSION`, ...) -- passed as a build arg of the same name by CI and `scripts/dev.sh`. Dockerfiles declare them with no default and helper scripts fail when one is missing, so the config is the only place a version lives. To add a dependency, add a line there with a `# renovate:` comment and an `ARG` in each stage that uses it. - `NGINX_VERSION` -- resolved per-OS from `scripts/conf/php-versions-base-config.yml` - `REPOSITORY_BUILD_VERSION` -- image version label @@ -85,21 +87,24 @@ scripts/dev.sh --variation frankenphp --version 8.5 --os bookworm --push ## PHP Version Pipeline PHP versions are NOT hardcoded. The pipeline works like this: -1. `scripts/get-php-versions.sh` fetches the latest active PHP releases from `https://www.php.net/releases/active.php` -2. It validates each version actually exists on DockerHub (with automatic fallback to previous patch if not yet published) -3. The fetched versions are merged with the base config (`scripts/conf/php-versions-base-config.yml`) which defines OS bases, variations, and NGINX versions -4. The merged result is written to `scripts/conf/php-versions.yml` -- this is the source of truth for CI builds -5. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON +1. `scripts/get-php-versions.sh` fetches the latest active PHP releases from `https://www.php.net/releases/active.php`. There is no Docker Hub check: we compile PHP ourselves, so a release builds as soon as php.net lists it. +2. The fetched versions are merged with the base config (`scripts/conf/php-versions-base-config.yml`) which defines OS bases, variations, dependency versions, and NGINX versions. A minor's `patch_versions` in the base config replaces php.net's, which pins or holds back a release. +3. The merged result is written to `scripts/conf/php-versions.yml` -- this is the source of truth for CI builds +4. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON, including each image's `php_base_tag` and `docker_library_ref` + +### How PHP is compiled +Each variation's `php_base` (`cli`, `fpm`, or `zts`) names the docker-library/php variant it builds on; a minor's `php_base_overrides` swaps it (8.6 has no `zts` variant, so FrankenPHP uses `cli`). A `-rc` version such as `8.6-rc` builds the newest release candidate from php.net's release-candidates feed and docker-library's `8.6-rc` directory. `scripts/prepare-php-base.sh` downloads docker-library/php at `DOCKER_LIBRARY_PHP_REF` (or the minor's own `docker_library_ref`, as 8.1 has), sets the PHP version, URL, and php.net SHA256, and removes `pecl update-channels`. It checks the result, so a layout change upstream fails loudly. Keep every other line of upstream's Dockerfile untouched: matching the official images' structure is the point. The `redis` extension is built from its GitHub source (with the `liblzf` submodule downloaded separately) so no default build step depends on PECL. When modifying the version pipeline, the base config (`php-versions-base-config.yml`) is the file you edit. Never edit `php-versions.yml` directly -- it's generated. ## CI/CD - Builds run via GitHub Actions using **Depot** (`depot/build-push-action`) for multi-arch (`linux/amd64` + `linux/arm64/v8`). Depot builds both architectures natively on its own builders; the GitHub runner only orchestrates, so runner size and architecture do not affect build speed. -- Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`. +- Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-php-base.yml` compiles each PHP base (one job per PHP version, base OS, and `php_base`) and saves it to the Depot Registry as `-php-base---`, always with `no-cache` so OS security fixes (a patched libbrotli or OpenSSL) land on the next run instead of when Debian or Alpine publish a new base image; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`. - A broken image can never reach a public tag: what users pull is byte-for-byte what the tests ran. Publishing is all or nothing: if any image fails to build or fails its test, no image from that run is published, including on the weekly rebuilds. Saved images live in the Depot Registry until the project's retention setting removes them. - The build matrix is generated from the PHP version pipeline described above. - Image tags follow the pattern: `serversideup/php:{version}-{variation}` (Debian default) or `serversideup/php:{version}-{variation}-{os}` (Alpine/specific OS). +- Fork PRs skip the base phase and build on the official `php:` images, so they can fail in the hours after a PHP release, and they never exercise changes to how PHP is compiled. - Depot authentication: the project ID lives in `depot.json` (not a secret). Same-repo runs authenticate through a Depot OIDC trust relationship (`id-token: write`). Pull requests from forks have no OIDC token, so Depot falls back to its open-source pull request flow (https://depot.dev/blog/github-actions-oss-fork-builds): the full matrix builds on ephemeral builders without the project cache, nothing is saved, and each build job loads its amd64 image onto the runner and runs `scripts/test-image.sh` there instead, so the build check still means a tested image. A maintainer publishes a fork's images to `serversideup/php-dev`, and gets the arm64 tests, by running the "Docker Publish (PR Images)" workflow manually with the PR number. - The CI helper script `scripts/build-summary.sh` is checked by `scripts/tests/run.sh`, a single file of plain assertions over sample images. Run it after changing the script and add an assertion when you add behavior. Keep jq programs explicit with parentheses: `a + b as $x | ...` parses differently across jq versions, and runners ship an older jq than most laptops. - The project is enrolled in Anthropic's OSS Scanner (`projects/serversideup-php` in anthropics/oss-scanner), which builds `.oss-scanner/Dockerfile` from `main` and audits it offline. That Dockerfile copies `src/` over each published web server image with the same `COPY` lines as `src/variations/*/Dockerfile`, and `.oss-scanner/run-variation` starts each one the way `docker run` would. Nothing fails when these drift, so update them in the same change as: diff --git a/SECURITY.md b/SECURITY.md index 429f3e4b..4a011fee 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,7 +51,7 @@ Our images bundle third-party software, each with its own support window. Before We publish images for end-of-life PHP versions so legacy applications have a path into containers, but only while two things remain true: -1. The official `php` image for that version still exists on Docker Hub, so we have a base to build from. +1. The official docker-library/php Dockerfile for that version exists at some commit, so we can still compile it the same way the official images did. 2. The operating system release underneath still receives security updates from its distribution. Debian LTS counts. Once a release passes its end of life, a rebuild cannot deliver any patches, and Debian removes the packages from its mirrors soon after, so the build fails anyway. When either condition stops holding, we stop rebuilding that image and its existing tags freeze at their last successful build. We do not pin archived or snapshot package repositories to keep an EOL base building: that produces a fresh tag with the same unpatched packages, which is worse than an honest frozen tag. Use an EOL base as a stepping stone, not a destination. See [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#operating-systems) for the trade-off. @@ -67,7 +67,7 @@ The following images are no longer built. Their existing tags remain pullable on | Anything on Debian Bullseye | 2026-09-03 | Debian 11 LTS ended on 2026-08-31. The following week Debian removed the Bullseye packages from `deb.debian.org` and `security.debian.org`, so `apt-get install` fails inside the build. | | Anything on Alpine 3.16 | 2026-09-03 | Alpine 3.16 reached end of life on 2024-05-23 and has received no security updates since. It was only ever used by PHP 7.4 and 8.0. | -PHP 8.1 is also end of life, but it is still built. Its official base images exist, and Debian Bookworm, Debian Trixie, and Alpine 3.22 still receive security updates, so a weekly rebuild delivers real operating system patches. It stops when those bases do. +PHP 8.1 is end of life and still built. We compile it from the last docker-library/php commit that included it. Debian Bookworm, Debian Trixie, and Alpine 3.22 still receive security updates, so a weekly rebuild delivers real operating system patches. It stops when those bases do. If you are running one of the frozen images, treat it as a migration deadline rather than a stable base. Move to a supported PHP version and base from [Choosing an image](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image). The [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide) covers moving between our releases. If you cannot move yet and need to install packages on a frozen Bullseye tag, the [major version migration guide](https://serversideup.net/open-source/docker-php/docs/guide/major-version-migrations#installing-packages-on-a-frozen-bullseye-image) shows how to do it in your own Dockerfile. diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index 8ef91c2c..d5787f5e 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -34,6 +34,12 @@ bash scripts/dev.sh --variation fpm-nginx --version 8.2.12 --os bookworm This will build `serversideup/php:8.2.12-fpm-nginx-bookworm` locally on your machine for testing and inspection. +By default, local builds start from the official PHP image on Docker Hub, which is fast. CI compiles PHP from source instead (see [How PHP is built](#how-php-is-built)). To build exactly what CI builds, add `--build-php-base` with a full patch version. Compiling PHP takes several minutes. The compiled base only exists in your local image store. It needs Docker's default builder and can't be combined with `--push`. + +```bash [Terminal] +bash scripts/dev.sh --variation fpm-nginx --version 8.4.27 --os bookworm --build-php-base +``` + ### Published Beta Images We also have beta images that are published to our Docker Hub and GitHub Packages repositories. @@ -52,15 +58,31 @@ Sometimes you need to just run a test web server locally to see if your changes docker run --rm -v $(pwd):/var/www/html -p 80:8080 -p 443:8443 serversideup/php:8.4-fpm-nginx ``` -## How PHP Versions are selected for distribution -We use the official PHP versions as our base image. To identify which versions should be built, we use a file called `scripts/conf/php-versions-base-config.yml` to explicitly select what versions should be built and any special rules/settings for each version (like base OS, default versions, etc). +## How PHP versions are selected for distribution +The file `scripts/conf/php-versions-base-config.yml` selects which PHP versions are built and the rules for each one, like base OS and default versions. -We then use a `scripts/get-php-versions.sh` script to download the [latest active releases from PHP](https://www.php.net/releases/active.php) and merge them into a final file called `scripts/conf/php-versions.yml`. +The `scripts/get-php-versions.sh` script downloads the [latest active releases from PHP](https://www.php.net/releases/active.php) and merges them into a final file called `scripts/conf/php-versions.yml`. To pin a minor version to a specific patch (or hold back a bad release), list it under that minor's `patch_versions` in the base config. A pinned patch replaces the one from php.net. The `php-versions.yml` file will include all final versions for tagging and building. We generate our tags with a file called `scripts/assemble-docker-tags.sh` which handles all the advanced logic of compiling our tags together. +## How PHP is built +We compile PHP from the php.net tarball with the same Dockerfiles that build the [official PHP images](https://github.com/docker-library/php). A security release can ship as soon as php.net publishes it, without waiting for the official images on Docker Hub. No step of the build depends on PECL. + +For every PHP version, base OS, and upstream variant (`cli`, `fpm`, or `zts`), `scripts/prepare-php-base.sh` downloads docker-library/php at a pinned commit and changes only two things in its Dockerfile: + +- The PHP version, download URL, and SHA256 are set to the patch we're building. The SHA256 comes from php.net, and the GPG signature check stays exactly as upstream runs it. +- The `pecl update-channels` call is removed, so a PECL outage can't fail the build. + +The build phase compiles each base once and saves it to a private registry, then every variation is built on top of it. Bases are always compiled without the build cache. That way every run picks up the latest Debian and Alpine packages PHP links against, like curl, Brotli, and OpenSSL, without waiting for a new official image. The `redis` extension is built from its GitHub source for the same reason. + +Release candidates work the same way. A version like `8.6-rc` in the base config builds the newest release candidate on php.net's [release candidates feed](https://www.php.net/release-candidates.php?format=json), from docker-library's `8.6-rc` directory, so testers get each RC as soon as php.net publishes it. + +Each variation names the docker-library variant it builds on with `php_base` (`cli`, `fpm`, or `zts`). When upstream changes its variants for a new PHP version, set `php_base_overrides` on that minor version. For example, docker-library builds every 8.6 image with ZTS and has no separate `zts` variant, so 8.6 sets `frankenphp: cli`. + +PHP 8.1 is no longer on docker-library's `master` branch. Its entry in the base config sets `docker_library_ref` to the last commit that had it. Do the same for any other version docker-library drops. + All the scripts above are designed to run locally and in GitHub Actions. Feel free to execute these scripts to see the help menus and how they work. ## GitHub Actions @@ -89,7 +111,7 @@ The Depot runner labels we use are listed in `.github/actionlint.yaml` so action ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. -- **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. +- **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. Fork builds can't save the compiled PHP base images, so they start from the official PHP images on Docker Hub instead. In the hours after a new PHP release, before Docker Hub has it, fork builds fail. Changes to how PHP is compiled are only tested when a maintainer runs the build for your PR. - **Pull requests from this repository** go through the same three phases as a release: build, test, publish. Images are published to `serversideup/php-dev` with your PR number as the tag prefix (for example `serversideup/php-dev:699-8.4-fpm-nginx-alpine`) only after the image tests pass. A comment on the PR shows the status and the pull commands. - **Every run ends with a summary table** listing each image with its compressed size per architecture, so a size regression is visible before merge. - **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, and serves a PHP file through its web server. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml index bec25e1f..7ce636d3 100644 --- a/scripts/conf/php-versions-base-config.yml +++ b/scripts/conf/php-versions-base-config.yml @@ -3,6 +3,9 @@ # of the same name. Renovate reads the comments and opens # a pull request when a newer version is out (see renovate.json). dependencies: + # The docker-library/php commit the PHP base images are compiled from. Renovate moves it + # to the latest commit on master once a week and merges it when every check passes. A minor version can pin its own with docker_library_ref. + DOCKER_LIBRARY_PHP_REF: 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb # renovate: datasource=github-releases depName=phpredis/phpredis PHPREDIS_VERSION: 6.3.0 # The liblzf commit that phpredis bundles as a submodule. GitHub tarballs leave submodules @@ -22,16 +25,23 @@ dependencies: # renovate: datasource=github-releases depName=e-dant/watcher WATCHER_VERSION: 0.14.5 +# php_base is the docker-library/php variant each variation is built on. A minor version +# can swap it with php_base_overrides (8.6 has no separate zts variant, for example). php_variations: - name: cli + php_base: cli default: true - name: fpm + php_base: fpm - name: fpm-apache + php_base: fpm supported_os: # Open a discussion on serversideup/php if you want to see Alpine support for fpm-apache (https://github.com/serversideup/docker-php/discussions/66) - bookworm - trixie - name: fpm-nginx + php_base: fpm - name: frankenphp + php_base: zts excluded_minor_versions: - "8.1" - "8.2" @@ -40,10 +50,12 @@ php_versions: - major: "8" minor_versions: # PHP 8.1 is end of life, so php.net no longer lists it in the active releases feed - # and the patch version must be pinned here. The official php:8.1.34 images still - # exist on Docker Hub, and Debian Bookworm, Trixie, and Alpine 3.22 still receive - # security updates, so weekly rebuilds keep delivering OS patches. + # and the patch version must be pinned here. docker-library/php removed 8.1 from master, + # so it builds from the last commit that had it ("Update 8.1 to 8.1.34"). Debian Bookworm, + # Trixie, and Alpine 3.22 still receive security updates, so weekly rebuilds keep + # delivering OS patches. - minor: "8.1" + docker_library_ref: 5c037abaf9dc0095e29807c048d9d1b97e6a9716 base_os: - name: alpine3.22 - name: bookworm @@ -82,11 +94,15 @@ php_versions: - name: trixie patch_versions: # - 8.5.8 # Pull latest from Official PHP source - # PHP 8.6 is blocked upstream: the official php:8.6-rc images no longer ship pecl/pear, - # and install-php-extensions does not support 8.6 yet (its pickle fallback is not 8.6 - # compatible), so no PECL extension can be installed. Uncomment once that is resolved. + # PHP 8.6 waits on install-php-extensions: docker-library's 8.6 images no longer ship + # pecl/pear, and the installer's 8.6 support is still in review + # (https://github.com/mlocati/docker-php-extension-installer/pull/1388). Uncomment once + # Renovate bumps PHP_EXT_INSTALLER_VERSION to a release that includes it. # https://github.com/serversideup/docker-php/issues/698 # + # "8.6-rc" builds the newest release candidate php.net lists. docker-library builds + # every 8.6 image with ZTS and has no separate zts variant, so FrankenPHP uses cli. + # # - minor: "8.6-rc" # base_os: # - name: alpine3.23 @@ -95,6 +111,8 @@ php_versions: # - name: trixie # patch_versions: # - 8.6-rc + # php_base_overrides: + # frankenphp: cli # php_extension_overrides: # - redis=phpredis/phpredis@develop diff --git a/scripts/dev.sh b/scripts/dev.sh index 824827f6..f7993644 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -28,6 +28,7 @@ DOCKER_REPOSITORY="${DOCKER_REPOSITORY:-"serversideup/php"}" DOCKER_ADDITIONAL_BUILD_ARGS=() PUSH_TO_REGISTRY=false PLATFORM="" +BUILD_PHP_BASE=false # UI Colors function ui_set_yellow { @@ -95,15 +96,42 @@ php_minor_version() { fi } +# Compiles PHP the way CI does (see scripts/prepare-php-base.sh) and builds on that +# instead of the official image from Docker Hub. +build_php_base_image() { + if ! [[ "$PHP_BUILD_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+|-rc)$ ]]; then + echo_color_message red "❌ --build-php-base needs a full patch version (e.g., 8.4.27) or a release candidate (e.g., 8.6-rc), not '$PHP_BUILD_VERSION'" + exit 1 + fi + + local php_base docker_library_ref base_context + php_base=$(VARIATION="$PHP_BUILD_VARIATION" MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r ' + [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .php_base_overrides[env(VARIATION)] // ""] + + [.php_variations[] | select(.name == env(VARIATION)) | .php_base] + | map(select(. != "")) | .[0] + ' "$BASE_PHP_VERSIONS_CONFIG_FILE") + docker_library_ref=$(MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r ' + [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .docker_library_ref // ""] + [.dependencies.DOCKER_LIBRARY_PHP_REF] + | map(select(. != "")) | .[0] + ' "$BASE_PHP_VERSIONS_CONFIG_FILE") + base_context="$PROJECT_ROOT_DIR/.php-base/${PHP_BUILD_VERSION}-${php_base}-${PHP_BUILD_BASE_OS}" + BASE_IMAGE="php-base:${PHP_BUILD_VERSION}-${php_base}-${PHP_BUILD_BASE_OS}" + + bash "$SCRIPT_DIR/prepare-php-base.sh" "$docker_library_ref" "$PHP_BUILD_VERSION" "$PHP_BUILD_BASE_OS" "$php_base" "$base_context" + + echo_color_message yellow "🐳 Compiling PHP base image: $BASE_IMAGE" + docker buildx build \ + --load \ + --platform "$PLATFORM" \ + --tag "$BASE_IMAGE" \ + "$base_context" + echo_color_message green "✅ PHP base image built: $BASE_IMAGE" +} + build_docker_image() { build_tag="${DOCKER_REPOSITORY}:${PHP_BUILD_PREFIX}${PHP_BUILD_VERSION}-${PHP_BUILD_VARIATION}-${PHP_BUILD_BASE_OS}" echo_color_message yellow "🐳 Building Docker Image: $build_tag" - # Set default platform if not specified - if [ -z "$PLATFORM" ]; then - PLATFORM=$(detect_platform) - fi - # Assemble build arguments local build_args=() build_args+=(--build-arg PHP_VARIATION="$PHP_BUILD_VARIATION") @@ -115,6 +143,10 @@ build_docker_image() { build_args+=(--build-arg "$dependency") done < <(yq -r '.dependencies | to_entries[] | "\(.key)=\(.value)"' "$BASE_PHP_VERSIONS_CONFIG_FILE") + if [ -n "$BASE_IMAGE" ]; then + build_args+=(--build-arg "BASE_IMAGE=$BASE_IMAGE") + fi + if [ -n "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then build_args+=(--build-arg "NGINX_VERSION=$NGINX_VERSION") fi @@ -154,6 +186,8 @@ help_menu() { echo " --prefix Set the prefix for the Docker image (e.g., beta)" echo " --registry Set a custom registry (e.g., localhost:5000)" echo " --platform Set the platform (default: detected from system architecture)" + echo " --build-php-base Compile PHP from source like CI does (needs a full patch version, e.g., 8.4.27, or 8.6-rc," + echo " and the default docker builder, which can use local images)" echo " --push Push the image to the registry" echo " --* Any additional options will be passed to the docker buildx command" } @@ -188,6 +222,10 @@ while [[ $# -gt 0 ]]; do PLATFORM="$2" shift 2 ;; + --build-php-base) + BUILD_PHP_BASE=true + shift + ;; --*) # If there's a next argument and it starts with '--', treat the current argument as standalone. # Otherwise, pair the current argument with the next. @@ -244,4 +282,12 @@ if [ -z "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then echo_color_message green "✅ Using NGINX version '$NGINX_VERSION' for OS '$PHP_BUILD_BASE_OS'" fi -build_docker_image \ No newline at end of file +if [ -z "$PLATFORM" ]; then + PLATFORM=$(detect_platform) +fi + +if [ "$BUILD_PHP_BASE" = true ]; then + build_php_base_image +fi + +build_docker_image diff --git a/scripts/generate-matrix.sh b/scripts/generate-matrix.sh index 3b058e7b..817f684c 100755 --- a/scripts/generate-matrix.sh +++ b/scripts/generate-matrix.sh @@ -36,14 +36,23 @@ yq -o=json "$PHP_VERSIONS_FILE" | jq -c ' ((($variation.supported_os // []) | any(os_family_match($os.name; .)))); . as $root - | [ ($root.php_variations[] | {name, supported_os, excluded_minor_versions}) as $variation + | [ ($root.php_variations[] | {name, php_base, supported_os, excluded_minor_versions}) as $variation | $root.php_versions[] | .minor_versions[] as $minor | select((($variation.excluded_minor_versions // []) | index($minor.minor)) | not) | ($minor.base_os // [])[] as $os | ($minor.patch_versions // [])[] as $patch | select(is_supported($variation; $os)) - | {patch_version: $patch, base_os: $os.name, php_variation: $variation.name, php_extension_overrides: (($minor.php_extension_overrides // []) | join(" "))} + | ((($minor.php_base_overrides // {})[$variation.name]) // $variation.php_base) as $php_base + | { + patch_version: $patch, + base_os: $os.name, + php_variation: $variation.name, + php_extension_overrides: (($minor.php_extension_overrides // []) | join(" ")), + php_base: $php_base, + php_base_tag: "php-base-\($patch)-\($php_base)-\($os.name)", + docker_library_ref: ($minor.docker_library_ref // $root.dependencies.DOCKER_LIBRARY_PHP_REF) + } ] | { include: ( . | sort_by(.patch_version | version_weight) | reverse ) } ' \ No newline at end of file diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh index 3fddfca1..b8b80967 100755 --- a/scripts/get-php-versions.sh +++ b/scripts/get-php-versions.sh @@ -1,23 +1,14 @@ #!/bin/bash ################################################### -# Usage: get-php-versions.sh [--skip-download] [--skip-dockerhub-validation] [--no-fallback] [--input PATH] [--output PATH] +# Usage: get-php-versions.sh [--skip-download] [--input PATH] [--output PATH] ################################################### # This file takes the official latest PHP releases from php.net merges them with our # "base php configuration". These files get merged into a final file called "php-versions.yml" # which is used to build our GitHub Actions jobs. # -# 🔍 DOCKERHUB VALIDATION & FALLBACK -# By default, this script validates that each PHP version from php.net is actually available -# on DockerHub before including it in the final configuration. Every base image our variations -# pull (cli, fpm, zts) is checked on every base OS configured for that minor version, because -# DockerHub publishes those tags in batches and a build fails if any one is missing. A tag only -# counts once it has both linux/amd64 and linux/arm64 images, since DockerHub creates tags empty. -# If a version is not available: -# 1. The script attempts to fall back to the previous patch version (e.g., 8.3.24 -> 8.3.23) -# 2. A GitHub Actions warning is displayed explaining the fallback -# 3. If the fallback version is also unavailable, the script exits with an error -# -# This ensures that Docker builds won't fail due to non-existent base images. +# Images are compiled from the php.net tarball (see scripts/prepare-php-base.sh), so a +# release can be built as soon as php.net lists it. A minor version with patch_versions +# in the base config uses those instead of php.net's, which pins or holds back a release. # # 👉 REQUIRED FILES # - BASE_PHP_VERSIONS_CONFIG_FILE must be valid and set to a valid file path @@ -25,8 +16,6 @@ # # 👉 OPTIONS # --skip-download: Skip downloading from php.net and use existing base config -# --skip-dockerhub-validation: Skip DockerHub validation (useful for testing/development) -# --no-fallback: Fail instead of falling back to the previous patch version (use before a security release) # --input: Input file path (defaults to scripts/conf/php-versions-base-config.yml) # --output: Output file path (defaults to scripts/conf/php-versions.yml) @@ -36,189 +25,13 @@ set -oue pipefail # set -x # trap read DEBUG -########################## -# DockerHub API Functions - -# Check if a PHP version exists on DockerHub -check_dockerhub_php_version() { - local version="$1" - local variant="${2:-cli}" - local os="${3:-}" - - # Our Dockerfiles always pull the OS-suffixed tag. The unsuffixed tag follows - # DockerHub's default OS, which is not necessarily the one we build on. - local image_tag - if [ -n "$os" ]; then - image_tag="${version}-${variant}-${os}" - else - image_tag="${version}-${variant}" - fi - - # Use Docker Hub API v2 to check if the tag exists with timeout and retry - local response body platform - local max_retries=3 - local retry_count=0 - - while [ $retry_count -lt $max_retries ]; do - body=$(mktemp) - response=$(curl -s --max-time 10 --connect-timeout 5 \ - -o "$body" -w "%{http_code}" \ - "https://registry.hub.docker.com/v2/repositories/library/php/tags/${image_tag}/") - - # Check if we got a valid HTTP response - if [ "$response" = "200" ]; then - # DockerHub creates the tag before any architecture is pushed to it, so a tag - # can exist with no images yet. We build for amd64 and arm64, so both must be there. - for platform in amd64 arm64; do - if ! jq -e --arg arch "$platform" 'any(.images[]?; .os == "linux" and .architecture == $arch)' "$body" > /dev/null; then - echo_color_message red "❌ php:${image_tag} exists but has no linux/${platform} image yet" >&2 - rm -f "$body" - return 1 - fi - done - rm -f "$body" - return 0 # Version exists for every platform we build - elif [ "$response" = "404" ]; then - rm -f "$body" - return 1 # Version definitely does not exist - else - rm -f "$body" - # Network error or other issue, retry - retry_count=$((retry_count + 1)) - if [ $retry_count -lt $max_retries ]; then - echo_color_message yellow "⚠️ DockerHub API request failed (HTTP $response), retrying in 2 seconds..." - sleep 2 - fi - fi - done - - # If we get here, all retries failed - echo_color_message red "❌ Failed to check DockerHub after $max_retries attempts for $image_tag" - return 1 -} - -# Check every official base image our variations pull for this version (cli, fpm, zts) -# on every base OS configured for its minor version. One missing tag fails the check. -check_dockerhub_base_images() { - local version="$1" - local minor variant os base_os_list - minor=$(echo "$version" | cut -d'.' -f1-2) - - base_os_list=$(yq -r ".php_versions[].minor_versions[] | select(.minor == \"$minor\") | .base_os[].name" "$BASE_PHP_VERSIONS_CONFIG_FILE") - if [ -z "$base_os_list" ]; then - echo_color_message yellow "⚠️ No base OS configured for PHP $minor. Checking the default cli image only." >&2 - check_dockerhub_php_version "$version" "cli" - return $? - fi - - for variant in cli fpm zts; do - for os in $base_os_list; do - if ! check_dockerhub_php_version "$version" "$variant" "$os"; then - echo_color_message red "❌ Missing on DockerHub: php:${version}-${variant}-${os}" >&2 - return 1 - fi - done - done - return 0 -} - -# Get previous patch version (e.g., 8.3.24 -> 8.3.23) -get_previous_patch_version() { - local version="$1" - local major_minor patch - - # Split version into major.minor and patch - major_minor=$(echo "$version" | cut -d'.' -f1-2) - patch=$(echo "$version" | cut -d'.' -f3) - - # Decrement patch version - if [ "$patch" -gt 0 ]; then - patch=$((patch - 1)) - echo "${major_minor}.${patch}" - else - # If patch is 0, we can't go lower - return 1 - fi -} - -# Add a new function for GitHub Actions annotations (around line 193) -function github_actions_annotation() { - local type="$1" # warning, error, notice, debug - local title="$2" # The title - local message="$3" # The message - - # Output the official workflow command format - echo "::${type} title=${title}::${message}" - - # Add to step summary for better visibility - case "$type" in - warning) - echo "⚠️ **Warning: ${title}** - ${message}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - ;; - error) - echo "❌ **Error: ${title}** - ${message}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - ;; - esac -} - -# Validate and potentially fallback a PHP version -validate_php_version_with_fallback() { - local version="$1" - local original_version="$version" - local fallback_attempted=false - - echo_color_message yellow "🔍 Checking PHP version $version on DockerHub..." >&2 - - # Check that every base image we build from exists on DockerHub - if check_dockerhub_base_images "$version"; then - echo_color_message green "✅ PHP $version is available on DockerHub" >&2 - echo "$version" # Output to stdout for capture - return 0 - else - echo_color_message red "❌ PHP $version is not available on DockerHub" >&2 - - # Try to get previous patch version - local fallback_version - if [ "$NO_FALLBACK" = true ]; then - github_actions_annotation "error" "PHP Version Unavailable" "PHP $original_version is not available on DockerHub and --no-fallback is set." - return 1 - elif fallback_version=$(get_previous_patch_version "$version"); then - fallback_attempted=true - echo_color_message yellow "⚠️ Attempting fallback to PHP $fallback_version..." >&2 - - if check_dockerhub_base_images "$fallback_version"; then - # Output GitHub Actions annotation without color formatting - github_actions_annotation "warning" "PHP Version Fallback" "PHP $original_version is not available on DockerHub. Falling back to PHP $fallback_version. This may indicate that DockerHub has not yet published the latest PHP release. Consider checking DockerHub availability before updating to newer versions." - echo_color_message green "✅ Fallback successful: Using PHP $fallback_version" >&2 - echo "$fallback_version" # Output to stdout for capture - return 0 - else - echo_color_message red "❌ Fallback version PHP $fallback_version is also not available on DockerHub" >&2 - fi - fi - - # If we get here, both original and fallback failed - if [ "$fallback_attempted" = true ]; then - github_actions_annotation "error" "PHP Version Unavailable" "Neither PHP $original_version nor fallback version $fallback_version are available on DockerHub. This suggests a significant lag in DockerHub publishing or a configuration issue. Please check DockerHub manually and consider using a known working version." - else - github_actions_annotation "error" "PHP Version Unavailable" "PHP $original_version is not available on DockerHub and no fallback version could be determined (patch version is 0). Please check DockerHub manually and use a known working version." - fi - - return 1 - fi -} - ########################## # Argument Parsing SKIP_DOWNLOAD="${SKIP_DOWNLOAD:-false}" -SKIP_DOCKERHUB_VALIDATION="${SKIP_DOCKERHUB_VALIDATION:-false}" -NO_FALLBACK="${NO_FALLBACK:-false}" while [[ "$#" -gt 0 ]]; do case $1 in --skip-download) SKIP_DOWNLOAD=true ;; - --skip-dockerhub-validation) SKIP_DOCKERHUB_VALIDATION=true ;; - --no-fallback) NO_FALLBACK=true ;; --input) if [ -z "$2" ]; then echo "Error: --input requires a file path." @@ -336,74 +149,8 @@ if [ "$SKIP_DOWNLOAD" = false ]; then exit 1 fi - # Parse the fetched JSON data and optionally validate PHP versions on DockerHub - if [ "$SKIP_DOCKERHUB_VALIDATION" = true ]; then - echo_color_message yellow "⚠️ Skipping DockerHub validation as requested..." - processed_json="$php_net_version_json" - else - echo_color_message yellow "🔍 Parsing and validating PHP versions from php.net..." - - # First, extract versions from the JSON - php_versions_raw=$(echo "$php_net_version_json" | jq -r " - . as \$major | - to_entries[] | - .value | - to_entries[] | - .value.version" | grep -v "null" | sort -u) - - # Create temporary files to store validation results - validated_versions_file=$(mktemp) - version_map_file=$(mktemp) - - # Validate each version - validation_failed=false - while IFS= read -r version; do - if [ -n "$version" ]; then - echo_color_message yellow "🔍 Validating PHP $version..." - # Capture validation result without color codes - if validated_version=$(validate_php_version_with_fallback "$version" | tail -n1); then - # Double check that we got a valid version back - if [ -n "$validated_version" ] && [ "$validated_version" != "VALIDATION_FAILED" ]; then - echo "$version:$validated_version" >> "$validated_versions_file" - if [ "$version" != "$validated_version" ]; then - # Escape special characters for sed - escaped_original=$(echo "$version" | sed 's/[[\.*^$(){}?+|/]/\\&/g') - escaped_validated=$(echo "$validated_version" | sed 's/[[\.*^$(){}?+|/]/\\&/g') - echo "s/${escaped_original}/${escaped_validated}/g" >> "$version_map_file" - fi - else - echo_color_message red "❌ Validation failed for PHP $version" - validation_failed=true - fi - else - echo_color_message red "❌ Validation failed for PHP $version" - validation_failed=true - fi - fi - done <<< "$php_versions_raw" - - # Exit if any validation failed - if [ "$validation_failed" = true ]; then - echo_color_message red "❌ One or more PHP versions failed validation. Stopping build." - rm -f "$validated_versions_file" "$version_map_file" - exit 1 - fi - - # Apply version substitutions if any fallbacks were used - processed_json="$php_net_version_json" - if [ -s "$version_map_file" ]; then - echo_color_message yellow "📝 Applying version fallbacks..." - while IFS= read -r substitution; do - processed_json=$(echo "$processed_json" | sed "$substitution") - done < "$version_map_file" - fi - - # Clean up temporary files - rm -f "$validated_versions_file" "$version_map_file" - fi - # Parse the fetched JSON data and transform it to a specific YAML structure using jq and yq. - php_net_yaml_data=$(echo "$processed_json" | jq -r " + php_net_yaml_data=$(echo "$php_net_version_json" | jq -r " { \"php_versions\": [ . as \$major | @@ -437,24 +184,29 @@ if [ "$SKIP_DOWNLOAD" = false ]; then dependencies: (.[1].dependencies // {}), php_variations: (.[1].php_variations // []), php_versions: ( - .[0].php_versions + .[1].php_versions + (.[0].php_versions + (.[1].php_versions | map(.minor_versions |= map(. + {from_base_config: true})))) | group_by(.major) | map({ major: .[0].major, minor_versions: ( map(.minor_versions[] | select(. != null)) | group_by(.minor) - | map({ - minor: .[0].minor, - base_os: (map(.base_os // []) | add), - patch_versions: (map(.patch_versions // []) | flatten | unique | select(. != null)), - php_extension_overrides: (map(.php_extension_overrides // []) | add | unique) - }) + | map( + (map(select(.from_base_config) | (.patch_versions // [])) | flatten) as $pinned + | { + minor: .[0].minor, + base_os: (map(.base_os // []) | add), + patch_versions: (if ($pinned | length) > 0 then $pinned else (map(.patch_versions // []) | flatten | unique) end), + php_extension_overrides: (map(.php_extension_overrides // []) | add | unique), + docker_library_ref: (map(.docker_library_ref // empty) | first), + php_base_overrides: (map(.php_base_overrides // empty) | add) + } + | with_entries(select(.value != null)) + ) ) }) ), operating_systems: (.[1].operating_systems // []) - } ' <(echo "$downloaded_and_normalized_json_data") <(echo "$base_json_data")) diff --git a/scripts/prepare-php-base.sh b/scripts/prepare-php-base.sh new file mode 100755 index 00000000..a48d8ec3 --- /dev/null +++ b/scripts/prepare-php-base.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: prepare-php-base.sh +# Example: prepare-php-base.sh 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb 8.4.27 trixie fpm .php-base/8.4.27-fpm-trixie +# prepare-php-base.sh 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb 8.6-rc trixie cli .php-base/8.6-rc-cli-trixie +# +# A version like "8.6-rc" builds the newest release candidate php.net lists for 8.6. +# Writes the build context for one official PHP image (docker-library/php at a pinned +# commit) set to compile from the php.net tarball. That lets us ship a +# release as soon as php.net publishes it, without waiting for Docker Hub. Only the version +# lines change, and "pecl update-channels" is removed so a PECL outage cannot fail the build. +# Everything else, including the GPG check, is exactly what the official images run. + +if [ "$#" -ne 5 ]; then + sed -n '4,5p' "$0" | sed 's/^# //' >&2 + exit 1 +fi + +ref="$1" +patch="$2" +base_os="$3" +php_base="$4" +output_dir="$5" + +scripts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +download="$scripts_dir/../src/common/usr/local/bin/docker-php-serversideup-download" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +fail() { + echo "🛑 ERROR (prepare-php-base.sh): $1" >&2 + exit 1 +} + +# docker-library keeps release candidates in their own directory, such as 8.6-rc +if [[ "$patch" == *-rc ]]; then + minor="$patch" + release=$("$download" "https://www.php.net/release-candidates.php?format=json" \ + | jq -c --arg prefix "${patch%-rc}." '[to_entries[] | select(.key | startswith($prefix)) | .value.release | select(.version != null and .files.xz.sha256 != "")][0] // empty') + [ -n "$release" ] || fail "php.net does not list a release candidate for ${patch%-rc}" + version=$(jq -r '.version' <<< "$release") + tarball_url=$(jq -r '.files.xz.path' <<< "$release") + sha256=$(jq -r '.files.xz.sha256' <<< "$release") +else + minor="${patch%.*}" + version="$patch" + tarball_url="https://www.php.net/distributions/php-${patch}.tar.xz" + sha256=$("$download" "https://www.php.net/releases/index.php?json&version=${patch}" \ + | jq -r --arg file "php-${patch}.tar.xz" '(.source // [])[] | select(.filename == $file) | .sha256') +fi +[ -n "$sha256" ] || fail "php.net does not list a SHA256 for PHP ${version}" + +"$download" "https://codeload.github.com/docker-library/php/tar.gz/${ref}" "$work_dir/docker-library-php.tar.gz" +rm -rf "$output_dir" +mkdir -p "$output_dir" +tar -xzf "$work_dir/docker-library-php.tar.gz" -C "$output_dir" --strip-components=4 "php-${ref}/${minor}/${base_os}/${php_base}" \ + || fail "docker-library/php@${ref} has no ${minor}/${base_os}/${php_base}" + +dockerfile="$output_dir/Dockerfile" +sed \ + -e "s|^ENV PHP_VERSION .*|ENV PHP_VERSION ${version}|" \ + -e "s|^ENV PHP_URL=.*|ENV PHP_URL=\"${tarball_url}\" PHP_ASC_URL=\"${tarball_url}.asc\"|" \ + -e "s|^ENV PHP_SHA256=.*|ENV PHP_SHA256=\"${sha256}\"|" \ + -e '/^[[:space:]]*pecl update-channels;/d' \ + "$dockerfile" > "$dockerfile.new" +mv "$dockerfile.new" "$dockerfile" + +# Check the result rather than the input, so a change in docker-library's layout fails here. +[ "$(grep -c "^ENV PHP_VERSION ${version}\$" "$dockerfile")" = "1" ] || fail "PHP_VERSION was not set to ${version}" +grep -qF "ENV PHP_URL=\"${tarball_url}\"" "$dockerfile" || fail "PHP_URL was not set to ${tarball_url}" +grep -qF "ENV PHP_SHA256=\"${sha256}\"" "$dockerfile" || fail "PHP_SHA256 was not set" +if grep -E '^[^#]*pecl' "$dockerfile"; then + fail "the Dockerfile still calls pecl" +fi + +echo "✅ ${output_dir}: docker-library/php ${minor}/${base_os}/${php_base} at ${ref:0:7}, PHP ${version}" diff --git a/scripts/test-image.sh b/scripts/test-image.sh index c35a4270..130f071f 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -25,6 +25,10 @@ image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{ echo "🔎 Testing $image" php_version=$(docker run --rm "$image" php -r 'echo PHP_VERSION;' | tail -n1) +# A release candidate tag like 8.6-rc reports a version like 8.6.0RC4 +if [[ "$expected_php" == *-rc ]] && [[ "$php_version" == "${expected_php%-rc}".*RC* ]]; then + expected_php="$php_version" +fi if [ -n "$expected_php" ] && [ "$php_version" != "$expected_php" ]; then fail "PHP reports $php_version, expected $expected_php" fi diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh index f6833cef..0bbb88f2 100755 --- a/scripts/tests/run.sh +++ b/scripts/tests/run.sh @@ -55,6 +55,44 @@ assert_contains "$summary" "| cli | 8.4.25 | bookworm | | | ❌ not built |" " assert_contains "$summary" "| built, not published |" "marks images that were built but not promoted" assert_contains "$summary" '`serversideup/php-dev:700-8.5.10-cli-trixie`' "shows the image reference without the registry prefix" +echo +echo "generate-matrix.sh" +versions_file="$image_details/php-versions.yml" +cat > "$versions_file" <<'YAML' +dependencies: + DOCKER_LIBRARY_PHP_REF: master-sha +php_variations: + - name: cli + php_base: cli + - name: fpm-nginx + php_base: fpm +php_versions: + - major: "8" + minor_versions: + - minor: "8.1" + docker_library_ref: frozen-sha + base_os: + - name: bookworm + patch_versions: + - 8.1.34 + - minor: "8.4" + base_os: + - name: trixie + patch_versions: + - 8.4.27 + - minor: "8.6-rc" + php_base_overrides: + fpm-nginx: cli + base_os: + - name: trixie + patch_versions: + - 8.6-rc +YAML +matrix_json=$(bash "$scripts_dir/generate-matrix.sh" "$versions_file" | jq -c '.include[]') +assert_contains "$matrix_json" '"php_variation":"fpm-nginx","php_extension_overrides":"","php_base":"fpm","php_base_tag":"php-base-8.4.27-fpm-trixie","docker_library_ref":"master-sha"' "builds variations on their docker-library base from the global ref" +assert_contains "$matrix_json" '"php_base_tag":"php-base-8.1.34-cli-bookworm","docker_library_ref":"frozen-sha"' "uses a minor version's own docker-library ref" +assert_contains "$matrix_json" '"php_variation":"fpm-nginx","php_extension_overrides":"","php_base":"cli","php_base_tag":"php-base-8.6-rc-cli-trixie"' "lets a minor version swap a variation's docker-library base" + echo if [ "$failures" -gt 0 ]; then echo "$failures check(s) failed" >&2 diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile index b1386d3e..53f82f6a 100644 --- a/src/variations/cli/Dockerfile +++ b/src/variations/cli/Dockerfile @@ -1,11 +1,12 @@ ARG BASE_OS_VERSION='bookworm' ARG PHP_VERSION='8.5' ARG PHP_VARIATION='cli' +ARG BASE_IMAGE="php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION}" ########## # CLI: Main Image ########## -FROM php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION} +FROM ${BASE_IMAGE} # brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile index 43427cb4..2575c64d 100644 --- a/src/variations/fpm-nginx/Dockerfile +++ b/src/variations/fpm-nginx/Dockerfile @@ -22,7 +22,7 @@ RUN docker-php-serversideup-s6-install #################### # NGINX Signing Key #################### -FROM php:${PHP_VERSION}-fpm-${BASE_OS_VERSION} AS nginx-repo-config +FROM ${BASE_IMAGE} AS nginx-repo-config ARG SIGNING_KEY_URL="https://nginx.org/keys/nginx_signing.key" ARG SIGNING_FINGERPRINT="573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62" diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile index ab10376c..62008d85 100644 --- a/src/variations/fpm/Dockerfile +++ b/src/variations/fpm/Dockerfile @@ -1,11 +1,12 @@ ARG BASE_OS_VERSION='bookworm' ARG PHP_VERSION='8.5' ARG PHP_VARIATION='fpm' +ARG BASE_IMAGE="php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION}" ########## # FPM: Main Image ########## -FROM php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION} +FROM ${BASE_IMAGE} # brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ From 3b63a6f69e41c8c35e0b75d5bc1c1a93ac73b720 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 18:30:32 +0000 Subject: [PATCH 04/13] Remove the Brotli package-upgrade workaround The compiled PHP bases install current Debian and Alpine packages, so they already ship the patched libbrotli for CVE-2026-111497. FrankenPHP's Bookworm Brotli build stays, because cbrotli needs libbrotli 1.1 or newer. --- src/variations/cli/Dockerfile | 3 --- src/variations/fpm-apache/Dockerfile | 3 --- src/variations/fpm-nginx/Dockerfile | 3 --- src/variations/fpm/Dockerfile | 3 --- src/variations/frankenphp/Dockerfile | 3 --- 5 files changed, 15 deletions(-) diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile index 53f82f6a..55aff695 100644 --- a/src/variations/cli/Dockerfile +++ b/src/variations/cli/Dockerfile @@ -8,12 +8,9 @@ ARG BASE_IMAGE="php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION}" ########## FROM ${BASE_IMAGE} -# brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ - brotli-libs \ shadow" ARG DEPENDENCY_PACKAGES_DEBIAN="\ - libbrotli1 \ procps \ zip" ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip' diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile index 3de22648..70af813e 100644 --- a/src/variations/fpm-apache/Dockerfile +++ b/src/variations/fpm-apache/Dockerfile @@ -24,15 +24,12 @@ RUN docker-php-serversideup-s6-install # FPM-APACHE: Main Image ########## FROM ${BASE_IMAGE} -# brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ - brotli-libs \ apache2 \ fcgi \ shadow" ARG DEPENDENCY_PACKAGES_DEBIAN="\ apache2 \ - libbrotli1 \ libfcgi-bin \ procps \ zip" diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile index 2575c64d..5e2ed5f4 100644 --- a/src/variations/fpm-nginx/Dockerfile +++ b/src/variations/fpm-nginx/Dockerfile @@ -99,15 +99,12 @@ RUN \ # FPM-NGINX: Main Image ########## FROM ${BASE_IMAGE} -# brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ - brotli-libs \ fcgi \ gettext \ shadow" ARG DEPENDENCY_PACKAGES_DEBIAN="\ gettext-base \ - libbrotli1 \ libfcgi-bin \ procps \ zip" diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile index 62008d85..bbf6c1c8 100644 --- a/src/variations/fpm/Dockerfile +++ b/src/variations/fpm/Dockerfile @@ -8,13 +8,10 @@ ARG BASE_IMAGE="php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION}" ########## FROM ${BASE_IMAGE} -# brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ - brotli-libs \ fcgi \ shadow" ARG DEPENDENCY_PACKAGES_DEBIAN="\ - libbrotli1 \ libfcgi-bin \ procps \ zip" diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile index af21cb07..b4b0b910 100644 --- a/src/variations/frankenphp/Dockerfile +++ b/src/variations/frankenphp/Dockerfile @@ -158,13 +158,10 @@ RUN if cat /etc/os-release | grep -q 'debian'; then \ # FrankenPHP Final #################### FROM common AS final -# brotli-libs and libbrotli1 upgrade past CVE-2026-111497. Remove them once the official PHP images ship the fix. ARG DEPENDENCY_PACKAGES_ALPINE="\ - brotli-libs \ libstdc++ \ shadow" ARG DEPENDENCY_PACKAGES_DEBIAN="\ - libbrotli1 \ libstdc++6 \ procps \ zip" From b354ca78e548d0d73f4ee439748ad3186279c6c5 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 18:31:07 +0000 Subject: [PATCH 05/13] Monitor dependencies with Renovate Replace Dependabot and get-nginx-versions.sh with Renovate. It reads the `# renovate:` comments in the base config and the lint workflow, checks each OS's NGINX package on nginx.org, groups GitHub Actions monthly, and merges the weekly docker-library/php bump once every check passes. Every other pin waits for a manual merge. --- .github/dependabot.yml | 16 -- .../workflows/action_publish-images-prs.yml | 18 +- .github/workflows/service_lint.yml | 3 + AGENTS.md | 1 + .../docs/1.getting-started/99.contributing.md | 23 +- renovate.json | 85 ++++++ scripts/get-nginx-versions.sh | 268 ------------------ 7 files changed, 105 insertions(+), 309 deletions(-) delete mode 100644 .github/dependabot.yml create mode 100644 renovate.json delete mode 100755 scripts/get-nginx-versions.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index eab96d2e..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/docs" - schedule: - interval: "daily" - allow: - - dependency-name: "@serversideup/project-switcher-bar" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" - groups: - github-actions: - patterns: - - "*" diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml index 9e4d1ca7..3548d7cd 100644 --- a/.github/workflows/action_publish-images-prs.yml +++ b/.github/workflows/action_publish-images-prs.yml @@ -51,10 +51,7 @@ jobs: publish: >- ${{ github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' - ) + github.event.pull_request.head.repo.full_name == github.repository }} build: @@ -78,15 +75,11 @@ jobs: tag-prefix: ${{ inputs.pr_number || github.event.pull_request.number }} release-type: testing # Only save and publish when the code comes from this repository or a maintainer asked for it. - # Pull requests from forks are built but never leave Depot's isolated builders. Dependabot - # runs have no access to the registry secrets, so they are treated the same way. + # Pull requests from forks are built but never leave Depot's isolated builders. publish: >- ${{ github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' - ) + github.event.pull_request.head.repo.full_name == github.repository }} secrets: inherit @@ -130,10 +123,7 @@ jobs: always() && ( github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' - ) + github.event.pull_request.head.repo.full_name == github.repository ) runs-on: ubuntu-24.04 timeout-minutes: 5 diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml index b385cfeb..380c0300 100644 --- a/.github/workflows/service_lint.yml +++ b/.github/workflows/service_lint.yml @@ -6,8 +6,11 @@ on: workflow_call: env: + # renovate: datasource=github-releases depName=rhysd/actionlint ACTIONLINT_VERSION: 1.7.12 + # renovate: datasource=github-releases depName=koalaman/shellcheck SHELLCHECK_VERSION: v0.11.0 + # renovate: datasource=github-releases depName=hadolint/hadolint HADOLINT_VERSION: v2.15.1 jobs: diff --git a/AGENTS.md b/AGENTS.md index 722e9fd9..412400df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-php-base.yml` compiles each PHP base (one job per PHP version, base OS, and `php_base`) and saves it to the Depot Registry as `-php-base---`, always with `no-cache` so OS security fixes (a patched libbrotli or OpenSSL) land on the next run instead of when Debian or Alpine publish a new base image; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`. - A broken image can never reach a public tag: what users pull is byte-for-byte what the tests ran. Publishing is all or nothing: if any image fails to build or fails its test, no image from that run is published, including on the weekly rebuilds. Saved images live in the Depot Registry until the project's retention setting removes them. - The build matrix is generated from the PHP version pipeline described above. +- Dependencies are monitored by Renovate (`renovate.json`), which replaced Dependabot. It reads the `# renovate:` comments in the base config and `service_lint.yml`, the `nginx_version` lines (from the nginx.org package listings), docker-library/php `master` (weekly, merged automatically once every check passes; every other pin waits for a manual merge), GitHub Actions, and one docs npm package. Merged bumps reach the published images with the next release; PHP patches need no release because they are resolved from php.net at run time. - Image tags follow the pattern: `serversideup/php:{version}-{variation}` (Debian default) or `serversideup/php:{version}-{variation}-{os}` (Alpine/specific OS). - Fork PRs skip the base phase and build on the official `php:` images, so they can fail in the hours after a PHP release, and they never exercise changes to how PHP is compiled. - Depot authentication: the project ID lives in `depot.json` (not a secret). Same-repo runs authenticate through a Depot OIDC trust relationship (`id-token: write`). Pull requests from forks have no OIDC token, so Depot falls back to its open-source pull request flow (https://depot.dev/blog/github-actions-oss-fork-builds): the full matrix builds on ephemeral builders without the project cache, nothing is saved, and each build job loads its amd64 image onto the runner and runs `scripts/test-image.sh` there instead, so the build check still means a tested image. A maintainer publishes a fork's images to `serversideup/php-dev`, and gets the arm64 tests, by running the "Docker Publish (PR Images)" workflow manually with the PR number. diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index d5787f5e..68ee5db5 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -118,22 +118,23 @@ Every pull request that touches `src/`, `scripts/`, or the workflows builds the - **Transient failures heal themselves.** If a few jobs fail because a registry or mirror hiccupped, CI re-runs just those jobs once. If a release or weekly rebuild is still red after that, CI opens an issue for the maintainers with the failed jobs and their errors. - **Maintainers** can publish a fork's images for testing by running the "Docker Publish (PR Images)" workflow manually with the PR number. -## NGINX Versions -We use the official NGINX repos to install the latest version of NGINX for each OS. The version to install is set by a build argument, which is loaded from the `scripts/conf/php-versions-base-config.yml` file. +## Dependency versions +Every version that ships in the image is pinned in `scripts/conf/php-versions-base-config.yml`. Most live under `dependencies`, where each key is passed to the Dockerfiles as a build arg of the same name. The NGINX version for each OS lives under `operating_systems`. PHP itself is the exception: its patch versions come from php.net at build time. -To view the current NGINX versions, run the following command: +[Renovate](https://docs.renovatebot.com/) watches every pin and opens a pull request when a newer version is out. Its Dependency Dashboard issue lists each one with its current and available versions. The rules live in `renovate.json`. -#### View NGINX versions -```bash [Terminal] -./scripts/get-nginx-versions.sh +To update a dependency by hand, edit its line in the base config. To add a new one, add a line under `dependencies` with a `# renovate:` comment above it. Then declare an `ARG` with the same name in each Dockerfile stage that uses it: + +```yaml [scripts/conf/php-versions-base-config.yml] +dependencies: + # renovate: datasource=github-releases depName=just-containers/s6-overlay versioning=loose + S6_VERSION: v3.2.3.2 ``` -This script will look at the official NGINX repos to find the latest version of NGINX for each OS. If you want to update the version, you can run the script with the `--write` flag. +A merged update reaches the published images with the next release. -#### Update NGINX versions -```bash [Terminal] -./scripts/get-nginx-versions.sh --write -``` +## NGINX versions +We install NGINX from the official NGINX repos. Renovate checks the package listings on nginx.org for each OS and updates the `nginx_version` lines in one pull request. ### NGINX repository key verification diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..ee6d969b --- /dev/null +++ b/renovate.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "enabledManagers": ["custom.regex", "github-actions", "npm"], + "customManagers": [ + { + "customType": "regex", + "description": "Versions with a '# renovate:' comment on the line above", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml"], + "matchStrings": [ + "#\\s*renovate:\\s*datasource=(?\\S+)\\s+depName=(?\\S+)(?:\\s+versioning=(?\\S+))?\\s*\\n\\s*[A-Z0-9_]+:\\s*\"?(?[^\"\\s]+)\"?" + ] + }, + { + "customType": "regex", + "description": "The docker-library/php commit the PHP base images are compiled from", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"], + "matchStrings": ["DOCKER_LIBRARY_PHP_REF: (?[a-f0-9]{40})"], + "currentValueTemplate": "master", + "depNameTemplate": "docker-library/php", + "packageNameTemplate": "https://github.com/docker-library/php", + "datasourceTemplate": "git-refs" + }, + { + "customType": "regex", + "description": "NGINX package for each Alpine version", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"], + "matchStrings": ["number: (?3\\.\\d+)\\n\\s+nginx_version: (?\\S+)"], + "depNameTemplate": "nginx-alpine{{{packageName}}}", + "datasourceTemplate": "custom.nginx-alpine", + "versioningTemplate": "apk", + "extractVersionTemplate": "^nginx-(?\\d+\\.\\d+\\.\\d+-r\\d+)\\.apk$" + }, + { + "customType": "regex", + "description": "NGINX package for each Debian version", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"], + "matchStrings": ["version: (?[a-z]+)\\n\\s+number: \\d+\\n\\s+nginx_version: (?\\S+)"], + "depNameTemplate": "nginx-{{{packageName}}}", + "datasourceTemplate": "custom.nginx-debian", + "versioningTemplate": "deb", + "extractVersionTemplate": "^nginx_(?\\d+\\.\\d+\\.\\d+-\\d+~{{{packageName}}})_amd64\\.deb$" + } + ], + "customDatasources": { + "nginx-alpine": { + "defaultRegistryUrlTemplate": "https://nginx.org/packages/alpine/v{{packageName}}/main/x86_64/", + "format": "html" + }, + "nginx-debian": { + "defaultRegistryUrlTemplate": "https://nginx.org/packages/debian/pool/nginx/n/nginx/", + "format": "html" + } + }, + "packageRules": [ + { + "description": "Bump NGINX on every OS in one pull request", + "matchDatasources": ["custom.nginx-alpine", "custom.nginx-debian"], + "groupName": "nginx" + }, + { + "description": "Merge the weekly docker-library/php bump once every check passes. Renovate merges it itself because GitHub's auto-merge would merge right away without required checks.", + "matchDepNames": ["docker-library/php"], + "schedule": ["before 6am on monday"], + "automerge": true, + "platformAutomerge": false + }, + { + "description": "The docs site only tracks the project switcher bar", + "matchManagers": ["npm"], + "matchPackageNames": ["!@serversideup/project-switcher-bar"], + "enabled": false + }, + { + "matchManagers": ["github-actions"], + "groupName": "github-actions", + "schedule": ["* 0-3 1 * *"] + }, + { + "description": "Runner images (ubuntu-24.04) are upgraded by hand", + "matchDatasources": ["github-runners"], + "enabled": false + } + ] +} diff --git a/scripts/get-nginx-versions.sh b/scripts/get-nginx-versions.sh deleted file mode 100755 index 93066af1..00000000 --- a/scripts/get-nginx-versions.sh +++ /dev/null @@ -1,268 +0,0 @@ -#!/bin/bash -################################################### -# Usage: get-nginx-versions.sh [--os ] [--write] -################################################### -# This script fetches the latest NGINX versions available for different -# operating systems from the official NGINX repositories. By default, it -# shows all operating systems from the base config file, but you can filter -# to a specific OS if needed. - -set -oe pipefail - -########################## -# Configuration -os_config() { - # Resolve config path relative to this script so --help works before SCRIPT_DIR is set - local this_script_dir - this_script_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - local config_file="$this_script_dir/conf/php-versions-base-config.yml" - - if ! command -v yq >/dev/null 2>&1; then - echo "yq is required but not found. Install 'yq' (https://github.com/mikefarah/yq) to continue." 1>&2 - return 1 - fi - - # shellcheck disable=SC2016 - yq -r '.operating_systems[] | .family as $f | .versions[] | "\(.version)|\($f)|\(.name)"' "$config_file" \ - | while IFS='|' read -r version family name; do - if [[ "$family" == "alpine" ]]; then - # version comes as alpineX.Y (e.g., alpine3.24) - key="$version" - alpine_num_version="${version#alpine}" - url="http://nginx.org/packages/alpine/v${alpine_num_version}/main/x86_64/" - pattern='nginx-[0-9][^"\n]*\.apk' - else - key="$version" - url="http://nginx.org/packages/debian/dists/${version}/nginx/binary-amd64/Packages" - pattern='^Package: nginx$' - fi - printf '%s|%s|%s|%s\n' "$key" "$name" "$url" "$pattern" - done -} - -########################## -# Functions - -help_menu() { - echo "Usage: $0 [--os ] [--write]" - echo - echo "This script fetches the latest NGINX versions available for different" - echo "operating systems from the PHP base config file." - echo - echo "Options:" - echo " --os Filter to a specific operating system" - echo " --write Write discovered versions back to the base config (yq)" - echo " --help, -h Show this help message" - echo - echo "Available Operating Systems:" - os_config | awk -F'|' '{printf " %-12s %s\n", $1, $2}' - echo - echo "Examples:" - echo " $0 # Show all operating systems" - echo " $0 --os alpine3.24 # Show only Alpine 3.24" - echo " $0 --os bookworm # Show only Debian Bookworm" -} - -########################## -# Argument Parsing - -FILTER_OS="" -WRITE_MODE=false -while [[ "$#" -gt 0 ]]; do - case $1 in - --os) - FILTER_OS="$2" - shift 2 - ;; - --write) - WRITE_MODE=true - shift 1 - ;; - --help|-h) - help_menu - exit 0 - ;; - *) - echo "Unknown parameter passed: $1" - help_menu - exit 1 - ;; - esac -done - -########################## -# Environment Settings - -# Script Configurations -SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -CONFIG_FILE="$SCRIPT_DIR/conf/php-versions-base-config.yml" - -# UI Colors -function ui_set_yellow { - printf $'\033[0;33m' -} - -function ui_set_green { - printf $'\033[0;32m' -} - -function ui_set_red { - printf $'\033[0;31m' -} - -function ui_set_blue { - printf $'\033[0;34m' -} - -function ui_set_bold { - printf $'\033[1m' -} - -function ui_reset_colors { - printf "\e[0m" -} - -function echo_color_message (){ - color=$1 - message=$2 - - ui_set_$color - echo "$message" - ui_reset_colors -} - -########################## -# Fetch helpers - -get_alpine_version() { - local url="$1" - local pattern="$2" - - local version - version=$(curl -s "$url" | grep -o "$pattern" | sort -V | tail -1) - if [[ -n "$version" ]]; then - # Extract version number from package name (e.g., nginx-1.24.0-r7.apk -> 1.24.0-r7) - echo "$version" | sed 's/nginx-\(.*\)\.apk/\1/' - else - echo "Unable to fetch" - fi -} - -get_debian_version() { - local url="$1" - - local version - version=$(curl -s "$url" \ - | awk 'BEGIN{RS=""; FS="\n"} { pkg=0; ver=""; for (i=1;i<=NF;i++){ if ($i ~ /^Package: nginx$/) pkg=1; if ($i ~ /^Version:/){ split($i,a,": *"); ver=a[2]; } } if (pkg && ver!="") print ver; }' \ - | sort -V | tail -1) - if [[ -n "$version" ]]; then - echo "$version" - else - echo "Unable to fetch" - fi -} - -compute_nginx_version() { - local url="$1" - local pattern="$2" - - local version="" - if [[ "$url" == *"alpine"* ]]; then - version=$(get_alpine_version "$url" "$pattern") - else - version=$(get_debian_version "$url") - fi - - echo "$version" -} - -update_config_nginx_version() { - local version_key="$1" # e.g., alpine3.24 or bookworm - local new_nginx_version="$2" - - if [[ -z "$new_nginx_version" || "$new_nginx_version" == "Unable to fetch" ]]; then - echo_color_message red "⚠️ Skipping update for $version_key (no version found)" - return 0 - fi - - # Preserve comments/spacing: perform a targeted line replacement using awk - # Strategy: when we encounter the specific 'version: ' line, the next - # 'nginx_version:' line in that list item will be replaced with the new value, - # while keeping indentation and any spacing after the colon intact. - local tmp_file - tmp_file="$(mktemp)" - - VERSION_KEY="$version_key" NEW_VER="$new_nginx_version" awk ' - BEGIN { target = ENVIRON["VERSION_KEY"]; replacement = ENVIRON["NEW_VER"]; in_block = 0 } - { - # Detect a version line and check if it matches our target value - if ($0 ~ /^[ \t]*version:[ \t]*/) { - line = $0 - gsub(/^[ \t]*version:[ \t]*/, "", line) - # Extract the first token before a space or comment - split(line, parts, /[ \t#]/) - val = parts[1] - in_block = (val == target) ? 1 : 0 - } - - if (in_block && $0 ~ /^[ \t]*nginx_version:[ \t]*/) { - # Capture exact prefix including key and any spaces after the colon - mpos = match($0, /^[ \t]*nginx_version:[ \t]*/) - if (mpos > 0) { - prefix = substr($0, RSTART, RLENGTH) - print prefix replacement - in_block = 0 - next - } - } - - print $0 - } - ' "$CONFIG_FILE" > "$tmp_file" && mv "$tmp_file" "$CONFIG_FILE" - - echo_color_message green "✍️ Updated nginx_version for $version_key -> $new_nginx_version" -} - -########################## -# Main script starts here - -echo_color_message yellow "🌐 NGINX Version Checker" -echo - -# If a specific OS is requested, validate it exists -if [[ -n "$FILTER_OS" ]]; then - if ! grep -q "^$FILTER_OS|" < <(os_config); then - echo_color_message red "❌ Unknown operating system: $FILTER_OS" - echo - help_menu - exit 1 - fi -fi - -# Process operating systems -os_config | while IFS='|' read -r os_key os_name url pattern; do - # Skip if filtering and this isn't the requested OS - if [[ -n "$FILTER_OS" && "$os_key" != "$FILTER_OS" ]]; then - continue - fi - - echo_color_message blue "🔍 Fetching NGINX version for $os_name from $url..." - version=$(compute_nginx_version "$url" "$pattern") - - ui_set_bold - ui_set_green - printf "%-20s" "$os_name:" - ui_reset_colors - echo " $version" - - if [[ "$WRITE_MODE" == true ]]; then - update_config_nginx_version "$os_key" "$version" - fi -done - -echo -if [[ "$WRITE_MODE" == true ]]; then - echo_color_message green "✅ NGINX version check and write complete!" -else - echo_color_message green "✅ NGINX version check complete!" -fi \ No newline at end of file From 35677635294af6d097bbbe98b3d83e44e3691ff4 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 19:22:45 +0000 Subject: [PATCH 06/13] Skip PR builds when a PHP base was cancelled A base job whose runner dropped reports cancelled, not failure, so builds still ran and failed on a base image that was never saved. Build only when the base phase succeeded or was skipped (fork PRs). --- .github/workflows/action_publish-images-prs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml index 3548d7cd..e64c7bd3 100644 --- a/.github/workflows/action_publish-images-prs.yml +++ b/.github/workflows/action_publish-images-prs.yml @@ -56,8 +56,8 @@ jobs: build: needs: [setup, base] - # Forks skip every job in the base phase. Build anyway unless a base failed. - if: ${{ !cancelled() && needs.setup.result == 'success' && needs.base.result != 'failure' }} + # Forks skip every job in the base phase, so build when it was skipped or succeeded. + if: ${{ !cancelled() && needs.setup.result == 'success' && (needs.base.result == 'success' || needs.base.result == 'skipped') }} name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -154,7 +154,7 @@ jobs: *) TEST_CELL="⏭️ Skipped" ;; esac # A failed base skips every build, which leaves the test phase nothing to test. - if [ "$BASE_RESULT" = "failure" ]; then + if [ "$BASE_RESULT" = "failure" ] || [ "$BASE_RESULT" = "cancelled" ]; then BUILD_CELL="❌ PHP base images failed to compile" TEST_CELL="⏭️ Skipped" fi From 552772da0fa3839b72db21a7fce9e125ad0eed7f Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 28 Oct 2026 20:18:35 +0000 Subject: [PATCH 07/13] Enhance auto-retry workflow to handle cancelled runs and improve documentation --- .../trigger_auto-retry-failed-builds.yml | 32 ++++++++++++++----- AGENTS.md | 2 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/trigger_auto-retry-failed-builds.yml b/.github/workflows/trigger_auto-retry-failed-builds.yml index 330a7b8c..045e8dab 100644 --- a/.github/workflows/trigger_auto-retry-failed-builds.yml +++ b/.github/workflows/trigger_auto-retry-failed-builds.yml @@ -5,6 +5,9 @@ name: Auto-retry failed image builds # the failure looks transient. When a run is still red after that, or was never worth # retrying, it opens an issue for a human. Pull request runs skip the issue because the # PR comment already carries the result. +# +# Runs are found by workflow file, because `workflow_run.name` holds the run's title +# (`run-name:`), not the workflow's name. on: workflow_run: workflows: @@ -17,7 +20,8 @@ permissions: {} jobs: triage: - if: github.event.workflow_run.conclusion == 'failure' + # A Depot runner that never connects leaves its job failed or cancelled, which can end the whole run as cancelled. + if: contains(fromJSON('["failure", "cancelled"]'), github.event.workflow_run.conclusion) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -30,7 +34,7 @@ jobs: RUN_URL: ${{ github.event.workflow_run.html_url }} RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} RUN_TITLE: ${{ github.event.workflow_run.display_title }} - WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} WORKFLOW_PATH: ${{ github.event.workflow_run.path }} TRIGGER: ${{ github.event.workflow_run.event }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} @@ -48,15 +52,27 @@ jobs: sleep 120 fi - LATEST=$(gh run list --workflow "$WORKFLOW_NAME" --branch "$HEAD_BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId') + LATEST=$(gh run list --workflow "$(basename "$WORKFLOW_PATH")" --branch "$HEAD_BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId') if [ "$LATEST" != "$RUN_ID" ]; then echo "::notice title=Skipped::A newer run already exists for $HEAD_BRANCH, so this one is stale." echo "action=none" >> "$GITHUB_OUTPUT" exit 0 fi - FAILED=$(gh run view "$RUN_ID" --json jobs --jq '[.jobs[] | select(.conclusion == "failure")] | length') - echo "Failed jobs in $RUN_URL (attempt $RUN_ATTEMPT): $FAILED" + JOBS=$(gh run view "$RUN_ID" --json jobs --jq '.jobs') + FAILED=$(echo "$JOBS" | jq '[.[] | select(.conclusion == "failure")] | length') + CANCELLED=$(echo "$JOBS" | jq '[.[] | select(.conclusion == "cancelled")] | length') + + # A run someone cancelled by hand has no failed jobs. A runner that never connects + # fails one job and cancels others, so that case is retried like any other failure. + if [ "$RUN_CONCLUSION" = "cancelled" ] && [ "$FAILED" = "0" ]; then + echo "::notice title=Skipped::The run was cancelled and no job failed, so it was most likely cancelled on purpose." + echo "action=none" >> "$GITHUB_OUTPUT" + exit 0 + fi + + FAILED=$((FAILED + CANCELLED)) + echo "Failed or cancelled jobs in $RUN_URL (attempt $RUN_ATTEMPT): $FAILED" if [ "$RUN_ATTEMPT" = "1" ] && [ "$FAILED" -le "$MAX_FAILED_JOBS" ]; then echo "action=retry" >> "$GITHUB_OUTPUT" @@ -76,7 +92,7 @@ jobs: echo "::notice title=Retry started::Re-running failed jobs for $RUN_URL. If this attempt also fails, an issue is opened." - name: Open an issue for a human - if: steps.check.outputs.action == 'issue' && !contains(env.WORKFLOW_NAME, 'PR Images') + if: steps.check.outputs.action == 'issue' && !endsWith(env.WORKFLOW_PATH, 'action_publish-images-prs.yml') env: REASON: ${{ steps.check.outputs.reason }} MAX_DETAILED_JOBS: 8 @@ -84,7 +100,7 @@ jobs: run: | JOBS=$(gh run view "$RUN_ID" --json jobs --jq '.jobs') TOTAL=$(echo "$JOBS" | jq 'length') - FAILED_JOBS=$(echo "$JOBS" | jq -c '[.[] | select(.conclusion == "failure") + FAILED_JOBS=$(echo "$JOBS" | jq -c '[.[] | select(.conclusion == "failure" or .conclusion == "cancelled") | { id: .databaseId, name, url, step: ((.steps[]? | select(.conclusion == "failure") | .name) // "-") }]') FAILED=$(echo "$FAILED_JOBS" | jq 'length') WORKFLOW_URL="https://github.com/${GH_REPO}/actions/workflows/$(basename "$WORKFLOW_PATH")" @@ -107,7 +123,7 @@ jobs: { echo "**[Re-run failed jobs]($RUN_URL)** · [Run summary]($RUN_URL#summary) · [Workflow history]($WORKFLOW_URL) · attempt $RUN_ATTEMPT" echo "" - echo "$FAILED of $TOTAL jobs failed on \`$HEAD_BRANCH\` at [\`$SHORT_SHA\`](https://github.com/${GH_REPO}/commit/${HEAD_SHA}), triggered by \`$TRIGGER\`. $REASON" + echo "$FAILED of $TOTAL jobs failed or were cancelled on \`$HEAD_BRANCH\` at [\`$SHORT_SHA\`](https://github.com/${GH_REPO}/commit/${HEAD_SHA}), triggered by \`$TRIGGER\`. $REASON" echo "" echo "### Failed jobs" echo "" diff --git a/AGENTS.md b/AGENTS.md index 412400df..48622b38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. - `scripts/test-image.sh` checks that an image starts, runs as an unprivileged user, has no world-writable files or directories (outside sticky ones like `/tmp`), reports the expected PHP version, loads the default extensions, reaches a healthy HEALTHCHECK, and (for images with a web server) serves a mounted `index.php` through NGINX, Apache, or Caddy. Run it locally against any image before opening a PR that touches startup behavior. Keep the checks generic across variations: it detects the web server from the image's `*_HTTP_PORT` environment variables rather than the image name. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. -- `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. +- `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. ## Verification From 55233603e9e9a455b8f01f951a75455a3cb0936a Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 11:18:59 +0000 Subject: [PATCH 08/13] Update documentation and scripts to include PHP_BUILD_PROVIDER in Dockerfiles --- AGENTS.md | 2 +- docs/content/docs/1.getting-started/99.contributing.md | 3 ++- scripts/prepare-php-base.sh | 9 +++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 48622b38..10667d97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ PHP versions are NOT hardcoded. The pipeline works like this: 4. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON, including each image's `php_base_tag` and `docker_library_ref` ### How PHP is compiled -Each variation's `php_base` (`cli`, `fpm`, or `zts`) names the docker-library/php variant it builds on; a minor's `php_base_overrides` swaps it (8.6 has no `zts` variant, so FrankenPHP uses `cli`). A `-rc` version such as `8.6-rc` builds the newest release candidate from php.net's release-candidates feed and docker-library's `8.6-rc` directory. `scripts/prepare-php-base.sh` downloads docker-library/php at `DOCKER_LIBRARY_PHP_REF` (or the minor's own `docker_library_ref`, as 8.1 has), sets the PHP version, URL, and php.net SHA256, and removes `pecl update-channels`. It checks the result, so a layout change upstream fails loudly. Keep every other line of upstream's Dockerfile untouched: matching the official images' structure is the point. The `redis` extension is built from its GitHub source (with the `liblzf` submodule downloaded separately) so no default build step depends on PECL. +Each variation's `php_base` (`cli`, `fpm`, or `zts`) names the docker-library/php variant it builds on; a minor's `php_base_overrides` swaps it (8.6 has no `zts` variant, so FrankenPHP uses `cli`). A `-rc` version such as `8.6-rc` builds the newest release candidate from php.net's release-candidates feed and docker-library's `8.6-rc` directory. `scripts/prepare-php-base.sh` downloads docker-library/php at `DOCKER_LIBRARY_PHP_REF` (or the minor's own `docker_library_ref`, as 8.1 has), sets the PHP version, URL, and php.net SHA256, removes `pecl update-channels`, and sets `PHP_BUILD_PROVIDER` to Server Side Up (shown as "Built by" in `php -v`). It checks the result, so a layout change upstream fails loudly. Keep every other line of upstream's Dockerfile untouched: matching the official images' structure is the point. The `redis` extension is built from its GitHub source (with the `liblzf` submodule downloaded separately) so no default build step depends on PECL. When modifying the version pipeline, the base config (`php-versions-base-config.yml`) is the file you edit. Never edit `php-versions.yml` directly -- it's generated. diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index 68ee5db5..c0f169dd 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -70,10 +70,11 @@ We generate our tags with a file called `scripts/assemble-docker-tags.sh` which ## How PHP is built We compile PHP from the php.net tarball with the same Dockerfiles that build the [official PHP images](https://github.com/docker-library/php). A security release can ship as soon as php.net publishes it, without waiting for the official images on Docker Hub. No step of the build depends on PECL. -For every PHP version, base OS, and upstream variant (`cli`, `fpm`, or `zts`), `scripts/prepare-php-base.sh` downloads docker-library/php at a pinned commit and changes only two things in its Dockerfile: +For every PHP version, base OS, and upstream variant (`cli`, `fpm`, or `zts`), `scripts/prepare-php-base.sh` downloads docker-library/php at a pinned commit and changes only three things in its Dockerfile: - The PHP version, download URL, and SHA256 are set to the patch we're building. The SHA256 comes from php.net, and the GPG signature check stays exactly as upstream runs it. - The `pecl update-channels` call is removed, so a PECL outage can't fail the build. +- `PHP_BUILD_PROVIDER` names us, so `php -v` shows "Built by Server Side Up (https://serversideup.net/php)". We compile and ship these binaries, so PHP questions should come to us rather than docker-library. The build phase compiles each base once and saves it to a private registry, then every variation is built on top of it. Bases are always compiled without the build cache. That way every run picks up the latest Debian and Alpine packages PHP links against, like curl, Brotli, and OpenSSL, without waiting for a new official image. The `redis` extension is built from its GitHub source for the same reason. diff --git a/scripts/prepare-php-base.sh b/scripts/prepare-php-base.sh index a48d8ec3..3acca746 100755 --- a/scripts/prepare-php-base.sh +++ b/scripts/prepare-php-base.sh @@ -9,8 +9,9 @@ set -euo pipefail # Writes the build context for one official PHP image (docker-library/php at a pinned # commit) set to compile from the php.net tarball. That lets us ship a # release as soon as php.net publishes it, without waiting for Docker Hub. Only the version -# lines change, and "pecl update-channels" is removed so a PECL outage cannot fail the build. -# Everything else, including the GPG check, is exactly what the official images run. +# lines change, "pecl update-channels" is removed so a PECL outage cannot fail the build, and +# `php -v` names us as the builder. Everything else, including the GPG check, is exactly what +# the official images run. if [ "$#" -ne 5 ]; then sed -n '4,5p' "$0" | sed 's/^# //' >&2 @@ -28,6 +29,8 @@ download="$scripts_dir/../src/common/usr/local/bin/docker-php-serversideup-downl work_dir="$(mktemp -d)" trap 'rm -rf "$work_dir"' EXIT +build_provider="Server Side Up (https://serversideup.net/php)" + fail() { echo "🛑 ERROR (prepare-php-base.sh): $1" >&2 exit 1 @@ -62,6 +65,7 @@ sed \ -e "s|^ENV PHP_VERSION .*|ENV PHP_VERSION ${version}|" \ -e "s|^ENV PHP_URL=.*|ENV PHP_URL=\"${tarball_url}\" PHP_ASC_URL=\"${tarball_url}.asc\"|" \ -e "s|^ENV PHP_SHA256=.*|ENV PHP_SHA256=\"${sha256}\"|" \ + -e "s|PHP_BUILD_PROVIDER='[^']*'|PHP_BUILD_PROVIDER='${build_provider}'|" \ -e '/^[[:space:]]*pecl update-channels;/d' \ "$dockerfile" > "$dockerfile.new" mv "$dockerfile.new" "$dockerfile" @@ -70,6 +74,7 @@ mv "$dockerfile.new" "$dockerfile" [ "$(grep -c "^ENV PHP_VERSION ${version}\$" "$dockerfile")" = "1" ] || fail "PHP_VERSION was not set to ${version}" grep -qF "ENV PHP_URL=\"${tarball_url}\"" "$dockerfile" || fail "PHP_URL was not set to ${tarball_url}" grep -qF "ENV PHP_SHA256=\"${sha256}\"" "$dockerfile" || fail "PHP_SHA256 was not set" +grep -qF "PHP_BUILD_PROVIDER='${build_provider}'" "$dockerfile" || fail "PHP_BUILD_PROVIDER was not set" if grep -E '^[^#]*pecl' "$dockerfile"; then fail "the Dockerfile still calls pecl" fi From af3d03aef466f37628bd48967f018cf424cee8ae Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 11:46:46 +0000 Subject: [PATCH 09/13] git commit -am "Add PHP 8.6 release candidates" -m "Build 8.6-rc on Alpine 3.23 and 3.24, Bookworm, and Trixie from the newest release candidate php.net lists. FrankenPHP uses the cli base, since docker-library builds every 8.6 image with ZTS. Upgrade install-php-extensions to 2.12.6 for its PHP 8.6 support. phpredis 6.3.0 does not compile on PHP 8.6, so 8.6 builds phpredis at 810984b, the last develop commit before an added build option that install-php-extensions answers out of order." --- README.md | 2 +- scripts/conf/php-versions-base-config.yml | 37 +++++++++++------------ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a1594ef3..6e243143 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ serversideup/php:{{version}}-{{variation-name}} > [!NOTE] > All images are available on [**Docker Hub**](https://hub.docker.com/r/serversideup/php/) and [**GitHub Packages**](https://github.com/serversideup/docker-php/pkgs/container/php). -We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images. PHP 8.1 is end of life but still built while its base operating systems receive security updates. See our [security policy](SECURITY.md#component-lifecycle-references) for the rule. +We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images. PHP 8.1 is end of life but still built while its base operating systems receive security updates. See our [security policy](SECURITY.md#component-lifecycle-references) for the rule. PHP 8.6 release candidates are available for testing as `8.6-rc` tags, such as `serversideup/php:8.6-rc-fpm-nginx`. Every rebuild uses the newest release candidate on php.net. [Learn More About Choosing an Image →](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image) diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml index 7ce636d3..47729c69 100644 --- a/scripts/conf/php-versions-base-config.yml +++ b/scripts/conf/php-versions-base-config.yml @@ -13,7 +13,7 @@ dependencies: # gh api repos/phpredis/phpredis/contents/liblzf?ref= --jq .sha PHPREDIS_LIBLZF_REF: fb25820c3c0aeafd127956ae6c115063b47e459a # renovate: datasource=github-releases depName=mlocati/docker-php-extension-installer - PHP_EXT_INSTALLER_VERSION: 2.12.0 + PHP_EXT_INSTALLER_VERSION: 2.12.6 # renovate: datasource=github-releases depName=just-containers/s6-overlay versioning=loose S6_VERSION: v3.2.3.2 # renovate: datasource=github-releases depName=renatomefi/php-fpm-healthcheck @@ -94,27 +94,24 @@ php_versions: - name: trixie patch_versions: # - 8.5.8 # Pull latest from Official PHP source - # PHP 8.6 waits on install-php-extensions: docker-library's 8.6 images no longer ship - # pecl/pear, and the installer's 8.6 support is still in review - # (https://github.com/mlocati/docker-php-extension-installer/pull/1388). Uncomment once - # Renovate bumps PHP_EXT_INSTALLER_VERSION to a release that includes it. - # https://github.com/serversideup/docker-php/issues/698 - # # "8.6-rc" builds the newest release candidate php.net lists. docker-library builds # every 8.6 image with ZTS and has no separate zts variant, so FrankenPHP uses cli. - # - # - minor: "8.6-rc" - # base_os: - # - name: alpine3.23 - # - name: alpine3.24 - # - name: bookworm - # - name: trixie - # patch_versions: - # - 8.6-rc - # php_base_overrides: - # frankenphp: cli - # php_extension_overrides: - # - redis=phpredis/phpredis@develop + - minor: "8.6-rc" + base_os: + - name: alpine3.23 + - name: alpine3.24 + - name: bookworm + - name: trixie + patch_versions: + - 8.6-rc + php_base_overrides: + frankenphp: cli + # phpredis 6.3.0 does not compile on PHP 8.6, so 8.6 builds the last develop commit + # before #2959. That change added an enable-redis-phpser build option, and + # install-php-extensions answers phpredis's build prompts in order, so newer commits + # turn on phpser and fail. Remove this once a phpredis release supports PHP 8.6. + php_extension_overrides: + - redis=phpredis/phpredis@810984b5a5aedc37e3ff3c19e33af8dc08c8290f operating_systems: - family: alpine From f9f7c5594619471ab5099c7bc92b3ce86c816e85 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 12:36:37 +0000 Subject: [PATCH 10/13] Update Renovate configuration to link GOLANG_VERSION and WATCHER_VERSION with FRANKENPHP_VERSION for synchronized updates --- renovate.json | 5 +++++ scripts/conf/php-versions-base-config.yml | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/renovate.json b/renovate.json index ee6d969b..01a33d7e 100644 --- a/renovate.json +++ b/renovate.json @@ -65,6 +65,11 @@ "automerge": true, "platformAutomerge": false }, + { + "description": "Go and watcher follow FrankenPHP, so its pull request reminds you to bump them together", + "matchDepNames": ["php/frankenphp"], + "prBodyNotes": ["Update `GOLANG_VERSION` in the same pull request to match `GO_VERSION` in [FrankenPHP's docker-bake.hcl](https://github.com/php/frankenphp/blob/v{{{newVersion}}}/docker-bake.hcl), and update `WATCHER_VERSION` to the [watcher release](https://github.com/e-dant/watcher/releases) that was current when this FrankenPHP version shipped."] + }, { "description": "The docs site only tracks the project switcher bar", "matchManagers": ["npm"], diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml index 47729c69..f01ef9a9 100644 --- a/scripts/conf/php-versions-base-config.yml +++ b/scripts/conf/php-versions-base-config.yml @@ -20,9 +20,11 @@ dependencies: PHP_FPM_HEALTHCHECK_VERSION: v0.6.0 # renovate: datasource=github-releases depName=php/frankenphp FRANKENPHP_VERSION: 1.13.4 - # renovate: datasource=docker depName=golang + # Go and watcher follow FrankenPHP rather than their own releases, so bump them in the + # same pull request as FRANKENPHP_VERSION. Go matches GO_VERSION in FrankenPHP's + # docker-bake.hcl at that tag. FrankenPHP downloads the latest watcher release when it + # builds, so use the watcher release that was current when that FrankenPHP version shipped. GOLANG_VERSION: "1.27" - # renovate: datasource=github-releases depName=e-dant/watcher WATCHER_VERSION: 0.14.5 # php_base is the docker-library/php variant each variation is built on. A minor version From 97852c4bde5e131478e9de9ce442dd0a7804ee75 Mon Sep 17 00:00:00 2001 From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:32:49 -0500 Subject: [PATCH 11/13] Split image tests into one file per feature area and test more features (#741) * Split image tests into one file per feature area Move the checks in scripts/test-image.sh into scripts/tests/image/ with shared helpers in lib.sh and PHP fixtures as real files. Every test runs even after one fails, and TEST_ONLY runs a single area. No checks were added or changed. * Test Laravel automations, startup scripts, set-id, and SSL modes Add laravel-automations.sh (a stand-in artisan records each command), entrypoint.sh, permissions.sh, and SSL mixed mode and HTTPS redirect tests. requires now also skips a single test, and build_image builds a test image from the one under test. * Test trusted proxies, PHP-FPM pool variables, and shutdown Add trusted-proxy.sh, fpm.sh, and lifecycle.sh (replacing healthcheck.sh), which also checks that docker stop finishes before Docker kills the container. The s6 images skip that check, since they wait to be killed on purpose for zero-downtime deployments (#647). Add a skip helper. --- .github/workflows/service_lint.yml | 1 + AGENTS.md | 13 +- .../docs/1.getting-started/99.contributing.md | 32 +- scripts/test-image.sh | 374 +++++------------- scripts/tests/image/.shellcheckrc | 3 + scripts/tests/image/entrypoint.sh | 38 ++ .../image/fixtures/entrypoint.d/10-second.sh | 3 + .../image/fixtures/entrypoint.d/100-third.sh | 1 + .../image/fixtures/entrypoint.d/2-first.sh | 1 + .../image/fixtures/entrypoint.d/20-fails.sh | 2 + scripts/tests/image/fixtures/laravel/artisan | 11 + .../fixtures/laravel/vendor/autoload.php | 1 + .../image/fixtures/public/finish-request.php | 4 + .../fixtures/public/frankenphp-worker.php | 6 + scripts/tests/image/fixtures/public/index.php | 1 + scripts/tests/image/fixtures/public/ip.php | 1 + .../fixtures/public/storage/uploaded.php | 1 + scripts/tests/image/fpm.sh | 22 ++ scripts/tests/image/frankenphp.sh | 12 + scripts/tests/image/laravel-automations.sh | 80 ++++ scripts/tests/image/lib.sh | 141 +++++++ scripts/tests/image/lifecycle.sh | 26 ++ scripts/tests/image/octane.sh | 42 ++ scripts/tests/image/permissions.sh | 27 ++ scripts/tests/image/php.sh | 49 +++ scripts/tests/image/security.sh | 17 + scripts/tests/image/ssl.sh | 51 +++ scripts/tests/image/trusted-proxy.sh | 36 ++ scripts/tests/image/web-server.sh | 15 + 29 files changed, 721 insertions(+), 290 deletions(-) create mode 100644 scripts/tests/image/.shellcheckrc create mode 100644 scripts/tests/image/entrypoint.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/10-second.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/100-third.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/2-first.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/20-fails.sh create mode 100644 scripts/tests/image/fixtures/laravel/artisan create mode 100644 scripts/tests/image/fixtures/laravel/vendor/autoload.php create mode 100644 scripts/tests/image/fixtures/public/finish-request.php create mode 100644 scripts/tests/image/fixtures/public/frankenphp-worker.php create mode 100644 scripts/tests/image/fixtures/public/index.php create mode 100644 scripts/tests/image/fixtures/public/ip.php create mode 100644 scripts/tests/image/fixtures/public/storage/uploaded.php create mode 100644 scripts/tests/image/fpm.sh create mode 100644 scripts/tests/image/frankenphp.sh create mode 100644 scripts/tests/image/laravel-automations.sh create mode 100644 scripts/tests/image/lib.sh create mode 100644 scripts/tests/image/lifecycle.sh create mode 100644 scripts/tests/image/octane.sh create mode 100644 scripts/tests/image/permissions.sh create mode 100644 scripts/tests/image/php.sh create mode 100644 scripts/tests/image/security.sh create mode 100644 scripts/tests/image/ssl.sh create mode 100644 scripts/tests/image/trusted-proxy.sh create mode 100644 scripts/tests/image/web-server.sh diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml index 380c0300..1e9b8012 100644 --- a/.github/workflows/service_lint.yml +++ b/.github/workflows/service_lint.yml @@ -43,6 +43,7 @@ jobs: --color=always --severity=warning \ scripts/*.sh \ scripts/tests/*.sh \ + scripts/tests/image/*.sh \ src/common/usr/local/bin/* \ src/s6/usr/local/bin/* \ .oss-scanner/run-variation \ diff --git a/AGENTS.md b/AGENTS.md index 10667d97..7ed41c0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,18 +115,19 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - a new way for untrusted input to reach the image (a web server location, a healthcheck, a workflow trigger), a change to `SECURITY.md` scope, or a new intentional default the scanner should not report: `.oss-scanner/threat_model.md` After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`. -- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. -- `scripts/test-image.sh` checks that an image starts, runs as an unprivileged user, has no world-writable files or directories (outside sticky ones like `/tmp`), reports the expected PHP version, loads the default extensions, reaches a healthy HEALTHCHECK, and (for images with a web server) serves a mounted `index.php` through NGINX, Apache, or Caddy. Run it locally against any image before opening a PR that touches startup behavior. Keep the checks generic across variations: it detects the web server from the image's `*_HTTP_PORT` environment variables rather than the image name. +- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `scripts/tests/image/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. +- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `fpm.sh`, `security.sh`, `entrypoint.sh`, `laravel-automations.sh`, `permissions.sh`, `lifecycle.sh`, `web-server.sh`, `ssl.sh`, `trusted-proxy.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, mounted files in `fixtures/` (`laravel/artisan` records commands instead of running Laravel). Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file or test declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`, `fpm`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. - `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. ## Verification -There is no automated test suite for image logic. To verify changes: +To verify image changes: 1. Build the affected variation locally with `scripts/dev.sh` -2. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` -3. For entrypoint script changes, test on both Debian and Alpine builds -4. Run `shellcheck` on any modified shell scripts when available +2. Run `bash scripts/test-image.sh ` against it, and add a test in `scripts/tests/image/` for new behavior +3. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` +4. For entrypoint script changes, test on both Debian and Alpine builds +5. Run `shellcheck` on any modified shell scripts when available ## Key Design Decisions diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index c0f169dd..ce6fe791 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -98,7 +98,7 @@ Every pull request starts with a `lint` group that runs before any image is buil docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \ - scripts/*.sh scripts/tests/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') + scripts/*.sh scripts/tests/*.sh scripts/tests/image/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile @@ -109,13 +109,41 @@ The script tests live in one file, `scripts/tests/run.sh`, as plain assertions o The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. +### Image tests +`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `entrypoint.sh`, `laravel-automations.sh`, `web-server.sh`, `ssl.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: + +```bash [Terminal] +bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx + +# Run one feature area +TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx +``` + +Every test runs even after one fails, and the summary lists each failure with its output. + +To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature (or just the test, when you call it inside one; `skip "reason"` skips one for any other reason), and `setup` starts one container that every test in the file shares: + +```bash [scripts/tests/image/web-server.sh] +requires healthcheck web_server + +setup() { + start_container +} + +test_serves_php() { + expect_body / "serversideup-php-ok:${php_version}" +} +``` + +A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. Files the tests mount go in `scripts/tests/image/fixtures/`: `public/` is the web server's document root, `laravel/` is a stand-in Laravel app whose `artisan` records each command it receives, and `entrypoint.d/` holds sample startup scripts. + ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. - **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. Fork builds can't save the compiled PHP base images, so they start from the official PHP images on Docker Hub instead. In the hours after a new PHP release, before Docker Hub has it, fork builds fail. Changes to how PHP is compiled are only tested when a maintainer runs the build for your PR. - **Pull requests from this repository** go through the same three phases as a release: build, test, publish. Images are published to `serversideup/php-dev` with your PR number as the tag prefix (for example `serversideup/php-dev:699-8.4-fpm-nginx-alpine`) only after the image tests pass. A comment on the PR shows the status and the pull commands. - **Every run ends with a summary table** listing each image with its compressed size per architecture, so a size regression is visible before merge. -- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, and serves a PHP file through its web server. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. +- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, stops cleanly, serves PHP through its web server in every SSL mode and trusted proxy setting, and runs startup scripts and Laravel automations the way the docs describe. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. - **Transient failures heal themselves.** If a few jobs fail because a registry or mirror hiccupped, CI re-runs just those jobs once. If a release or weekly rebuild is still red after that, CI opens an issue for the maintainers with the failed jobs and their errors. - **Maintainers** can publish a fork's images for testing by running the "Docker Publish (PR Images)" workflow manually with the PR number. diff --git a/scripts/test-image.sh b/scripts/test-image.sh index 130f071f..db969b94 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,89 +1,50 @@ #!/usr/bin/env bash +# shellcheck disable=SC2034,SC2154 # variables are shared with scripts/tests/image/lib.sh and the test files set -euo pipefail # Usage: test-image.sh [expected-php-version] # -# Runs a published or locally built image and checks the things a user would notice -# first: it starts, it runs as an unprivileged user, nothing is world-writable, PHP reports the expected version, -# the default extensions load, and images that ship a web server serve a PHP file -# through it. Works against any image reference. -# Commands go through the image's own entrypoint so every entrypoint.d script runs. -# The entrypoint prints a welcome banner first, so a command's own output is the last line. +# Runs a published or locally built image through the tests in scripts/tests/image/, +# one file per feature area. A file skips itself when the image lacks its feature, +# so the same tests run against every variation. Every test runs even after one +# fails, and the script exits non-zero if any did. +# +# Set TEST_ONLY to a file name without .sh (for example web-server) to run one area. image="${1:?Usage: test-image.sh [expected-php-version]}" expected_php="${2:-}" -health_timeout_seconds=90 -http_timeout_seconds=30 - -# Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile). -expected_extensions="opcache pcntl pdo_mysql pdo_pgsql redis zip" - -pass() { echo "✅ $1"; } -fail() { echo "❌ $1" >&2; exit 1; } -image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; } - -echo "🔎 Testing $image" - -php_version=$(docker run --rm "$image" php -r 'echo PHP_VERSION;' | tail -n1) -# A release candidate tag like 8.6-rc reports a version like 8.6.0RC4 -if [[ "$expected_php" == *-rc ]] && [[ "$php_version" == "${expected_php%-rc}".*RC* ]]; then - expected_php="$php_version" -fi -if [ -n "$expected_php" ] && [ "$php_version" != "$expected_php" ]; then - fail "PHP reports $php_version, expected $expected_php" -fi -pass "PHP $php_version" +tests_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/tests/image" && pwd)" -uid=$(docker run --rm "$image" id -u | tail -n1) -[ "$uid" != "0" ] || fail "Container runs as root by default" -pass "Runs as unprivileged user (uid $uid)" +# shellcheck source=tests/image/lib.sh +source "$tests_dir/lib.sh" -# A COPY without --chmod gives existing destination directories the build context's modes. -# Sticky directories like /tmp are expected. -world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \ - 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print') -[ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable" -pass "No world-writable files or directories" - -loaded_extensions=$(docker run --rm "$image" php -r 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));' | tail -n1) -missing_extensions="" -for extension in $expected_extensions; do - case " $loaded_extensions " in - *" $extension "*) ;; - *"zend $extension "*) ;; - *) missing_extensions="$missing_extensions $extension" ;; - esac -done -[ -z "$missing_extensions" ] || fail "PHP extensions not loaded:$missing_extensions" -pass "Extensions loaded: $expected_extensions" - -# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few -# of the different value types (size, boolean, list) and confirm PHP sees them. -# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults. -ini_values=$(docker run --rm \ - --env PHP_MEMORY_LIMIT=512M \ - --env PHP_REALPATH_CACHE_SIZE=8M \ - --env PHP_SESSION_COOKIE_HTTPONLY=0 \ - --env PHP_DISABLE_FUNCTIONS=shell_exec \ - --env PHP_OPCACHE_ENABLE=1 \ - --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60 \ - "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' | tail -n1) -[ "$ini_values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values" -pass "Environment variables apply to php.ini" +test_label="serversideup-php-test=$$" +work_dir=$(mktemp -d) +cleanup() { + local containers images + containers=$(docker ps --all --quiet --filter "label=$test_label") + images=$(docker images --quiet --filter "label=$test_label" | sort -u) + # shellcheck disable=SC2086 # one ID per word + [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true + # shellcheck disable=SC2086 + [ -z "$images" ] || docker rmi --force $images >/dev/null 2>&1 || true + rm -rf "$work_dir" +} +trap cleanup EXIT -# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server. -cli_opcache=$(docker run --rm --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0 \ - "$image" php -r 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' | tail -n1) -[ "$cli_opcache" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $cli_opcache" -pass "PHP_OPCACHE_ENABLE_CLI disables the CLI cache" +# The container runs unprivileged, so mounted fixtures must be world readable. +fixtures_dir="$work_dir/fixtures" +cp -R "$tests_dir/fixtures" "$fixtures_dir" +chmod -R u=rwX,go=rX "$fixtures_dir" +web_dir="$fixtures_dir/public" +php_version=$(run_php 'echo PHP_VERSION;') has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image") -if [ -z "$has_healthcheck" ]; then - pass "No HEALTHCHECK defined, skipping startup check" - exit 0 -fi +is_frankenphp=$(image_env CADDY_HTTP_PORT) +is_fpm=$(image_env PHP_FPM_POOL_NAME) -# Web images expose their HTTP port and document root as environment variables. +# Web images expose their HTTP port and document root as environment variables, and +# serve TLS on their HTTPS port once SSL_MODE is on. http_port="" web_root="" for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT CADDY_HTTP_PORT:CADDY_SERVER_ROOT; do @@ -95,229 +56,78 @@ for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT break fi done - -# The same images serve TLS on their HTTPS port once SSL_MODE is on, which is the only -# way to reach them over HTTP/2. https_port="" for variable in NGINX_HTTPS_PORT APACHE_HTTPS_PORT CADDY_HTTPS_PORT; do https_port=$(image_env "$variable") [ -z "$https_port" ] || break done -# Web images run with OPcache enabled so the health check and the served page -# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults. -run_args=(--detach --env PHP_OPCACHE_ENABLE=1) -if [ -n "$http_port" ]; then - # The container runs unprivileged, so the mounted document root must be world readable. - web_dir=$(mktemp -d) - chmod 755 "$web_dir" - echo ' "$web_dir/index.php" - chmod 644 "$web_dir/index.php" - mkdir -p "$web_dir/storage" - chmod 755 "$web_dir/storage" - echo ' "$web_dir/storage/uploaded.php" - chmod 644 "$web_dir/storage/uploaded.php" - # Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running - # terminate callbacks after it, so the web server has to survive a request that finishes - # before PHP does. - cat > "$web_dir/finish-request.php" <<'PHP' -/dev/null 2>&1 || true - done - [ -z "${web_dir:-}" ] || rm -rf "$web_dir" -} -trap cleanup EXIT - -dump_container_state() { - echo "--- container logs ---" >&2 - docker logs "$1" >&2 2>&1 || true - echo "--- last health check ---" >&2 - docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true +failures_file="$work_dir/failures" +touch "$failures_file" + +output="$work_dir/output" + +report() { + local name="$1" status="$2" + case "$status" in + 0) echo "✅ $name" ;; + "$skip_status") echo "⏭️ $name, $(tail -n1 "$output")" ;; + *) + echo "❌ $name" + sed 's/^/ /' "$output" + echo "$area: $name" >> "$failures_file" + ;; + esac } -start_container() { - container=$(docker run "${run_args[@]}" "$@" "$image") - containers+=("$container") - - status=starting - for _ in $(seq 1 "$health_timeout_seconds"); do - status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone") - case "$status" in - healthy) break ;; - unhealthy|gone) break ;; - esac - sleep 1 - done - - if [ "$status" != "healthy" ]; then - dump_container_state "$container" - fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" +# A file stops early when requires skips it or setup fails. +report_file_stopped() { + if [ "$1" -eq "$skip_status" ]; then + echo "⏭️ $(tail -n1 "$output")" + else + report setup "$1" fi - [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://') - [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://') } -# Retries until the response body matches, since the web server may still be warming up. -expect_body() { - path="$1" - expected="$2" - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true) - [ "$body" = "$expected" ] && return 0 - sleep 1 - done - dump_container_state "$container" - fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}" -} - -# Uploaded PHP files under /storage must never run, including through PATH_INFO -# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute. -expect_storage_blocked() { - for path in /storage/uploaded.php /storage/uploaded.php/anything; do - response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true) - [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}" - done -} - -# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log. -# Retries because the log line lands a moment after the response. -expect_authorization_redacted() { - curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true - for _ in $(seq 1 "$http_timeout_seconds"); do - logs=$(docker logs "$container" 2>&1) - case "$logs" in - *"octane-secret"*) fail "Access log contains the authorization query parameter" ;; - *"authorization=REDACTED"*) return 0 ;; - esac - sleep 1 - done - dump_container_state "$container" - fail "Access log does not redact the authorization query parameter" -} - -# PHP carrying on after fastcgi_finish_request() has to leave the server able to take the -# next request. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only over -# HTTP/2, so both protocol versions are worth checking. -expect_work_after_finish_request() { - for protocol in --http1.1 --http2; do - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true) - [ "$body" = "finish-request-ok:${php_version}" ] && break - sleep 1 - done - if [ "$body" != "finish-request-ok:${php_version}" ]; then - dump_container_state "$container" - fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}" - fi - - # The crash lands after the response, so give the process a moment to fall over. - sleep 2 - status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone) - if [ "$status" != "running" ]; then - dump_container_state "$container" - fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)" - fi +# Runs in its own subshell so one file's containers and variables never reach the next. +# The file and its setup() run in that shell so the tests can use the container setup +# starts; an EXIT trap reports them when they stop it. Each test runs in a subshell with +# errexit on, so a failed command ends that test only. +run_test_file() { + local file="$1" test_function status + exec 3>&1 + trap 'report_file_stopped $? >&3' EXIT + # shellcheck source=/dev/null + source "$file" >"$output" 2>&1 + if declare -F setup >/dev/null; then + setup >"$output" 2>&1 + fi + trap - EXIT + for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do + set +e + ( set -e; "$test_function" ) >"$output" 2>&1 + status=$? + set -e + report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" done } -# A detached container has no terminal, so Caddy's default format is json and its default -# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON. -expect_json_logs_on_stderr() { - stdout_logs=$(docker logs "$container" 2>/dev/null) - stderr_logs=$(docker logs "$container" 2>&1 >/dev/null) - case "$stderr_logs" in - *'"msg":"handled request"'*) ;; - *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;; - esac - case "$stdout_logs" in - *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;; - esac -} - -start_container -pass "Container became healthy" - -[ -n "$http_port" ] || exit 0 - -expect_body / "serversideup-php-ok:${php_version}" -pass "Web server serves PHP on port ${http_port}" - -expect_storage_blocked -pass "Web server blocks PHP execution under /storage" +echo "🔎 Testing $image (PHP $php_version)" +for file in "$tests_dir"/*.sh; do + area=$(basename "$file" .sh) + [ "$area" != lib ] || continue + [ -z "${TEST_ONLY:-}" ] || [ "$area" = "$TEST_ONLY" ] || continue + echo + echo "$area" + set +e + ( set -e; run_test_file "$file" ) + set -e +done -# HTTP/2 needs TLS, so this runs against its own container with SSL_MODE=full, which -# generates a self-signed certificate and stops serving the plain HTTP port. The checks -# after it still expect the container the rest of the suite has been using. -if [ -n "$https_port" ]; then - default_container="$container" - default_host_port="$host_port" - start_container --env SSL_MODE=full - expect_work_after_finish_request - pass "Web server survives a request that finishes before PHP does, over HTTP/1.1 and HTTP/2" - container="$default_container" - host_port="$default_host_port" +echo +if [ -s "$failures_file" ]; then + echo "$(wc -l < "$failures_file" | tr -d ' ') test(s) failed:" >&2 + sed 's/^/ /' "$failures_file" >&2 + exit 1 fi - -# The rest applies to FrankenPHP only: Caddy's log defaults and Laravel Octane. -[ -n "$(image_env CADDY_HTTP_PORT)" ] || exit 0 - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Web server writes JSON logs to stderr and redacts the authorization query parameter" - -# Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from -# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch -# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration. -cat > "$web_dir/frankenphp-worker.php" <<'PHP' -}" -pass "Octane mode exposes the Caddy admin API on Octane's admin port" - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Octane mode writes JSON logs to stderr and redacts the authorization query parameter" +echo "All tests passed" diff --git a/scripts/tests/image/.shellcheckrc b/scripts/tests/image/.shellcheckrc new file mode 100644 index 00000000..f8238048 --- /dev/null +++ b/scripts/tests/image/.shellcheckrc @@ -0,0 +1,3 @@ +# The test files read and set variables that scripts/test-image.sh and lib.sh share +# with them (image, php_version, container, ...), which ShellCheck can't see across files. +disable=SC2034,SC2154 diff --git a/scripts/tests/image/entrypoint.sh b/scripts/tests/image/entrypoint.sh new file mode 100644 index 00000000..c57c80cd --- /dev/null +++ b/scripts/tests/image/entrypoint.sh @@ -0,0 +1,38 @@ +# shellcheck shell=bash +# docker-php-serversideup-entrypoint: the scripts in /etc/entrypoint.d, then the command. + +# Usage: run_entrypoint