← back to Grant

deploy.sh

198 lines

#!/usr/bin/env bash
# deploy.sh — Mac2 → Kamatera deploy for grant-app (Next.js, port 7450)
#
# Usage:
#   ./deploy.sh              # full deploy: rsync + npm install + build + reload + verify
#   ./deploy.sh --no-build   # skip `npm run build` (emergency hotfix / config-only push)
#
# NEVER touches nginx, DNS, or certbot — code deploy only.
# Safe to re-run (idempotent): rsync is incremental, pm2 reload is graceful.
#
# Requires: rsync, ssh, git (all standard on macOS)

set -euo pipefail

# ---------------------------------------------------------------------------
# Config — edit here if topology changes
# ---------------------------------------------------------------------------
REMOTE_HOST="root@45.61.58.125"
REMOTE_DIR="/root/Projects/Grant"
PM2_PROC="grant-app"
APP_PORT="7450"
LOCAL_DIR="/Users/macstudio3/Projects/Grant"

# ---------------------------------------------------------------------------
# Flag parsing
# ---------------------------------------------------------------------------
DO_BUILD=true
for arg in "$@"; do
  case "$arg" in
    --no-build) DO_BUILD=false ;;
    *) echo "Unknown flag: $arg"; exit 1 ;;
  esac
done

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
phase() { echo; echo "==> $*"; }
ok()    { echo "    OK: $*"; }
fail()  { echo "    FAIL: $*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# Phase 1: rsync — ship only git-tracked files
#
# Strategy: `git ls-files` drives the file set rather than a blanket rsync
# with --exclude lists. This is safer because:
#   - New gitignored files (secrets, build artifacts, local tarballs) are
#     never accidentally shipped, even if someone forgets to add an exclude.
#   - Only files the repo actually knows about land on prod.
#   - Explicit excludes below are belt-and-suspenders for edge cases.
#
# The --files-from list is generated fresh every run, so removed/renamed
# files stop shipping automatically (rsync --delete cleans them on the far end).
# ---------------------------------------------------------------------------
phase "Phase 1: rsync tracked files → ${REMOTE_HOST}:${REMOTE_DIR}/"

cd "$LOCAL_DIR"

# Verify we're inside a git repo
git rev-parse --git-dir > /dev/null 2>&1 || fail "Not a git repository: $LOCAL_DIR"

# Build the file list from git (includes committed + staged; excludes untracked/ignored)
# Use process substitution to avoid a temp file (BSD/macOS compatible, no mapfile needed)
RSYNC_FILE_LIST="$(mktemp /tmp/grant-deploy-files.XXXXXX)"
git ls-files > "$RSYNC_FILE_LIST"

# Always include package-lock.json even if not tracked (needed for npm ci)
# and ecosystem.config.js (pm2 manifest). Both are tracked, but explicit here.

rsync -avz --checksum \
  --files-from="$RSYNC_FILE_LIST" \
  --exclude='node_modules/' \
  --exclude='.next/' \
  --exclude='.env*' \
  --exclude='*.log' \
  --exclude='tmp/' \
  --exclude='.git/' \
  --exclude='*.bak' \
  --exclude='*.tsbuildinfo' \
  ./ "${REMOTE_HOST}:${REMOTE_DIR}/"

rm -f "$RSYNC_FILE_LIST"
ok "rsync complete"

# ---------------------------------------------------------------------------
# Phase 2: Dependencies
#
# Note: package.json contains a local file: dependency
#   "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz"
# That tarball does NOT exist on prod (it lives at /tmp on Mac2 only).
# `npm ci` will hard-fail if the tarball is missing. We therefore:
#   - Skip `npm ci` and run `npm install --no-audit --no-fund` instead.
#   - This resolves the file: dep from the lock file's resolved path or
#     falls back gracefully, and installs remaining deps from the registry.
# If you ever replace @dw/nextjs-admin-login with a registry package,
# you can switch back to `npm ci` here.
# ---------------------------------------------------------------------------
phase "Phase 2: install dependencies on remote"

ssh "${REMOTE_HOST}" bash -s << ENDSSH
set -euo pipefail
cd "${REMOTE_DIR}"

echo "    Node: \$(node --version)  npm: \$(npm --version)"

# Install deps — skipping npm ci because of the file: tarball dep (see comment above)
npm install --no-audit --no-fund --prefer-offline 2>&1 | tail -5

echo "    deps installed"
ENDSSH

ok "npm install complete"

# ---------------------------------------------------------------------------
# Phase 3: Build (Next.js production build)
# ---------------------------------------------------------------------------
if [ "$DO_BUILD" = true ]; then
  phase "Phase 3: npm run build on remote (this takes ~60-120s)"

  ssh "${REMOTE_HOST}" bash -s << ENDSSH
set -euo pipefail
cd "${REMOTE_DIR}"

# Ensure NODE_ENV=production for the build so Next.js optimises correctly
export NODE_ENV=production

npm run build 2>&1
echo "    build exited \$?"
ENDSSH

  ok "build complete"
else
  phase "Phase 3: SKIPPED (--no-build)"
fi

# ---------------------------------------------------------------------------
# Phase 4: pm2 graceful reload
# ---------------------------------------------------------------------------
phase "Phase 4: pm2 reload ${PM2_PROC} --update-env"

ssh "${REMOTE_HOST}" bash -s << ENDSSH
set -euo pipefail
cd "${REMOTE_DIR}"

# --update-env picks up any new/changed env vars in the pm2 ecosystem or .env
pm2 reload "${PM2_PROC}" --update-env

# Brief settle window — Next.js startup is fast but give it a moment
sleep 3

pm2 show "${PM2_PROC}" | grep -E "status|restart"
ENDSSH

ok "pm2 reload complete"

# ---------------------------------------------------------------------------
# Phase 5: Smoke-test — curl from the box itself (loopback, no DNS needed)
# ---------------------------------------------------------------------------
phase "Phase 5: smoke-test on ${REMOTE_HOST}"

SMOKE_RESULT=$(ssh "${REMOTE_HOST}" bash -s << ENDSSH
set -uo pipefail
BASE="http://localhost:${APP_PORT}"

check() {
  local label="\$1" path="\$2"
  local code
  code=\$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "\${BASE}\${path}")
  if [ "\$code" = "200" ]; then
    echo "PASS \$label → HTTP \$code"
  else
    echo "FAIL \$label → HTTP \$code"
  fi
}

check "/"        "/"
check "/landing" "/landing"
ENDSSH
)

echo "$SMOKE_RESULT"

# Exit non-zero if any check failed
if echo "$SMOKE_RESULT" | grep -q "^FAIL"; then
  fail "One or more smoke tests failed — check pm2 logs: ssh ${REMOTE_HOST} pm2 logs ${PM2_PROC} --lines 50"
fi

# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
phase "Deploy complete"
echo
echo "  App  : https://grant.agentabrams.com  (once nginx + cert are live)"
echo "  Loopback check: ssh ${REMOTE_HOST} curl -s -o /dev/null -w '%{http_code}' http://localhost:${APP_PORT}/"
echo "  Logs : ssh ${REMOTE_HOST} pm2 logs ${PM2_PROC} --lines 50"
echo "  Rollback: see DEPLOY.md § Rollback"
echo