SkateSpots/scripts/auto-deploy.sh
SkateSpots f46f6a8967 chore(deploy): throttle production deploys to one per 24h
The timer deployed on every site-affecting commit at 15 Netlify credits each
(~68 deploys / ~1,020 credits in a week). auto-deploy.sh now enforces a minimum
gap between successful deploys, default 24h, overridable with
SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds> (0 disables). A throttled tick records
nothing so the change still deploys later; failures retry immediately.

Deploy on demand with: scripts/auto-deploy.sh --force

scripts/docs only — skipped by the timer, so this costs 0 credits.
2026-09-28 13:20:31 +07:00

134 lines
4.9 KiB
Bash
Executable file

#!/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; node/npm live under nvm here.
export PATH="/home/sovereignsk8/.nvm/versions/node/v22.23.1/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"
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=<seconds> (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)] $*"; }
# 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"
exit 0
fi
cd "$REPO"
# 1. What is published now?
if ! git fetch --quiet origin main; then
log "git fetch failed — skipping"
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
exit 0 # nothing new since the last deploy
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"
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)"
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."
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"
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
# 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)"
exit 1
fi
# 7. Deploy to Netlify production.
log "deploying to Netlify…"
netlify deploy --prod --dir=dist
echo "$REMOTE_SHA" > "$SHA_FILE"
date +%s > "$INTERVAL_FILE"
log "deployed ${REMOTE_SHA:0:9} ✓"