- NIP-17 wraps now carry the app's ['skatespots','push'] marker on every admin copy (including the sender), so the push service wakes the phone with the app closed. Push test wraps 39fbbd4e…/c88c9f55… verified on the community relay. - The weekly scan-only job DMs only when the OSV summary changes; unchanged scans are logged, not messaged. - auto-deploy alerts on Netlify failure and on a post-deploy production URL health-check failure; deploy-failed/health-failed appear in ops-health and the daily report.
220 lines
8.7 KiB
Bash
Executable file
220 lines
8.7 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; 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=<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)] $*"; }
|
|
|
|
# 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} ✓"
|