The 11-admin spec walked links that terminate on FrankBook-Pro (192.168.0.112), which sleeps when idle — every deploy gate timed out there (measured 2026-10-05, two deploy windows). Split: the frankbook links move to tests/e2e/specs/19-frankbook.spec.ts under a frankbook project that run.sh excludes by default and deploy.sh triggers ONLY when a frankbook blast-radius path changed (the spec, frankbook-pro/, the homepage registry, the Caddyfile arr vhosts). deploy.sh computes the trigger from the same BASE..HEAD revision range the plan uses.
289 lines
9.9 KiB
Bash
Executable File
289 lines
9.9 KiB
Bash
Executable File
#!/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 <dankclaw|dankserver|frankbook-pro> [--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 <dankclaw|dankserver|frankbook-pro> [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-primary
|
|
./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-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
|
|
|
|
# --- frankbook e2e trigger --------------------------------------------------
|
|
# The *arr/qBittorrent e2e units walk links that terminate on FrankBook-Pro
|
|
# (192.168.0.112), which sleeps when idle. The [3/3] e2e gate therefore runs
|
|
# the frankbook Playwright project ONLY when something in its blast radius
|
|
# changed since origin/main: the frankbook spec itself, the frankbook-pro
|
|
# stack, the homepage registry, or the Caddyfile serving the arr vhosts.
|
|
# Every other deploy skips it (tests/e2e/run.sh excludes the project by
|
|
# default). See tests/e2e/specs/19-frankbook.spec.ts.
|
|
FB_E2E_TRIGGER=0
|
|
while IFS= read -r fb_path; do
|
|
case "$fb_path" in
|
|
tests/e2e/specs/19-frankbook.spec.ts|frankbook-pro/*|shared/homepage/services.json|dankclaw/Caddyfile) FB_E2E_TRIGGER=1 ;;
|
|
esac
|
|
done < <(git diff --name-only "$BASE_REVISION" "$HEAD_REVISION")
|
|
|
|
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'
|
|
if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then
|
|
echo "[dry-run] e2e gate: frankbook project INCLUDED (frankbook trigger paths changed)."
|
|
else
|
|
echo "[dry-run] e2e gate: frankbook project EXCLUDED (no frankbook trigger paths)."
|
|
fi
|
|
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..."
|
|
E2E_ENV=()
|
|
if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then
|
|
echo " frankbook trigger paths changed: including the frankbook e2e project."
|
|
E2E_ENV=(E2E_INCLUDE_FRANKBOOK=1)
|
|
fi
|
|
if env "${E2E_ENV[@]}" 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 ==="
|