From a903de77c5d8b08c68eda2cc16412ca254b14283 Mon Sep 17 00:00:00 2001 From: Cody Hutchens Date: Tue, 23 Jun 2026 17:12:17 -0400 Subject: [PATCH] feat: pin custom images to a deploy version via IMAGE_TAG default --- .env.example | 10 ++++++++-- .github/workflows/build.yml | 7 +++++-- Taskfile.yml | 6 ++++-- stacks/apps/claudecodeui/docker-compose.yml | 2 +- stacks/apps/code-server/docker-compose.yml | 2 +- stacks/apps/fiber/docker-compose.yml | 2 +- stacks/apps/kolibri/docker-compose.yml | 2 +- stacks/apps/takeout-manager/docker-compose.yml | 4 ++-- stacks/apps/warden/docker-compose.yml | 2 +- stacks/monitoring/docker-compose.yml | 4 ++-- tools/ci/ci/affected.py | 7 +++++-- tools/ci/tests/test_affected.py | 13 +++++++++++++ 12 files changed, 44 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index e548bfe..216765e 100644 --- a/.env.example +++ b/.env.example @@ -203,8 +203,14 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Where custom images live — one registry + namespace for every built image # (warden, fiber, takeout-manager/-worker, kolibri-oidc, iperf3-server/-exporter, -# homelab-devbox). Tags are pinned literally in each compose file, so there are -# no per-app *_IMAGE_TAG / *_REGISTRY_* variables. +# homelab-devbox). +# +# Image tags: each compose file pins a deploy version literally as the default of +# image: .../:${IMAGE_TAG:-} +# Deploys use that pinned default (IMAGE_TAG unset) — to roll out a new release, bump +# the literal version in the compose files and redeploy. IMAGE_TAG is only set to +# `latest` by the build tooling (CI build.yml, `task build`/`publish`) so the same +# image: line produces :latest at build time; do NOT set it in .env. REGISTRY=ghcr.io REGISTRY_NAMESPACE=your-github-username diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2cf4900..5d488f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,6 +96,10 @@ jobs: - name: Build${{ github.event_name != 'pull_request' && ' & push' || '' }} ${{ matrix.image_name }} run: | set -euo pipefail + # The compose image: tag is ${IMAGE_TAG:-}. Builds + # must tag :latest, not the pinned deploy version, so set IMAGE_TAG=latest + # — bake then resolves image: to :latest for every service. + export IMAGE_TAG=latest repo="${REGISTRY}/${REGISTRY_NAMESPACE}/${{ matrix.image_name }}" # bake resolves relative context/dockerfile from CWD, so run it from # the compose file's directory (compose-relative paths then resolve). @@ -111,8 +115,7 @@ jobs: args+=(--set "${{ matrix.service }}.output=type=cacheonly") docker "${args[@]}" else - # Push the compose image tag (:latest), then add the immutable :sha - # as a server-side retag. (bake --set tags= doesn't split a comma list.) + # Push :latest, then add the immutable :sha as a server-side retag. args+=(--push) docker "${args[@]}" docker buildx imagetools create "${repo}:latest" --tag "${repo}:${GITHUB_SHA}" diff --git a/Taskfile.yml b/Taskfile.yml index cd83906..35388a5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -80,17 +80,19 @@ tasks: - uv run pytest # Build images locally via buildx bake (reads compose build: sections). + # IMAGE_TAG=latest: the compose image: tag is ${IMAGE_TAG:-}, so + # builds/pushes target :latest, never the pinned deploy version. build: desc: "Build an app's image(s) locally — task build -- warden" dir: stacks/apps/{{.CLI_ARGS}} cmds: - - docker buildx bake --allow fs.read=* -f docker-compose.yml + - IMAGE_TAG=latest docker buildx bake --allow fs.read=* -f docker-compose.yml publish: desc: "Build & push an app's image(s) — task publish -- warden (requires registry login)" dir: stacks/apps/{{.CLI_ARGS}} cmds: - - docker buildx bake --allow fs.read=* -f docker-compose.yml --push + - IMAGE_TAG=latest docker buildx bake --allow fs.read=* -f docker-compose.yml --push test-fast: desc: Run core tests with faster execution diff --git a/stacks/apps/claudecodeui/docker-compose.yml b/stacks/apps/claudecodeui/docker-compose.yml index c7fe2eb..8a21dc6 100644 --- a/stacks/apps/claudecodeui/docker-compose.yml +++ b/stacks/apps/claudecodeui/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: claudecodeui: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/homelab-devbox:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/homelab-devbox:${IMAGE_TAG:-3.20.0} build: context: ../../../images/devbox dockerfile: Dockerfile diff --git a/stacks/apps/code-server/docker-compose.yml b/stacks/apps/code-server/docker-compose.yml index bcd419c..51070fa 100644 --- a/stacks/apps/code-server/docker-compose.yml +++ b/stacks/apps/code-server/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: code-server: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/homelab-devbox:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/homelab-devbox:${IMAGE_TAG:-3.20.0} build: context: ../../../images/devbox dockerfile: Dockerfile diff --git a/stacks/apps/fiber/docker-compose.yml b/stacks/apps/fiber/docker-compose.yml index 4c180c9..734b4cf 100644 --- a/stacks/apps/fiber/docker-compose.yml +++ b/stacks/apps/fiber/docker-compose.yml @@ -1,6 +1,6 @@ services: fiber: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/fiber:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/fiber:${IMAGE_TAG:-3.20.0} build: context: . dockerfile: app/Dockerfile diff --git a/stacks/apps/kolibri/docker-compose.yml b/stacks/apps/kolibri/docker-compose.yml index 6af5b2e..2b85658 100644 --- a/stacks/apps/kolibri/docker-compose.yml +++ b/stacks/apps/kolibri/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: kolibri: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/kolibri-oidc:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/kolibri-oidc:${IMAGE_TAG:-3.20.0} build: context: . dockerfile: Dockerfile diff --git a/stacks/apps/takeout-manager/docker-compose.yml b/stacks/apps/takeout-manager/docker-compose.yml index 54b97b7..b924e6f 100644 --- a/stacks/apps/takeout-manager/docker-compose.yml +++ b/stacks/apps/takeout-manager/docker-compose.yml @@ -7,7 +7,7 @@ services: # - GitHub Container Registry: ghcr.io/YOUR_USERNAME/takeout-manager:latest # - Docker Hub: YOUR_USERNAME/takeout-manager:latest # - Private Registry: registry.YOUR_DOMAIN.com/takeout-manager:latest - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/takeout-manager:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/takeout-manager:${IMAGE_TAG:-3.20.0} build: context: manager dockerfile: Dockerfile @@ -48,7 +48,7 @@ services: - "homepage.description=Google Photos Takeout Management" takeout-worker: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/takeout-worker:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/takeout-worker:${IMAGE_TAG:-3.20.0} build: context: worker dockerfile: Dockerfile diff --git a/stacks/apps/warden/docker-compose.yml b/stacks/apps/warden/docker-compose.yml index e8afe6d..8cca4bf 100644 --- a/stacks/apps/warden/docker-compose.yml +++ b/stacks/apps/warden/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: warden: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/warden:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/warden:${IMAGE_TAG:-3.20.0} build: context: . dockerfile: app/Dockerfile diff --git a/stacks/monitoring/docker-compose.yml b/stacks/monitoring/docker-compose.yml index df2009e..4b98ffe 100644 --- a/stacks/monitoring/docker-compose.yml +++ b/stacks/monitoring/docker-compose.yml @@ -227,7 +227,7 @@ services: - "traefik.enable=false" iperf3-server: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/iperf3-server:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/iperf3-server:${IMAGE_TAG:-3.20.0} build: context: . dockerfile: Dockerfile.iperf3-server @@ -246,7 +246,7 @@ services: - "traefik.enable=false" iperf3-exporter: - image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/iperf3-exporter:latest + image: ${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/iperf3-exporter:${IMAGE_TAG:-3.20.0} build: context: custom-exporter dockerfile: Dockerfile diff --git a/tools/ci/ci/affected.py b/tools/ci/ci/affected.py index 56fca98..ab02a9c 100644 --- a/tools/ci/ci/affected.py +++ b/tools/ci/ci/affected.py @@ -68,8 +68,11 @@ def as_dict(self) -> dict: def _image_key(image: str) -> str: image = image.split("@", 1)[0] # drop @sha256:... digest slash = image.rfind("/") - colon = image.rfind(":") - if colon > slash: # a tag (not a registry :port, which sits before the last /) + # The tag separator is the FIRST colon after the last '/'. Using rfind here would + # grab a colon inside a templated tag like ${IMAGE_TAG:-3.20.0}; image names can't + # contain ':', so the first colon after the name is always the tag separator. + colon = image.find(":", slash + 1) + if colon != -1: # a tag (registry :port sits before the last /, so it's excluded) image = image[:colon] return image diff --git a/tools/ci/tests/test_affected.py b/tools/ci/tests/test_affected.py index 6df773d..262a35f 100644 --- a/tools/ci/tests/test_affected.py +++ b/tools/ci/tests/test_affected.py @@ -116,6 +116,19 @@ def test_image_name_is_bare_last_segment(): assert templated.image_name == "warden" +def test_templated_tag_with_default_is_stripped(): + # The deploy pin is ${IMAGE_TAG:-3.20.0} — its ':-' colon must not be mistaken + # for the name:tag separator (which is the first colon after the last '/'). + u = _unit( + "warden", + "${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/warden:${IMAGE_TAG:-3.20.0}", + "stacks/apps/warden", + ["stacks/apps/warden/**"], + ) + assert u.image_name == "warden" + assert u.image_key == "${REGISTRY:-ghcr.io}/${REGISTRY_NAMESPACE:-chutch3}/warden" + + def test_tooling_change_flags_everything(): assert tooling_changed(["tools/ci/ci/affected.py"]) is True assert tooling_changed([".github/workflows/build.yml"]) is True