1#!/usr/bin/env bash
2#
3# scripts/verify-clean-clone.sh — Phase 0 reproducibility gate (TASK-0012).
4#
5# Drives the full clean-clone runbook end-to-end against a git clone:
6# preflight -> pnpm install -> infra up -> wait deps -> migrate -> seed ->
7# db:verify -> apps up -> poll app health -> typecheck -> test -> lint ->
8# test:perf -> PASS/FAIL summary.
9#
10# Usage:
11# bash scripts/verify-clean-clone.sh [clone-dir] [flags]
12#
13# clone-dir Directory containing the repo (default: "."). CI checks out a
14# fresh `git clone` to a scratch dir and passes it here so the
15# working checkout is never touched.
16# --skip-build Skip the slow verification gates (typecheck/test/lint/
17# test:perf) for CI speed. The infrastructure + health gates
18# still run.
19# --preflight Run only the docker/port preflight checks, then exit 0. Used
20# by `tests/scripts/clean-clone.test.ts` to exercise the
21# port-check logic without starting anything.
22#
23# Guarantees (per the task contract):
24# * `set -euo pipefail` + `set -x` (fail fast, trace every command).
25# * Never mutates git state.
26# * Never runs `docker compose down -v` (re-runs are safe against existing
27# volumes); only the `apps`-profile containers THIS script started are
28# cleaned up on exit.
29# * Non-zero exit on any failure; a human-readable PASS/FAIL summary always
30# prints (via the trap) before the process exits.
31#
32# The four dependency healthchecks are bounded to <= 120 s and the four app
33# health polls to <= 120 s (per the contract).
34
35set -euo pipefail
36set -x
37
38# ---------------------------------------------------------------------------
39# Arguments (flags in any position; the first non-flag argument is the clone
40# dir)
41# ---------------------------------------------------------------------------
42CLONE_DIR="."
43SKIP_BUILD=0
44PREFLIGHT_ONLY=0
45for a in "$@"; do
46 case "$a" in
47 --skip-build) SKIP_BUILD=1 ;;
48 --preflight) PREFLIGHT_ONLY=1 ;;
49 -*) ;; # ignore unknown flags
50 *) CLONE_DIR="$a" ;;
51 esac
52done
53
54COMPOSE="docker compose -f ${CLONE_DIR}/infrastructure/docker/docker-compose.yml"
55
56# ---------------------------------------------------------------------------
57# Summary bookkeeping
58# ---------------------------------------------------------------------------
59declare -a STEPS=()
60declare -a FAILURES=()
61step() { echo; echo "==== $* ===="; }
62ok() { STEPS+=("$1"); echo " [ok] $1"; }
63fail() {
64 FAILURES+=("$1")
65 STEPS+=("$1 (FAILED)")
66 echo " [FAIL] $1"
67}
68
69print_summary() {
70 echo
71 echo "==================== SUMMARY ===================="
72 if [ "${#STEPS[@]}" -gt 0 ]; then
73 printf '%s\n' "${STEPS[@]}" | sed 's/^/ /'
74 fi
75 if [ "${#FAILURES[@]}" -eq 0 ]; then
76 echo " RESULT: PASS (${#STEPS[@]} steps, 0 failures)"
77 echo "==============================================="
78 else
79 echo " RESULT: FAIL (${#FAILURES[@]} failures):"
80 printf '%s\n' "${FAILURES[@]}" | sed 's/^/ - /'
81 echo "==============================================="
82 fi
83}
84
85# Clean up only the apps-profile containers we started (never infra, never -v).
86APPS_STARTED=0
87on_exit() {
88 local rc=$?
89 if [ "$APPS_STARTED" -eq 1 ]; then
90 echo; echo "==== cleanup (apps profile started by this script) ===="
91 "$COMPOSE" --profile apps down || true
92 fi
93 print_summary
94 # Preserve the real exit code (an `if`, not `&&`, so a passing run does not
95 # inherit the false status of the [ test as the function's exit status).
96 if [ $rc -ne 0 ]; then exit $rc; fi
97 exit 0
98}
99trap on_exit EXIT
100
101# All repo commands run from the clone dir.
102cd "$CLONE_DIR"
103
104# ---------------------------------------------------------------------------
105# 0. Preflight: docker available + required ports free
106# ---------------------------------------------------------------------------
107step "Preflight"
108command -v docker >/dev/null 2>&1 || { fail "docker is not installed / not on PATH"; exit 1; }
109docker compose version >/dev/null 2>&1 || { fail "docker compose v2 is not available"; exit 1; }
110ok "docker + compose v2 available"
111
112port_free() {
113 # Returns 0 if the TCP port is NOT in use (free).
114 if command -v lsof >/dev/null 2>&1; then
115 ! lsof -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
116 else
117 # /dev/tcp probe: a connect that succeeds means the port is taken.
118 (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 1; }
119 return 0
120 fi
121}
122
123BUSY=0
124for p in 3000 4000 4001 4002 4003 5432 6379 9000 9001 9200; do
125 if ! port_free "$p"; then
126 echo " port $p already in use"
127 BUSY=1
128 fi
129done
130[ "$BUSY" -eq 0 ] && ok "required ports free" || fail "one or more required ports are busy (see above)"
131[ "$BUSY" -eq 0 ] || exit 1
132
133# --preflight: stop after the preflight gate (used by tests/scripts to
134# exercise the port-check logic without starting anything).
135if [ "$PREFLIGHT_ONLY" -eq 1 ]; then
136 exit 0
137fi
138
139# ---------------------------------------------------------------------------
140# 1. pnpm install (frozen lockfile)
141# ---------------------------------------------------------------------------
142step "pnpm install --frozen-lockfile"
143pnpm install --frozen-lockfile
144ok "pnpm install"
145
146# ---------------------------------------------------------------------------
147# 2. Infra up (postgres, redis, minio, opensearch)
148# ---------------------------------------------------------------------------
149step "pnpm docker:up (infra)"
150pnpm docker:up
151ok "infra started"
152
153# ---------------------------------------------------------------------------
154# 3. Wait for the 4 dependency healthchecks (<= 120 s)
155# ---------------------------------------------------------------------------
156step "Wait for dependency healthchecks (<=120s)"
157wait_healthy() {
158 local svc="$1" deadline=$(( $(date +%s) + 120 ))
159 while [ "$(date +%s)" -lt "$deadline" ]; do
160 local status
161 status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "factory-${svc}" 2>/dev/null || echo "none")
162 [ "$status" = "healthy" ] && return 0
163 sleep 2
164 done
165 return 1
166}
167DEP_FAIL=0
168for svc in postgres redis minio opensearch; do
169 if wait_healthy "$svc"; then
170 ok "${svc} healthy"
171 else
172 fail "${svc} did not become healthy within 120 s"
173 DEP_FAIL=1
174 fi
175done
176[ "$DEP_FAIL" -eq 0 ] || exit 1
177
178# ---------------------------------------------------------------------------
179# 4. migrate + seed + verify
180# ---------------------------------------------------------------------------
181step "db:migrate"
182pnpm db:migrate
183ok "db:migrate"
184
185step "db:seed"
186pnpm db:seed
187ok "db:seed"
188
189step "db:verify"
190pnpm db:verify
191ok "db:verify"
192
193# ---------------------------------------------------------------------------
194# 5. Apps up (web, api, worker, realtime)
195# ---------------------------------------------------------------------------
196step "pnpm docker:up:apps"
197pnpm docker:up:apps
198APPS_STARTED=1
199ok "apps started"
200
201# ---------------------------------------------------------------------------
202# 6. Poll all 4 apps' health (<= 120 s; readiness must be 200 with checks ok)
203# ---------------------------------------------------------------------------
204step "Poll app health (<=120s)"
205http_code() {
206 local url="$1"
207 curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$url" 2>/dev/null || echo "000"
208}
209wait_ready() {
210 local name="$1" live="$2" ready="$3" deadline
211 deadline=$(( $(date +%s) + 120 ))
212 while [ "$(date +%s)" -lt "$deadline" ]; do
213 local c_live c_ready
214 c_live=$(http_code "$live")
215 c_ready=$(http_code "$ready")
216 if [ "$c_live" = "200" ] && [ "$c_ready" = "200" ]; then
217 return 0
218 fi
219 sleep 2
220 done
221 echo " last: $name liveness=${c_live} readiness=${c_ready}"
222 return 1
223}
224APP_FAIL=0
225wait_ready "web" "http://localhost:3000/api/v1/health" "http://localhost:3000/health/ready" && ok "web ready" || { fail "web not ready"; APP_FAIL=1; }
226wait_ready "api" "http://localhost:4000/api/v1/health" "http://localhost:4000/api/v1/health/ready" && ok "api ready" || { fail "api not ready"; APP_FAIL=1; }
227wait_ready "worker" "http://localhost:4003/api/v1/health" "http://localhost:4003/health/ready" && ok "worker ready" || { fail "worker not ready"; APP_FAIL=1; }
228wait_ready "realtime" "http://localhost:4002/health" "http://localhost:4002/health/ready" && ok "realtime ready" || { fail "realtime not ready"; APP_FAIL=1; }
229[ "$APP_FAIL" -eq 0 ] || exit 1
230
231# realtime /metrics must parse and expose the four required metric names.
232step "realtime /metrics sanity"
233METRICS=$(curl -s --max-time 3 http:
234MET_FAIL=0
235for m in uptime_seconds connected_clients events_processed_total ws_handshake_failures_total; do
236 if echo "$METRICS" | grep -q "^${m}"; then
237 ok "metric ${m} present"
238 else
239 fail "metric ${m} missing from /metrics"
240 MET_FAIL=1
241 fi
242done
243[ "$MET_FAIL" -eq 0 ] || exit 1
244
245# ---------------------------------------------------------------------------
246# 7. Verification gates (skippable with --skip-build)
247# ---------------------------------------------------------------------------
248if [ "$SKIP_BUILD" -eq 1 ]; then
249 step "Skipping build gates (--skip-build)"
250 ok "build gates skipped (--skip-build)"
251else
252 step "pnpm typecheck"
253 pnpm typecheck && ok "typecheck" || { fail "typecheck"; exit 1; }
254
255 step "pnpm test"
256 pnpm test && ok "test" || { fail "test"; exit 1; }
257
258 step "pnpm lint"
259 pnpm lint && ok "lint" || { fail "lint"; exit 1; }
260
261 step "pnpm test:perf"
262 pnpm test:perf && ok "test:perf" || { fail "test:perf"; exit 1; }
263fi
264
265step "All gates passed"
266ok "verify-clean-clone complete"
267exit 0
268