#!/usr/bin/env bash # Build one landing from git into a fresh release directory and swap it in. # Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user. # # sudo -u exo exo-deploy medcenterphysio # deploys origin/main # sudo -u exo exo-deploy medcenterphysio my-branch set -Eeuo pipefail APP="${1:?usage: exo-deploy [git-ref]}" REF="${2:-main}" BASE="/srv/exo/$APP" ENVFILE="/etc/exo/$APP.env" BUILDENV="/etc/exo/$APP.build.env" KEEP=3 REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)" for f in "$ENVFILE" "$BUILDENV"; do [[ -r $f ]] || { echo "FATAL: cannot read $f"; exit 1; } done [[ -d $BASE/repo ]] || { echo "FATAL: no bare repo at $BASE/repo"; exit 1; } PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")" [[ -n $PORT ]] || { echo "FATAL: PORT not set in $ENVFILE"; exit 1; } echo "==> fetching $REF" git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*' git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null echo "==> extracting $APP/ into $REL" mkdir -p "$REL" git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1 [[ -f $REL/package.json ]] || { echo "FATAL: $APP/ not found at $REF"; rm -rf "$REL"; exit 1; } # 2.6-3.9 MB of base64-embedded reference HTML that is never served. rm -rf "$REL/legacy" # Public build-time vars only. The amoCRM token stays in $ENVFILE, which # systemd injects at runtime; dotenv does not override real env vars, so the # two never collide. cp "$BUILDENV" "$REL/.env" cd "$REL" # devDependencies are REQUIRED here: vite, typescript and tailwindcss all live # there and `npm run build` needs them. --omit=dev breaks the build. echo "==> npm ci" npm ci --no-audit --no-fund echo "==> npm run build" npm run build # Fail loudly rather than shipping broken SEO tags or an empty bundle. [[ -s dist/client/index.html ]] || { echo "FATAL: dist/client/index.html missing or empty"; exit 1; } if grep -q '%VITE_SITE_URL%' dist/client/index.html; then echo "FATAL: VITE_SITE_URL was not substituted — check $BUILDENV"; exit 1 fi # Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release. echo "==> pruning devDependencies" npm prune --omit=dev # Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose. find dist/client -type f \ \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \ -exec gzip -9 -k -f {} + echo "==> swapping $BASE/current -> $REL" PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)" ln -sfn "$REL" "$BASE/current.tmp" mv -Tf "$BASE/current.tmp" "$BASE/current" # single rename(2): atomic sudo systemctl restart "exo@$APP" echo "==> waiting for health on 127.0.0.1:$PORT" for _ in $(seq 1 20); do if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi sleep 1 done HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || { echo "FATAL: health check failed. Rolling back." [[ -n $PREV ]] && { ln -sfn "$PREV" "$BASE/current.tmp"; mv -Tf "$BASE/current.tmp" "$BASE/current"; sudo systemctl restart "exo@$APP"; } exit 1 } echo " $HEALTH" grep -q '"ok":true' <<<"$HEALTH" || { echo "FATAL: health not ok"; exit 1; } grep -q '"amo":true' <<<"$HEALTH" || echo " WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE." # Prune old releases, never the live one. CURRENT="$(readlink -f "$BASE/current")" ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do [[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue rm -rf "$old" done echo "==> deployed $APP @ $REF ($(git -C "$BASE/repo" rev-parse --short "$REF")) -> $REL"