Skip to content
Merged
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
16 changes: 14 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/rebuild.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")"
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading