#!/usr/bin/env bash # # release-fleet.sh — one command to release the current claire working tree to # the WHOLE fleet and restart every service. Runs FROM plum (the source of # truth + the only host with the launchd services). # # scripts/release-fleet.sh # test → deploy apricot+black → restart plum # scripts/release-fleet.sh --no-test # skip the pre-deploy pytest gate # scripts/release-fleet.sh --no-plum # leave plum's services running (only push workers) # scripts/release-fleet.sh --hosts apricot # restrict the worker host list # scripts/release-fleet.sh --dry-run # print the plan, change nothing # # What it does, in order: # 1. (gate) run the test suite — abort the whole release if it fails. # 2. workers (apricot, black): scripts/deploy-agent.sh # → rsync working tree + `uv pip install -e .` + restart claire-agent.service. # 3. plum: `launchctl kickstart -k` claire-serve + claire-tray # → editable install, so a restart is all that's needed to load new code. # # ⚠ Restarting plum's `com.lilith.claire-serve` briefly drops the web / API / # MCP endpoint (~a few seconds). Anything mid-call against claire's MCP tools # — INCLUDING the orchestrator itself — will blip until it comes back. Run it # when you can tolerate that, or pass --no-plum and restart plum by hand. # # Requires (same as deploy-agent.sh): `remote-run` on PATH, ssh to the worker # hosts, uv/python on the remotes, NTP-synced clocks. set -euo pipefail SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOSTS=(apricot black) RUN_TESTS=1 RESTART_PLUM=1 DRY_RUN=0 PLUM_SERVICES=(com.lilith.claire-serve com.lilith.claire-tray) say() { printf '\033[1;35m▸\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m⚠\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; } run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else eval "$@"; fi; } while [ $# -gt 0 ]; do case "$1" in --no-test) RUN_TESTS=0; shift ;; --no-plum) RESTART_PLUM=0; shift ;; --dry-run) DRY_RUN=1; shift ;; --hosts) shift; IFS=' ' read -r -a HOSTS <<< "${1:?--hosts needs a value}"; shift ;; -h|--help) sed -n '2,30p' "$0"; exit 0 ;; *) die "unknown arg: $1 (try --help)" ;; esac done # --- 1. test gate ---------------------------------------------------------- if [ "$RUN_TESTS" = 1 ]; then say "test gate: pytest (use --no-test to skip)" # Run via the project venv (where pytest + dev deps live); fall back to uv's # managed env. `uv run pytest` is deliberately NOT used — pytest is a dev-extra # in .venv, not a uv tool, so that spawn fails on this repo. if [ -x "$SRC/.venv/bin/python" ]; then run "(cd '$SRC' && .venv/bin/python -m pytest -q)" || die "tests failed — release aborted" elif command -v uv >/dev/null 2>&1; then run "(cd '$SRC' && uv run python -m pytest -q)" || die "tests failed — release aborted" else die "no .venv/bin/python and no uv — cannot run the test gate (use --no-test to override)" fi else warn "skipping test gate (--no-test)" fi # --- 2. worker hosts ------------------------------------------------------- for h in "${HOSTS[@]}"; do say "deploy worker → $h" run "'$SRC/scripts/deploy-agent.sh' '$h'" || die "deploy-agent.sh $h failed" done # --- 3. plum services ------------------------------------------------------ if [ "$RESTART_PLUM" = 1 ]; then warn "restarting plum services ${PLUM_SERVICES[*]} — claire-serve restart blips the MCP/web endpoint" uid="$(id -u)" for svc in "${PLUM_SERVICES[@]}"; do if launchctl print "gui/$uid/$svc" >/dev/null 2>&1; then say "kickstart $svc" run "launchctl kickstart -k 'gui/$uid/$svc'" || warn "kickstart $svc returned non-zero" else warn "$svc not loaded in gui/$uid — skipping (load its LaunchAgent first)" fi done else warn "leaving plum services running (--no-plum) — restart them by hand to load new code" fi if [ "$DRY_RUN" = 1 ]; then say "release plan printed (dry-run — nothing changed)."; else say "release complete."; fi