From aa3b37dd35bf788141f0dd2ffa06e3d226d57691 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:17:20 +0000 Subject: [PATCH 1/2] Add GitHub Actions workflow that builds the images Builds each PHP version in its own job, uses the old BuildKit for 5.2-5.5, and pushes to Docker Hub on manual runs. build.sh gets --list so the workflow reads the versions from one place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NxBArk6ZTH6vAmDaok99S --- .github/workflows/build.yml | 103 ++++++++++++++++++++++++++++++++++++ README.md | 24 +++++++++ build.sh | 6 +++ 3 files changed, 133 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..94b7f9e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,103 @@ +name: Build images + +on: + push: + branches: [main] + paths: + - "Dockerfile*" + - "build.sh" + - "common/**" + - "pecl/**" + - "php5.3/**" + - ".github/workflows/build.yml" + pull_request: + paths: + - "Dockerfile*" + - "build.sh" + - "common/**" + - "pecl/**" + - "php5.3/**" + - ".github/workflows/build.yml" + workflow_dispatch: + inputs: + versions: + description: "Versions to build, space separated (empty = all)" + required: false + default: "" + push: + description: "Push images to the registry" + type: boolean + default: false + no_cache: + description: "Build without layer cache (use after security fixes)" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + versions: + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.list.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: list + env: + VERSIONS: ${{ inputs.versions }} + run: | + if [ -n "${VERSIONS// }" ]; then + list=$(printf '%s\n' $VERSIONS) + else + list=$(./build.sh --list) + fi + echo "matrix=$(printf '%s\n' "$list" | jq -R . | jq -cs .)" >> "$GITHUB_OUTPUT" + + build: + needs: versions + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.versions.outputs.matrix) }} + name: PHP ${{ matrix.version }} + env: + # Image name with the registry host, e.g. registry.example.com/php. + # Without a host it is Docker Hub (narel/php). + REGISTRY: ${{ vars.REGISTRY || 'narel/php' }} + PUSH: ${{ github.event_name == 'workflow_dispatch' && inputs.push && '1' || '' }} + NO_CACHE: ${{ inputs.no_cache && '1' || '' }} + steps: + - uses: actions/checkout@v4 + + # The default BuildKit hangs on apt from the jessie/wheezy era, so the + # legacy 5.x images use an older BuildKit (see README, "Building"). + - name: Set up old BuildKit for PHP 5.2 - 5.5 + if: contains(fromJSON('["5.5","5.4","5.3","5.2"]'), matrix.version) + run: | + docker buildx create --name oldbuilder --driver docker-container \ + --driver-opt image=moby/buildkit:v0.12.5 + echo "BUILDER=oldbuilder" >> "$GITHUB_ENV" + echo "PLATFORM=linux/amd64" >> "$GITHUB_ENV" + + # REGISTRY_HOST empty = Docker Hub. For your own registry set it to + # the host, e.g. registry.example.com or registry.example.com:5000. + - name: Log in to the registry + if: env.PUSH == '1' + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_HOST }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build + run: ./build.sh "${{ matrix.version }}" + + - name: Smoke test + run: | + docker run --rm --entrypoint php "${REGISTRY}:${{ matrix.version }}" -v diff --git a/README.md b/README.md index 9e8857e..b997bc3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,30 @@ The full set of 13 images takes about 9 GB, plus a layer cache that grows to several GB. `docker builder prune -f` frees the cache only and **does not touch images**. `docker system prune` can delete finished images. +### GitHub Actions + +`.github/workflows/build.yml` builds every version in a separate job, in +parallel. It runs on pushes to `main` and on pull requests that touch the +Dockerfiles, `build.sh`, `common/`, `pecl/` or `php5.3/`. Only build, no push. + +To build and push by hand: **Actions → Build images → Run workflow**. Inputs: + +- `versions`: space separated, for example `8.5 7.4` (empty = all) +- `push`: push to the registry (see below) +- `no_cache`: build without layer cache, after security fixes + +Pushing is configured in **Settings → Secrets and variables → Actions**: + +| Name | Kind | Example | Meaning | +|------|------|---------|---------| +| `REGISTRY` | variable | `registry.example.com/php` | image name, with the registry host; default `narel/php` (Docker Hub) | +| `REGISTRY_HOST` | variable | `registry.example.com` | host to log in to; empty = Docker Hub | +| `REGISTRY_USERNAME` | secret | `ci-bot` | registry user | +| `REGISTRY_PASSWORD` | secret | | password or access token | + +The registry must be reachable from GitHub-hosted runners, over HTTPS with a +publicly trusted certificate. + --- ## Running diff --git a/build.sh b/build.sh index acd7cb8..c4c1ac2 100755 --- a/build.sh +++ b/build.sh @@ -9,6 +9,7 @@ # NO_PULL=1 ./build.sh 8.2 # do not refresh the base image # NO_CACHE=1 ./build.sh 8.5 # no layer cache - use after security fixes # BUILDER=oldbuilder ./build.sh 5.5 # build with the given buildx builder +# ./build.sh --list # print the available versions and exit # # --pull refreshes only the base image. If it has not changed, the apt-get # layer comes from the cache and new packages from the repository do NOT @@ -129,6 +130,11 @@ build_one() { fi } +if [ "${1:-}" = "--list" ]; then + all_versions + exit 0 +fi + if [ "$#" -gt 0 ]; then targets=("$@") else From 6f87c057927d07e754ad99a8b0f75a5be03e0ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:14:52 +0000 Subject: [PATCH 2/2] Rebuild images automatically when their base image changes build.sh records the base image name and digest as OCI labels. A daily workflow compares that digest with the current one and calls build.yml (now reusable) to rebuild and push only the outdated versions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NxBArk6ZTH6vAmDaok99S --- .github/workflows/build.yml | 16 +++++++-- .github/workflows/rebuild.yml | 65 +++++++++++++++++++++++++++++++++++ README.md | 14 ++++++++ build.sh | 39 +++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/rebuild.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94b7f9e..fb99810 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,12 +32,24 @@ on: description: "Build without layer cache (use after security fixes)" type: boolean default: false + # Called by rebuild.yml for images whose base image has changed. + workflow_call: + inputs: + versions: + type: string + default: "" + push: + type: boolean + default: false + no_cache: + type: boolean + default: false permissions: contents: read concurrency: - group: build-${{ github.ref }} + group: build-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: @@ -70,7 +82,7 @@ jobs: # Image name with the registry host, e.g. registry.example.com/php. # Without a host it is Docker Hub (narel/php). REGISTRY: ${{ vars.REGISTRY || 'narel/php' }} - PUSH: ${{ github.event_name == 'workflow_dispatch' && inputs.push && '1' || '' }} + PUSH: ${{ inputs.push && '1' || '' }} NO_CACHE: ${{ inputs.no_cache && '1' || '' }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml new file mode 100644 index 0000000..8797da6 --- /dev/null +++ b/.github/workflows/rebuild.yml @@ -0,0 +1,65 @@ +name: Rebuild on base image update + +# Every day, compare the digest of each image's base image with the one +# recorded in the pushed image (label org.opencontainers.image.base.digest). +# Rebuild and push only the versions whose base image has changed. An image +# without the label (pushed before it existed) is rebuilt too. + +on: + schedule: + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + outputs: + versions: ${{ steps.check.outputs.versions }} + env: + REGISTRY: ${{ vars.REGISTRY || 'narel/php' }} + steps: + - uses: actions/checkout@v4 + + - name: Log in to the registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_HOST }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - id: check + run: | + outdated=() + for v in $(./build.sh --list); do + base=$(./build.sh --base "$v") + current=$(docker buildx imagetools inspect "$base" --format '{{.Manifest.Digest}}' 2>/dev/null || true) + if [ -z "$current" ]; then + echo "::warning::$v: cannot read base image $base, skipping" + continue + fi + # A single image gives its config, a multi-platform one a map + # of platform -> config. + recorded=$(docker buildx imagetools inspect "${REGISTRY}:${v}" --format '{{json .Image}}' 2>/dev/null \ + | jq -r 'if has("config") then . else (to_entries[0].value // {}) end + | .config.Labels["org.opencontainers.image.base.digest"] // empty' || true) + if [ "$current" = "$recorded" ]; then + echo "$v: up to date ($base)" + else + echo "$v: base image $base changed (${recorded:-none} -> $current)" + outdated+=("$v") + fi + done + echo "versions=${outdated[*]}" >> "$GITHUB_OUTPUT" + echo "### Outdated: ${outdated[*]:-none}" >> "$GITHUB_STEP_SUMMARY" + + rebuild: + needs: check + if: needs.check.outputs.versions != '' + uses: ./.github/workflows/build.yml + with: + versions: ${{ needs.check.outputs.versions }} + push: true + secrets: inherit diff --git a/README.md b/README.md index b997bc3..30bb62f 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,20 @@ Pushing is configured in **Settings → Secrets and variables → Actions**: The registry must be reachable from GitHub-hosted runners, over HTTPS with a publicly trusted certificate. +### Automatic rebuilds + +`.github/workflows/rebuild.yml` runs every day at 04:17 UTC (and on demand +from the Actions tab). For every version it compares the digest of the base +image (`ubuntu:26.04`, `debian:trixie`, …) with the digest recorded in the +pushed image, in the `org.opencontainers.image.base.digest` label that +`build.sh` sets. Only the versions whose base image changed are rebuilt and +pushed. An image without the label is rebuilt once, which adds it. + +`./build.sh --base 8.5` prints the base image of a version. + +This catches new base images only. Security fixes that reach Debian, Ubuntu or +Sury between base image releases still need a manual run with `no_cache`. + --- ## Running diff --git a/build.sh b/build.sh index c4c1ac2..b5c6c1e 100755 --- a/build.sh +++ b/build.sh @@ -10,6 +10,11 @@ # NO_CACHE=1 ./build.sh 8.5 # no layer cache - use after security fixes # BUILDER=oldbuilder ./build.sh 5.5 # build with the given buildx builder # ./build.sh --list # print the available versions and exit +# ./build.sh --base 8.5 # print the base image of a version and exit +# +# Every image gets the labels org.opencontainers.image.base.name and +# org.opencontainers.image.base.digest. The scheduled rebuild workflow +# compares that digest with the current one to find outdated images. # # --pull refreshes only the base image. If it has not changed, the apt-get # layer comes from the cache and new packages from the repository do NOT @@ -71,6 +76,29 @@ find_entry() { echo "$VERSIONS" | grep -v '^[[:space:]]*$' | awk -F'|' -v v="$1" '$1 == v {print; exit}' } +# Base image of a version: from the table, or from the FROM line of its +# Dockerfile (legacy images and 7.4-mongodb1.18 use their own default). +base_of() { + local entry ver dockerfile base rest from + entry="$(find_entry "$1")" + [ -n "$entry" ] || return 1 + IFS='|' read -r ver dockerfile base rest <<<"$entry" + if [ -z "$base" ]; then + from="$(awk 'toupper($1) == "FROM" {print $2; exit}' "$dockerfile")" + if [[ "$from" == *'${BASE_IMAGE}'* ]]; then + base="$(sed -n 's/^ARG BASE_IMAGE=//p' "$dockerfile" | head -n1)" + else + base="$from" + fi + fi + echo "$base" +} + +# Digest of the base image in its registry, empty if it cannot be read. +base_digest() { + docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}' 2>/dev/null || true +} + build_one() { local entry ver dockerfile base imap req opt tags repo entry="$(find_entry "$1")" @@ -90,6 +118,12 @@ build_one() { args=(build) fi args+=(--file "$dockerfile" --tag "${REGISTRY}:${ver}") + + local base_name base_dig + base_name="$(base_of "$ver")" + base_dig="$(base_digest "$base_name")" + args+=(--label "org.opencontainers.image.base.name=${base_name}") + [ -n "$base_dig" ] && args+=(--label "org.opencontainers.image.base.digest=${base_dig}") [ -z "$NO_PULL" ] && args+=(--pull) [ -n "$NO_CACHE" ] && args+=(--no-cache) [ -n "$PLATFORM" ] && args+=(--platform "$PLATFORM") @@ -135,6 +169,11 @@ if [ "${1:-}" = "--list" ]; then exit 0 fi +if [ "${1:-}" = "--base" ]; then + base_of "${2:-}" || { echo "!! unknown version: ${2:-}" >&2; exit 1; } + exit 0 +fi + if [ "$#" -gt 0 ]; then targets=("$@") else