#!/bin/sh # Oikos-plugins deploy script — triggered by Gitea webhook on push to dtoro/oikos-plugins. # Runs on mac-mini via launchd unit network.hubris.oikos-deploy-webhook (route: /deploy-plugins). # # Deployment vehicle is the in-tree clone at $DSH_DIR/packages/oikos: the web # profile symlinks its packages, and their @deepseek-ai peer deps resolve # through the harness workspace root node_modules. UI and node halves ship as # committed lib/ artifacts, so no build step runs here. On failure the script # restores the previous SHAs and notifies via the oikos API (OIKOS_API_TOKEN) # and Matrix (MATRIX_WEBHOOK_URL) when configured. set -e DSH_DIR="${DSH_DIR:-$HOME/Projects/deepseek-harness}" PLUGIN_DIR="${PLUGIN_DIR:-$DSH_DIR/packages/oikos}" PROFILE_DIR="${PROFILE_DIR:-$HOME/.dsh/profiles/web}" PORT="${PORT:-3080}" LOCKDIR="${LOCKDIR:-/tmp/oikos-plugins-deploy.lock}" DSH_BRANCH="${DSH_BRANCH:-master}" HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:$PORT/}" RETRIES=${RETRIES:-90} ROLLBACK_RETRIES=${ROLLBACK_RETRIES:-30} SLEEP=${SLEEP:-2} AGENT_LABEL="network.hubris.dsh-web" UID_N=$(id -u) notify_deploy_failure() { reason="$1" echo "NOTIFY: deploy failed — $reason" if [ -n "${OIKOS_API_TOKEN:-}" ]; then curl -sf -X POST "http://localhost:8090/api/v1/events" \ -H "Authorization: Bearer $OIKOS_API_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"type\":\"deploy.failed\",\"severity\":\"critical\",\"source\":\"webhook\",\"data\":{\"repo\":\"oikos-plugins\",\"reason\":\"$reason\"}}" \ >/dev/null 2>&1 || true fi if [ -n "${MATRIX_WEBHOOK_URL:-}" ]; then curl -sf -X POST "$MATRIX_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d "{\"msgtype\":\"m.text\",\"body\":\"🚨 oikos-plugins deploy failed: $reason\"}" \ >/dev/null 2>&1 || true fi } # Serialize deploys. mkdir is atomic on POSIX (macOS lacks flock); the # stale-pid check recovers a lock left by a SIGKILLed or rebooted deploy. if ! mkdir "$LOCKDIR" 2>/dev/null; then oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "") if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then echo "deploy already in progress (pid $oldpid) — exiting" exit 0 fi echo "removing stale deploy lock (pid ${oldpid:-?} not running)" rm -rf "$LOCKDIR" mkdir "$LOCKDIR" fi echo $$ > "$LOCKDIR/pid" trap 'rc=$?; rm -rf "$LOCKDIR" 2>/dev/null || true; if [ "$_ok" != "1" ] && [ "$_notified" != "1" ]; then notify_deploy_failure "deploy aborted (exit $rc)"; fi' EXIT _ok=0 _notified=0 # Any HTTP response counts as healthy: this probes liveness (is the port # serving), not a specific route. wait_healthy() { tries="$1" i=1 while [ "$i" -le "$tries" ]; do if curl -s -o /dev/null --max-time 2 "$HEALTH_URL"; then echo "healthy after $((i * SLEEP))s" return 0 fi sleep "$SLEEP" i=$((i + 1)) done return 1 } restart_dsh() { if ! launchctl print "gui/$UID_N/$AGENT_LABEL" >/dev/null 2>&1; then launchctl bootstrap "gui/$UID_N" "$HOME/Library/LaunchAgents/$AGENT_LABEL.plist" sleep 1 fi launchctl kickstart -k "gui/$UID_N/$AGENT_LABEL" } echo "=== oikos-plugins deploy started ===" [ -d "$PLUGIN_DIR/.git" ] || { echo "ERROR: $PLUGIN_DIR is not a git clone of dtoro/oikos-plugins — refusing" exit 1 } PLUGIN_OLD=$(git -C "$PLUGIN_DIR" rev-parse HEAD 2>/dev/null || echo "") DSH_OLD=$(git -C "$DSH_DIR" rev-parse HEAD 2>/dev/null || echo "") echo "pre-deploy: plugins=${PLUGIN_OLD:-none} dsh=${DSH_OLD:-none}" rollback() { _notified=1 echo "=== rolling back ===" if [ -n "$PLUGIN_OLD" ] && [ "$(git -C "$PLUGIN_DIR" rev-parse HEAD)" != "$PLUGIN_OLD" ]; then git -C "$PLUGIN_DIR" reset --hard "$PLUGIN_OLD" fi if [ -n "$DSH_OLD" ] && [ "$(git -C "$DSH_DIR" rev-parse HEAD)" != "$DSH_OLD" ]; then git -C "$DSH_DIR" reset --hard "$DSH_OLD" pnpm -C "$DSH_DIR" install --no-frozen-lockfile >/dev/null 2>&1 || true git -C "$DSH_DIR" checkout -- pnpm-lock.yaml 2>/dev/null || true fi restart_dsh if wait_healthy "$ROLLBACK_RETRIES"; then notify_deploy_failure "deploy failed; rolled back to plugins@${PLUGIN_OLD:-?} dsh@${DSH_OLD:-?}" else notify_deploy_failure "deploy failed and rollback unhealthy — dsh web DOWN on port $PORT" fi } # 1. Plugins: advance the in-tree clone to the pushed state. Uncommitted local # edits mean someone is developing here — never destroy them; deploy the dirty # tree as-is and say so. git -C "$PLUGIN_DIR" fetch origin master if git -C "$PLUGIN_DIR" diff --quiet && git -C "$PLUGIN_DIR" diff --cached --quiet; then git -C "$PLUGIN_DIR" reset --hard origin/master echo "plugins at $(git -C "$PLUGIN_DIR" rev-parse --short HEAD)" else echo "WARN: $PLUGIN_DIR has uncommitted changes — deploying the dirty tree as-is" fi # 2. Harness: advance by fast-forward only (never discards local commits; # refuses when diverged). The untracked packages/oikos member is invisible to # origin/master's lockfile, so install non-frozen and restore the lockfile # afterwards: node_modules keeps the resolution, the tree stays clean. git -C "$DSH_DIR" fetch origin "$DSH_BRANCH" git -C "$DSH_DIR" pull --ff-only origin "$DSH_BRANCH" pnpm -C "$DSH_DIR" install --no-frozen-lockfile git -C "$DSH_DIR" checkout -- pnpm-lock.yaml echo "dsh at $(git -C "$DSH_DIR" rev-parse --short HEAD)" # 2b. Rebuild the frontend dist: the web-app bundle serves the gitignored # apps/web dist through workspace exports, so a harness advance without a # rebuild keeps serving the previous UI. pnpm -C "$DSH_DIR" --filter @deepseek-ai/dsh-web-frontend run build >/dev/null echo "frontend dist rebuilt" # 3. Symlink packages into dsh profile for pkg in ui mcp-scope session-summary bundle evals; do name=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$PLUGIN_DIR/$pkg/package.json')).name)") ln -sf "$PLUGIN_DIR/$pkg" "$PROFILE_DIR/node_modules/$name" echo "linked $name" done # 4. Patch migration: the oikos overlay used to live only in /tmp (wiped on # reboot). If the profile patch layer is still empty and the /tmp copy exists, # move it into the profile so launchd boots need no --patch flag. if [ -f /tmp/oikos-mcp-patch.yml ] && ! grep -q 'id:' "$PROFILE_DIR/cordis.patch.yml" 2>/dev/null; then cp /tmp/oikos-mcp-patch.yml "$PROFILE_DIR/cordis.patch.yml" echo "migrated oikos patch into $PROFILE_DIR/cordis.patch.yml" fi # 5. Restart under launchd and health-check restart_dsh if ! wait_healthy "$RETRIES"; then echo "ERROR: health check failed after $((RETRIES * SLEEP))s" rollback exit 1 fi _ok=1 echo "=== oikos-plugins deploy complete ==="