From 30766f153e7187813b87f81c16e08e7e0913e883 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 22:35:46 +0200 Subject: [PATCH] fix(macmini): auto-rebuild stale sveltekit-base before per-app web builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOTE: the previous commit 048184bef carried this commit message but accidentally bundled an unrelated PickerOverlay refactor instead of this script change (lint-staged stash interaction). This is the actual fix. Per-app web Dockerfiles do `FROM sveltekit-base:local` and do NOT re-COPY packages/shared-* — those packages are baked into the base image. So a change to packages/shared-utils, packages/shared-ui, etc. only reaches the live web app if the base image is also rebuilt. This bit us THREE times on 2026-04-08 alone: 1. CSP fix in shared-utils ('wasm-unsafe-eval') sat unused in production for over an hour because every `build-app.sh mana-web` reused the cached base layer with old shared-utils. 2. The BaseListView export in shared-ui after the ListView consolidation refactor — mana-web's build failed because Rollup couldn't resolve the new symbol from the stale base. 3. Same shape, different package, repeatedly during the Gemma 4 migration push. The pattern is identical every time and the manual workaround (`build-app.sh --base` first) is something you only think to run if you already know how the layering works. Make the script catch it. New `is_base_image_stale` helper compares the base image's `Created` timestamp against the latest git commit touching paths the base image actually depends on (packages/, docker/Dockerfile.sveltekit-base, pnpm-lock.yaml). When building any *-web service, if the image is stale or missing, the base is rebuilt automatically before the per-app build kicks off, with the triggering commit's oneline printed for transparency. Date parsing handles macOS Docker's local-TZ-offset RFC3339 format (`...+02:00`, not Z). We strip from char 19 onward and parse the literal local clock time with BSD date (no -u). GNU date is the fallback for Linux dev boxes. If parsing fails for any reason we conservatively force a rebuild rather than risk shipping stale code. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/mac-mini/build-app.sh | 62 +++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/mac-mini/build-app.sh b/scripts/mac-mini/build-app.sh index 120da1474..e3bb69593 100755 --- a/scripts/mac-mini/build-app.sh +++ b/scripts/mac-mini/build-app.sh @@ -113,6 +113,55 @@ build_base_images() { echo "" } +# Returns 0 (true) if the sveltekit-base:local image is older than the most +# recent commit touching any path that would actually change its contents. +# +# Why this exists: per-app web Dockerfiles do `FROM sveltekit-base:local` and +# do NOT re-copy packages/shared-* — those packages are baked into the base +# image. So a change to e.g. packages/shared-utils only reaches the live web +# app if the base image is also rebuilt. We learned this the hard way on +# 2026-04-08 when a CSP fix in shared-utils sat unused in production for an +# hour because every `build-app.sh mana-web` reused the cached base layer. +# +# Detection: compare the base image's Created timestamp against the last git +# commit that touched packages/, the base Dockerfile, or pnpm-lock.yaml. If +# any such commit is newer than the image, the image is stale. +is_base_image_stale() { + # No image at all → definitely needs building + if ! $DOCKER image inspect sveltekit-base:local >/dev/null 2>&1; then + return 0 + fi + + local image_iso image_epoch latest_commit_epoch + image_iso=$($DOCKER inspect -f '{{.Created}}' sveltekit-base:local 2>/dev/null) + + # Docker on the Mac Mini emits RFC3339 with the *local* TZ offset, e.g. + # "2026-04-08T18:30:12.871278257+02:00". On Linux it's typically a Z + # suffix. We strip everything from char 19 onward (fractional + offset), + # leaving "2026-04-08T18:30:12", and parse it as LOCAL time — which is + # what the original timestamp meant on this host. The GNU fallback can + # handle the full string directly because GNU date understands offsets. + image_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${image_iso:0:19}" +%s 2>/dev/null \ + || date -d "$image_iso" +%s 2>/dev/null \ + || echo 0) + + if [ "$image_epoch" = "0" ]; then + # Couldn't parse — be conservative and force a rebuild + return 0 + fi + + # %ct is the committer date as Unix epoch — directly comparable. + latest_commit_epoch=$(git -C "$PROJECT_ROOT" log -1 --format=%ct -- \ + packages/ \ + docker/Dockerfile.sveltekit-base \ + pnpm-lock.yaml 2>/dev/null || echo 0) + + if [ "$latest_commit_epoch" -gt "$image_epoch" ]; then + return 0 # stale + fi + return 1 # fresh +} + build_services() { local services=("$@") @@ -120,9 +169,18 @@ build_services() { for svc in "${services[@]}"; do case "$svc" in *-web) - if ! $DOCKER image inspect sveltekit-base:local >/dev/null 2>&1; then - echo "=== Building sveltekit-base (first time) ===" + if is_base_image_stale; then + if ! $DOCKER image inspect sveltekit-base:local >/dev/null 2>&1; then + echo "=== Building sveltekit-base (first time) ===" + else + local last_commit + last_commit=$(git -C "$PROJECT_ROOT" log -1 --oneline -- \ + packages/ docker/Dockerfile.sveltekit-base pnpm-lock.yaml 2>/dev/null || echo "?") + echo "=== Rebuilding sveltekit-base (stale: newer commit touches packages/) ===" + echo " Triggering commit: $last_commit" + fi $DOCKER build -f "$PROJECT_ROOT/docker/Dockerfile.sveltekit-base" -t sveltekit-base:local "$PROJECT_ROOT" + echo "" fi break ;;