diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml index f49daf9..0e7afe8 100644 --- a/.gitea/workflows/build-images.yaml +++ b/.gitea/workflows/build-images.yaml @@ -29,6 +29,8 @@ jobs: image: - name: postgres-custom context: postgres + base_image: postgres:18 + primary_tag: git.ajpanton.se/ajp_anton/postgres-custom:18 tags: | git.ajpanton.se/ajp_anton/postgres-custom:18 git.ajpanton.se/ajp_anton/postgres-custom:18-postgis3-vchord0.5.3 @@ -37,21 +39,31 @@ jobs: PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 + fingerprint_command: | + { dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256sum {} + | LC_ALL=C sort; } - name: php8-pgsql context: php8-pgsql + base_image: php:8-fpm-alpine + primary_tag: git.ajpanton.se/ajp_anton/php8-pgsql:8 tags: | git.ajpanton.se/ajp_anton/php8-pgsql:8 git.ajpanton.se/ajp_anton/php8-pgsql:8-fpm-alpine git.ajpanton.se/ajp_anton/php8-pgsql:latest build_args: | PHP_VERSION=8 - - name: ai-tools-utils - context: ai-tools-utils + fingerprint_command: | + { apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } + - name: python-tools + context: python-tools + base_image: python:3 + primary_tag: git.ajpanton.se/ajp_anton/python-tools:3 tags: | - git.ajpanton.se/ajp_anton/ai-tools-utils:3 - git.ajpanton.se/ajp_anton/ai-tools-utils:latest + git.ajpanton.se/ajp_anton/python-tools:3 + git.ajpanton.se/ajp_anton/python-tools:latest build_args: | PYTHON_VERSION=3 + fingerprint_command: | + dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort steps: - name: Check out repository @@ -65,10 +77,12 @@ jobs: printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin - name: Build and push ${{ matrix.image.name }} - uses: docker/build-push-action@v6 - with: - context: ./${{ matrix.image.context }} - push: true - tags: ${{ matrix.image.tags }} - build-args: ${{ matrix.image.build_args }} - pull: true + env: + IMAGE_NAME: ${{ matrix.image.name }} + BUILD_CONTEXT: ${{ matrix.image.context }} + BASE_IMAGE: ${{ matrix.image.base_image }} + PRIMARY_TAG: ${{ matrix.image.primary_tag }} + TAGS: ${{ matrix.image.tags }} + BUILD_ARGS: ${{ matrix.image.build_args }} + FINGERPRINT_COMMAND: ${{ matrix.image.fingerprint_command }} + run: ./scripts/build-image-if-changed.sh diff --git a/README.md b/README.md index 986591e..c289f2b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ a few commonly useful database/runtime extensions. | --- | --- | | `git.ajpanton.se/ajp_anton/postgres-custom:18` | PostgreSQL 18 with selected extension packages installed and `pg_cron`/VectorChord preloaded. | | `git.ajpanton.se/ajp_anton/php8-pgsql:8` | PHP 8 FPM on Alpine with PostgreSQL client runtime support and the `pdo_pgsql` PHP extension enabled. | -| `git.ajpanton.se/ajp_anton/ai-tools-utils:3` | Python 3 utility image with ExifTool for AI/helper workflows. | +| `git.ajpanton.se/ajp_anton/python-tools:3` | Python 3 utility image with ExifTool for AI/helper workflows. | ## Included Components @@ -65,7 +65,7 @@ Compiled PHP extension: The compiled extension and PHP config are copied from a build stage into the final runtime image. -### `ai-tools-utils` +### `python-tools` Base image: @@ -90,7 +90,7 @@ Moving deployment tags: - `git.ajpanton.se/ajp_anton/postgres-custom:18` - `git.ajpanton.se/ajp_anton/php8-pgsql:8` -- `git.ajpanton.se/ajp_anton/ai-tools-utils:3` +- `git.ajpanton.se/ajp_anton/python-tools:3` Descriptive tags: @@ -101,15 +101,17 @@ Convenience tags: - `git.ajpanton.se/ajp_anton/postgres-custom:latest` - `git.ajpanton.se/ajp_anton/php8-pgsql:latest` -- `git.ajpanton.se/ajp_anton/ai-tools-utils:latest` +- `git.ajpanton.se/ajp_anton/python-tools:latest` -Tags are mutable. The digest behind a tag may change after a rebuild even when -the tag name stays the same. +Tags are mutable. A weekly candidate build only repoints a deployment tag when +its content fingerprint changed: base image, build context, build arguments, +installed packages, or image-specific generated files. ## Update Cadence Images are rebuilt weekly. They may also be rebuilt after repository changes or -manual runs. +manual runs. Each run builds a candidate without cache, then publishes it only +when its content fingerprint differs from the current deployment image. Each rebuild pulls the current upstream base image and installs the currently available apt/apk packages. If the base image or repository packages changed, diff --git a/ai-tools-utils/Dockerfile b/python-tools/Dockerfile similarity index 100% rename from ai-tools-utils/Dockerfile rename to python-tools/Dockerfile diff --git a/scripts/build-image-if-changed.sh b/scripts/build-image-if-changed.sh new file mode 100755 index 0000000..7a58a54 --- /dev/null +++ b/scripts/build-image-if-changed.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${IMAGE_NAME:?}" +: "${BUILD_CONTEXT:?}" +: "${BASE_IMAGE:?}" +: "${PRIMARY_TAG:?}" +: "${TAGS:?}" +: "${BUILD_ARGS:?}" +: "${FINGERPRINT_COMMAND:?}" + +candidate_tag="local/${IMAGE_NAME}:candidate" + +cleanup() { + docker image rm -f "$candidate_tag" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +mapfile -t build_args < <(printf '%s\n' "$BUILD_ARGS" | sed '/^[[:space:]]*$/d') +build_flags=(--pull --no-cache --load --tag "$candidate_tag") +for build_arg in "${build_args[@]}"; do + build_flags+=(--build-arg "$build_arg") +done + +context_hash="$( + cd "$BUILD_CONTEXT" + find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}' +)" + +docker pull "$BASE_IMAGE" >/dev/null +base_image_id="$(docker image inspect "$BASE_IMAGE" --format '{{.Id}}')" + +docker buildx build "${build_flags[@]}" "$BUILD_CONTEXT" + +fingerprint() { + { + printf 'base-image-id=%s\n' "$base_image_id" + printf 'context-hash=%s\n' "$context_hash" + printf 'build-args=%s\n' "$BUILD_ARGS" + docker run --rm --entrypoint /bin/sh "$1" -c "$FINGERPRINT_COMMAND" + } | sha256sum | awk '{print $1}' +} + +candidate_fingerprint="$(fingerprint "$candidate_tag")" +docker pull "$PRIMARY_TAG" >/dev/null 2>&1 || true +current_fingerprint="$(docker image inspect "$PRIMARY_TAG" --format '{{ index .Config.Labels "org.ajpanton.content-fingerprint" }}' 2>/dev/null || true)" +if [[ -z "$current_fingerprint" ]] && docker image inspect "$PRIMARY_TAG" >/dev/null 2>&1; then + current_fingerprint="$(fingerprint "$PRIMARY_TAG")" +fi + +if [[ -n "$current_fingerprint" && "$current_fingerprint" == "$candidate_fingerprint" ]]; then + echo "${IMAGE_NAME}: unchanged; not publishing" + exit 0 +fi + +docker buildx build "${build_flags[@]}" --label "org.ajpanton.content-fingerprint=${candidate_fingerprint}" "$BUILD_CONTEXT" +final_fingerprint="$(fingerprint "$candidate_tag")" +if [[ "$final_fingerprint" != "$candidate_fingerprint" ]]; then + echo "${IMAGE_NAME}: inputs changed while rebuilding; retry the workflow" >&2 + exit 1 +fi + +while IFS= read -r tag; do + [[ -z "$tag" ]] && continue + docker tag "$candidate_tag" "$tag" + docker push "$tag" +done <<< "$TAGS" + +echo "${IMAGE_NAME}: published"