#!/usr/bin/env bash # # scripts/verify-clean-clone.sh — Phase 0 reproducibility gate (TASK-0012). # # Drives the full clean-clone runbook end-to-end against a git clone: # preflight -> pnpm install -> infra up -> wait deps -> migrate -> seed -> # db:verify -> apps up -> poll app health -> typecheck -> test -> lint -> # test:perf -> PASS/FAIL summary. # # Usage: # bash scripts/verify-clean-clone.sh [clone-dir] [flags] # # clone-dir Directory containing the repo (default: "."). CI checks out a # fresh `git clone` to a scratch dir and passes it here so the # working checkout is never touched. # --skip-build Skip the slow verification gates (typecheck/test/lint/ # test:perf) for CI speed. The infrastructure + health gates # still run. # --preflight Run only the docker/port preflight checks, then exit 0. Used # by `tests/scripts/clean-clone.test.ts` to exercise the # port-check logic without starting anything. # # Guarantees (per the task contract): # * `set -euo pipefail` + `set -x` (fail fast, trace every command). # * Never mutates git state. # * Never runs `docker compose down -v` (re-runs are safe against existing # volumes); only the `apps`-profile containers THIS script started are # cleaned up on exit. # * Non-zero exit on any failure; a human-readable PASS/FAIL summary always # prints (via the trap) before the process exits. # # The four dependency healthchecks are bounded to <= 120 s and the four app # health polls to <= 120 s (per the contract). set -euo pipefail set -x # --------------------------------------------------------------------------- # Arguments (flags in any position; the first non-flag argument is the clone # dir) # --------------------------------------------------------------------------- CLONE_DIR="." SKIP_BUILD=0 PREFLIGHT_ONLY=0 for a in "$@"; do case "$a" in --skip-build) SKIP_BUILD=1 ;; --preflight) PREFLIGHT_ONLY=1 ;; -*) ;; # ignore unknown flags *) CLONE_DIR="$a" ;; esac done COMPOSE="docker compose -f ${CLONE_DIR}/infrastructure/docker/docker-compose.yml" # --------------------------------------------------------------------------- # Summary bookkeeping # --------------------------------------------------------------------------- declare -a STEPS=() declare -a FAILURES=() step() { echo; echo "==== $* ===="; } ok() { STEPS+=("$1"); echo " [ok] $1"; } fail() { FAILURES+=("$1") STEPS+=("$1 (FAILED)") echo " [FAIL] $1" } print_summary() { echo echo "==================== SUMMARY ====================" if [ "${#STEPS[@]}" -gt 0 ]; then printf '%s\n' "${STEPS[@]}" | sed 's/^/ /' fi if [ "${#FAILURES[@]}" -eq 0 ]; then echo " RESULT: PASS (${#STEPS[@]} steps, 0 failures)" echo "===============================================" else echo " RESULT: FAIL (${#FAILURES[@]} failures):" printf '%s\n' "${FAILURES[@]}" | sed 's/^/ - /' echo "===============================================" fi } # Clean up only the apps-profile containers we started (never infra, never -v). APPS_STARTED=0 on_exit() { local rc=$? if [ "$APPS_STARTED" -eq 1 ]; then echo; echo "==== cleanup (apps profile started by this script) ====" "$COMPOSE" --profile apps down || true fi print_summary # Preserve the real exit code (an `if`, not `&&`, so a passing run does not # inherit the false status of the [ test as the function's exit status). if [ $rc -ne 0 ]; then exit $rc; fi exit 0 } trap on_exit EXIT # All repo commands run from the clone dir. cd "$CLONE_DIR" # --------------------------------------------------------------------------- # 0. Preflight: docker available + required ports free # --------------------------------------------------------------------------- step "Preflight" command -v docker >/dev/null 2>&1 || { fail "docker is not installed / not on PATH"; exit 1; } docker compose version >/dev/null 2>&1 || { fail "docker compose v2 is not available"; exit 1; } ok "docker + compose v2 available" port_free() { # Returns 0 if the TCP port is NOT in use (free). if command -v lsof >/dev/null 2>&1; then ! lsof -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 else # /dev/tcp probe: a connect that succeeds means the port is taken. (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 1; } return 0 fi } BUSY=0 for p in 3000 4000 4001 4002 4003 5432 6379 9000 9001 9200; do if ! port_free "$p"; then echo " port $p already in use" BUSY=1 fi done [ "$BUSY" -eq 0 ] && ok "required ports free" || fail "one or more required ports are busy (see above)" [ "$BUSY" -eq 0 ] || exit 1 # --preflight: stop after the preflight gate (used by tests/scripts to # exercise the port-check logic without starting anything). if [ "$PREFLIGHT_ONLY" -eq 1 ]; then exit 0 fi # --------------------------------------------------------------------------- # 1. pnpm install (frozen lockfile) # --------------------------------------------------------------------------- step "pnpm install --frozen-lockfile" pnpm install --frozen-lockfile ok "pnpm install" # --------------------------------------------------------------------------- # 2. Infra up (postgres, redis, minio, opensearch) # --------------------------------------------------------------------------- step "pnpm docker:up (infra)" pnpm docker:up ok "infra started" # --------------------------------------------------------------------------- # 3. Wait for the 4 dependency healthchecks (<= 120 s) # --------------------------------------------------------------------------- step "Wait for dependency healthchecks (<=120s)" wait_healthy() { local svc="$1" deadline=$(( $(date +%s) + 120 )) while [ "$(date +%s)" -lt "$deadline" ]; do local status status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "factory-${svc}" 2>/dev/null || echo "none") [ "$status" = "healthy" ] && return 0 sleep 2 done return 1 } DEP_FAIL=0 for svc in postgres redis minio opensearch; do if wait_healthy "$svc"; then ok "${svc} healthy" else fail "${svc} did not become healthy within 120 s" DEP_FAIL=1 fi done [ "$DEP_FAIL" -eq 0 ] || exit 1 # --------------------------------------------------------------------------- # 4. migrate + seed + verify # --------------------------------------------------------------------------- step "db:migrate" pnpm db:migrate ok "db:migrate" step "db:seed" pnpm db:seed ok "db:seed" step "db:verify" pnpm db:verify ok "db:verify" # --------------------------------------------------------------------------- # 5. Apps up (web, api, worker, realtime) # --------------------------------------------------------------------------- step "pnpm docker:up:apps" pnpm docker:up:apps APPS_STARTED=1 ok "apps started" # --------------------------------------------------------------------------- # 6. Poll all 4 apps' health (<= 120 s; readiness must be 200 with checks ok) # --------------------------------------------------------------------------- step "Poll app health (<=120s)" http_code() { local url="$1" curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$url" 2>/dev/null || echo "000" } wait_ready() { local name="$1" live="$2" ready="$3" deadline deadline=$(( $(date +%s) + 120 )) while [ "$(date +%s)" -lt "$deadline" ]; do local c_live c_ready c_live=$(http_code "$live") c_ready=$(http_code "$ready") if [ "$c_live" = "200" ] && [ "$c_ready" = "200" ]; then return 0 fi sleep 2 done echo " last: $name liveness=${c_live} readiness=${c_ready}" return 1 } APP_FAIL=0 wait_ready "web" "http://localhost:3000/api/v1/health" "http://localhost:3000/health/ready" && ok "web ready" || { fail "web not ready"; APP_FAIL=1; } wait_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; } wait_ready "worker" "http://localhost:4003/api/v1/health" "http://localhost:4003/health/ready" && ok "worker ready" || { fail "worker not ready"; APP_FAIL=1; } wait_ready "realtime" "http://localhost:4002/health" "http://localhost:4002/health/ready" && ok "realtime ready" || { fail "realtime not ready"; APP_FAIL=1; } [ "$APP_FAIL" -eq 0 ] || exit 1 # realtime /metrics must parse and expose the four required metric names. step "realtime /metrics sanity" METRICS=$(curl -s --max-time 3 http://localhost:4002/metrics || echo "") MET_FAIL=0 for m in uptime_seconds connected_clients events_processed_total ws_handshake_failures_total; do if echo "$METRICS" | grep -q "^${m}"; then ok "metric ${m} present" else fail "metric ${m} missing from /metrics" MET_FAIL=1 fi done [ "$MET_FAIL" -eq 0 ] || exit 1 # --------------------------------------------------------------------------- # 7. Verification gates (skippable with --skip-build) # --------------------------------------------------------------------------- if [ "$SKIP_BUILD" -eq 1 ]; then step "Skipping build gates (--skip-build)" ok "build gates skipped (--skip-build)" else step "pnpm typecheck" pnpm typecheck && ok "typecheck" || { fail "typecheck"; exit 1; } step "pnpm test" pnpm test && ok "test" || { fail "test"; exit 1; } step "pnpm lint" pnpm lint && ok "lint" || { fail "lint"; exit 1; } step "pnpm test:perf" pnpm test:perf && ok "test:perf" || { fail "test:perf"; exit 1; } fi step "All gates passed" ok "verify-clean-clone complete" exit 0