diff --git a/docs/superpowers/evidence/website-force-recreate-2026-08-29.json b/docs/superpowers/evidence/website-force-recreate-2026-08-29.json new file mode 100644 index 00000000..edbead20 --- /dev/null +++ b/docs/superpowers/evidence/website-force-recreate-2026-08-29.json @@ -0,0 +1,42 @@ +{ + "intent": "Make the website deploy apply Caddyfile changes and survive a replaced dist/ directory, instead of reporting a healthy container that serves 404s.", + "scope": [ + "linux_configuration/features/lib/website_build.sh (start_website and wait_for_website only)", + "Non-goal: no change to the Gitea stack, the firewall step, the build, or the edge reload" + ], + "changes": [ + "start_website now runs docker compose up -d --force-recreate; compose cannot detect a bind-mounted Caddyfile's contents changing, nor a bind mount left on an unlinked inode, so it reported 'Running' and applied neither", + "wait_for_website records the actual HTTP status instead of using curl -sf, so a failure distinguishes 'no response' from 'answered 404' -- different bugs that had one message", + "The 404 message names the two real causes: a stale /srv bind mount, or an empty dist/" + ], + "verification": [ + { + "command": "observed live after setup_personal_website.sh", + "result": "fail", + "evidence": "Container 'Up 6 hours', Caddy logs healthy, but docker exec ls /srv returned empty and curl 127.0.0.1:8088/ returned 404; dist/ had been replaced with a new inode (84695030) after container start" + }, + { + "command": "rm -rf dist && pnpm build, then curl", + "result": "fail", + "evidence": "Reproduced deterministically: inode 84695030 -> 84695031, container /srv empty, curl -> 404" + }, + { + "command": "source website_build.sh && start_website (patched), against that broken state", + "result": "pass", + "evidence": "Container Recreated; /srv lists assets blog cv.pdf index.html; / -> 200, /blog/hello-world -> 200 with title 'Hello, world — Krzysztof Rudnicki'" + }, + { + "command": "shellcheck + bash -n linux_configuration/features/lib/website_build.sh", + "result": "pass", + "evidence": "no findings" + } + ], + "risks": [ + "Every deploy now restarts the static container, so there is a sub-second window where the loopback port refuses connections; the edge Caddy retries and the wait loop tolerates it", + "--force-recreate discards any state inside the container; this service is a read-only static file server and holds none" + ], + "rollback": [ + "Drop --force-recreate and restore the curl -sf wait loop", + "After rollback, verify a Caddyfile edit actually takes effect by curling a prerendered blog post and checking its , since that is what silently regresses" + ] +} diff --git a/linux_configuration/features/lib/website_build.sh b/linux_configuration/features/lib/website_build.sh index 8cd861e9..66e435a0 100755 --- a/linux_configuration/features/lib/website_build.sh +++ b/linux_configuration/features/lib/website_build.sh @@ -76,10 +76,17 @@ EOF } # --- Phase 8: start the website container ----------------------------------- +# Last HTTP status seen by wait_for_website, so a failure can say whether the +# server was unreachable or merely answering wrongly. Those are different bugs +# and the old message named only the first one. +WEBSITE_LAST_CODE="" + wait_for_website() { local _ for _ in $(seq 1 30); do - if curl -sf -o /dev/null "http://127.0.0.1:${WEBSITE_PORT}/"; then + WEBSITE_LAST_CODE="$(curl -s -o /dev/null -w '%{http_code}' \ + --max-time 5 "http://127.0.0.1:${WEBSITE_PORT}/" || echo "000")" + if [[ "$WEBSITE_LAST_CODE" == "200" ]]; then return 0 fi sleep 1 @@ -89,11 +96,30 @@ wait_for_website() { start_website() { log_info "Starting the personal-website container…" - docker compose -f "$WEBSITE_COMPOSE" up -d + # --force-recreate is required, not tidiness. Two failure modes need it, + # and compose cannot see either one: + # + # 1. The Caddyfile is a BIND MOUNT, so its contents are not part of the + # container's config hash. Editing it changes nothing compose compares, + # it reports "Running", and the new directives never load. A blog post + # served as the landing page is exactly this bug. + # 2. A bind mount is resolved once, at container start. Anything that + # replaces dist/ rather than emptying it (rm -rf, a git clean, moving + # the checkout) leaves the container mounted on the old, unlinked + # inode -- /srv reads as empty and every request 404s while the + # container still looks healthy. + # + # Recreating is under a second for a static file server, so it is cheaper + # than either failure being diagnosed by hand. + docker compose -f "$WEBSITE_COMPOSE" up -d --force-recreate if wait_for_website; then log_ok "Website answering on http://127.0.0.1:${WEBSITE_PORT}/." else - die "Website did not become reachable on 127.0.0.1:${WEBSITE_PORT}." + if [[ "$WEBSITE_LAST_CODE" == "000" ]]; then + die "Website unreachable on 127.0.0.1:${WEBSITE_PORT} (no response)." + fi + die "Website on 127.0.0.1:${WEBSITE_PORT} answered HTTP ${WEBSITE_LAST_CODE}, not 200. \ +A 404 here usually means the container's /srv bind mount is stale or ${WEBSITE_SRC}/dist is empty." fi }