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