← back to Nationalrealestate
usre: safe scoped deploy script (backup+smoke+auto-rollback, no rsync --delete)
14507afcbb959916f67f9b36dd2c71f1dff15117 · 2026-08-15 09:55:17 -0700 · Steve Abrams
Closes the TK-10526 gap: usre has no .deploy.conf, so the shared deploy.sh
full-repo rsync --delete would delete real prod source (property.html,
src/index.ts, src/zip_county.ts). This deploys ONLY named files, backs each
up, restarts, smoke-tests /healthz + shipped static pages, and auto-rolls-back
on failure. Dry-run by default (APPLY=1 to fire). Verified in dry-run: all 7
TK-10526 files currently match prod -> clean no-op exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A scripts/deploy-usre-scoped.sh
Diff
commit 14507afcbb959916f67f9b36dd2c71f1dff15117
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 15 09:55:17 2026 -0700
usre: safe scoped deploy script (backup+smoke+auto-rollback, no rsync --delete)
Closes the TK-10526 gap: usre has no .deploy.conf, so the shared deploy.sh
full-repo rsync --delete would delete real prod source (property.html,
src/index.ts, src/zip_county.ts). This deploys ONLY named files, backs each
up, restarts, smoke-tests /healthz + shipped static pages, and auto-rolls-back
on failure. Dry-run by default (APPLY=1 to fire). Verified in dry-run: all 7
TK-10526 files currently match prod -> clean no-op exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/deploy-usre-scoped.sh | 167 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 167 insertions(+)
diff --git a/scripts/deploy-usre-scoped.sh b/scripts/deploy-usre-scoped.sh
new file mode 100755
index 0000000..56b4893
--- /dev/null
+++ b/scripts/deploy-usre-scoped.sh
@@ -0,0 +1,167 @@
+#!/usr/bin/env bash
+# deploy-usre-scoped.sh — SAFE, scoped, reversible deploy for usre (usrealestate) → Kamatera.
+# =============================================================================
+# WHY THIS EXISTS (TK-10526 finding, 2026-08-15):
+# usre has NO .deploy.conf, so the shared _shared/scripts/deploy.sh is the only
+# generic path — but that does `rsync -az --delete` over the WHOLE repo. A dry-run
+# proved that against usre would DELETE REAL PROD SOURCE (property.html, src/index.ts,
+# src/zip_county.ts — present on prod, absent locally) and push ~1000 mixed-ticket
+# files. That is fail-dangerous. This script is the safe alternative: it copies ONLY
+# the files you name, backs up every file it replaces, restarts, smoke-tests, and
+# AUTO-ROLLS-BACK on any regression. It NEVER uses --delete and never touches a file
+# you didn't list. Modeled on the repo's proven scripts/deploy-usre-code-to-kamatera.sh.
+#
+# USAGE:
+# # DRY-RUN (default) — prints the plan + per-file local-vs-prod md5 diff, writes NOTHING:
+# bash scripts/deploy-usre-scoped.sh public/deals.html src/server/index.ts
+#
+# # APPLY — actually deploy (backup → copy → pm2 restart → smoke → rollback-on-fail):
+# APPLY=1 bash scripts/deploy-usre-scoped.sh public/deals.html src/server/index.ts
+#
+# # No file args → defaults to the TK-10526 grid/sort feature set (all currently == prod).
+#
+# # Optional deeper API smoke (endpoints are auth-gated → pass creds to exercise sort):
+# SMOKE_AUTH='admin:PASS' APPLY=1 bash scripts/deploy-usre-scoped.sh src/server/index.ts
+#
+# GUARANTEES:
+# - Never runs rsync --delete; never git commits/pushes; never edits .env content.
+# - Only the listed files are backed up + replaced. A new file (absent on prod) is
+# tracked so rollback REMOVES it. Everything else on prod is untouched.
+# - Aborts + rolls back on the first smoke failure; keeps the backup dir for forensics.
+# =============================================================================
+set -euo pipefail
+
+KAM="${KAM:-root@45.61.58.125}"
+USRE_DIR="${USRE_DIR:-/root/public-projects/nationalrealestate}"
+PM2_NAME="${PM2_NAME:-usrealestate}"
+APPLY="${APPLY:-0}"
+SMOKE_AUTH="${SMOKE_AUTH:-}" # optional "user:pass" for auth-gated /api smoke
+LOCAL_ROOT="$HOME/Projects/nationalrealestate"
+
+# Default file set = the TK-10526 list/table/grid + backend-sort feature (parameterizable).
+DEFAULT_FILES=(
+ public/crcp-grid.js public/crcp-grid.css
+ public/deals.html public/contractors.html public/commercial-feed.html public/brokers.html
+ src/server/index.ts
+)
+FILES=("$@"); [ "${#FILES[@]}" -eq 0 ] && FILES=("${DEFAULT_FILES[@]}")
+
+STAMP="$(cd "$LOCAL_ROOT" && git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d-%H%M%S)"
+cd "$LOCAL_ROOT"
+
+echo "──────────────────────────────────────────────────────────────"
+echo " usre scoped deploy mode=$([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN) stamp=$STAMP"
+echo " remote: $KAM:$USRE_DIR pm2: $PM2_NAME"
+echo " files (${#FILES[@]}):"; printf ' - %s\n' "${FILES[@]}"
+echo "──────────────────────────────────────────────────────────────"
+
+# ── pre-flight: every named file must exist locally ──────────────────────────
+for f in "${FILES[@]}"; do
+ [ -f "$f" ] || { echo "❌ local file missing: $f — aborting."; exit 1; }
+done
+
+# ── per-file local-vs-prod md5 (skip no-ops, show real deltas) ────────────────
+echo "── diff vs prod (only DIFFERS files are worth shipping) ──"
+PROD_MD=$(ssh -o BatchMode=yes "$KAM" "cd '$USRE_DIR' && for f in ${FILES[*]}; do if [ -f \"\$f\" ]; then echo \"\$f \$(md5sum \"\$f\" | cut -d' ' -f1)\"; else echo \"\$f PROD-MISSING\"; fi; done")
+CHANGED=(); NEWFILES=()
+for f in "${FILES[@]}"; do
+ lm=$(md5 -q "$f" 2>/dev/null || md5sum "$f" | cut -d' ' -f1)
+ pm=$(awk -v F="$f" '$1==F{print $2}' <<<"$PROD_MD")
+ if [ "$pm" = "PROD-MISSING" ]; then echo " NEW $f"; CHANGED+=("$f"); NEWFILES+=("$f")
+ elif [ "$lm" = "$pm" ]; then echo " SAME $f (skip — already on prod)"
+ else echo " DIFFERS $f local=${lm:0:8} prod=${pm:0:8}"; CHANGED+=("$f"); fi
+done
+
+if [ "${#CHANGED[@]}" -eq 0 ]; then
+ echo "✔ nothing to deploy — every listed file already matches prod. Exiting clean."
+ exit 0
+fi
+
+if [ "$APPLY" != "1" ]; then
+ echo
+ echo "DRY-RUN only — no writes. Would deploy ${#CHANGED[@]} file(s) with backup+smoke+rollback."
+ echo "Re-run with APPLY=1 to execute: APPLY=1 bash scripts/deploy-usre-scoped.sh ${FILES[*]}"
+ exit 0
+fi
+
+# ── APPLY: stage → backup → copy → restart → smoke → rollback-on-fail ─────────
+echo; echo "── APPLY: staging ${#CHANGED[@]} changed file(s) to Kamatera ──"
+STAGE="/tmp/usre-scoped-$STAMP"
+ssh "$KAM" "rm -rf '$STAGE' && mkdir -p '$STAGE'"
+for f in "${CHANGED[@]}"; do
+ ssh "$KAM" "mkdir -p '$STAGE/$(dirname "$f")'"
+ scp -q "$f" "$KAM:$STAGE/$f"
+done
+
+# Serialize file lists for the remote heredoc.
+CHANGED_STR="${CHANGED[*]}"
+NEW_STR="${NEWFILES[*]:-}"
+
+ssh "$KAM" "bash -s" <<REMOTE
+set -uo pipefail
+cd "$USRE_DIR" || { echo "❌ usre dir missing"; exit 1; }
+PORT=\$(sed -n 's/^PORT=//p' .env 2>/dev/null | tr -dc '0-9'); PORT=\${PORT:-9913}
+BK="/root/backups/usre-scoped-$STAMP"; mkdir -p "\$BK"
+echo "backup dir: \$BK (port \$PORT)"
+
+CHANGED_FILES="$CHANGED_STR"
+NEW_FILES="$NEW_STR"
+
+# --- backup every file we're about to replace (new files have nothing to back up) ---
+for f in \$CHANGED_FILES; do
+ if [ -f "\$f" ]; then mkdir -p "\$BK/\$(dirname "\$f")"; cp -p "\$f" "\$BK/\$f" && echo "backed up \$f"; fi
+done
+
+# --- deploy the staged files into place (NO --delete, only these paths) ---
+for f in \$CHANGED_FILES; do
+ mkdir -p "\$(dirname "\$f")"
+ cp "$STAGE/\$f" "\$f" && echo "deployed \$f"
+done
+
+echo "--- pm2 restart $PM2_NAME ---"
+pm2 restart "$PM2_NAME" --update-env >/dev/null 2>&1; sleep 3
+
+# --- smoke: /healthz must stay 200 (server up + routing intact) ---
+HZ=\$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:\$PORT/healthz")
+echo "smoke: /healthz=\$HZ"
+FAIL=0; [ "\$HZ" = "200" ] || FAIL=1
+
+# --- smoke: each shipped public/*.html must serve 200 (static route intact) ---
+for f in \$CHANGED_FILES; do
+ case "\$f" in
+ public/*.html)
+ url="/\${f#public/}"
+ code=\$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:\$PORT\$url")
+ echo "smoke: \$url=\$code"; [ "\$code" = "200" ] || FAIL=1 ;;
+ esac
+done
+
+# --- optional deeper API sort smoke (only if creds passed) ---
+if [ -n "$SMOKE_AUTH" ]; then
+ A=\$(curl -s -u "$SMOKE_AUTH" "http://localhost:\$PORT/api/deals?limit=1&sort=doc_number&order=asc")
+ D=\$(curl -s -u "$SMOKE_AUTH" "http://localhost:\$PORT/api/deals?limit=1&sort=doc_number&order=desc")
+ ac=\$(curl -s -o /dev/null -w '%{http_code}' -u "$SMOKE_AUTH" "http://localhost:\$PORT/api/deals?limit=1&sort=doc_number&order=asc")
+ echo "smoke: /api/deals sort http=\$ac asc!=desc=\$([ "\$A" != "\$D" ] && echo yes || echo no)"
+ [ "\$ac" = "200" ] || FAIL=1
+fi
+
+if [ "\$FAIL" = "0" ]; then
+ echo "✅ DEPLOY OK — smoke green. Backup kept at \$BK; staging cleaned."
+ rm -rf "$STAGE"
+else
+ echo "❌ SMOKE FAILED — ROLLING BACK."
+ for f in \$CHANGED_FILES; do
+ if [ -f "\$BK/\$f" ]; then cp -p "\$BK/\$f" "\$f"; echo "restored \$f";
+ else
+ # file was NEW on prod (no backup) → remove it to fully revert
+ for nf in \$NEW_FILES; do [ "\$nf" = "\$f" ] && rm -f "\$f" && echo "removed new \$f"; done
+ fi
+ done
+ pm2 restart "$PM2_NAME" --update-env >/dev/null 2>&1; sleep 2
+ HZ2=\$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:\$PORT/healthz")
+ echo "ROLLED BACK to $STAMP baseline (/healthz=\$HZ2). Backup at \$BK. Investigate before retry."
+ exit 1
+fi
+REMOTE
+
+echo "── done ──"
← ce73325 usre: true server-side sort on /api/brokers + /api/firms (wh
·
back to Nationalrealestate
·
chore: v0.19.0 (session close — residential desk + server-si 33399fa →