From bbbc42c79fa80daae1ca2d7fe774c457830220f3 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 23 Aug 2026 20:52:06 +0200 Subject: [PATCH 1/4] fix(ci): build with the plain kaniko executor instead of the debug image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `-debug` image ships a busybox shell so a failing build can be inspected. It was being used as the build image because the steps run shell to assemble registry auth and compute a tag, which is not what that variant is for. The plain executor has no shell, so the eight static builds now pass flags via `entrypoint` and read credentials from DOCKER_CONFIG. A single registry-auth step writes `.docker/config.json` into the workspace once, in alpine, where a shell is the intended interface. Images are tagged `${CI_COMMIT_SHA:0:8}` instead of a build date. Woodpecker exposes no formatted-date variable — every timestamp built in is UNIX — and kaniko takes `--destination` only as a literal argument, with all its file-based flags being outputs. The commit SHA is available through normal substitution and identifies the source revision the image was built from, which a build date does not. Existing dated tags stay in the registry, so the pins that reference them keep resolving; only newly created tags change format. Also moves off v1.23.2, picking up the go-git fix for CVE-2025-21613 and containerd 1.7.18 -> 1.7.27. ci-claude-refresh.yml keeps its shell step: its destination tag is a runtime value read from a file written by an earlier step, and the build is conditionally skipped, neither of which survives a static entrypoint. Left for #552. Needs a pipeline run to confirm that substitution applies to `entrypoint` and `environment` values, and that the auth file written by registry-auth is visible to the build steps. Refs #426, #552 --- .woodpecker/build-ci-images.yml | 220 +++++++++++++------------------- 1 file changed, 91 insertions(+), 129 deletions(-) diff --git a/.woodpecker/build-ci-images.yml b/.woodpecker/build-ci-images.yml index 1d6c2b24..7f04ae5e 100644 --- a/.woodpecker/build-ci-images.yml +++ b/.woodpecker/build-ci-images.yml @@ -14,6 +14,12 @@ # Flux-deployed CronJobs, so it is tracked separately in #330 rather than # bundled here. Note this pipeline runs no LLM agent, so the exfil path that # motivated the other cutovers does not apply to it. +# +# The build steps run the plain kaniko executor, which has no shell: they pass +# flags via `entrypoint` and read credentials from DOCKER_CONFIG. Registry auth +# is written once by the registry-auth step below. Images are tagged with the +# short commit SHA rather than a build date — there is no formatted-date CI +# variable, and the commit identifies the source the image was built from. when: - event: push path: @@ -24,10 +30,22 @@ when: evaluate: 'PIPELINE == "build-ci-images"' steps: - - name: build-ansible - image: gcr.io/kaniko-project/executor:v1.23.2-debug - directory: ci/images/ansible + - name: registry-auth + image: alpine:3.24.1 depends_on: [] + environment: + GITEA_USER: gitea_admin + GITEA_TOKEN: + from_secret: ci_images_token + commands: + - mkdir -p .docker + - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') + - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > .docker/config.json + + - name: build-ansible + image: gcr.io/kaniko-project/executor:v1.24.0 + directory: ci/images/ansible + depends_on: [registry-auth] # Only rebuild when THIS image's source (or the pipeline file) changed, or # on a manual run. Without this every step ran on any matching push, so a # secret-rotator commit needlessly rebuilt all four ~1.3G ci-* images and @@ -37,68 +55,47 @@ steps: - event: push path: ["ci/images/ansible/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-ansible:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-ansible:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/ci-ansible:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/ci-ansible:latest - name: build-k8s - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/k8s - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/k8s/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-k8s:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/ci-k8s:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/ci-k8s:latest - name: build-tofu - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/tofu - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/tofu/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-tofu:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-tofu:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/ci-tofu:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/ci-tofu:latest # NOTE: ci-claude is deliberately NOT built here. It tracks upstream Claude # Code releases on its own cadence and is built + version-tagged by @@ -108,28 +105,21 @@ steps: # Runtime image, not CI tooling: a static musl binary in a FROM-scratch image, # used as the base for secret-rotation CronJobs. Replaces per-job inline Python. - name: build-secret-rotator - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: secret-rotator - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["secret-rotator/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/secret-rotator:$TAG \ - --destination gitea.renner.uno/gitea_admin/secret-rotator:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/secret-rotator:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/secret-rotator:latest # Runtime image, not CI tooling: a general-purpose alpine + jq base for # workloads that need to read JSON from a shell script. Named for what it IS, @@ -137,28 +127,21 @@ steps: # container, but nothing about it is zigbee-specific. Exists so tools come from # the image rather than an `apk add` on every container start. - name: build-alpine-jq - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/alpine-jq - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/alpine-jq/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \ - --destination gitea.renner.uno/gitea_admin/alpine-jq:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/alpine-jq:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/alpine-jq:latest # Runtime image, not CI tooling: upstream containernetworking/plugins binaries # baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a @@ -166,28 +149,21 @@ steps: # critical path of every node rebuild. The download now happens here, once, # where the sha256 is checked — see the Containerfile for the full rationale. - name: build-cni-plugins - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/cni-plugins - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/cni-plugins/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \ - --destination gitea.renner.uno/gitea_admin/cni-plugins:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/cni-plugins:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/cni-plugins:latest # Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts # needing s_client/x509. Named for what it is; first consumer is the paperless @@ -195,28 +171,21 @@ steps: # every paperless pod start. The openssl CLI is genuinely absent from the alpine # base, so this one needs an image rather than a busybox substitute. - name: build-alpine-openssl - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/alpine-openssl - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/alpine-openssl/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \ - --destination gitea.renner.uno/gitea_admin/alpine-openssl:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:latest # Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image # run as two services on edge-1. Replaces pip-into-venv on the host, where an @@ -224,25 +193,18 @@ steps: # Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it # builds the same way and lands in the same registry so the audit sees it. - name: build-heating-can - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/heating-can - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/heating-can:$TAG \ - --destination gitea.renner.uno/gitea_admin/heating-can:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/heating-can:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/heating-can:latest -- 2.54.0 From 80c3051dc705688f03bd9928dde0456e3bb2daba Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 23 Aug 2026 20:57:15 +0200 Subject: [PATCH 2/4] build(ci): record the build timestamp in the image instead of the tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The date was previously computed by `date` in the pipeline step and used as the image tag, which is why the build step needed a shell and hence the kaniko debug image. It does not need to cross between CI steps at all. The base images have a shell, so a RUN in the Containerfile writes the timestamp during the build itself — kaniko executes the layer and the value lands in the image. Nothing has to be propagated, and the build step stays shell-less. This also separates two things the tag was conflating: the tag says which source revision was built, /etc/image-build-date says when. Placed last so it invalidates only its own layer. secret-rotator writes it in the rust build stage and copies it across, since its final image is FROM scratch and has no shell. Verified against ci/images/alpine-jq: $ docker run --rm localtest:jq cat /etc/image-build-date 2026-08-23T18:56:51Z Note this makes builds non-reproducible byte-for-byte, which a675bf6c deliberately arranged for ci-claude. ci-claude is built by ci-claude-refresh.yml and is not touched here. --- ci/images/alpine-jq/Containerfile | 5 +++++ ci/images/alpine-openssl/Containerfile | 5 +++++ ci/images/ansible/Containerfile | 5 +++++ ci/images/cni-plugins/Containerfile | 5 +++++ ci/images/heating-can/Containerfile | 5 +++++ ci/images/k8s/Containerfile | 5 +++++ ci/images/tofu/Containerfile | 5 +++++ secret-rotator/Containerfile | 6 ++++++ 8 files changed, 41 insertions(+) diff --git a/ci/images/alpine-jq/Containerfile b/ci/images/alpine-jq/Containerfile index 3ef4d48d..5a94807f 100644 --- a/ci/images/alpine-jq/Containerfile +++ b/ci/images/alpine-jq/Containerfile @@ -22,3 +22,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650 # unanchored extraction therefore yields a device link key, and herdsman then # forms a NEW Zigbee network and drops every device. RUN apk add --no-cache jq + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/alpine-openssl/Containerfile b/ci/images/alpine-openssl/Containerfile index e36de510..f06c8262 100644 --- a/ci/images/alpine-openssl/Containerfile +++ b/ci/images/alpine-openssl/Containerfile @@ -19,3 +19,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650 # for paperless to trust. busybox has no s_client equivalent, so openssl is not # substitutable there. RUN apk add --no-cache openssl + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/ansible/Containerfile b/ci/images/ansible/Containerfile index decacc72..363a7c12 100644 --- a/ci/images/ansible/Containerfile +++ b/ci/images/ansible/Containerfile @@ -24,3 +24,8 @@ RUN pip3 install --break-system-packages --no-cache-dir \ RUN ansible-galaxy collection install \ community.proxmox \ community.docker + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/cni-plugins/Containerfile b/ci/images/cni-plugins/Containerfile index d42686e4..5bafa6eb 100644 --- a/ci/images/cni-plugins/Containerfile +++ b/ci/images/cni-plugins/Containerfile @@ -66,3 +66,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650 # Alpine rather than scratch: the installer needs a shell to copy the binaries # onto the host. busybox covers cp/mv/chown/ls, so nothing is added. COPY --from=fetch /plugins/ /opt/cni-plugins/ + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/heating-can/Containerfile b/ci/images/heating-can/Containerfile index f9ea2118..741a0208 100644 --- a/ci/images/heating-can/Containerfile +++ b/ci/images/heating-can/Containerfile @@ -120,3 +120,8 @@ USER open3e WORKDIR /var/lib/open3e ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"] + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/k8s/Containerfile b/ci/images/k8s/Containerfile index c646bf0e..c18a4546 100644 --- a/ci/images/k8s/Containerfile +++ b/ci/images/k8s/Containerfile @@ -42,3 +42,8 @@ RUN curl -sfL -o /tmp/velero.tar.gz \ && tar -xz -C /tmp -f /tmp/velero.tar.gz \ && install "/tmp/velero-${VELERO_VERSION}-linux-amd64/velero" /usr/local/bin/velero \ && rm -rf "/tmp/velero-${VELERO_VERSION}-linux-amd64" /tmp/velero.tar.gz + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/tofu/Containerfile b/ci/images/tofu/Containerfile index 4cfbafe0..c9516871 100644 --- a/ci/images/tofu/Containerfile +++ b/ci/images/tofu/Containerfile @@ -31,3 +31,8 @@ RUN pip3 install --break-system-packages --no-cache-dir \ RUN ansible-galaxy collection install \ community.proxmox \ community.docker + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/secret-rotator/Containerfile b/secret-rotator/Containerfile index 128f673c..5ecf509c 100644 --- a/secret-rotator/Containerfile +++ b/secret-rotator/Containerfile @@ -21,8 +21,14 @@ COPY src ./src RUN cargo build --release --target x86_64-unknown-linux-musl \ && strip target/x86_64-unknown-linux-musl/release/secret-rotator +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Written here because the final +# image is scratch and has no shell to run `date` in. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /build/image-build-date + FROM scratch COPY --from=build /build/target/x86_64-unknown-linux-musl/release/secret-rotator /secret-rotator +COPY --from=build /build/image-build-date /etc/image-build-date # Matches runAsUser on the rotation Jobs. There is no /etc/passwd in a scratch # image, so this is a bare uid by necessity — which is also all Kubernetes needs. -- 2.54.0 From aff01d5a3224493039ba586e449a6c058ecbb479 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 23 Aug 2026 20:59:29 +0200 Subject: [PATCH 3/4] build(ci): record the build timestamp in ci-claude too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the pattern across every Containerfile in the repo. ci-claude was the last one without it. It was skipped initially over the reproducibility work in a675bf6c, on a misreading. That commit did not make the whole image reproducible — it made the npm install layer byte-stable so a rebuild of the same version dedups in the registry instead of orphaning ~260 MB (#336, #351). A timestamp added as its own final layer leaves the npm layer untouched, so that dedup still holds. The cost is a few dozen bytes per build, not 260 MB. --- ci/images/claude/Containerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ci/images/claude/Containerfile b/ci/images/claude/Containerfile index 0700cd43..c0c87f91 100644 --- a/ci/images/claude/Containerfile +++ b/ci/images/claude/Containerfile @@ -44,3 +44,9 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ /usr \ /root \ /tmp + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# version was built, this says when. Deliberately the LAST layer and its own +# layer, so the byte-stable npm layer above still dedups in the registry — that +# dedup is what #351 was for, and a few dozen bytes here does not affect it. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date -- 2.54.0 From 803c741362514599a1e52ec4646cb6e8c5ec7eea Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 23 Aug 2026 21:14:51 +0200 Subject: [PATCH 4/4] fix(ci): tag images with a dated literal, not the commit SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SHA tag was a workaround for not being able to compute a date in a shell-less build step. That problem is gone — the build timestamp now lives in the image at /etc/image-build-date — so the tag no longer has to carry it. A SHA reads as noise at the point of use and breaks the existing convention every consumer pin follows (ci-k8s:2026-08-10, ci-ansible:2026-04-29, alpine-jq:2026-07-30). Dated literals keep the pins uniform and are bumped in the commit that changes an image, which is the same trigger the `when:` path filters already use. --- .woodpecker/build-ci-images.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.woodpecker/build-ci-images.yml b/.woodpecker/build-ci-images.yml index 7f04ae5e..f7188c28 100644 --- a/.woodpecker/build-ci-images.yml +++ b/.woodpecker/build-ci-images.yml @@ -17,9 +17,9 @@ # # The build steps run the plain kaniko executor, which has no shell: they pass # flags via `entrypoint` and read credentials from DOCKER_CONFIG. Registry auth -# is written once by the registry-auth step below. Images are tagged with the -# short commit SHA rather than a build date — there is no formatted-date CI -# variable, and the commit identifies the source the image was built from. +# is written once by the registry-auth step below. Dated tags are literals: bump +# them in the commit that changes an image. The build timestamp itself lives in +# the image at /etc/image-build-date, written by a RUN in the Containerfile. when: - event: push path: @@ -60,7 +60,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/ci-ansible:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/ci-ansible:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/ci-ansible:latest - name: build-k8s @@ -77,7 +77,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/ci-k8s:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/ci-k8s:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/ci-k8s:latest - name: build-tofu @@ -94,7 +94,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/ci-tofu:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/ci-tofu:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/ci-tofu:latest # NOTE: ci-claude is deliberately NOT built here. It tracks upstream Claude @@ -118,7 +118,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/secret-rotator:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/secret-rotator:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/secret-rotator:latest # Runtime image, not CI tooling: a general-purpose alpine + jq base for @@ -140,7 +140,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/alpine-jq:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/alpine-jq:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/alpine-jq:latest # Runtime image, not CI tooling: upstream containernetworking/plugins binaries @@ -162,7 +162,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/cni-plugins:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/cni-plugins:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/cni-plugins:latest # Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts @@ -184,7 +184,7 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:latest # Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image @@ -206,5 +206,5 @@ steps: - /kaniko/executor - --context=. - --dockerfile=Containerfile - - --destination=gitea.renner.uno/gitea_admin/heating-can:${CI_COMMIT_SHA:0:8} + - --destination=gitea.renner.uno/gitea_admin/heating-can:2026-08-23 - --destination=gitea.renner.uno/gitea_admin/heating-can:latest -- 2.54.0