testsAndMisc/kcd2_dice_solver/deploy_build.sh
Krzysztof kuhy Rudnicki db9c8921a2
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
kcd2 dice solver: lock deploy_build.sh so two runs cannot publish half a bundle
deploy_build.sh has two entry points — setup_kcd2_dice_solver.sh calls it
directly, and the post-commit hook starts a unit that runs it — and nothing
serialised them.

When they overlap, the second run's `vite build --emptyOutDir` truncates
dist.staging while the first run's rsync is still reading it. rsync exits 24,
`set -e` aborts before assert_mount_live, and dist/ is left holding a fresh
index.html that references assets which never arrived, with the previous
bundle's files already deleted by --delete-after.

Nothing downstream notices, which is what makes it worth fixing rather than
tolerating: `try_files {path} /index.html` answers the missing chunks with the
SPA HTML, so the browser is handed HTML where it expects JavaScript and no 404
reaches any log (verified against the live site: /assets/does-not-exist.js
returns 200 text/html). The hook's `|| true` then swallows the unit failure, so
the commit succeeds and the site is quietly broken.

Reproduced with a throttled rsync: rc=24, live left with index.html and two of
six chunks. With flock, two concurrent runs each publish a complete bundle.

Also tightened assert_mount_live, which announced success in three cases where
it had verified nothing: docker absent, container not running, and a failed
`docker exec` — the last because docker reports OCI runtime errors on stdout,
so `2>/dev/null` does not suppress them and the guard's non-empty test passed
on a 184-byte error string. It now requires an actual inode and says plainly
when the mount was not verified.

Found by the working-diff review these same changes had skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:33:38 +02:00

132 lines
5.4 KiB
Bash
Executable File

#!/bin/bash
#
# deploy_build.sh — build the static bundle that dice.kuhy.duckdns.org serves.
#
# The single build path, used by both setup_kcd2_dice_solver.sh and the
# post-commit auto-rebuild, so the live site can never be produced two
# different ways.
#
# Why it does not just run `vite build`:
#
# The serving container bind-mounts dist/ at /srv. A bind mount follows the
# INODE, so anything that replaces the directory (rm -rf dist && mv new dist)
# silently detaches the mount until the container is restarted — the site then
# serves the old files forever with no error anywhere. And a plain `vite build`
# empties dist/ before writing, so the site 404s for the length of the build.
#
# Building into dist.staging/ and rsyncing into dist/ avoids both, but only
# with --delete-after --delay-updates: plain --delete removes stale files
# BEFORE writing the new ones, so a client that fetched index.html moments
# earlier can 404 on the very asset it is asking for.
#
# Preserving the inode is necessary but not sufficient, so the publish is
# followed by a check that the container is still mounted on this directory —
# see assert_mount_live for why that cannot be done by watching the inode here.
#
# Usage:
# deploy_build.sh Build and publish into dist/.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly STAGING="${SCRIPT_DIR}/dist.staging"
readonly LIVE="${SCRIPT_DIR}/dist"
# Must match setup_kcd2_dice_solver.sh's DICE_CONTAINER.
readonly CONTAINER="kcd2-dice"
die() {
echo "deploy_build: $1" >&2
exit 1
}
# nvm-installed toolchains are not on PATH for systemd/git-hook invocations.
ensure_toolchain() {
if [[ -s ${NVM_DIR:-$HOME/.nvm}/nvm.sh ]]; then
# shellcheck disable=SC1091 # sourced at runtime, path varies by machine
source "${NVM_DIR:-$HOME/.nvm}/nvm.sh"
fi
command -v node >/dev/null || die "node not found."
command -v pnpm >/dev/null || die "pnpm not found."
command -v rsync >/dev/null || die "rsync not found."
}
# Confirm the serving container is still mounted on THIS directory.
#
# Watching dist/'s own inode across the rsync cannot detect a detached mount and
# never could: rsync into an existing directory never changes it, and if dist/
# had already been removed (it is gitignored, so `git clean -xdff` removes it)
# the mkdir above recreates it at a fresh inode before anything is measured.
# Either way the check passes while the container serves the pre-deletion
# snapshot forever, with no error anywhere. The only thing that actually
# answers the question is what the CONTAINER sees.
assert_mount_live() {
local running host_inode container_inode
if ! command -v docker >/dev/null 2>&1; then
echo "deploy_build: docker not found — mount NOT verified." >&2
return 0
fi
running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)"
if ! grep -qx "$CONTAINER" <<<"$running"; then
echo "deploy_build: ${CONTAINER} not running — mount NOT verified." >&2
return 0
fi
host_inode="$(stat -c %i "$LIVE")"
# `docker exec` reports OCI runtime errors on STDOUT, so 2>/dev/null does not
# suppress them and a failure arrives as a long string rather than as empty
# output. Require an actual inode number instead of merely non-empty.
container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)"
if [[ ! $container_inode =~ ^[0-9]+$ ]]; then
echo "deploy_build: could not read /srv inode from ${CONTAINER} — mount NOT verified." >&2
return 0
fi
if [[ $host_inode != "$container_inode" ]]; then
echo "deploy_build: dist/ was replaced — ${CONTAINER} is serving a detached copy." >&2
echo "deploy_build: restarting ${CONTAINER} to re-attach the bind mount…" >&2
docker restart "$CONTAINER" >/dev/null
container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)"
[[ $host_inode == "$container_inode" ]] ||
die "${CONTAINER} still not mounted on ${LIVE} after a restart."
echo "deploy_build: ${CONTAINER} re-attached." >&2
fi
}
main() {
# Both entry points run this same script — setup_kcd2_dice_solver.sh calls it
# directly, and the post-commit hook starts a unit that runs it — so one lock
# closes the race between them. Without it, a second run's `vite --emptyOutDir`
# truncates dist.staging while the first run's rsync is still reading it:
# rsync exits 24, `set -e` stops the script before assert_mount_live, and dist/
# is left holding a fresh index.html that references assets which never
# arrived. Nothing downstream notices — `try_files` serves index.html for the
# missing chunks, so the browser is handed HTML where it expects JavaScript and
# no 404 reaches any log.
#
# Wait rather than fail: a rebuild that arrives during a setup run still needs
# to happen, and the unit has no start timeout.
exec 9>"${SCRIPT_DIR}/.deploy.lock"
flock -w 600 9 || die "another deploy_build is still running after 10 minutes."
ensure_toolchain
cd "$SCRIPT_DIR"
echo "Installing dependencies…"
pnpm install --frozen-lockfile
echo "Building into dist.staging…"
pnpm exec tsc -b
pnpm exec vite build --outDir dist.staging --emptyOutDir
# Fail before touching the live directory, never after.
[[ -f "${STAGING}/index.html" ]] || die "build produced no index.html."
mkdir -p "$LIVE"
rsync -a --delete-after --delay-updates "${STAGING}/" "${LIVE}/"
assert_mount_live
echo "Published $(find "$LIVE" -type f | wc -l) files to ${LIVE} (inode $(stat -c %i "$LIVE"))."
}
main "$@"