#!/usr/bin/env bash # # Auto-deploy SkateSpots to Netlify when the published origin/main changes. # # Driven by the systemd user timer `skatespots-deploy.timer`. It is SAFE by # construction: it only ever deploys committed code, and it never touches a # dirty working tree. # # One-time setup (see AGENTS.md): # netlify login # netlify link # choose the existing SkateSpots site # # Manual run / debug: # scripts/auto-deploy.sh # journalctl --user -u skatespots-deploy -n 50 # set -euo pipefail REPO="/home/sovereignsk8/SkateSpots" # systemd runs with a minimal PATH; resolve the newest nvm Node so a Node # upgrade doesn't silently break deploys. if [ -d "$HOME/.nvm/versions/node" ]; then NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)" [ -n "$NODE_BIN" ] && export PATH="$NODE_BIN:$PATH" fi export PATH="$HOME/go/bin:$PATH" STATE_DIR="$HOME/.local/state/skatespots-deploy" SHA_FILE="$STATE_DIR/last-deployed-sha" LOCK_FILE="$STATE_DIR/last-lock-hash" INTERVAL_FILE="$STATE_DIR/last-deploy-epoch" DIST_HASH_FILE="$STATE_DIR/last-dist-hash" # Diagnostics for `scripts/ops-health.sh`: what the last run did (or why it did # nothing). Without it a skipped deploy is invisible. SKIP_FILE="$STATE_DIR/last-skip" RUN_FILE="$STATE_DIR/last-run" mkdir -p "$STATE_DIR" # Production deploys cost 15 Netlify credits each, and this timer polls every # ~3 minutes — so a busy session can spend hundreds of credits on deploys. Enforce # a minimum gap between successful production deploys (default 24h). Override with # SKATESPOTS_DEPLOY_MIN_INTERVAL= (0 disables). To deploy immediately, # bypassing the wait: scripts/auto-deploy.sh --force FORCE=0 [ "${1:-}" = "--force" ] && FORCE=1 MIN_INTERVAL="${SKATESPOTS_DEPLOY_MIN_INTERVAL:-86400}" case "$MIN_INTERVAL" in ''|*[!0-9]*) MIN_INTERVAL=0 ;; esac log() { echo "[$(date -Is)] $*"; } # Record why this run did nothing, so ops-health.sh can explain a pending deploy # instead of it silently sitting there. Format: epoch|reason|sha|detail record_skip() { printf '%s|%s|%s|%s\n' "$(date +%s)" "$1" "${REMOTE_SHA:-}" "${2:-}" > "$SKIP_FILE" } # Serialise runs (a slow deploy must not overlap the next timer tick). exec 9>"$STATE_DIR/.lock" if ! flock -n 9; then log "another deploy is already running — skipping" record_skip locked "another deploy was running" exit 0 fi cd "$REPO" date +%s > "$RUN_FILE" # 1. What is published now? if ! git fetch --quiet origin main; then log "git fetch failed — skipping" record_skip fetch-failed "git fetch failed" exit 0 fi REMOTE_SHA="$(git rev-parse origin/main)" LAST_SHA="$(cat "$SHA_FILE" 2>/dev/null || true)" if [ "$REMOTE_SHA" = "$LAST_SHA" ]; then rm -f "$SKIP_FILE" # nothing pending — clear any stale skip reason exit 0 fi # 2. Only deploy committed code (ignore untracked build/generated files). if [ -n "$(git status --porcelain --untracked-files=no)" ]; then log "origin/main moved to ${REMOTE_SHA:0:9} but tracked files are modified — skipping" record_skip dirty-tree "tracked files are modified" exit 0 fi # 2b. Skip when the change cannot affect the deployed site. Netlify bills 15 # credits per production deploy, so don't spend them on docs-only commits # (AGENTS.md, NIP.md, scripts/, the standalone CVM server, etc.). if [ -n "$LAST_SHA" ] && git cat-file -e "${LAST_SHA}^{commit}" 2>/dev/null; then changed="$(git diff --name-only "$LAST_SHA" "$REMOTE_SHA" || true)" site_affecting="$(printf '%s\n' "$changed" | grep -vE '^([^/]*\.md|scripts/|cvm-server/|skills/|\.agents/)' || true)" if [ -n "$changed" ] && [ -z "$site_affecting" ]; then log "origin/main moved to ${REMOTE_SHA:0:9} but only non-site files changed — skipping deploy (no credits spent)" record_skip non-site-only "only non-site files changed" echo "$REMOTE_SHA" > "$SHA_FILE" exit 0 fi fi # 2c. Minimum interval between production deploys (15 credits each). On skip we do # NOT record the SHA, so the pending change is still waiting next tick. if [ "$FORCE" -eq 0 ] && [ "$MIN_INTERVAL" -gt 0 ]; then LAST_EPOCH="$(cat "$INTERVAL_FILE" 2>/dev/null || echo 0)" case "$LAST_EPOCH" in ''|*[!0-9]*) LAST_EPOCH=0 ;; esac ELAPSED=$(( $(date +%s) - LAST_EPOCH )) if [ "$ELAPSED" -lt "$MIN_INTERVAL" ]; then WAIT_MIN=$(( (MIN_INTERVAL - ELAPSED + 59) / 60 )) log "origin/main moved to ${REMOTE_SHA:0:9} but last deploy was $((ELAPSED / 60))m ago — batching (min ${MIN_INTERVAL}s, ~${WAIT_MIN}m left). Use --force to deploy now." record_skip throttled "~${WAIT_MIN}m left on the 24h deploy throttle" exit 0 fi fi # 3. Move the repo to the published commit (fast-forward only). if [ "$(git rev-parse HEAD)" != "$REMOTE_SHA" ]; then if git merge --ff-only --quiet origin/main; then log "fast-forwarded to ${REMOTE_SHA:0:9}" else log "local main has diverged from origin/main — skipping" record_skip diverged "local main diverged from origin/main" exit 0 fi fi # 4. Re-install only when the lockfile changed (avoid `npm run build`'s own `npm i`, # which can rewrite the lock and dirty the tree). LOCK_HASH="$(git rev-parse HEAD:package-lock.json)" if [ "$LOCK_HASH" != "$(cat "$LOCK_FILE" 2>/dev/null || true)" ]; then log "package-lock changed — npm ci" npm ci --silent echo "$LOCK_HASH" > "$LOCK_FILE" fi # 4b. Fast gate before spending a deploy: typecheck (app + servers) + lint + # i18n + unit tests. The build/smoke below still guard runtime. if [ ! -d node_modules ]; then log "node_modules missing — npm ci" npm ci --silent fi log "preflight: typecheck + lint + i18n + tests…" if ! "$REPO/scripts/preflight.sh"; then log "PREFLIGHT FAILED — not deploying" record_skip preflight-failed "the test gate failed" timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deploy is paused because the test gate failed. The live site is unchanged." || true exit 0 fi # 5. Build the web bundle directly (skip the `npm i` in `npm run build`). log "building…" ./node_modules/.bin/vite build -l error cp dist/index.html dist/404.html # 6. Smoke-test the built bundle — abort if it doesn't boot. # `vite build` never executes its output, so this is the only guard against # a production-only crash (e.g. the manualChunks TDZ) reaching the site. log "smoke-testing the built bundle…" if ! node scripts/smoke-test.mjs; then log "SMOKE TEST FAILED — aborting deploy (the built bundle did not boot)" record_skip smoke-failed "the built bundle failed the smoke test" timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deploy is paused because the built app did not start. The live site is unchanged." || true exit 1 fi # 7. Skip the deploy entirely when the upload would be byte-identical to what is # already live. Metadata-only changes (a package.json version/homepage, a dep # bump that doesn't alter the bundle) otherwise burn a 15-credit production # deploy for nothing. The snapshot covers exactly what `netlify deploy # --dir=dist` uploads: dist/, netlify/ (functions) and netlify.toml. snapshot_hash() { { find dist -type f -print0 2>/dev/null | sort -z | xargs -0 -r sha256sum if [ -d netlify ]; then find netlify -type f -print0 | sort -z | xargs -0 -r sha256sum; fi if [ -f netlify.toml ]; then sha256sum netlify.toml; fi } | sha256sum | awk '{print $1}' } DIST_HASH="$(snapshot_hash)" if [ -f "$DIST_HASH_FILE" ] && [ "$DIST_HASH" = "$(cat "$DIST_HASH_FILE")" ]; then log "build output is unchanged from the live deploy — skipping Netlify (no credits spent)" record_skip dist-unchanged "build output identical to the live deploy" echo "$REMOTE_SHA" > "$SHA_FILE" exit 0 fi # 7b. Deploy to Netlify production. log "deploying to Netlify…" if ! netlify deploy --prod --dir=dist; then log "NETLIFY DEPLOY FAILED" record_skip deploy-failed "netlify deploy failed" timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deploy failed at Netlify. The live site is unchanged or incomplete. Please check the Netlify dashboard." || true exit 1 fi echo "$REMOTE_SHA" > "$SHA_FILE" echo "$DIST_HASH" > "$DIST_HASH_FILE" date +%s > "$INTERVAL_FILE" # 7c. Production health check. The CDN can take a few seconds, so retry # briefly. If it never answers, leave a health-failed marker (the next tick # sees LAST_SHA == REMOTE_SHA and won't loop-redeploy) and alert. health_ok=0 for _ in 1 2 3 4; do if curl -fsSL --max-time 20 https://skate-spots.org/ >/dev/null 2>&1; then health_ok=1 break fi sleep 5 done if [ "$health_ok" -ne 1 ]; then log "POST-DEPLOY HEALTH CHECK FAILED" record_skip health-failed "the production URL did not answer" timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deployed but did not answer its health check. Please check it." || true exit 1 fi rm -f "$SKIP_FILE" log "deployed ${REMOTE_SHA:0:9} ✓"