Cursor Agent b5b88db862 feat(ops): auto-deploy evOo-local on Evo with health check and rollback
Add idempotent deploy helper and GitHub workflow that runs on the
self-hosted Evo runner (no SSH secrets). Pulls main or a pinned SHA,
migrates, restarts via systemd/tmux/compose, probes :3100/:5173, and
resets on health failure.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-23 22:58:14 +00:00

311 lines
9.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# evOo-local — idempotent deploy helper for the Evo host checkout.
# Pull (or pin) a revision, migrate, restart services, health-check, rollback on failure.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- defaults (override via env; see ops/evo-deploy/README.md) ---
EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}"
EVO_DEPLOY_REF="${EVO_DEPLOY_REF:-main}"
EVO_GIT_REMOTE="${EVO_GIT_REMOTE:-origin}"
EVO_RESTART_MODE="${EVO_RESTART_MODE:-auto}" # auto | systemd | tmux | compose | script | none
EVO_SYSTEMD_UNIT="${EVO_SYSTEMD_UNIT:-}"
EVO_SYSTEMD_SCOPE="${EVO_SYSTEMD_SCOPE:-user}" # user | system
EVO_TMUX_SESSION="${EVO_TMUX_SESSION:-evoo}"
EVO_TMUX_COMMAND="${EVO_TMUX_COMMAND:-pnpm dev}"
EVO_COMPOSE_FILE="${EVO_COMPOSE_FILE:-infra/compose/docker-compose.yml}"
EVO_COMPOSE_ENV_FILE="${EVO_COMPOSE_ENV_FILE:-.env}"
EVO_COMPOSE_SERVICES="${EVO_COMPOSE_SERVICES:-api web worker}"
EVO_RESTART_SCRIPT="${EVO_RESTART_SCRIPT:-}"
EVO_API_HEALTH_URL="${EVO_API_HEALTH_URL:-http://127.0.0.1:3100/health}"
EVO_WEB_HEALTH_URL="${EVO_WEB_HEALTH_URL:-http://127.0.0.1:5173/}"
EVO_HEALTH_TIMEOUT_SEC="${EVO_HEALTH_TIMEOUT_SEC:-120}"
EVO_MIGRATE="${EVO_MIGRATE:-true}"
EVO_INSTALL="${EVO_INSTALL:-true}"
EVO_DRY_RUN="${EVO_DRY_RUN:-false}"
EVO_STATE_FILE="${EVO_STATE_FILE:-${EVO_DEPLOY_DIR}/.evo-deploy/state.json}"
DRY_RUN=false
log() { printf '[evo-deploy] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
usage() {
cat <<'EOF'
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install]
Environment: EVO_DEPLOY_DIR, EVO_DEPLOY_REF, EVO_RESTART_MODE, EVO_SYSTEMD_UNIT,
EVO_TMUX_SESSION, EVO_API_HEALTH_URL, EVO_WEB_HEALTH_URL, EVO_HEALTH_TIMEOUT_SEC, …
See ops/evo-deploy/README.md.
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=true
EVO_DRY_RUN=true
shift
;;
--ref)
[[ $# -ge 2 ]] || die "--ref requires a value"
EVO_DEPLOY_REF="$2"
shift 2
;;
--skip-migrate)
EVO_MIGRATE=false
shift
;;
--skip-install)
EVO_INSTALL=false
shift
;;
-h | --help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
if [[ "$EVO_DRY_RUN" == "true" ]]; then
DRY_RUN=true
fi
}
run() {
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] $*"
return 0
fi
"$@"
}
run_shell() {
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] $*"
return 0
fi
bash -lc "$*"
}
validate_deploy_dir() {
[[ -d "$EVO_DEPLOY_DIR" ]] || die "EVO_DEPLOY_DIR does not exist: $EVO_DEPLOY_DIR"
[[ -d "$EVO_DEPLOY_DIR/.git" ]] || die "EVO_DEPLOY_DIR is not a git checkout: $EVO_DEPLOY_DIR"
}
resolve_restart_mode() {
if [[ "$EVO_RESTART_MODE" != "auto" ]]; then
printf '%s\n' "$EVO_RESTART_MODE"
return
fi
if [[ -n "$EVO_SYSTEMD_UNIT" ]]; then
printf 'systemd\n'
return
fi
if [[ -n "$EVO_RESTART_SCRIPT" && -x "$EVO_RESTART_SCRIPT" ]]; then
printf 'script\n'
return
fi
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "$EVO_TMUX_SESSION" 2>/dev/null; then
printf 'tmux\n'
return
fi
if [[ -f "$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE" ]]; then
printf 'compose\n'
return
fi
printf 'none\n'
}
systemctl_cmd() {
if [[ "$EVO_SYSTEMD_SCOPE" == "system" ]]; then
if [[ "$(id -u)" -eq 0 ]]; then
systemctl "$@"
else
sudo systemctl "$@"
fi
else
systemctl --user "$@"
fi
}
restart_systemd() {
[[ -n "$EVO_SYSTEMD_UNIT" ]] || die "EVO_RESTART_MODE=systemd requires EVO_SYSTEMD_UNIT"
log "restarting systemd unit: $EVO_SYSTEMD_UNIT ($EVO_SYSTEMD_SCOPE scope)"
run systemctl_cmd restart "$EVO_SYSTEMD_UNIT"
}
restart_tmux() {
command -v tmux >/dev/null 2>&1 || die "tmux not found; set EVO_RESTART_MODE=systemd or install tmux"
log "restarting tmux session '$EVO_TMUX_SESSION' in $EVO_DEPLOY_DIR"
if [[ "$DRY_RUN" != true ]] && tmux has-session -t "$EVO_TMUX_SESSION" 2>/dev/null; then
tmux kill-session -t "$EVO_TMUX_SESSION"
fi
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && tmux new-session -d -s $(printf '%q' "$EVO_TMUX_SESSION") -c $(printf '%q' "$EVO_DEPLOY_DIR") $(printf '%q' "$EVO_TMUX_COMMAND")"
}
restart_compose() {
local compose_path="$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE"
[[ -f "$compose_path" ]] || die "compose file not found: $compose_path"
log "restarting compose services ($EVO_COMPOSE_SERVICES)"
local env_args=()
[[ -f "$EVO_DEPLOY_DIR/$EVO_COMPOSE_ENV_FILE" ]] && env_args=(--env-file "$EVO_DEPLOY_DIR/$EVO_COMPOSE_ENV_FILE")
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && docker compose ${env_args[*]} -f $(printf '%q' "$EVO_COMPOSE_FILE") up -d --wait $EVO_COMPOSE_SERVICES"
}
restart_script() {
[[ -n "$EVO_RESTART_SCRIPT" ]] || die "EVO_RESTART_MODE=script requires EVO_RESTART_SCRIPT"
[[ -x "$EVO_RESTART_SCRIPT" ]] || die "EVO_RESTART_SCRIPT is not executable: $EVO_RESTART_SCRIPT"
log "running restart script: $EVO_RESTART_SCRIPT"
run env EVO_DEPLOY_DIR="$EVO_DEPLOY_DIR" "$EVO_RESTART_SCRIPT"
}
restart_services() {
local mode
mode="$(resolve_restart_mode)"
case "$mode" in
systemd) restart_systemd ;;
tmux) restart_tmux ;;
compose) restart_compose ;;
script) restart_script ;;
none)
log "EVO_RESTART_MODE=none — skipping service restart (health check only)"
;;
*)
die "unsupported restart mode: $mode"
;;
esac
}
wait_for_url() {
local url="$1"
local label="$2"
local timeout="$EVO_HEALTH_TIMEOUT_SEC"
local start now last=""
start="$(date +%s)"
log "health-check $label: $url (timeout ${timeout}s)"
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] would wait for $label at $url"
return 0
fi
while true; do
now="$(date +%s)"
if (( now - start >= timeout )); then
log "ERROR: $label health check timed out after ${timeout}s (last: ${last:-unknown})"
return 1
fi
if curl -fsS --max-time 5 "$url" >/dev/null 2>&1; then
log "$label healthy"
return 0
fi
last="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo unreachable)"
sleep 2
done
}
health_check() {
wait_for_url "$EVO_API_HEALTH_URL" "API"
wait_for_url "$EVO_WEB_HEALTH_URL" "web"
}
write_state() {
local previous_sha="$1" target_sha="$2" status="$3"
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] would write state to $EVO_STATE_FILE"
return 0
fi
mkdir -p "$(dirname "$EVO_STATE_FILE")"
cat >"$EVO_STATE_FILE" <<EOF
{
"previousSha": "$previous_sha",
"deployedSha": "$target_sha",
"ref": "$EVO_DEPLOY_REF",
"status": "$status",
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
}
git_fetch_and_checkout() {
local previous_sha target_sha
previous_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse HEAD)"
log "previous HEAD: $previous_sha"
log "fetching $EVO_GIT_REMOTE…"
run git -C "$EVO_DEPLOY_DIR" fetch "$EVO_GIT_REMOTE" --prune
if git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_DEPLOY_REF}^{commit}")"
elif git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}")"
else
die "cannot resolve ref: $EVO_DEPLOY_REF"
fi
log "checking out $target_sha ($EVO_DEPLOY_REF)"
run git -C "$EVO_DEPLOY_DIR" reset --hard "$target_sha"
printf '%s %s\n' "$previous_sha" "$target_sha"
}
run_migrate() {
[[ "$EVO_MIGRATE" == "true" ]] || { log "skipping migrate (EVO_MIGRATE=false)"; return 0; }
if [[ ! -f "$EVO_DEPLOY_DIR/package.json" ]]; then
die "package.json missing in $EVO_DEPLOY_DIR"
fi
if ! grep -q '"db:migrate"' "$EVO_DEPLOY_DIR/package.json"; then
log "no db:migrate script in package.json — skipping migrations"
return 0
fi
log "running pnpm db:generate && pnpm db:migrate"
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate && pnpm db:migrate"
}
run_install() {
[[ "$EVO_INSTALL" == "true" ]] || { log "skipping install (EVO_INSTALL=false)"; return 0; }
log "running pnpm install --frozen-lockfile"
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm install --frozen-lockfile"
}
rollback() {
local previous_sha="$1"
log "ROLLBACK: resetting to $previous_sha and restarting services"
run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha"
restart_services
write_state "$previous_sha" "$previous_sha" "rolled-back"
die "deploy failed; rolled back to $previous_sha"
}
main() {
parse_args "$@"
validate_deploy_dir
local resolved_mode
resolved_mode="$(resolve_restart_mode)"
log "deploy dir: $EVO_DEPLOY_DIR"
log "target ref: $EVO_DEPLOY_REF"
log "restart mode: $resolved_mode"
[[ "$DRY_RUN" == true ]] && log "dry-run enabled — no mutating commands"
local previous_sha target_sha
read -r previous_sha target_sha < <(git_fetch_and_checkout)
if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then
log "already at $target_sha — continuing with migrate/restart/health"
fi
run_install
run_migrate
restart_services
if ! health_check; then
rollback "$previous_sha"
exit 1
fi
write_state "$previous_sha" "$target_sha" "success"
log "deploy succeeded: $previous_sha -> $target_sha"
}
main "$@"