Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/service_lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
uses: actions/checkout@v7

- name: Lint Dockerfiles
run: docker run --rm -v "$PWD:/repo" -w /repo "hadolint/hadolint:${HADOLINT_VERSION}" hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile
run: docker run --rm -v "$PWD:/repo" -w /repo "hadolint/hadolint:${HADOLINT_VERSION}" hadolint src/base/Dockerfile src/variations/*/Dockerfile .oss-scanner/Dockerfile

script-tests:
name: scripts
Expand Down
2 changes: 1 addition & 1 deletion .hadolint.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# hadolint configuration for src/variations/*/Dockerfile
# hadolint configuration for src/base/Dockerfile, src/variations/*/Dockerfile, and .oss-scanner/Dockerfile
# Each ignored rule is a deliberate project decision, not an oversight.
ignored:
- DL3008 # apt packages are intentionally unpinned: weekly rebuilds pick up security updates
Expand Down
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project maintains open source PHP Docker images (`serversideup/php`) for La

```
src/
base/ # serversideup/php-base: PHP compiled from source, a drop-in for the official php images
variations/ # One Dockerfile per image variation (cli, fpm, fpm-apache, fpm-nginx, frankenphp)
common/ # Shared scripts and configs copied into ALL variations
usr/local/bin/ # Entrypoint and helper scripts (POSIX /bin/sh)
Expand All @@ -15,6 +16,7 @@ src/
utilities-webservers/ # Shared web server entrypoint utilities (SSL, etc.)
scripts/ # Build tooling (Bash, not POSIX)
dev.sh # Local image builds
get-php-source.sh # Resolves one PHP version to its php.net tarball, sha256, and GPG keys
conf/ # PHP version matrix and base config (YAML)
generate-matrix.sh # Generates CI build matrix from YAML config
assemble-docker-tags.sh
Expand Down Expand Up @@ -54,8 +56,17 @@ Each variation Dockerfile uses multi-stage builds:
2. Variation-specific configs live in `src/variations/<variation>/etc/`
3. The build context is the **project root** (not `src/`), so COPY paths are relative to root: `COPY --chmod=755 src/common/ /`

### Base image (`src/base/`)
`src/base/Dockerfile` compiles PHP from the php.net tarball into `serversideup/php-base:<version>-<cli|fpm|zts>-<os>`, an image meant to be interchangeable with the official `php:<version>-<cli|fpm|zts>-<os>`: same paths, ENV, `docker-php-ext-*` scripts, configure flags, and `/usr/local/etc` contents. It is a port of docker-library/php's `Dockerfile-linux.template`, pinned to the commit in `src/base/bin/UPSTREAM`; when upstream changes how the official images are built, port the change here too. One Dockerfile covers every PHP minor, SAPI, and OS: the per-minor differences are computed from `PHP_VERSION` inside the RUN steps.

- The tarball URL, sha256, and signing keys come from `scripts/get-php-source.sh`, which reads only php.net plus the `gpg_keys` of each minor in the base config. Add a new minor's release manager keys (https://www.php.net/gpg-keys.php) when you add the minor.
- The OS image to build on is `base_image` on each OS entry in the base config.
- Build one locally with `scripts/dev.sh --variation base --version 8.4 --os bookworm --sapi fpm`, then check it against the official image with `scripts/test-base-image.sh serversideup/php-base:8.4-fpm-bookworm 8.4.27 php:8.4.27-fpm-bookworm`. Any difference from the official image other than `PHP_BUILD_PROVIDER` is a bug.
- Variations take the image to build on as `BASE_IMAGE` (default: the official image), so `scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm --build-arg BASE_IMAGE=serversideup/php-base:8.4-fpm-bookworm` builds a variation on a local base.

### Build args used across Dockerfiles
- `PHP_VERSION`, `BASE_OS_VERSION`, `PHP_VARIATION` -- set by CI matrix or `scripts/dev.sh`
- `BASE_IMAGE` -- the PHP image a variation builds on; defaults to the official `php:` image
- `NGINX_VERSION` -- resolved per-OS from `scripts/conf/php-versions-base-config.yml`
- `REPOSITORY_BUILD_VERSION` -- image version label

Expand Down Expand Up @@ -108,7 +119,7 @@ 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 <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.
- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/base/Dockerfile`, `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, 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/1.getting-started/99.contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ 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')

docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile
docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/base/Dockerfile src/variations/*/Dockerfile .oss-scanner/Dockerfile

bash scripts/tests/run.sh
```
Expand Down
34 changes: 34 additions & 0 deletions scripts/conf/php-versions-base-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ php_variations:
- "8.1"
- "8.2"

# gpg_keys are the release managers who sign each minor's tarballs, from
# https://www.php.net/gpg-keys.php (the same table docker-library/php keeps). The
# base image (src/base/Dockerfile) refuses a tarball whose signature does not
# verify against one of them.
php_versions:
- major: "8"
minor_versions:
Expand All @@ -24,6 +28,10 @@ php_versions:
- name: alpine3.22
- name: bookworm
- name: trixie
gpg_keys:
- 528995BFEDFBA7191D46839EF9BA0ADA31CBD89E # patrickallaert
- 39B641343D8C104B2B146DC3F9C39DC0B9698544 # ramsey
- F1F692238FBC1666E5A5CCD4199F9DFEF6FFBAFD # krakjoe
patch_versions:
- 8.1.34
- minor: "8.2"
Expand All @@ -32,6 +40,10 @@ php_versions:
- name: alpine3.24
- name: bookworm
- name: trixie
gpg_keys:
- 39B641343D8C104B2B146DC3F9C39DC0B9698544 # ramsey
- E60913E4DF209907D8E30D96659A97C9CF2A795A # sergey
- 1198C0117593497A5EC5C199286AF1F9897469DC # pierrick
patch_versions:
# - 8.2.32 # Pull latest from Official PHP source
- minor: "8.3"
Expand All @@ -40,6 +52,10 @@ php_versions:
- name: alpine3.24
- name: bookworm
- name: trixie
gpg_keys:
- 1198C0117593497A5EC5C199286AF1F9897469DC # pierrick
- C28D937575603EB4ABB725861C0779DC5C0A9DE4 # bukka
- AFD8691FDAEDF03BDF6E460563F15A9B715376CA # ericmann
patch_versions:
# - 8.3.32 # Pull latest from Official PHP source
- minor: "8.4"
Expand All @@ -48,6 +64,10 @@ php_versions:
- name: alpine3.24
- name: bookworm
- name: trixie
gpg_keys:
- AFD8691FDAEDF03BDF6E460563F15A9B715376CA # ericmann
- 9D7F99A0CB8F05C8A6958D6256A97AF7600A39A6 # calvinb
- 0616E93D95AF471243E26761770426E17EBBB3DD # saki
patch_versions:
# - 8.4.23 # Pull latest from Official PHP source
- minor: "8.5"
Expand All @@ -56,6 +76,10 @@ php_versions:
- name: alpine3.24
- name: bookworm
- name: trixie
gpg_keys:
- 1198C0117593497A5EC5C199286AF1F9897469DC # pierrick
- 49D9AF6BC72A80D6691719C8AA23F5BE9C7097D4 # edorian
- D95C03BC702BE9515344AE3374E44BC9067701A5 # daniels
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,
Expand All @@ -73,30 +97,40 @@ php_versions:
# - 8.6-rc
# php_extension_overrides:
# - redis=phpredis/phpredis@develop
# gpg_keys:
# - D95C03BC702BE9515344AE3374E44BC9067701A5 # daniels
# - 016895DE9A475111D537A6E69134FF30BC5A99B5 # mbeccati
# - 5CFF17B64DC1C244F5D0EAC3E43535E2EB19010E # svpernova09

# base_image is what src/base/Dockerfile builds PHP on for that OS.
operating_systems:
- family: alpine
versions:
- name: "Alpine 3.22"
version: alpine3.22
number: 3.22
base_image: alpine:3.22
nginx_version: 1.30.6-r1
- name: "Alpine 3.23"
version: alpine3.23
number: 3.23
base_image: alpine:3.23
nginx_version: 1.30.6-r1
- name: "Alpine 3.24"
version: alpine3.24
number: 3.24
base_image: alpine:3.24
nginx_version: 1.30.6-r1
- family: debian
default: true
versions:
- name: "Debian Bookworm"
version: bookworm
number: 12
base_image: debian:bookworm-slim
nginx_version: 1.30.6-1~bookworm
- name: "Debian Trixie"
version: trixie
number: 13
base_image: debian:trixie-slim
nginx_version: 1.30.6-1~trixie
63 changes: 61 additions & 2 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ PHP_BUILD_VERSION=""
PHP_BUILD_VARIATION=""
PHP_BUILD_BASE_OS=""
PHP_BUILD_PREFIX=""
PHP_BUILD_SAPI=""
DOCKER_REPOSITORY="${DOCKER_REPOSITORY:-"serversideup/php"}"
PHP_BASE_REPOSITORY="${PHP_BASE_REPOSITORY:-"serversideup/php-base"}"
DOCKER_ADDITIONAL_BUILD_ARGS=()
PUSH_TO_REGISTRY=false
PLATFORM=""
Expand Down Expand Up @@ -126,7 +128,44 @@ build_docker_image() {
--file "$PROJECT_ROOT_DIR/src/variations/$PHP_BUILD_VARIATION/Dockerfile" \
"$PROJECT_ROOT_DIR"
echo_color_message green "✅ Docker Image Built: $build_tag"

push_docker_image "$build_tag"
}

build_base_image() {
build_tag="${PHP_BASE_REPOSITORY}:${PHP_BUILD_PREFIX}${PHP_BUILD_VERSION}-${PHP_BUILD_SAPI}-${PHP_BUILD_BASE_OS}"

local base_image source
base_image=$(BASE_OS="$PHP_BUILD_BASE_OS" yq -r '.operating_systems[].versions[] | select(.version == env(BASE_OS)) | .base_image // ""' "$BASE_PHP_VERSIONS_CONFIG_FILE")
if [ -z "$base_image" ]; then
echo_color_message red "❌ No base_image for OS '$PHP_BUILD_BASE_OS' in $BASE_PHP_VERSIONS_CONFIG_FILE"
exit 1
fi
source=$("$SCRIPT_DIR/get-php-source.sh" --config "$BASE_PHP_VERSIONS_CONFIG_FILE" "$PHP_BUILD_VERSION")
echo_color_message yellow "🐳 Building PHP $(jq -r '.php_version' <<< "$source") from source on $base_image: $build_tag"

if [ -z "$PLATFORM" ]; then
PLATFORM=$(detect_platform)
fi

docker buildx build \
${DOCKER_ADDITIONAL_BUILD_ARGS[@]+"${DOCKER_ADDITIONAL_BUILD_ARGS[@]}"} \
--platform "$PLATFORM" \
--build-arg BASE_IMAGE="$base_image" \
--build-arg PHP_SAPI="$PHP_BUILD_SAPI" \
--build-arg PHP_VERSION="$(jq -r '.php_version' <<< "$source")" \
--build-arg PHP_URL="$(jq -r '.url' <<< "$source")" \
--build-arg PHP_ASC_URL="$(jq -r '.asc_url' <<< "$source")" \
--build-arg PHP_SHA256="$(jq -r '.sha256' <<< "$source")" \
--build-arg GPG_KEYS="$(jq -r '.gpg_keys' <<< "$source")" \
--tag "$build_tag" \
--file "$PROJECT_ROOT_DIR/src/base/Dockerfile" \
"$PROJECT_ROOT_DIR"
echo_color_message green "✅ Docker Image Built: $build_tag"
push_docker_image "$build_tag"
}

push_docker_image() {
local build_tag=$1
if [ "$PUSH_TO_REGISTRY" = true ]; then
echo_color_message yellow "🚀 Pushing image to registry: $build_tag"
docker push "$build_tag"
Expand All @@ -143,9 +182,11 @@ help_menu() {
echo "arguments to the docker build command."
echo
echo "Options:"
echo " --variation <variation> Set the PHP variation (e.g., apache, fpm)"
echo " --variation <variation> Set the PHP variation (e.g., apache, fpm), or 'base' to compile PHP"
echo " from source into serversideup/php-base (needs --sapi)"
echo " --version <version> Set the PHP version (e.g., 8.4, 8.5, or a pinned patch like 8.1.34)"
echo " --os <os> Set the base OS (e.g., bookworm, trixie, alpine3.24)"
echo " --sapi <sapi> With --variation base: cli, fpm, or zts"
echo " --prefix <prefix> Set the prefix for the Docker image (e.g., beta)"
echo " --registry <registry> Set a custom registry (e.g., localhost:5000)"
echo " --platform <platform> Set the platform (default: detected from system architecture)"
Expand Down Expand Up @@ -174,6 +215,10 @@ while [[ $# -gt 0 ]]; do
PHP_BUILD_PREFIX="$2-"
shift 2
;;
--sapi)
PHP_BUILD_SAPI="$2"
shift 2
;;
--registry)
DOCKER_REPOSITORY="$2"
shift 2
Expand Down Expand Up @@ -209,6 +254,20 @@ check_vars \
PHP_BUILD_VERSION \
PHP_BUILD_BASE_OS

if [ "$PHP_BUILD_VARIATION" = "base" ]; then
case "$PHP_BUILD_SAPI" in
cli|fpm|zts) ;;
*)
echo_color_message red "🚨 --variation base needs --sapi cli, fpm, or zts"
echo
help_menu
exit 1
;;
esac
build_base_image
exit 0
fi

# Auto-resolve PHP extension source overrides for the minor version being built
if [ -z "$PHP_EXTENSION_OVERRIDES" ]; then
PHP_EXTENSION_OVERRIDES=$(MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r '
Expand Down
88 changes: 88 additions & 0 deletions scripts/get-php-source.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/bin/bash
###################################################
# Usage: get-php-source.sh <version> [--config PATH]
###################################################
# Prints the build args src/base/Dockerfile needs to compile one PHP version, as JSON:
#
# {"php_version": "8.4.27", "minor": "8.4", "url": "https://www.php.net/distributions/php-8.4.27.tar.xz",
# "asc_url": "...tar.xz.asc", "sha256": "...", "gpg_keys": "AFD8... 9D7F... 0616..."}
#
# <version> is a minor (8.4) or an exact release (8.4.27). A minor resolves to the
# patch pinned in the base config if it has one (8.1), otherwise to the current
# release on https://www.php.net/releases/active.php. An exact release is looked up on
# https://www.php.net/releases/index.php. The GPG keys come from the minor's gpg_keys in
# the base config.
#
# Only php.net is consulted. If it does not list the version, this fails instead of
# falling back to an older patch.

set -euo pipefail

SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
PROJECT_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
DOWNLOAD="$PROJECT_ROOT_DIR/src/common/usr/local/bin/docker-php-serversideup-download"
BASE_PHP_VERSIONS_CONFIG_FILE="${BASE_PHP_VERSIONS_CONFIG_FILE:-"$SCRIPT_DIR/conf/php-versions-base-config.yml"}"
PHP_NET="${PHP_NET:-https://www.php.net}"

fail() {
echo "❌ $*" >&2
exit 1
}

VERSION=""
while [ "$#" -gt 0 ]; do
case "$1" in
--config) BASE_PHP_VERSIONS_CONFIG_FILE="${2:?--config requires a path}"; shift 2 ;;
-*) fail "Unknown option: $1" ;;
*) VERSION="$1"; shift ;;
esac
done

[[ "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail "Usage: $0 <version>, for example 8.4 or 8.4.27"
command -v jq > /dev/null || fail "jq is required"
command -v yq > /dev/null || fail "yq is required (https://github.com/mikefarah/yq)"

MINOR="$(echo "$VERSION" | cut -d. -f1,2)"

fetch_json() {
local body
body="$("$DOWNLOAD" "$1")" || fail "Could not fetch $1"
jq -e . > /dev/null 2>&1 <<< "$body" || fail "$1 did not return JSON"
echo "$body"
}

minor_config="$(MINOR="$MINOR" yq -o=json -I0 '.php_versions[].minor_versions[] | select(.minor == env(MINOR))' "$BASE_PHP_VERSIONS_CONFIG_FILE")"
[ -n "$minor_config" ] || fail "PHP $MINOR is not in $BASE_PHP_VERSIONS_CONFIG_FILE"
gpg_keys="$(jq -r '.gpg_keys // [] | join(" ")' <<< "$minor_config")"
[ -n "$gpg_keys" ] || fail "PHP $MINOR has no gpg_keys in $BASE_PHP_VERSIONS_CONFIG_FILE"

if [ "$VERSION" = "$MINOR" ]; then
pinned="$(jq -r '.patch_versions // [] | .[0] // empty' <<< "$minor_config")"
else
pinned="$VERSION"
fi

if [ -n "$pinned" ]; then
release="$(fetch_json "$PHP_NET/releases/index.php?json&version=$pinned")"
if jq -e '.error' > /dev/null <<< "$release"; then
fail "php.net does not know PHP $pinned: $(jq -r '.error' <<< "$release")"
fi
release="$(jq -c --arg v "$pinned" '{version: $v, source}' <<< "$release")"
else
release="$(fetch_json "$PHP_NET/releases/active.php" | jq -c --arg minor "$MINOR" '.[$minor | split(".")[0]][$minor] // empty')"
[ -n "$release" ] || fail "php.net does not list PHP $MINOR as active. If it is end of life, pin its patch_versions in $BASE_PHP_VERSIONS_CONFIG_FILE."
fi

tarball="$(jq -c '[.source[]? | select(.filename | endswith(".tar.xz"))][0] // empty' <<< "$release")"
[ -n "$tarball" ] || fail "php.net lists no .tar.xz tarball for PHP $(jq -r '.version' <<< "$release")"
sha256="$(jq -r '.sha256 // empty' <<< "$tarball")"
[[ "$sha256" =~ ^[0-9a-f]{64}$ ]] || fail "php.net lists no sha256 for $(jq -r '.filename' <<< "$tarball")"

url="$PHP_NET/distributions/$(jq -r '.filename' <<< "$tarball")"
jq -n -c \
--arg php_version "$(jq -r '.version' <<< "$release")" \
--arg minor "$MINOR" \
--arg url "$url" \
--arg sha256 "$sha256" \
--arg gpg_keys "$gpg_keys" \
'{php_version: $php_version, minor: $minor, url: $url, asc_url: ($url + ".asc"), sha256: $sha256, gpg_keys: $gpg_keys}'
Loading
Loading