The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"), but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have moved the build while the gates kept reading the old folder. - internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it for shell scripts. - A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and vite.config.js (its duplicate readMfeName is gone) were done by hand. - The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte. Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's fallback, the private-repo-name pattern in public-hygiene-check). This renames nothing visible; the eventual rename becomes a one-line config change.
109 lines
6 KiB
Bash
Executable file
109 lines
6 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# atomic-public-swap.sh — swap a fully-built STAGE tree into the served public/ web-root atomically.
|
|
#
|
|
# WHY: `public/` is THREE things at once — the committed served web-root (`serve` = http.server on public/),
|
|
# vite's outDir, AND the holder of the flock build mutex at `public/.build.lock`. A naive whole-dir swap
|
|
# (`mv public public.old && mv staging public`) moves the lock file out from under the flock fd → silently
|
|
# voids the concurrent-build mutex, and briefly leaves NO web-root (a reader hits a half-populated or missing
|
|
# tree — the class of failure behind the live-down incident: an rsync --delete into a live web-root mid-churn).
|
|
#
|
|
# THE FIX (from RESULT-source-hygiene.md Card 3b): build fully into a STAGE dir, verify, then swap ONLY the
|
|
# swappable parts using same-filesystem rename(2) (POSIX-atomic — a reader sees old-tree or new-tree, never a
|
|
# half-tree):
|
|
# 1. rename public/doxa-maps -> public/doxa-maps.old (instant; old still fully valid)
|
|
# 2. rename STAGE/doxa-maps -> public/doxa-maps (instant; new fully valid, built+verified)
|
|
# 3. cp STAGE/index.html -> public/index.html (root gallery page)
|
|
# 4. rm -rf public/doxa-maps.old
|
|
# public/assets/, public/.gitignore, and public/.build.lock are NEVER touched — the flock handle stays valid.
|
|
#
|
|
# This is the LOCAL form of the atomic-publish discipline; the catalog MERGE (into platform/mfe-index) has its
|
|
# own hard-gate runbook. This script only makes the LOCAL public/ swap safe.
|
|
#
|
|
# USAGE:
|
|
# internal/atomic-public-swap.sh <STAGE_DIR>
|
|
# <STAGE_DIR> must contain a fully-built <STAGE_DIR>/doxa-maps/ tree (with manifest.json) and an
|
|
# <STAGE_DIR>/index.html. Typically produced by a build with BUILD_STAGE_DIR pointed here.
|
|
# Env:
|
|
# PUBLIC_DIR (default: public) the served web-root to swap into
|
|
# DRY_RUN=1 print the plan, make no changes
|
|
set -euo pipefail
|
|
|
|
STAGE_DIR="${1:-}"
|
|
PUBLIC_DIR="${PUBLIC_DIR:-public}"
|
|
DRY_RUN="${DRY_RUN:-0}"
|
|
|
|
die() { echo "❌ atomic-public-swap: $*" >&2; exit 1; }
|
|
|
|
# The tree's name comes from the build config (internal/_tree-name.mjs), never a literal: this script
|
|
# renames live folders, so it must fail rather than swap the wrong one if the name cannot be read.
|
|
TREE="$(node "$(dirname "$0")/_tree-name.mjs")" || die "could not read the tree name (internal/_tree-name.mjs)"
|
|
[ -n "$TREE" ] || die "empty tree name from internal/_tree-name.mjs"
|
|
|
|
[ -n "$STAGE_DIR" ] || die "usage: atomic-public-swap.sh <STAGE_DIR> (dir holding a built $TREE/ + index.html)"
|
|
[ -d "$STAGE_DIR" ] || die "STAGE_DIR '$STAGE_DIR' is not a directory"
|
|
[ -d "$PUBLIC_DIR" ] || die "PUBLIC_DIR '$PUBLIC_DIR' is not a directory (run from the studio root)"
|
|
|
|
STAGE_TREE="$STAGE_DIR/$TREE"
|
|
STAGE_INDEX="$STAGE_DIR/index.html"
|
|
|
|
# --- GATE: the stage must be a COMPLETE, verified tree before we touch the live web-root ---------------------
|
|
# (This is the same "verify is a GATE that aborts, never chained after the destructive op" lesson.)
|
|
[ -d "$STAGE_TREE" ] || die "stage tree missing: $STAGE_TREE"
|
|
[ -f "$STAGE_TREE/manifest.json" ] || die "stage tree has no manifest.json — refusing to swap an incomplete build"
|
|
[ -f "$STAGE_INDEX" ] || die "stage index.html missing: $STAGE_INDEX"
|
|
|
|
# a complete tree has >=15 bundle dirs (the known catalog size floor); guard against a mid-churn/empty stage.
|
|
tree_dirs=$(find "$STAGE_TREE" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
|
|
[ "$tree_dirs" -ge 15 ] || die "stage tree has only $tree_dirs bundle dirs (<15) — looks incomplete; refusing swap"
|
|
|
|
# every bundle referenced in the manifest must resolve to a real .js in the stage (no dangling entry).
|
|
missing=$(python3 - "$STAGE_TREE" <<'PY'
|
|
import json,os,sys
|
|
tree=sys.argv[1]
|
|
man=json.load(open(os.path.join(tree,'manifest.json')))
|
|
profiles=man.get('profiles',man) if isinstance(man,dict) else {}
|
|
missing=[]
|
|
# manifest shape: {"profiles": {name: {...embed.script...}}} — resolve each bundle's js if present
|
|
def bundles(m):
|
|
if isinstance(m,dict):
|
|
for k,v in m.items():
|
|
if isinstance(v,dict):
|
|
yield k,v
|
|
for name,prof in bundles(profiles):
|
|
# each bundle folder should hold <name>/<name>.js (best-effort: check the dir has a .js)
|
|
d=os.path.join(tree,name)
|
|
if os.path.isdir(d) and not any(f.endswith('.js') for f in os.listdir(d)):
|
|
missing.append(name)
|
|
print(",".join(missing))
|
|
PY
|
|
)
|
|
[ -z "$missing" ] || die "stage bundles missing a .js: $missing — refusing to swap a broken tree"
|
|
|
|
echo "✅ stage verified: $tree_dirs bundle dirs + manifest.json + index.html, all bundles resolve."
|
|
|
|
# --- GUARD: stage and public must be on the SAME filesystem for rename() to be atomic ------------------------
|
|
stage_dev=$(stat -c '%d' "$STAGE_DIR")
|
|
pub_dev=$(stat -c '%d' "$PUBLIC_DIR")
|
|
[ "$stage_dev" = "$pub_dev" ] || die "STAGE_DIR and PUBLIC_DIR are on different filesystems ($stage_dev != $pub_dev) — rename() would not be atomic. Stage under the same volume as public/."
|
|
|
|
OLD_TREE="$PUBLIC_DIR/$TREE.old.$$"
|
|
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
echo "DRY_RUN — plan (no changes):"
|
|
echo " 1. mv $PUBLIC_DIR/$TREE -> $OLD_TREE"
|
|
echo " 2. mv $STAGE_TREE -> $PUBLIC_DIR/$TREE"
|
|
echo " 3. cp $STAGE_INDEX -> $PUBLIC_DIR/index.html"
|
|
echo " 4. rm -rf $OLD_TREE"
|
|
echo " (NEVER touched: $PUBLIC_DIR/assets/ $PUBLIC_DIR/.gitignore $PUBLIC_DIR/.build.lock)"
|
|
exit 0
|
|
fi
|
|
|
|
# --- THE SWAP (scoped; rename-based; public/ itself and the lock never move) ---------------------------------
|
|
if [ -d "$PUBLIC_DIR/$TREE" ]; then
|
|
mv "$PUBLIC_DIR/$TREE" "$OLD_TREE" # 1. instant; old tree still fully valid under new name
|
|
fi
|
|
mv "$STAGE_TREE" "$PUBLIC_DIR/$TREE" # 2. instant; new tree (built+verified) becomes live
|
|
cp "$STAGE_INDEX" "$PUBLIC_DIR/index.html" # 3. fresh root gallery page
|
|
rm -rf "$OLD_TREE" # 4. drop the superseded tree
|
|
|
|
echo "🔀 atomic swap complete — $PUBLIC_DIR/$TREE is the freshly-built tree; assets/ + .build.lock untouched."
|