#!/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" 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 -h, --help show this help Examples: ./scripts/deploy.sh dankclaw ./scripts/deploy.sh dankclaw --service searxng ./scripts/deploy.sh dankserver --service frank-default-small ./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" ;; *) echo "Unknown target: $TARGET" >&2 usage >&2 exit 2 ;; esac DRY_RUN=0 EXPLICIT_SERVICES=() while [[ "$#" -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=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:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|dankclaw:searxng|\ dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\ dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\ dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\ dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\ dankclaw:frank-default-large|dankclaw:frank-default-npu|\ dankserver:mariadb|dankserver:redis|dankserver:romm|dankserver:copyparty|\ dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\ dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\ dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\ dankserver:frank-default-small) 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") 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" bash -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF' set -euo pipefail TARGET="$1" EXPECTED_REVISION="$2" shift 2 cd "/home/dank/serverAppConfigs" 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 ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@" \ --from-revision "$OLD_REVISION" REMOTE_EOF echo "=== Deploy complete: $TARGET ==="