fix(macmini): auto-rebuild stale sveltekit-base before per-app web builds

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 22:35:46 +02:00
parent 048184bef0
commit 30766f153e

View file

@ -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
;;