#!/bin/sh # oikos-web deploy script — triggered by Gitea webhook on push to dtoro/oikos-web. # Runs on mac-mini as non-root user via launchd unit oikos-web-deploy-webhook.service. # # Mirrors the essentials of dtoro/oikos's scripts/deploy.sh (plans D1/D2 there): # D1 — CI gate: blocks deploy unless Gitea reports a green run for the SHA. # D2 — versioned images: tags the image v$VERSION (from VERSION file), # keeps the last 3 tags for rollback. # No pg_dump / seed steps — this stack serves static files only. # Notify on deploy failure. Uses the Oikos API to raise an event so the # scheduler picks it up (best-effort, silent failure). notify_deploy_failure() { local reason="$1" local sha="${SHA:-unknown}" 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-web\",\"sha\":\"$sha\",\"reason\":\"$reason\"}}" \ >/dev/null 2>&1 || true fi } set -e REPO_DIR="${REPO_DIR:-$PWD}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" HEALTH_URL="${HEALTH_URL:-http://localhost:8091/}" RETRIES=${RETRIES:-30} SLEEP=${SLEEP:-2} # CI gate (D1). Set GITEA_URL + GITEA_TOKEN to enable; without them the gate # is skipped with a warning (dev/local builds). GITEA_URL="${GITEA_URL:-}" GITEA_TOKEN="${GITEA_TOKEN:-}" GITEA_OWNER="${GITEA_OWNER:-dtoro}" GITEA_REPO="${GITEA_REPO:-oikos-web}" CI_POLL_INTERVAL="${CI_POLL_INTERVAL:-15}" CI_TIMEOUT="${CI_TIMEOUT:-1200}" # Serialize deploys (mkdir lock — atomic on POSIX, no flock on macOS). LOCKDIR="${LOCKDIR:-/tmp/oikos-web-deploy.lock}" 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" ]; then notify_deploy_failure "deploy aborted (exit $rc)"; fi' EXIT _ok=0 cd "$REPO_DIR" echo "=== oikos-web deploy: $(date) ===" # Resolve the SHA we are ABOUT to deploy from the remote (read-only) so the # CI gate can run before anything is touched. REMOTE_FULL=$(git ls-remote origin refs/heads/main 2>/dev/null | awk '{print $1}') if [ -z "$REMOTE_FULL" ]; then echo "ERROR: could not resolve origin/main (offline?) — aborting before any change" exit 1 fi REMOTE_SHA=$(printf '%s' "$REMOTE_FULL" | cut -c1-12) echo "remote SHA: $REMOTE_SHA" # ── 1. CI gate (D1) ────────────────────────────────────────────────────── echo "[1/6] verify CI status for $REMOTE_SHA" verify_ci() { sha=$1 if [ -z "$GITEA_URL" ] || [ -z "$GITEA_TOKEN" ]; then echo "SKIP: GITEA_URL/GITEA_TOKEN not set — CI gate disabled. Set both to enforce." return 0 fi origin=$(git remote get-url origin 2>/dev/null || echo "") seg= case "$origin" in *@*:*) seg=${origin##*:}; seg=${seg%.git} ;; http://*|https://*) seg=${origin#*://}; seg=${seg#*/}; seg=${seg%.git} ;; esac case "$seg" in */*) GITEA_OWNER=${seg%%/*}; GITEA_REPO=${seg#*/} ;; esac api="$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/commits/$sha/status" body=$(mktemp) elapsed=0 saw_ci=0 no_signal=0 while [ "$elapsed" -lt "$CI_TIMEOUT" ]; do code=$(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" | \ curl -sS -o "$body" -w '%{http_code}' --config - "$api" 2>/dev/null) || code="000" state=$(sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$body" | head -n1) case "$code" in 200) case "$state" in success) rm -f "$body" echo "CI: green for $sha after ${elapsed}s" return 0 ;; failure|error) rm -f "$body" echo "ERROR: CI $state for $sha — refusing to deploy." echo " See $GITEA_URL/$GITEA_OWNER/$GITEA_REPO/actions" return 1 ;; pending|"") saw_ci=1 no_signal=0 ;; esac ;; 404) rm -f "$body" echo "WARN: Gitea has no CI status for $sha (404)." echo " Is Gitea Actions configured with a runner for $GITEA_OWNER/$GITEA_REPO?" echo " Proceeding without a gate." return 0 ;; 401|403) rm -f "$body" echo "WARN: GITEA_TOKEN rejected by Gitea ($code) — cannot verify CI." echo " Fix the token to enforce the gate; proceeding without one." return 0 ;; *) no_signal=$((no_signal + 1)) ;; esac if [ "$saw_ci" -eq 0 ] && [ "$no_signal" -ge 4 ]; then rm -f "$body" echo "WARN: no CI signal from Gitea after ${elapsed}s (last code=$code)." echo " CI may be down or misconfigured; proceeding without a gate." return 0 fi sleep "$CI_POLL_INTERVAL" elapsed=$((elapsed + CI_POLL_INTERVAL)) printf '\rCI: waiting (%ss, code=%s state=%s)...' "$elapsed" "$code" "${state:-none}" done rm -f "$body" echo "" echo "ERROR: CI did not reach a terminal state within ${CI_TIMEOUT}s for $sha — refusing to deploy." return 1 } verify_ci "$REMOTE_SHA" || exit 1 # ── 2. Update working tree to the verified commit ──────────────────────── echo "[2/6] git pull (ff-only)" git pull --ff-only origin main # TOCTOU guard: origin/main may have advanced during the CI wait; refuse to # ship an unverified commit — a retry verifies the new tip. PULLED_FULL=$(git rev-parse HEAD) if [ "$PULLED_FULL" != "$REMOTE_FULL" ]; then echo "ERROR: origin/main advanced during deploy (verified $REMOTE_SHA, now at $(git rev-parse --short HEAD)) — aborting; retry verifies the new tip" exit 1 fi SHA=$(git rev-parse --short HEAD) echo "SHA (deployed): $SHA" # Resolve the deploy version AFTER pull (D2) so the tag matches the code # being built. Compose interpolates $OIKOS_VERSION into the image: tag. VERSION_FILE="$REPO_DIR/VERSION" if [ -f "$VERSION_FILE" ]; then OIKOS_VERSION="v$(head -n1 "$VERSION_FILE" | tr -d '[:space:]')" export OIKOS_VERSION echo "VERSION: $OIKOS_VERSION" else echo "WARNING: VERSION file missing — image will use :latest (rollback unavailable)" fi # ── 3. Build version-tagged image (D2) ─────────────────────────────────── echo "[3/6] docker compose build" DOCKER_BUILDKIT=1 docker compose -f "$COMPOSE_FILE" build \ --build-arg BUILDKIT_INLINE_CACHE=1 # ── 4. Rolling restart ─────────────────────────────────────────────────── echo "[4/6] docker compose up -d" docker compose -f "$COMPOSE_FILE" up -d --remove-orphans # ── 5. Prune old image tags — keep the 3 newest so rollback ────────────── # (OIKOS_VERSION=v0.x.y docker compose up) stays available. echo "[5/6] prune old image tags (keep 3)" if [ -n "$OIKOS_VERSION" ]; then images=$(docker compose -f "$COMPOSE_FILE" config --images 2>/dev/null || true) if [ -z "$images" ]; then images="oikos-web" fi printf '%s\n' $images | sed 's/:.*//' | sort -u | while read -r repo; do docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do docker rmi "$repo:$tag" >/dev/null 2>&1 || true done done fi # ── 6. Health check wait (SPA answers on the published port) ───────────── echo "[6/6] health check" healthy=0 for i in $(seq 1 $RETRIES); do if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then echo "healthy after ${i}x${SLEEP}s" healthy=1 break fi sleep "$SLEEP" done if [ "$healthy" -ne 1 ]; then echo "ERROR: health check failed after $((RETRIES * SLEEP))s" exit 1 fi # All steps completed successfully — clear failure trap _ok=1 exit 0