# CD Pipeline: Auto-deploy to Mac Mini on push to main # # Requires a self-hosted GitHub Actions runner on the Mac Mini. # Setup: see docs/MAC_MINI_RUNNER_SETUP.md # # Flow: # Push → main : Detects changed services across the FULL push range # (github.event.before..sha), rebuilds & restarts only # those containers. # # Service → path mapping lives in one array (SERVICE_SOURCES) — add a # new service by adding one line. The Deploy job reads a single # `services` output, so no per-service wiring in two places. name: CD Mac Mini on: push: branches: - main workflow_dispatch: inputs: service: description: 'Service to deploy (or "all" for everything)' required: false default: 'all' type: choice options: - all - mana-auth - mana-ai - mana-credits - mana-research - mana-events - mana-geocoding - mana-user - mana-subscriptions - mana-analytics - mana-search - mana-sync - mana-notify - mana-crawler - mana-api-gateway - mana-media - mana-llm - mana-landing-builder - mana-web - mana-api - manavoxel-web - memoro-server - memoro-audio-server concurrency: group: cd-macmini cancel-in-progress: false # Don't cancel in-progress deploys env: PROJECT_DIR: /Users/mana/projects/managarten COMPOSE_FILE: docker-compose.macmini.yml ENV_FILE: .env.macmini DOCKER_BUILDKIT: 1 PATH: /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin jobs: # =========================================== # Detect what changed # =========================================== detect-changes: name: Detect Changes runs-on: self-hosted if: github.event_name == 'push' outputs: services: ${{ steps.changes.outputs.services }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Check for changes id: changes run: | cd "${{ env.PROJECT_DIR }}" # Compare the full push range, not just the tip. github.event.before # is the commit main pointed at BEFORE this push — so a push of N # commits lands with before = HEAD~N. Previously we only looked at # HEAD~1 HEAD, which made pushes of more than one commit silently # drop all-but-the-tip's changes. BEFORE="${{ github.event.before }}" AFTER="${{ github.sha }}" if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then # First push to the branch / force-push with no shared history. # Fall back to the last commit only — better than failing open. echo "No valid 'before' SHA (first push or force reset), using HEAD~1" CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "") else # Make sure we actually have the 'before' commit locally. The # self-hosted runner works in a long-lived clone that usually does, # but a forced push to a rebased branch can leave it missing. if ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then echo "Fetching origin to resolve $BEFORE..." git fetch origin main --no-tags --quiet || true fi CHANGED=$(git diff --name-only "$BEFORE" "$AFTER" 2>/dev/null || git diff --name-only HEAD~1 HEAD) fi # Shared packages trigger rebuilds for all web services that import # them. Go services use packages/shared-go/ and are handled per-svc. SHARED_CHANGED="false" if echo "$CHANGED" | grep -qE "^packages/(shared-ui|shared-theme|shared-icons|shared-tailwind|shared-auth|shared-branding|shared-i18n|shared-utils|shared-types|shared-pwa|shared-vite-config)/"; then SHARED_CHANGED="true" fi # Root-level SvelteKit base Dockerfile or pnpm-lock also force a # web-app rebuild — the per-app Dockerfiles do FROM sveltekit-base. WEB_BASE_CHANGED="false" if echo "$CHANGED" | grep -qE "^(docker/Dockerfile.sveltekit-base|pnpm-lock.yaml)$"; then WEB_BASE_CHANGED="true" fi # Service → source paths. One line per compose service. Space- # separated paths are OR'd: any of them matching triggers a rebuild. # Keep names aligned with `docker compose config --services`. # # NOTE 2026-05-08 — 5 Plattform-Services (mana-auth, mana-credits, # mana-notify, mana-media, mana-llm) bauen ab dem Cutover aus # `../mana/services/...`. Ihre Source-Pfade liegen im Schwester-Repo # `mana/` und werden von diesem `git diff` (das nur das Monorepo # sieht) NICHT mehr automatisch erkannt. Manuelles Deploy geht # weiterhin via `workflow_dispatch` mit `service: mana-` — # `docker compose build` zieht dann aus `../mana/`. Für # Auto-Detect bei Plattform-Code-Änderungen gehört ein eigener # CD-Workflow ins `mana/`-Repo (Offener Punkt, Phase 8). SERVICE_SOURCES=( "mana-ai|services/mana-ai/" "mana-research|services/mana-research/" "mana-events|services/mana-events/" "mana-geocoding|services/mana-geocoding/" "mana-user|services/mana-user/" "mana-subscriptions|services/mana-subscriptions/" "mana-analytics|services/mana-analytics/" "mana-search|services/mana-search/ packages/shared-go/" "mana-sync|services/mana-sync/ packages/shared-go/" "mana-crawler|services/mana-crawler/ packages/shared-go/" "mana-api-gateway|services/mana-api-gateway/ packages/shared-go/" "mana-landing-builder|services/mana-landing-builder/ packages/shared-landing-ui/ packages/shared-types/" "mana-web|apps/mana/apps/web/ apps/mana/packages/" "mana-api|apps/api/" "manavoxel-web|apps/manavoxel/apps/web/ apps/manavoxel/packages/" "memoro-server|apps/memoro/apps/server/ apps/memoro/packages/" "memoro-audio-server|apps/memoro/apps/audio-server/" ) echo "Changed files (first 40):" echo "$CHANGED" | head -40 echo "" echo "Shared web packages changed: $SHARED_CHANGED" echo "SvelteKit base changed: $WEB_BASE_CHANGED" echo "" SERVICES="" for entry in "${SERVICE_SOURCES[@]}"; do name="${entry%%|*}" paths="${entry#*|}" hit="false" for path in $paths; do if echo "$CHANGED" | grep -q "^$path"; then hit="true" break fi done # Web services also rebuild on shared-package or base-image churn case "$name" in *-web|mana-api|mana-landing-builder) if [ "$SHARED_CHANGED" = "true" ] || [ "$WEB_BASE_CHANGED" = "true" ]; then hit="true" fi ;; esac if [ "$hit" = "true" ]; then SERVICES="$SERVICES $name" echo " ✓ $name" fi done # Trim leading whitespace SERVICES="${SERVICES# }" ANY="false" [ -n "$SERVICES" ] && ANY="true" echo "" echo "Deploy list: ${SERVICES:-}" echo "services=$SERVICES" >> "$GITHUB_OUTPUT" echo "any-changes=$ANY" >> "$GITHUB_OUTPUT" # =========================================== # Deploy changed services # =========================================== deploy: name: Deploy runs-on: self-hosted needs: [detect-changes] if: | always() && (needs.detect-changes.result == 'success' && needs.detect-changes.outputs.any-changes == 'true') || github.event_name == 'workflow_dispatch' steps: - name: Pull latest code run: | cd "${{ env.PROJECT_DIR }}" git pull origin main - name: Init deploy tracking id: init run: | cd "${{ env.PROJECT_DIR }}" source scripts/deploy-metrics.sh deploy_timer_start echo "start_epoch=$DEPLOY_START_EPOCH" >> $GITHUB_OUTPUT ensure_deploy_schema - name: Ensure env vars exist run: | cd "${{ env.PROJECT_DIR }}" # Add CALENDAR_ENCRYPTION_KEY if not present if ! grep -q "CALENDAR_ENCRYPTION_KEY" "${{ env.ENV_FILE }}" 2>/dev/null; then echo "CALENDAR_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "${{ env.ENV_FILE }}" echo "Added CALENDAR_ENCRYPTION_KEY to ${{ env.ENV_FILE }}" fi - name: Determine services to deploy id: services run: | cd "${{ env.PROJECT_DIR }}" if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then INPUT="${{ inputs.service }}" if [ "$INPUT" == "all" ]; then echo "Manual deploy: all services" echo "deploy-all=true" >> $GITHUB_OUTPUT echo "services=" >> $GITHUB_OUTPUT exit 0 fi echo "services=$INPUT" >> $GITHUB_OUTPUT echo "deploy-all=false" >> $GITHUB_OUTPUT echo "Services to deploy: $INPUT" else SERVICES="${{ needs.detect-changes.outputs.services }}" echo "services=$SERVICES" >> $GITHUB_OUTPUT echo "deploy-all=false" >> $GITHUB_OUTPUT echo "Services to deploy: $SERVICES" fi - name: Build shared base image run: | cd "${{ env.PROJECT_DIR }}" SERVICES="${{ steps.services.outputs.services }}" DEPLOY_ALL="${{ steps.services.outputs.deploy-all }}" NEEDS_WEB_BASE=false if [ "$DEPLOY_ALL" == "true" ]; then NEEDS_WEB_BASE=true else for svc in $SERVICES; do case "$svc" in *-web|mana-api|mana-landing-builder) NEEDS_WEB_BASE=true; break ;; esac done fi if [ "$NEEDS_WEB_BASE" == "true" ]; then echo "=== Building shared SvelteKit base image ===" docker build -f docker/Dockerfile.sveltekit-base -t sveltekit-base:local . 2>&1 | tail -5 echo "SvelteKit base image built" else echo "No web apps to deploy, skipping SvelteKit base image" fi - name: Build and deploy services id: build run: | cd "${{ env.PROJECT_DIR }}" source scripts/deploy-metrics.sh DEPLOY_ALL="${{ steps.services.outputs.deploy-all }}" SERVICES="${{ steps.services.outputs.services }}" # Determine final service list if [ "$DEPLOY_ALL" == "true" ]; then # Get all service names from compose file SERVICES=$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" config --services | tr '\n' ' ') echo "=== Rebuilding ALL services ===" elif [ -z "$SERVICES" ]; then echo "No services to deploy" echo "build-times=" >> $GITHUB_OUTPUT exit 0 else echo "=== Rebuilding: $SERVICES ===" fi # mana-web's Vite build needs 8 GiB of Node heap and Colima's # VM is sized at 12 GiB. With ~3.5 GiB of other containers # running, peak RSS occasionally OOMs the build (we hit this # on 2026-04-28). Pause the non-critical monitoring stack # for the duration of the build to free ~700 MiB of headroom; # the trap inside the wrapper restores it on exit, even on # build failure. No-op if mana-web isn't in $SERVICES. PAUSE_MONITORING=false if echo " $SERVICES " | grep -q ' mana-web '; then PAUSE_MONITORING=true echo "=== Pausing monitoring stack (mana-web build needs RAM headroom) ===" ./scripts/mac-mini/build-memory-headroom.sh start fi # Resume monitoring no matter how the build phase exits. if [ "$PAUSE_MONITORING" = "true" ]; then trap './scripts/mac-mini/build-memory-headroom.sh stop' EXIT fi # Build each service individually to capture build times BUILD_TIMES="" for svc in $SERVICES; do echo "--- Building $svc ---" build_start=$(date +%s) docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build "$svc" 2>&1 || true build_end=$(date +%s) build_dur=$(( build_end - build_start )) BUILD_TIMES="$BUILD_TIMES $svc:$build_dur" echo " $svc built in ${build_dur}s" done # Resume monitoring before the migration / start steps run — # they need cAdvisor + exporters back online to record the # deploy metrics step further down. if [ "$PAUSE_MONITORING" = "true" ]; then ./scripts/mac-mini/build-memory-headroom.sh stop trap - EXIT fi # Apply Drizzle schema migrations BEFORE we restart the # service containers — additive-only, see # scripts/mac-mini/safe-db-push.sh for the destructive guard. # If a service has no Drizzle config or no schema diff this is # a fast no-op. We must source POSTGRES_PASSWORD from the env # file because the workflow env doesn't carry it. echo "=== Applying schema migrations ===" set -a # shellcheck source=/dev/null . "$ENV_FILE" set +a PG_PASSWORD="${POSTGRES_PASSWORD:-mana123}" # `drizzle-kit` reads `drizzle.config.ts`, which itself # `import {defineConfig} from 'drizzle-kit'`. Node's resolver # only finds that import when the package lives in the local # node_modules — `pnpm dlx` puts it in the global cache, # invisible to a from-cwd resolve. So before running any # migration we install workspace deps for every Drizzle # service in this deploy. pnpm's lockfile cache makes the # second-and-later runs near-instant. DRIZZLE_SVCS="" for svc in $SERVICES; do if [ -f "services/$svc/drizzle.config.ts" ] || [ -f "services/$svc/drizzle.config.js" ]; then DRIZZLE_SVCS="$DRIZZLE_SVCS $svc" fi done if [ -n "$DRIZZLE_SVCS" ]; then echo "Installing workspace deps for Drizzle services:$DRIZZLE_SVCS" # Use pnpm's path-based filter (`--filter ./services/...`) # because our service package names don't follow a uniform # convention (`@mana/auth` vs `@mana/credits-service` etc.). # The trailing `...` includes transitive workspace deps. FILTER_FLAGS="" for svc in $DRIZZLE_SVCS; do FILTER_FLAGS="$FILTER_FLAGS --filter ./services/$svc..." done # shellcheck disable=SC2086 pnpm install $FILTER_FLAGS --frozen-lockfile --ignore-scripts 2>&1 | tail -5 || true fi # Most services live in mana_platform; mana-sync (Go, no # Drizzle) and a handful of others use mana_sync. Per-service # routing is read straight from compose's DATABASE_URL env. for svc in $SERVICES; do # Pull the literal DATABASE_URL from the compose definition, # then swap host postgres → localhost (we run on the host, # not inside the docker network). db_url=$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" config "$svc" 2>/dev/null \ | awk '/DATABASE_URL:/ {print $2; exit}' \ | sed 's|@postgres:|@localhost:|') if [ -z "$db_url" ]; then continue; fi DATABASE_URL="$db_url" PROJECT_DIR="${{ env.PROJECT_DIR }}" \ ./scripts/mac-mini/safe-db-push.sh "$svc" || { echo "[deploy] safe-db-push failed for $svc — aborting before restart" exit 1 } done # Start all services at once (no rebuild, images already built) echo "=== Starting services ===" if [ "$DEPLOY_ALL" == "true" ]; then docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d else docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --no-deps $SERVICES fi echo "=== Waiting for services to start ===" sleep 10 echo "build-times=$BUILD_TIMES" >> $GITHUB_OUTPUT - name: Health checks id: health run: | cd "${{ env.PROJECT_DIR }}" source scripts/deploy-metrics.sh # Service → internal health URL (localhost, post-deploy curl on the # Mac Mini). Services without an entry here get a "no health endpoint # configured" skip — check_health_timed handles it gracefully. health_url_for() { case "$1" in mana-auth) echo "http://localhost:3001/health" ;; mana-ai) echo "http://localhost:3067/health" ;; mana-credits) echo "http://localhost:3002/health" ;; mana-research) echo "http://localhost:3068/health" ;; mana-geocoding) echo "http://localhost:3018/health" ;; mana-user) echo "http://localhost:3062/health" ;; mana-subscriptions) echo "http://localhost:3063/health" ;; mana-analytics) echo "http://localhost:3064/health" ;; mana-search) echo "http://localhost:3012/health" ;; mana-sync) echo "http://localhost:3050/health" ;; mana-notify) echo "http://localhost:3013/health" ;; mana-crawler) echo "http://localhost:3014/health" ;; mana-api-gateway) echo "http://localhost:3016/health" ;; mana-media) echo "http://localhost:3011/health" ;; mana-llm) echo "http://localhost:3025/health" ;; mana-events) echo "http://localhost:3065/health" ;; mana-api) echo "http://localhost:3060/health" ;; mana-web) echo "http://localhost:5000/health" ;; manavoxel-web) echo "http://localhost:5028/health" ;; memoro-server) echo "http://localhost:3015/health" ;; *) echo "" ;; esac } DEPLOY_ALL="${{ steps.services.outputs.deploy-all }}" SERVICES="${{ steps.services.outputs.services }}" # On "deploy all", health-check a representative set (add more as # endpoints stabilise). Per-push deploys only check what was rebuilt. if [ "$DEPLOY_ALL" == "true" ]; then SERVICES="mana-auth mana-ai mana-credits mana-research mana-user mana-subscriptions mana-analytics mana-sync mana-api-gateway mana-api mana-web" fi HEALTH_RESULTS="" echo "=== Health Checks ===" for svc in $SERVICES; do url=$(health_url_for "$svc") if [ -z "$url" ]; then echo " - $svc: no health endpoint configured" HEALTH_RESULTS="$HEALTH_RESULTS $svc:skipped:0:0" continue fi result=$(check_health_timed "$svc" "$url" 2>/dev/null) || true status=$(echo "$result" | awk '{print $1}') elapsed=$(echo "$result" | awk '{print $2}') http_code=$(echo "$result" | awk '{print $3}') if [ -z "$status" ]; then status="skipped" elapsed="0" http_code="0" fi if [ "$status" = "ok" ]; then echo " ✓ $svc: OK (${elapsed}s)" else echo " ✗ $svc: $status (HTTP $http_code, ${elapsed}s)" fi HEALTH_RESULTS="$HEALTH_RESULTS $svc:$status:$http_code:$elapsed" done echo "health-results=$HEALTH_RESULTS" >> $GITHUB_OUTPUT - name: Record deploy metrics if: always() run: | cd "${{ env.PROJECT_DIR }}" source scripts/deploy-metrics.sh START_EPOCH="${{ steps.init.outputs.start_epoch }}" NOW=$(date +%s) DURATION=$(( NOW - START_EPOCH )) # Determine overall status STATUS="success" if [ "${{ job.status }}" != "success" ]; then STATUS="failure" fi # Determine services list DEPLOY_ALL="${{ steps.services.outputs.deploy-all }}" SERVICES="${{ steps.services.outputs.services }}" if [ "$DEPLOY_ALL" == "true" ]; then SERVICES_CSV="all" else SERVICES_CSV=$(echo "$SERVICES" | tr ' ' ',') fi COMMIT_MSG=$(git log -1 --pretty=%s 2>/dev/null | head -c 200 || echo "unknown") BRANCH="${{ github.ref_name }}" # Insert deployment row DEPLOY_ID=$(insert_deployment \ "${{ github.run_id }}" \ "${{ github.run_attempt }}" \ "${{ github.sha }}" \ "$COMMIT_MSG" \ "$BRANCH" \ "${{ github.event_name }}" \ "${{ github.actor }}" \ "$SERVICES_CSV" \ "$STATUS" 2>/dev/null) || DEPLOY_ID="" if [ -n "$DEPLOY_ID" ]; then # Finalise with duration finalise_deployment "$DEPLOY_ID" "$STATUS" "$DURATION" 2>/dev/null || true # Helper: lookup value from "key:val key2:val2" string # Usage: lookup "key" "key:val key2:val2" [field_index] (default: 2nd field) lookup() { local needle="$1" haystack="$2" field="${3:-2}" for item in $haystack; do if [ "${item%%:*}" = "$needle" ]; then echo "$item" | cut -d: -f"$field" return fi done echo "" } BUILD_TIMES="${{ steps.build.outputs.build-times }}" HEALTH_RESULTS="${{ steps.health.outputs.health-results }}" # Collect unique service names from both build and health data ALL_SVCS=$(echo "$BUILD_TIMES $HEALTH_RESULTS" | tr ' ' '\n' | cut -d: -f1 | sort -u | tr '\n' ' ') for svc in $ALL_SVCS; do [ -z "$svc" ] && continue build_dur=$(lookup "$svc" "$BUILD_TIMES" 2) build_dur="${build_dur:-0}" img_mb=$(get_image_size_mb "$svc" 2>/dev/null || echo "0") startup=$(lookup "$svc" "$HEALTH_RESULTS" 4) startup="${startup:-0}" health=$(lookup "$svc" "$HEALTH_RESULTS" 2) health="${health:-skipped}" http_code=$(lookup "$svc" "$HEALTH_RESULTS" 3) http_code="${http_code:-0}" insert_deploy_service "$DEPLOY_ID" "$svc" "$build_dur" "$img_mb" "$startup" "$health" "$http_code" 2>/dev/null || true push_service_metrics "$svc" "$build_dur" "$img_mb" "$health" 2>/dev/null || true done fi # Push overall metrics to Pushgateway push_deploy_metrics "$STATUS" "$DURATION" "$BRANCH" 2>/dev/null || true echo "Deploy tracking recorded: status=$STATUS duration=${DURATION}s" - name: Notify on failure if: failure() run: | cd "${{ env.PROJECT_DIR }}" SERVICES="${{ steps.services.outputs.services }}" [ "${{ steps.services.outputs.deploy-all }}" == "true" ] && SERVICES="all" COMMIT_MSG=$(git log -1 --pretty=%s 2>/dev/null | head -c 100) RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" MSG="⚠️ Deploy failed: ${SERVICES} (commit ${COMMIT_MSG} by ${{ github.actor }}) — ${RUN_URL}" echo "$MSG" - name: Cleanup old images if: always() run: | cd "${{ env.PROJECT_DIR }}" echo "=== Pruning dangling images ===" docker image prune -f 2>/dev/null || true echo "=== Pruning unused images older than 7 days ===" docker image prune -a -f --filter "until=168h" 2>/dev/null || true - name: Summary if: always() run: | cd "${{ env.PROJECT_DIR }}" START_EPOCH="${{ steps.init.outputs.start_epoch }}" NOW=$(date +%s) DURATION=$(( NOW - START_EPOCH )) echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "**Duration:** ${DURATION}s" >> $GITHUB_STEP_SUMMARY echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ "${{ steps.services.outputs.deploy-all }}" == "true" ]; then echo "**Services:** All" >> $GITHUB_STEP_SUMMARY else echo "**Services:** ${{ steps.services.outputs.services }}" >> $GITHUB_STEP_SUMMARY fi # Build times table BUILD_TIMES="${{ steps.build.outputs.build-times }}" if [ -n "$BUILD_TIMES" ]; then echo "" >> $GITHUB_STEP_SUMMARY echo "### Build Times" >> $GITHUB_STEP_SUMMARY echo "| Service | Duration |" >> $GITHUB_STEP_SUMMARY echo "|---------|----------|" >> $GITHUB_STEP_SUMMARY for entry in $BUILD_TIMES; do svc="${entry%%:*}" dur="${entry#*:}" echo "| $svc | ${dur}s |" >> $GITHUB_STEP_SUMMARY done fi # Health results table HEALTH_RESULTS="${{ steps.health.outputs.health-results }}" if [ -n "$HEALTH_RESULTS" ]; then echo "" >> $GITHUB_STEP_SUMMARY echo "### Health Checks" >> $GITHUB_STEP_SUMMARY echo "| Service | Status | HTTP | Startup |" >> $GITHUB_STEP_SUMMARY echo "|---------|--------|------|---------|" >> $GITHUB_STEP_SUMMARY for entry in $HEALTH_RESULTS; do svc=$(echo "$entry" | cut -d: -f1) h_status=$(echo "$entry" | cut -d: -f2) h_code=$(echo "$entry" | cut -d: -f3) h_time=$(echo "$entry" | cut -d: -f4) icon="✓" [ "$h_status" != "ok" ] && icon="✗" echo "| $svc | $icon $h_status | $h_code | ${h_time}s |" >> $GITHUB_STEP_SUMMARY done fi