[object Object]

← back to Norma

Norma pre-deploy gate: run durable smoke battery before any live restart

cbfbf086ccbd1fbe60cf9e48f677fe50cdde7bb4 · 2026-08-08 08:55:22 -0700 · Steve Abrams

Makes the TK-10293 suites actually DURABLE by wiring them into the deploy path
so a REGRESSION (not just a broken build) can't reach :7400:

- scripts/predeploy-gate.sh (npm run gate): spins an isolated instance on its
  own port (sdcc_test, all sends sinked, reuses test-instance.sh), runs the full
  battery (api/regression/write/crud/authz/e2e), tears down via EXIT trap, exits
  non-zero on the first failing suite. Readiness gates on Next's "Ready" marker +
  two consecutive /login 200s + settle (a single non-000 poll raced the boot and
  ECONNREFUSED'd the early suites).
- scripts/deploy-local.sh (npm run deploy:local): build -> gate -> restart+health.
- scripts/reload.sh: SKIP_BUILD hook so deploy:local doesn't build twice; stays
  the fast no-gate emergency-recovery path.
- scripts/test-instance.sh: PORT now overridable (${PORT:-7411}) for the gate.

Verified: gate exits 0 all-green on a clean tree and 1 (BLOCKED) when a suite
fails; live :7400 never touched (isolated ports + sdcc_test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit cbfbf086ccbd1fbe60cf9e48f677fe50cdde7bb4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 8 08:55:22 2026 -0700

    Norma pre-deploy gate: run durable smoke battery before any live restart
    
    Makes the TK-10293 suites actually DURABLE by wiring them into the deploy path
    so a REGRESSION (not just a broken build) can't reach :7400:
    
    - scripts/predeploy-gate.sh (npm run gate): spins an isolated instance on its
      own port (sdcc_test, all sends sinked, reuses test-instance.sh), runs the full
      battery (api/regression/write/crud/authz/e2e), tears down via EXIT trap, exits
      non-zero on the first failing suite. Readiness gates on Next's "Ready" marker +
      two consecutive /login 200s + settle (a single non-000 poll raced the boot and
      ECONNREFUSED'd the early suites).
    - scripts/deploy-local.sh (npm run deploy:local): build -> gate -> restart+health.
    - scripts/reload.sh: SKIP_BUILD hook so deploy:local doesn't build twice; stays
      the fast no-gate emergency-recovery path.
    - scripts/test-instance.sh: PORT now overridable (${PORT:-7411}) for the gate.
    
    Verified: gate exits 0 all-green on a clean tree and 1 (BLOCKED) when a suite
    fails; live :7400 never touched (isolated ports + sdcc_test).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package.json              |  4 ++-
 scripts/deploy-local.sh   | 15 +++++++++
 scripts/predeploy-gate.sh | 77 +++++++++++++++++++++++++++++++++++++++++++++++
 scripts/reload.sh         |  8 +++--
 scripts/test-instance.sh  |  2 +-
 5 files changed, 102 insertions(+), 4 deletions(-)

diff --git a/package.json b/package.json
index e14eefb..e252f59 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,9 @@
     "test:e2e": "playwright test",
     "test:crud": "node tests/api-crud-lifecycle.mjs",
     "test:authz": "node tests/api-authz-matrix.mjs",
-    "reload": "bash scripts/reload.sh"
+    "reload": "bash scripts/reload.sh",
+    "gate": "bash scripts/predeploy-gate.sh",
+    "deploy:local": "bash scripts/deploy-local.sh"
   },
   "dependencies": {
     "@codemirror/lang-html": "^6.4.11",
diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh
new file mode 100755
index 0000000..18f07f7
--- /dev/null
+++ b/scripts/deploy-local.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Safe LOCAL deploy for Norma: BUILD → GATE (full durable battery) → RESTART+HEALTH.
+# A broken build OR a regression stops the deploy before the live app is touched.
+# This is the path for shipping NEW code; scripts/reload.sh is the fast
+# emergency-recovery path (no gate) for an already-down app.
+#
+# Usage: npm run deploy:local
+set -euo pipefail
+export PATH="/opt/homebrew/bin:/opt/homebrew/opt/postgresql@14/bin:$PATH"
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+
+echo "[deploy] 1/3 build…";  npm run build
+echo "[deploy] 2/3 gate (full durable battery on isolated instance)…"; npm run gate
+echo "[deploy] 3/3 restart live + health-gate…"; SKIP_BUILD=1 bash scripts/reload.sh
+echo "[deploy] ✅ built, gated green, live + healthy."
diff --git a/scripts/predeploy-gate.sh b/scripts/predeploy-gate.sh
new file mode 100755
index 0000000..93db094
--- /dev/null
+++ b/scripts/predeploy-gate.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Pre-deploy GATE for Norma — runs the full durable smoke battery (TK-10293)
+# against an isolated instance BEFORE any live restart, so a REGRESSION (not
+# just a broken build) can never reach :7400. Exits non-zero on the first
+# failing suite. Reuses scripts/test-instance.sh (sdcc_test, all sends sinked)
+# on its own port so it never touches live :7400 / the sdcc DB.
+#
+# Usage: npm run gate                 (or: bash scripts/predeploy-gate.sh)
+#   env: GATE_PORT (default 7414), GATE_SKIP_E2E=1 to skip Playwright.
+set -uo pipefail
+export PATH="/opt/homebrew/opt/postgresql@14/bin:/opt/homebrew/bin:$PATH"
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+
+GATE_PORT="${GATE_PORT:-7414}"
+export NORMA_TEST_URL="http://127.0.0.1:${GATE_PORT}"
+export NORMA_TEST_DB="postgresql://127.0.0.1:5432/sdcc_test"
+LOG="/tmp/norma-gate-instance-${GATE_PORT}.log"
+
+if lsof -ti ":${GATE_PORT}" >/dev/null 2>&1; then
+  echo "[gate] FATAL: port ${GATE_PORT} is busy — set GATE_PORT to a free port."; exit 2
+fi
+
+INSTANCE_PID=""
+cleanup() {
+  [ -n "${INSTANCE_PID}" ] && kill "${INSTANCE_PID}" >/dev/null 2>&1 || true
+  lsof -ti ":${GATE_PORT}" 2>/dev/null | xargs kill >/dev/null 2>&1 || true
+}
+trap cleanup EXIT INT TERM
+
+echo "[gate] starting isolated instance on :${GATE_PORT} (sdcc_test, sends sinked)…"
+PORT="${GATE_PORT}" bash scripts/test-instance.sh > "${LOG}" 2>&1 &
+INSTANCE_PID=$!
+
+# Readiness must be BULLETPROOF: a single non-000 poll fires before Next is
+# truly accepting connections (the first suites then hit ECONNREFUSED). Require
+# Next's own "Ready" log marker AND two consecutive 200s on a real route
+# (/login), then settle — so no suite races the boot.
+ready=""
+for i in $(seq 1 60); do
+  if grep -q "Ready in" "${LOG}" 2>/dev/null; then
+    c1="$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 "${NORMA_TEST_URL}/login" 2>/dev/null || echo 000)"
+    sleep 1
+    c2="$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 "${NORMA_TEST_URL}/login" 2>/dev/null || echo 000)"
+    [ "${c1}" = "200" ] && [ "${c2}" = "200" ] && { ready=1; break; }
+  fi
+  kill -0 "${INSTANCE_PID}" 2>/dev/null || { echo "[gate] instance process exited during boot."; break; }
+  sleep 1
+done
+if [ -z "${ready}" ]; then
+  echo "[gate] FATAL: test instance never became ready on :${GATE_PORT}. Instance log:"; tail -25 "${LOG}"; exit 3
+fi
+sleep 1  # settle
+echo "[gate] instance ready (Ready marker + /login 200×2) — running durable battery…"
+
+fails=""
+run() { # run <label> <cmd...>
+  local label="$1"; shift
+  echo "──────── ${label} ────────"
+  if "$@"; then echo "[gate] ${label}: PASS"; else echo "[gate] ${label}: FAIL"; fails="${fails} ${label}"; fi
+}
+
+run "api-smoke"      node tests/api-smoke.mjs
+run "security-regression" node tests/security-regression.mjs
+run "write-smoke"    node tests/api-write-smoke.mjs
+run "crud-lifecycle" node tests/api-crud-lifecycle.mjs
+run "authz-matrix"   node tests/api-authz-matrix.mjs
+if [ "${GATE_SKIP_E2E:-0}" = "1" ]; then
+  echo "[gate] e2e: SKIPPED (GATE_SKIP_E2E=1)"
+else
+  run "e2e-playwright" npx playwright test
+fi
+
+echo "════════════════════════════════════════"
+if [ -n "${fails}" ]; then
+  echo "[gate] ❌ BLOCKED — failing suite(s):${fails}"; exit 1
+fi
+echo "[gate] ✅ all suites green — safe to deploy."
diff --git a/scripts/reload.sh b/scripts/reload.sh
index dd819de..33574b7 100755
--- a/scripts/reload.sh
+++ b/scripts/reload.sh
@@ -20,8 +20,12 @@ APP="${NORMA_PM2_NAME:-norma-email}"
 PORT="${NORMA_PORT:-7400}"
 URL="http://127.0.0.1:${PORT}/"
 
-echo "[reload] 1/3 building (.next) — a broken build stops here, live app untouched…"
-npm run build
+if [ "${SKIP_BUILD:-0}" = "1" ]; then
+  echo "[reload] 1/3 build SKIPPED (SKIP_BUILD=1 — caller already built + gated)."
+else
+  echo "[reload] 1/3 building (.next) — a broken build stops here, live app untouched…"
+  npm run build
+fi
 
 echo "[reload] 2/3 restarting pm2 process '${APP}'…"
 pm2 restart "${APP}"
diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh
index 7573231..c6cc9a0 100755
--- a/scripts/test-instance.sh
+++ b/scripts/test-instance.sh
@@ -9,7 +9,7 @@ set -euo pipefail
 export PATH="/opt/homebrew/opt/postgresql@14/bin:/opt/homebrew/bin:$PATH"
 cd "$(dirname "${BASH_SOURCE[0]}")/.."
 
-export PORT=7411
+export PORT="${PORT:-7411}"   # override for the pre-deploy gate (scripts/predeploy-gate.sh)
 export NODE_ENV=production
 export DATABASE_URL="postgresql://127.0.0.1:5432/sdcc_test"
 export SESSION_SECRET="norma-test-secret-do-not-use-in-prod-0f3a9c"

← f2f2df6 deploy hygiene (TK-10370): build-then-restart-then-health-ga  ·  back to Norma  ·  TK-10395: bounded retry-on-2207076 in reel skill (Meta trans 9897011 →