From f2a8052f4d69cc2d71514b100828cc33b20c2a94 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:22:36 +0200 Subject: [PATCH] feat(ops): auto-deploy evOo-local on Evo after main advances (#85) ops/evo-deploy: deploy.sh with health+rollback, evo-deploy.yml on self-hosted evoo-ci (gated by EVO_DEPLOY_ENABLED), README. --- .github/workflows/evo-deploy.yml | 83 +++++++++ ops/evo-deploy/README.md | 103 ++++++++++ ops/evo-deploy/deploy.sh | 310 +++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 .github/workflows/evo-deploy.yml create mode 100644 ops/evo-deploy/README.md create mode 100755 ops/evo-deploy/deploy.sh diff --git a/.github/workflows/evo-deploy.yml b/.github/workflows/evo-deploy.yml new file mode 100644 index 0000000..697afc9 --- /dev/null +++ b/.github/workflows/evo-deploy.yml @@ -0,0 +1,83 @@ +name: evo-deploy + +# Deploy evOo-local on the Evo host when main advances, or on manual dispatch. +# Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow). +on: + push: + branches: [main] + workflow_dispatch: + inputs: + ref: + description: Git ref or SHA to deploy (default main tip) + required: false + default: main + dry_run: + description: Dry run (no git/service changes) + type: boolean + default: false + skip_migrate: + description: Skip database migrations + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: evo-deploy + cancel-in-progress: false + +jobs: + deploy: + if: vars.EVO_DEPLOY_ENABLED == 'true' + runs-on: [self-hosted, evoo-ci, Linux] + timeout-minutes: 30 + steps: + - name: Checkout deploy scripts + uses: actions/checkout@v5 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} + + - name: Deploy evOo-local on Evo + env: + EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }} + EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} + EVO_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }} + EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }} + EVO_SYSTEMD_UNIT: ${{ vars.EVO_SYSTEMD_UNIT || '' }} + EVO_SYSTEMD_SCOPE: ${{ vars.EVO_SYSTEMD_SCOPE || 'user' }} + EVO_TMUX_SESSION: ${{ vars.EVO_TMUX_SESSION || 'evoo' }} + EVO_API_HEALTH_URL: ${{ vars.EVO_API_HEALTH_URL || 'http://127.0.0.1:3100/health' }} + EVO_WEB_HEALTH_URL: ${{ vars.EVO_WEB_HEALTH_URL || 'http://127.0.0.1:5173/' }} + EVO_HEALTH_TIMEOUT_SEC: ${{ vars.EVO_HEALTH_TIMEOUT_SEC || '120' }} + run: | + set -euo pipefail + EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}" + export EVO_DEPLOY_DIR + test -d "$EVO_DEPLOY_DIR" || { echo "EVO_DEPLOY_DIR missing: $EVO_DEPLOY_DIR" >&2; exit 1; } + args=() + [[ "$EVO_DRY_RUN" == "true" ]] && args+=(--dry-run) + [[ "$EVO_MIGRATE" == "false" ]] && args+=(--skip-migrate) + bash "${GITHUB_WORKSPACE}/ops/evo-deploy/deploy.sh" "${args[@]}" + + - name: Deploy summary + if: always() + env: + EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }} + run: | + EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}" + EVO_STATE_FILE="$EVO_DEPLOY_DIR/.evo-deploy/state.json" + { + echo "### Evo deploy" + echo + echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}\`" + echo "- Dir: \`$EVO_DEPLOY_DIR\`" + if [[ -f "$EVO_STATE_FILE" ]]; then + echo "- State:" + echo '```json' + cat "$EVO_STATE_FILE" + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/ops/evo-deploy/README.md b/ops/evo-deploy/README.md new file mode 100644 index 0000000..f4de14d --- /dev/null +++ b/ops/evo-deploy/README.md @@ -0,0 +1,103 @@ +# Evo auto-deploy (evOo-local) + +Idempotent deploy helper for the **source checkout** on the Evo host (`~/Projects/evOo-local`, user `cynar`). Pulls `main` (or a pinned SHA), runs migrations, restarts the existing stack, health-checks API `:3100` and web `:5173`, and rolls back on failure. + +This is separate from production Docker deploys under `/srv/rakazo` (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`). + +## Prerequisites on Evo + +1. Git checkout at `~/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`). +2. `.env` with a working `DATABASE_URL` (Postgres reachable for `pnpm db:migrate`). +3. A restart path the script can use (pick one): + - **systemd (recommended for unattended deploy):** user unit, e.g. `evoo-local.service`, that starts `pnpm dev` or your stack wrapper. Set repo variable `EVO_SYSTEMD_UNIT`. + - **tmux:** session `evoo` running `pnpm dev` (matches `scripts/start-tmux.sh` on Mac; on Evo set `EVO_TMUX_SESSION` if different). + - **compose:** `infra/compose/docker-compose.yml` with `api` / `web` / `worker` (dev stack). + - **script:** executable hook at `EVO_RESTART_SCRIPT`. +4. Self-hosted GitHub Actions runner on Evo with labels `[self-hosted, evoo-ci, Linux]` (already used by CI). + +No SSH keys or deploy secrets are stored in this repo. The workflow runs **on** the Evo runner against the local checkout. + +## Enable the workflow + +1. Merge this PR (or copy `ops/evo-deploy/` and `.github/workflows/evo-deploy.yml` to `main`). +2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for `cynarAI/evOo-local`: + - `EVO_DEPLOY_ENABLED` = `true` + - Optional: `EVO_DEPLOY_DIR` (default `~/Projects/evOo-local` on the runner user) + - Optional: `EVO_RESTART_MODE` (`auto`, `systemd`, `tmux`, `compose`, `script`, `none`) + - Optional: `EVO_SYSTEMD_UNIT`, `EVO_TMUX_SESSION`, health URLs, timeouts +3. Confirm the runner user can read/write `EVO_DEPLOY_DIR` and restart services (`systemctl --user` or tmux). + +Until `EVO_DEPLOY_ENABLED` is `true`, pushes to `main` do not deploy (workflow job is skipped). + +## Manual / dry-run + +On Evo (or any machine with the checkout and stack): + +```bash +cd ~/Projects/evOo-local + +# Show what would happen (no git reset, migrate, restart, or health wait) +bash ops/evo-deploy/deploy.sh --dry-run + +# Deploy main tip +bash ops/evo-deploy/deploy.sh + +# Pin a SHA +EVO_DEPLOY_REF=abc1234 bash ops/evo-deploy/deploy.sh + +# Force systemd restart +EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy/deploy.sh +``` + +GitHub: **Actions → evo-deploy → Run workflow** (optional `ref`, `dry_run`, `skip_migrate`). + +## Rollback behavior + +1. Before `git fetch`, the script records `previousSha` (current `HEAD`). +2. After pull, migrate, and restart, it waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for: + - `EVO_API_HEALTH_URL` (default `http://127.0.0.1:3100/health`) + - `EVO_WEB_HEALTH_URL` (default `http://127.0.0.1:5173/`) +3. On failure: `git reset --hard` to `previousSha`, restart services again, exit non-zero. +4. Last outcome is written to `.evo-deploy/state.json` under the checkout. + +Manual rollback to a known good SHA: + +```bash +cd ~/Projects/evOo-local +git reset --hard +EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy/deploy.sh --skip-migrate --ref HEAD +``` + +(or restart your unit/tmux session by hand, then verify health URLs). + +## Autopilot / Grok Bot after merge + +After a PR merges to `main`: + +1. **Preferred:** rely on the `evo-deploy` workflow (once `EVO_DEPLOY_ENABLED=true`). No SSH step required. +2. **Fallback (manual):** SSH to Evo and run: + ```bash + cd ~/Projects/evOo-local && bash ops/evo-deploy/deploy.sh + ``` +3. **Verify:** API `curl -fsS http://127.0.0.1:3100/health`, web `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`. + +Do not commit host passwords, tokens, or SSH private keys. Configure restart targets via repo variables or env on the runner host only. + +## Environment reference + +| Variable | Default | Purpose | +|----------|---------|---------| +| `EVO_DEPLOY_DIR` | `$HOME/Projects/evOo-local` | Git checkout root | +| `EVO_DEPLOY_REF` | `main` | Branch name or commit SHA | +| `EVO_RESTART_MODE` | `auto` | `systemd` / `tmux` / `compose` / `script` / `none` | +| `EVO_SYSTEMD_UNIT` | _(empty)_ | Required for `systemd` mode | +| `EVO_SYSTEMD_SCOPE` | `user` | `user` or `system` | +| `EVO_TMUX_SESSION` | `evoo` | tmux session name | +| `EVO_TMUX_COMMAND` | `pnpm dev` | Command started in new tmux session | +| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` | +| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout | +| `EVO_API_HEALTH_URL` | `http://127.0.0.1:3100/health` | API health probe | +| `EVO_WEB_HEALTH_URL` | `http://127.0.0.1:5173/` | Web health probe | +| `EVO_HEALTH_TIMEOUT_SEC` | `120` | Per-check timeout (seconds) | + +If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, deploy fails with a clear error — set `EVO_RESTART_MODE` and the matching unit/session explicitly. diff --git a/ops/evo-deploy/deploy.sh b/ops/evo-deploy/deploy.sh new file mode 100755 index 0000000..cb1074e --- /dev/null +++ b/ops/evo-deploy/deploy.sh @@ -0,0 +1,310 @@ +#!/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" </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 "$@"