Files
felhom-controller/controller/build.sh
T
admin 9cc7ffcf3b
gates / gates (push) Failing after 44s
A version number is pushed once, and every build addresses its CSS differently
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.

What it was, in four measured facts:
  1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
     0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
  2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
     cached copy cannot be revalidated - it can only expire;
  3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
     from the origin's 3600 to 14400 - four hours;
  4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
     holds an entry that has aged out.

Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.

THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:

build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).

assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.

The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:08:01 +02:00

263 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# felhom-controller — Docker image build script
# =============================================================================
# Location: /mnt/5_hdd/felhom.eu/build/felhom-controller/build.sh (moved off the DooPlex SSD 2026-07-18)
#
# Copies source from the git repo, syncs app assets, and builds the image.
# Build artifacts stay here — the git repo stays clean.
#
# Usage:
# ./build.sh # Build for current platform, tag as :dev
# ./build.sh 0.1.0 # Build with version tag
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
# =============================================================================
set -euo pipefail
# --- Configuration (edit these if your paths differ) ---
REPO_DIR="/mnt/5_hdd/felhom.eu/git/felhom-controller"
CONTROLLER_SRC="${REPO_DIR}/controller"
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
REGISTRY="gitea.dooplex.hu/admin"
IMAGE="${REGISTRY}/felhom-controller"
# Build workspace — a temp directory next to this script, NOT in the git repo
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="${SCRIPT_DIR}/workspace"
# --- Parse arguments ---
VERSION="${1:-dev}"
ACTION="${2:-}" # --push or --multiarch
# --- Colors ---
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
# --- Pre-flight checks ---
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
error "Controller source not found: ${CONTROLLER_SRC}"
error "Clone the repo first: git clone https://gitea.dooplex.hu/admin/felhom-controller.git ${REPO_DIR}"
exit 1
fi
if ! command -v docker &>/dev/null; then
error "Docker not found."
exit 1
fi
# Git metadata from the repo
GIT_COMMIT="unknown"
if [[ -d "${REPO_DIR}/.git" ]]; then
GIT_COMMIT=$(cd "${REPO_DIR}" && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
fi
echo ""
info "╔══════════════════════════════════════╗"
info "║ felhom-controller image builder ║"
info "╚══════════════════════════════════════╝"
info "Version: ${VERSION}"
info "Commit: ${GIT_COMMIT}"
info "Source: ${CONTROLLER_SRC}"
info "Build dir: ${BUILD_DIR}"
info "Image: ${IMAGE}:${VERSION}"
echo ""
# =========================================================================
# A VERSION NUMBER IS PUSHED ONCE (v0.316.0)
# =========================================================================
# 0.315.0 was built and pushed THREE times from three different commits. A box that pulled between
# the first and the last runs different code under the same number — and will never fetch the fix,
# because to the box, and to the floor, that number is already current (`09-update-architecture.md`:
# a box compares VERSIONS, and re-pulls a tag it already has only when the tag changes). Nothing in
# the build stopped it. This does.
#
# The check runs BEFORE the build, so a refusal costs no minutes. A registry that cannot be reached
# is a REFUSAL too: the point is to prove the tag is new, and "I could not ask" is not that proof.
# Local builds (no --push) are unaffected — reusing a version while iterating is the normal case.
#
# `docker manifest inspect` is the probe, so a test can put a fake `docker` on PATH
# (scripts/test_build_push_once.sh).
if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then
step "0/4 — Checking the registry: is ${VERSION} already published?"
# `set -e` is on, and a failing command substitution would abort the script before the rc is
# ever read — which would turn "the registry said no such tag" into a silent exit. The `|| true`
# keeps the failure for us to classify; that classification is the whole check.
probe_rc=0
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
if [[ ${probe_rc} -eq 0 ]]; then
error "${IMAGE}:${VERSION} already exists in the registry — bump the version."
error "A version number is pushed once. Two images under one number mean a box can run"
error "either of them and never learn the difference. If this is a fix for something already"
error "pushed, release it as the next patch version instead."
exit 1
fi
# Absent is the ONLY other acceptable answer. Anything that is not a clean "not found" (the
# registry is down, the login expired, DNS failed) leaves the question unanswered, and an
# unanswered question is a refusal.
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
error "Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build."
error "The registry answered: ${probe_out}"
error "Proving the tag is new is the point of this check; 'I could not ask' is not that proof."
exit 1
fi
info "${VERSION} is not present in the registry — proceeding."
fi
# =========================================================================
# Step 1: Prepare clean build workspace
# =========================================================================
step "1/4 — Preparing build workspace..."
rm -rf "${BUILD_DIR}"
mkdir -p "${BUILD_DIR}"
# Copy the entire controller directory (preserving subdirectory structure)
cp -a "${CONTROLLER_SRC}/." "${BUILD_DIR}/"
# Verify the expected Go package structure exists
MISSING=()
for required in cmd/controller/main.go internal/config/config.go internal/stacks/manager.go internal/api/router.go internal/web/server.go; do
if [[ ! -f "${BUILD_DIR}/${required}" ]]; then
MISSING+=("${required}")
fi
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
error "Directory structure check FAILED. Missing files:"
for f in "${MISSING[@]}"; do
error " ✗ ${f}"
done
error ""
error "The Go source must be in package subdirectories, not flat."
error "Run the restructure script first:"
error " cd ${CONTROLLER_SRC} && bash restructure.sh"
error " git add -A && git commit -m 'Restructure into Go package dirs'"
rm -rf "${BUILD_DIR}"
exit 1
fi
GO_COUNT=$(find "${BUILD_DIR}" -name '*.go' | wc -l)
info "Source copied: ${GO_COUNT} Go files"
# =========================================================================
# Step 2: Sync app assets (logos + screenshots)
# =========================================================================
step "2/4 — Syncing app assets..."
mkdir -p "${BUILD_DIR}/assets"
if [[ -d "${WEBSITE_ASSETS_DIR}" ]]; then
# Count before copy
SVG_N=$(find "${WEBSITE_ASSETS_DIR}" -maxdepth 1 -name '*-logo.svg' 2>/dev/null | wc -l)
PNG_N=$(find "${WEBSITE_ASSETS_DIR}" -maxdepth 1 -name '*-logo.png' 2>/dev/null | wc -l)
SS_N=$(find "${WEBSITE_ASSETS_DIR}" -maxdepth 1 -name '*-screenshot-*.webp' 2>/dev/null | wc -l)
cp "${WEBSITE_ASSETS_DIR}"/*-logo.svg "${BUILD_DIR}/assets/" 2>/dev/null || true
cp "${WEBSITE_ASSETS_DIR}"/*-logo.png "${BUILD_DIR}/assets/" 2>/dev/null || true
cp "${WEBSITE_ASSETS_DIR}"/*-screenshot-*.webp "${BUILD_DIR}/assets/" 2>/dev/null || true
TOTAL=$(find "${BUILD_DIR}/assets" -type f ! -name 'README.md' 2>/dev/null | wc -l)
info "Synced ${TOTAL} assets (${SVG_N} SVG + ${PNG_N} PNG logos, ${SS_N} screenshots)"
else
warn "Website assets not found: ${WEBSITE_ASSETS_DIR}"
warn "Building without logos/screenshots. Set WEBSITE_ASSETS_DIR if needed."
fi
# =========================================================================
# Step 3: Run go mod tidy (to generate go.sum if missing)
# =========================================================================
step "3/4 — Preparing Go modules..."
cd "${BUILD_DIR}"
# The Dockerfile handles go mod download internally, but verify go.mod exists
if [[ ! -f "go.mod" ]]; then
error "go.mod not found in build workspace!"
exit 1
fi
# If go is installed locally, run tidy to catch issues early
if command -v go &>/dev/null; then
info "Running go mod tidy..."
go mod tidy 2>&1 || warn "go mod tidy had issues (Docker build may still work)"
else
info "Go not installed locally — Docker build stage will handle dependencies."
fi
# =========================================================================
# Step 4: Docker build
# =========================================================================
step "4/4 — Building Docker image..."
BUILD_ARGS=(
--build-arg "VERSION=${VERSION}"
--build-arg "GIT_COMMIT=${GIT_COMMIT}"
)
case "${ACTION}" in
--push)
info "Building for current platform + pushing..."
docker build "${BUILD_ARGS[@]}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
.
info "Pushing..."
docker push "${IMAGE}:${VERSION}"
docker push "${IMAGE}:latest"
;;
--multiarch)
info "Building multi-arch (amd64 + arm64) + pushing..."
# Ensure buildx builder exists
if ! docker buildx inspect felhom-builder &>/dev/null; then
info "Creating buildx builder (one-time setup)..."
docker buildx create --name felhom-builder --use --bootstrap
else
docker buildx use felhom-builder
fi
docker buildx build "${BUILD_ARGS[@]}" \
--platform linux/amd64,linux/arm64 \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
--push \
.
;;
*)
info "Building for current platform (local only)..."
docker build "${BUILD_ARGS[@]}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
.
;;
esac
# =========================================================================
# Summary
# =========================================================================
echo ""
info "╔══════════════════════════════════════╗"
info "║ Build complete ✓ ║"
info "╚══════════════════════════════════════╝"
info "Image: ${IMAGE}:${VERSION}"
# Show image size if available locally
SIZE=$(docker image inspect "${IMAGE}:${VERSION}" --format='{{.Size}}' 2>/dev/null || echo "")
if [[ -n "${SIZE}" ]]; then
SIZE_HUMAN=$(numfmt --to=iec "${SIZE}" 2>/dev/null || echo "${SIZE} bytes")
info "Size: ${SIZE_HUMAN}"
fi
echo ""
if [[ "${ACTION}" == "" ]]; then
info "Image is local only. To push:"
info " ./build.sh ${VERSION} --push # current arch"
info " ./build.sh ${VERSION} --multiarch # amd64 + arm64"
fi