#!/usr/bin/env bash # deploy.sh — push main, pull one target, and deploy only selected services. # # Planning is path-aware and happens before the push. The target-side runner # then applies only those services, with per-service deployment revisions kept # outside the Git checkout. A docs-only commit is a no-op. An LLM service is # restarted only when its own files (or the shared chat template) are selected. # # Usage: # ./scripts/deploy.sh [--service NAME]... [--dry-run] # # Without --service, the planner selects services changed since origin/main and # the remote runner also applies pending service changes from its state store. # --service intentionally restricts the run to the named services; it is useful # for a safe, explicit retry after another service failed. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" PLANNER="$SCRIPT_DIR/deploy-plan.py" REMOTE_USER="dank" REMOTE_DIR="/home/dank/serverAppConfigs" REMOTE_SHELL="bash" usage() { cat <<'EOF' Usage: deploy.sh [options] Options: --service NAME deploy only NAME (repeatable; bypasses automatic planning) --dry-run show the plan without pushing or connecting to a target --skip-e2e skip the mandatory Playwright live-fleet gate (emergency only) -h, --help show this help Examples: ./scripts/deploy.sh dankclaw ./scripts/deploy.sh dankclaw --service searxng ./scripts/deploy.sh dankserver --service frank-medium ./scripts/deploy.sh dankclaw --dry-run EOF } TARGET="${1:-}" if [[ "$TARGET" == "-h" || "$TARGET" == "--help" ]]; then usage exit 0 fi if [[ -z "$TARGET" ]]; then usage >&2 exit 2 fi shift case "$TARGET" in dankclaw) REMOTE_HOST="dankclaw" ;; dankserver) REMOTE_HOST="dankserver" ;; frankbook-pro) REMOTE_HOST="frankbook-pro" REMOTE_USER="dank" REMOTE_DIR="/Users/dank/Projects/serverAppConfigs" # macOS ships bash 3.2; the runner needs bash >= 4 (associative # arrays). /usr/local/bin/bash is the Intel-homebrew interpreter, # provisioned with the Mac checkout. REMOTE_SHELL="/usr/local/bin/bash" ;; *) echo "Unknown target: $TARGET" >&2 usage >&2 exit 2 ;; esac DRY_RUN=0 SKIP_E2E=0 EXPLICIT_SERVICES=() while [[ "$#" -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1 shift ;; --skip-e2e) SKIP_E2E=1 shift ;; --service) [[ "$#" -ge 2 ]] || { echo "--service requires a name" >&2; exit 2; } EXPLICIT_SERVICES+=("$2") shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esac done cd "$REPO_ROOT" if [[ ! -x "$PLANNER" ]]; then echo "FAIL: missing executable planner: $PLANNER" >&2 exit 1 fi # A deployment is a release operation, not a way to publish an arbitrary # worktree. Include untracked files in this guard; the old diff-only check let # an untracked secret or half-written config pass the pre-flight. if [[ -n "$(git status --porcelain)" ]]; then echo "FAIL: Local working tree has uncommitted changes." >&2 echo "Commit or remove them before deploying." >&2 exit 1 fi if [[ "$(git branch --show-current)" != "main" ]]; then echo "FAIL: deploy.sh must run from the main branch." >&2 exit 1 fi HEAD_REVISION="$(git rev-parse HEAD)" if BASE_REVISION="$(git rev-parse origin/main 2>/dev/null)"; then : else BASE_REVISION="$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)" fi valid_service() { local service="$1" case "$TARGET:$service" in dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\ dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\ dankclaw:gitea-server|dankclaw:homepage|\ dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\ dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\ dankclaw:alertmanager|\ dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\ dankclaw:frank-large|dankclaw:frank-small|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\ dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\ dankserver:frank-medium|dankserver:frank-primary|\ frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\ frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\ frankbook-pro:readarr) return 0 ;; *) return 1 ;; esac } for service in "${EXPLICIT_SERVICES[@]}"; do if ! valid_service "$service"; then echo "Service '$service' is not deployed on $TARGET." >&2 exit 2 fi done PLAN=() if [[ "${#EXPLICIT_SERVICES[@]}" -gt 0 ]]; then PLAN=("${EXPLICIT_SERVICES[@]}") else mapfile -t PLAN < <( python3 "$PLANNER" --target "$TARGET" --repo "$REPO_ROOT" \ --base "$BASE_REVISION" --head "$HEAD_REVISION" ) fi # Preserve planner order while removing duplicate explicit service names. UNIQUE_PLAN=() declare -A SEEN=() for service in "${PLAN[@]}"; do [[ -n "$service" ]] || continue if [[ -z "${SEEN[$service]:-}" ]]; then UNIQUE_PLAN+=("$service") SEEN[$service]=1 fi done PLAN=("${UNIQUE_PLAN[@]}") printf '=== Deployment plan: %s ===\n' "$TARGET" printf ' checkout: %s\n' "$HEAD_REVISION" if [[ "${#PLAN[@]}" -eq 0 ]]; then echo " changed services: (none; the remote will check pending service state)" else printf ' changed services:\n' printf ' - %s\n' "${PLAN[@]}" fi REMOTE="${REMOTE_USER}@${REMOTE_HOST}" REMOTE_ARGS=("$TARGET" "$HEAD_REVISION" "$REMOTE_DIR" "$REMOTE_SHELL") if [[ "${#EXPLICIT_SERVICES[@]}" -eq 0 ]]; then REMOTE_ARGS+=(--pending) fi REMOTE_ARGS+=("${PLAN[@]}") if [[ "$DRY_RUN" -eq 1 ]]; then echo "[dry-run] Would push main to origin." echo "[dry-run] Would SSH to $REMOTE, pull with --ff-only, and run the service-scoped runner." printf '[dry-run] Runner arguments:' printf ' %q' "${REMOTE_ARGS[@]}" printf '\n' exit 0 fi echo "[1/2] Pushing main to origin..." git push origin main echo "[2/2] Pulling and deploying on $REMOTE_HOST..." # The target runner computes any pending revisions from its own state after # the pull. This keeps a target that missed an earlier service deployment # recoverable without restarting services that are already current. ssh "$REMOTE" "$REMOTE_SHELL" -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF' set -euo pipefail TARGET="$1" EXPECTED_REVISION="$2" REPO_DIR="$3" REMOTE_SHELL="$4" shift 4 export REMOTE_DIR="$REPO_DIR" cd "$REPO_DIR" OLD_REVISION="$(git rev-parse HEAD)" echo " pulling origin/main..." git pull --ff-only origin main NEW_REVISION="$(git rev-parse HEAD)" if [[ "$NEW_REVISION" != "$EXPECTED_REVISION" ]]; then echo "FAIL: target checkout is $NEW_REVISION, expected $EXPECTED_REVISION" >&2 exit 1 fi exec "$REMOTE_SHELL" ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@" \ --from-revision "$OLD_REVISION" REMOTE_EOF # --- Playwright e2e gate (MANDATORY after behavior-affecting deploys) --- # The unit seams in tests/run.sh cannot see the live guard chain, LAN DNS, or # the rendered homepage — exactly the layers that broke on 2026-09-12. This gate # drives the LIVE fleet (Anubis -> Authentik outpost -> apps) and fails the # deploy on any regression. It needs tests/e2e/.env.local with the qa-e2e IdP # credentials (see .env.local.example). Use --skip-e2e for an emergency only. if [[ "$SKIP_E2E" -eq 1 ]]; then echo "[3/3] e2e gate SKIPPED (--skip-e2e): live fleet NOT verified." >&2 else echo "[3/3] Running Playwright e2e gate against the live fleet..." if bash "$REPO_ROOT/tests/e2e/run.sh"; then echo "e2e gate PASSED." else echo "=== DEPLOY INCOMPLETE: e2e gate FAILED on $TARGET ===" >&2 echo "The live-fleet Playwright suite did not pass. Do NOT treat this" >&2 echo "deploy as good. Re-run manually: tests/e2e/run.sh" >&2 exit 1 fi fi echo "=== Deploy complete: $TARGET ==="