diff --git a/AGENTS.md b/AGENTS.md index 14cfded..41ea90d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,3 +122,4 @@ wait, and tests pin the retry to 0. - lint: `pre-commit run ruff --all-files` - coverage: `python -m pytest -n auto -p no:sugar -q` - coverage-gaps: `coverage-gaps coverage.lcov` +- deploy: `git -C ~/src/screen-locker pull --ff-only && bash ~/src/screen-locker/scripts/deploy.sh` (reinstalls, restarts web/poke/runnerup units, health-checks them, then release-deploys the workout app; exit 46 from the phone guard means kuhy reruns it with `PHONE_PROTECT_ALLOW=com.kuhy.workout_app`) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..896adec --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Deploy screen-locker for real: update the live clone, reinstall, restart the +# long-running user units, prove they are healthy, then ship the workout app. +# +# Usage: bash scripts/deploy.sh [--health-only] +# +# Every path is anchored on the LIVE clone (~/src/screen-locker), never on this +# script's own directory: install.sh installs editable from wherever it sits, +# so running a worktree copy of it would point the services at scratch code. +# +# workout-locker.service is deliberately NOT restarted: it is the login-time +# gate and starting it can lock the display. Its timer is only re-enabled. +# +# Phone: phone_deploy.sh refuses com.kuhy.workout_app (exit 46) unless kuhy +# runs this himself with PHONE_PROTECT_ALLOW=com.kuhy.workout_app and the app +# closed. This script never sets that variable; it passes the caller's +# environment through and fails loudly when the guard refuses. +# +# Seams: SCREEN_LOCKER_LIVE (live clone), DEPLOY_WEB_PORT (health port). +# Exit: 0 healthy and installed everywhere | 1 any step or check failed. + +set -euo pipefail + +readonly LIVE="${SCREEN_LOCKER_LIVE:-$HOME/src/screen-locker}" +readonly PY="/usr/bin/python3" +readonly APP_DIR="stronglift_replacement/workout_app" +readonly PHONE_DEPLOY="$HOME/.claude/scripts/phone_deploy.sh" +readonly UNITS=(screen-locker-web.service workout-poke.service runnerup-watch.service) + +die() { echo "deploy: FAIL: $*" >&2; exit 1; } +ok() { echo "deploy: ok: $*"; } + +main_pid() { systemctl --user show -p MainPID --value "$1"; } + +web_port() { + if [[ -n "${DEPLOY_WEB_PORT:-}" ]]; then + echo "$DEPLOY_WEB_PORT" + else + (cd /tmp && "$PY" -c 'from screen_locker._web_server import DEFAULT_PORT; print(DEFAULT_PORT)') + fi +} + +# /api/health answers and reports enforcement armed; retried while the +# freshly restarted server binds its port. +check_web() { + local port body i + port="$(web_port)" + for i in $(seq 1 15); do + if body="$(curl -fsS --max-time 2 "http://127.0.0.1:$port/api/health" 2>/dev/null)"; then + jq -e '.armed == true' >/dev/null <<<"$body" \ + || die "web health on :$port says enforcement is NOT armed" + ok "web /api/health on :$port armed (try $i)" + return 0 + fi + sleep 1 + done + die "web /api/health on :$port did not answer within 15 s" +} + +health() { + local unit + for unit in "${UNITS[@]}"; do + systemctl --user is-active --quiet "$unit" || die "$unit is not active" + ok "$unit active" + done + check_web + "$PY" "$LIVE/scripts/check_enforcement_armed.py" --quiet \ + || die "check_enforcement_armed.py: enforcement is not armed" + ok "enforcement armed" + "$PY" "$LIVE/scripts/poke_selftest.py" sandbox >/dev/null \ + || die "workout-poke sandbox self-test failed" + ok "workout-poke signed sandbox round trip" + local mod + mod="$(cd /tmp && "$PY" -c 'import screen_locker; print(screen_locker.__file__)')" + [[ "$mod" == "$LIVE"/* ]] || die "screen_locker imports from $mod, not $LIVE" + ok "screen_locker imports from the live clone" +} + +desktop() { + local unit + declare -A before=() + git -C "$LIVE" pull --ff-only || die "git pull --ff-only in $LIVE (dirty or diverged?)" + [[ "$(git -C "$LIVE" rev-parse HEAD)" == "$(git -C "$LIVE" rev-parse '@{u}')" ]] \ + || die "$LIVE HEAD is not its upstream after the pull" + ok "live clone at $(git -C "$LIVE" rev-parse --short HEAD)" + bash "$LIVE/install.sh" || die "install.sh failed" + for unit in "${UNITS[@]}"; do before[$unit]="$(main_pid "$unit")"; done + systemctl --user restart "${UNITS[@]}" || die "restart of ${UNITS[*]} failed" + for unit in "${UNITS[@]}"; do + [[ "$(main_pid "$unit")" != "${before[$unit]}" ]] \ + || die "$unit kept PID ${before[$unit]}: restart did not take" + done + ok "restarted ${UNITS[*]}" +} + +phone() { + local rc=0 + bash "$PHONE_DEPLOY" "$LIVE/$APP_DIR" --release || rc=$? + case "$rc" in + 0) ok "workout app release build installed on the phone" ;; + 46) die "phone_deploy protect guard (exit 46) refused com.kuhy.workout_app;" \ + "kuhy runs: PHONE_PROTECT_ALLOW=com.kuhy.workout_app bash $LIVE/scripts/deploy.sh" \ + "with the workout app closed" ;; + *) die "phone_deploy.sh exited $rc (see its exit-code table)" ;; + esac +} + +if [[ "${1:-}" == "--health-only" ]]; then + health + exit 0 +fi +desktop +health +phone +ok "deployed"