#!/usr/bin/env bash # evOo-local — idempotent deploy helper for the Evo host checkout. # Pull (or pin) a revision, install, build the web app, migrate, restart services, health-check, # roll back on failure. set -euo pipefail # --- 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_SYSTEMD_AUTO_UNIT="${EVO_SYSTEMD_AUTO_UNIT:-evoo-local.service}" 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_BUILD="${EVO_BUILD:-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; } is_valid_git_sha() { [[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]] } usage() { cat <<'EOF' Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] [--skip-build] 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 ;; --skip-build) EVO_BUILD=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" } systemd_unit_usable() { local unit="$1" local scope="$2" command -v systemctl >/dev/null 2>&1 || return 1 local -a ctl=(systemctl) if [[ "$scope" == "user" ]]; then ctl=(systemctl --user) fi if "${ctl[@]}" is-active --quiet "$unit" 2>/dev/null; then return 0 fi if "${ctl[@]}" is-enabled --quiet "$unit" 2>/dev/null; then return 0 fi local load_state load_state="$("${ctl[@]}" show -p LoadState --value "$unit" 2>/dev/null || true)" [[ "$load_state" == "loaded" ]] } # When EVO_RESTART_MODE=auto and EVO_SYSTEMD_UNIT is unset, pick a unit that is active or installed. auto_detect_systemd() { local unit="${EVO_SYSTEMD_UNIT:-$EVO_SYSTEMD_AUTO_UNIT}" local scope for scope in user system; do if systemd_unit_usable "$unit" "$scope"; then EVO_SYSTEMD_UNIT="$unit" EVO_SYSTEMD_SCOPE="$scope" return 0 fi done return 1 } # Side effects must run outside $(resolve_restart_mode) — command substitution uses a subshell. prepare_auto_restart_mode() { [[ "$EVO_RESTART_MODE" == "auto" && -z "$EVO_SYSTEMD_UNIT" ]] || return 0 auto_detect_systemd || true } 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() { prepare_auto_restart_mode 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" is_valid_git_sha "$previous_sha" || die "refusing to write state: invalid previousSha: $previous_sha" is_valid_git_sha "$target_sha" || die "refusing to write state: invalid deployedSha: $target_sha" 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" <&2 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" >&2 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" } # Rollback regenerates the client for the previous schema; applied migrations stay applied. run_generate() { [[ "$EVO_MIGRATE" == "true" ]] || return 0 log "running pnpm db:generate" run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate" } 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" } # The build goes to a staging directory, so a failing build never touches the one a running # `vite preview` serves; activate_web_build swaps it in right before the restart. WEB_STAGING_DIR="$EVO_DEPLOY_DIR/.evo-deploy/web-dist" WEB_BUILD_LOG="$EVO_DEPLOY_DIR/.evo-deploy/web-build.log" WEB_DIST_DIR="$EVO_DEPLOY_DIR/apps/web/dist" WEB_BUILD_REPLACED=false build_web() { [[ "$EVO_BUILD" == "true" ]] || { log "skipping web build (EVO_BUILD=false)"; return 0; } log "building the web app" if [[ "$DRY_RUN" == true ]]; then log "[dry-run] pnpm --filter @rakazo/web build --outDir $WEB_STAGING_DIR" return 0 fi rm -rf "$WEB_STAGING_DIR" && mkdir -p "$(dirname "$WEB_BUILD_LOG")" || return 1 if run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/web build --outDir $(printf '%q' "$WEB_STAGING_DIR") --emptyOutDir" >"$WEB_BUILD_LOG" 2>&1 && [[ -f "$WEB_STAGING_DIR/index.html" ]]; then return 0 fi # Stack frames bury the message that names the file and line. grep -v -E '^[[:space:]]+at ' "$WEB_BUILD_LOG" | tail -n 60 >&2 || true log "ERROR: web build failed (full log: $WEB_BUILD_LOG)" return 1 } activate_web_build() { [[ "$EVO_BUILD" == "true" ]] || return 0 local outgoing="$WEB_STAGING_DIR.outgoing" log "activating the web build" WEB_BUILD_REPLACED=true run rm -rf "$outgoing" || return 1 if [[ -d "$WEB_DIST_DIR" ]]; then run mv "$WEB_DIST_DIR" "$outgoing" || return 1 fi run mv "$WEB_STAGING_DIR" "$WEB_DIST_DIR" || return 1 run rm -rf "$outgoing" } # Restores the previous commit with its dependencies, client and web build, restarts and # health-checks it. Every step is attempted so the service comes back even if one of them fails. rollback() { local previous_sha="$1" status="rolled-back" is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha" log "ROLLBACK: resetting to $previous_sha" if ! run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2; then write_state "$previous_sha" "$previous_sha" "rollback-failed" die "deploy failed and the checkout could not be reset to $previous_sha" fi run_install || status="rollback-failed" run_generate || status="rollback-failed" # Until activation the served build is still the previous one; rebuild only once it was replaced. if [[ "$WEB_BUILD_REPLACED" == true ]] && ! { build_web && activate_web_build; }; then status="rollback-failed" fi if ! { restart_services && health_check; }; then status="rollback-failed" fi write_state "$previous_sha" "$previous_sha" "$status" [[ "$status" == "rolled-back" ]] || die "deploy failed; rollback to $previous_sha did not complete" die "deploy failed; rolled back to $previous_sha" } main() { parse_args "$@" validate_deploy_dir prepare_auto_restart_mode 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) is_valid_git_sha "$previous_sha" || die "invalid previous_sha captured from checkout: $previous_sha" is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha" if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then log "already at $target_sha — continuing with install/build/migrate/restart/health" fi # Any failure rolls back; a failing install, build or migration stops before the restart. if ! { run_install && build_web && run_migrate && activate_web_build && restart_services && health_check; }; then rollback "$previous_sha" fi write_state "$previous_sha" "$target_sha" "success" log "deploy succeeded: $previous_sha -> $target_sha" } main "$@"