- 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.
293 lines
13 KiB
Bash
Executable file
293 lines
13 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# Scheduled dependency update + vulnerability scan (every ~16 days).
|
|
#
|
|
# Driven by the systemd user timer `skatespots-deps.timer` (a daily tick; this
|
|
# script self-gates to one real run per INTERVAL_DAYS). A second weekly timer
|
|
# (`skatespots-deps-scan.timer`) runs this with --scan-only for earlier CVE
|
|
# visibility without touching the lockfile. What it does:
|
|
# 1. bails if the working tree is dirty (never mixes with your own changes);
|
|
# 2. scans `npm outdated` + `npm audit` into a dated report;
|
|
# 3. applies NON-breaking updates (`npm update` + `npm audit fix` — never --force);
|
|
# 4. runs the full test suite (`npm test`) AND the boot smoke test;
|
|
# 5. on success commits + pushes the lockfile/package.json (the deploy timer then
|
|
# ships it); on failure it RESTORES the previous lockfile and reports.
|
|
#
|
|
# Flags:
|
|
# --scan-only report only (no apply, no commit)
|
|
# --no-push apply + test + commit locally, but don't push
|
|
# --force ignore the interval gate
|
|
#
|
|
# Env: DEPS_INTERVAL_DAYS (default 20), DEPS_STATE_DIR (default ~/.local/state/skatespots-deps)
|
|
set -uo pipefail
|
|
|
|
REPO="/home/sovereignsk8/SkateSpots"
|
|
# Resolve the newest nvm Node so a Node upgrade doesn't silently break the
|
|
# timer (the old hard-coded v22.23.1 path did).
|
|
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"
|
|
# The job runs the full gate itself; don't make the pre-push hook repeat it.
|
|
export SKIP_PREFLIGHT=1
|
|
cd "$REPO"
|
|
|
|
INTERVAL_DAYS="${DEPS_INTERVAL_DAYS:-16}"
|
|
STATE_DIR="${DEPS_STATE_DIR:-$HOME/.local/state/skatespots-deps}"
|
|
mkdir -p "$STATE_DIR"
|
|
LAST="$STATE_DIR/last-run"
|
|
REPORT="$STATE_DIR/report-$(LC_ALL=C date +%F).txt"
|
|
FAILURE_FILE="$STATE_DIR/last-failure"
|
|
FAILURE_DIR_BASE="$STATE_DIR/failures"
|
|
OUTDATED_JSON="$STATE_DIR/outdated-$(LC_ALL=C date +%F).json"
|
|
# OSV-Scanner (broader than npm audit: queries the OSV database for the lockfile).
|
|
# Installed with: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest
|
|
OSV_BIN="${OSV_BIN:-$HOME/go/bin/osv-scanner}"
|
|
|
|
SCAN_ONLY=0; NO_PUSH=0; FORCE=0
|
|
for a in "$@"; do
|
|
case "$a" in
|
|
--scan-only) SCAN_ONLY=1 ;;
|
|
--no-push) NO_PUSH=1 ;;
|
|
--force) FORCE=1 ;;
|
|
esac
|
|
done
|
|
|
|
log() { echo "[$(date -Is)] $*"; }
|
|
notify() {
|
|
command -v notify-send >/dev/null 2>&1 && notify-send -a SkateSpots "Dependency job" "$1" 2>/dev/null || true
|
|
}
|
|
finish_timer() { date +%s > "$LAST"; }
|
|
|
|
# ── fallback helpers ────────────────────────────────────────────────────────
|
|
# Fast gate for the per-package fallback. The full `npm test` + smoke still runs
|
|
# once at the end; this keeps the fallback from taking an hour.
|
|
fast_gate() {
|
|
npx tsc --noEmit >/dev/null 2>&1 \
|
|
&& npx tsc -p cvm-server/tsconfig.json >/dev/null 2>&1 \
|
|
&& npx tsc -p ai-proxy/tsconfig.json >/dev/null 2>&1 \
|
|
&& npx tsc -p push-service/tsconfig.json >/dev/null 2>&1 \
|
|
&& node scripts/check-i18n.mjs >/dev/null 2>&1 \
|
|
&& npx vitest run --reporter=dot --silent >/dev/null 2>&1
|
|
}
|
|
|
|
# If the blanket `npm update` fails, retry only patch-level bumps one package at
|
|
# a time; keep each one that passes the fast gate and skip the rest. This keeps
|
|
# safe updates instead of reverting the whole batch. Requires OUTDATED_JSON
|
|
# captured before the full update.
|
|
patch_only_fallback() {
|
|
local tmp list pkg kept=() skipped=()
|
|
tmp="$(mktemp -d)"
|
|
cp package.json package-lock.json "$tmp"/ 2>/dev/null || true
|
|
|
|
list="$(node -e '
|
|
const fs = require("fs");
|
|
let j = {};
|
|
try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch {}
|
|
for (const [name, v] of Object.entries(j)) {
|
|
const cur = String(v.current || ""), want = String(v.wanted || "");
|
|
if (!cur || !want || cur === want) continue;
|
|
const a = cur.split("."), b = want.split(".");
|
|
if (a.length < 3 || b.length < 3) continue;
|
|
if (a[0] === b[0] && a[1] === b[1] && Number(b[2]) > Number(a[2])) console.log(name);
|
|
}
|
|
' "$OUTDATED_JSON" 2>/dev/null || true)"
|
|
|
|
if [ -z "$list" ]; then
|
|
log "fallback: no patch-only updates available"
|
|
rm -rf "$tmp"
|
|
return 1
|
|
fi
|
|
|
|
while IFS= read -r pkg; do
|
|
[ -n "$pkg" ] || continue
|
|
log "fallback: trying $pkg"
|
|
if npm update "$pkg" >/dev/null 2>&1 \
|
|
&& npm dedupe >/dev/null 2>&1 \
|
|
&& fast_gate; then
|
|
kept+=("$pkg")
|
|
# New baseline for the next package.
|
|
cp package.json package-lock.json "$tmp"/ 2>/dev/null || true
|
|
else
|
|
log "fallback: $pkg failed the fast gate — reverting it"
|
|
cp "$tmp/package.json" "$tmp/package-lock.json" . 2>/dev/null || true
|
|
npm i --silent >/dev/null 2>&1 || true
|
|
skipped+=("$pkg")
|
|
fi
|
|
done <<< "$list"
|
|
|
|
rm -rf "$tmp"
|
|
log "fallback: kept ${#kept[@]} (${kept[*]:-none}), skipped ${#skipped[@]} (${skipped[*]:-none})"
|
|
[ "${#kept[@]}" -gt 0 ]
|
|
}
|
|
|
|
# ── interval gate ───────────────────────────────────────────────────────────
|
|
if [ "$FORCE" -eq 0 ] && [ "$SCAN_ONLY" -eq 0 ] && [ -f "$LAST" ]; then
|
|
last=$(cat "$LAST" 2>/dev/null || echo 0)
|
|
now=$(date +%s)
|
|
elapsed=$(( now - last ))
|
|
if [ "$elapsed" -lt $(( INTERVAL_DAYS * 86400 )) ]; then
|
|
log "not due yet ($(( (INTERVAL_DAYS * 86400 - elapsed) / 86400 ))d left)"
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# ── scan ────────────────────────────────────────────────────────────────────
|
|
{
|
|
echo "=== SkateSpots dependency scan — $(date -Is) ==="
|
|
echo
|
|
echo "=== npm outdated ==="
|
|
npm outdated 2>&1 || true
|
|
echo
|
|
echo "=== npm audit ==="
|
|
npm audit 2>&1 || true
|
|
echo
|
|
echo "=== osv-scanner (OSV database) ==="
|
|
if [ -x "$OSV_BIN" ]; then
|
|
"$OSV_BIN" scan source -L package-lock.json 2>&1 || true
|
|
else
|
|
echo "osv-scanner not installed (expected $OSV_BIN)"
|
|
fi
|
|
} | tee "$REPORT"
|
|
|
|
# The OSV summary line (e.g. "Total 27 packages affected by 59 known ...") is the
|
|
# number worth surfacing in notifications; npm audit's counts are narrower.
|
|
OSV_SUMMARY="$(grep -E '^Total [0-9]+ packages affected' "$REPORT" | tail -1)"
|
|
[ -n "$OSV_SUMMARY" ] || OSV_SUMMARY="OSV scan: no summary (scanner missing?)"
|
|
|
|
# Rotate diagnostics so the state dir cannot grow forever.
|
|
ls -1t "$STATE_DIR"/report-*.txt 2>/dev/null | tail -n +13 | xargs -r rm -f
|
|
ls -1dt "$FAILURE_DIR_BASE"/* 2>/dev/null | tail -n +9 | xargs -r rm -rf
|
|
|
|
if [ "$SCAN_ONLY" -eq 1 ]; then
|
|
log "scan-only; report: $REPORT"
|
|
echo "$OSV_SUMMARY"
|
|
PREV_REPORT="$(ls -1t "$STATE_DIR"/report-*.txt 2>/dev/null | sed -n '2p' | head -1 || true)"
|
|
PREV_OSV=""
|
|
if [ -n "$PREV_REPORT" ]; then
|
|
PREV_OSV="$(grep -E '^Total [0-9]+ packages affected' "$PREV_REPORT" | tail -1 || true)"
|
|
fi
|
|
if [ "$OSV_SUMMARY" != "$PREV_OSV" ]; then
|
|
notify "Vulnerability scan changed: $OSV_SUMMARY (report: $REPORT)"
|
|
timeout 90 "$REPO/scripts/send-ops-alert.sh" "Security scan changed. $OSV_SUMMARY. Check the server report for details." || true
|
|
else
|
|
log "scan unchanged since the previous report — not alerting"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
# ── never mix with the user's uncommitted work ──────────────────────────────
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
log "working tree is dirty — skipping (commit or stash first)"
|
|
exit 0
|
|
fi
|
|
|
|
cp package.json "$STATE_DIR/package.json.bak" 2>/dev/null || true
|
|
cp package-lock.json "$STATE_DIR/package-lock.json.bak" 2>/dev/null || true
|
|
|
|
# Snapshot the pre-update outdated list for the patch-only fallback.
|
|
npm outdated --json --depth=0 > "$OUTDATED_JSON" 2>/dev/null || true
|
|
|
|
# ── apply non-breaking updates ──────────────────────────────────────────────
|
|
log "applying non-breaking updates (npm update + npm dedupe + npm audit fix)…"
|
|
npm update 2>&1 | tail -3
|
|
# Collapse duplicate transitive copies (e.g. @tanstack/query-core, @nostrify/*).
|
|
# Without this a minor bump can nest a second copy and break type identity in tsc.
|
|
npm dedupe 2>&1 | tail -2
|
|
npm audit fix 2>&1 | tail -3
|
|
{
|
|
echo; echo "=== post-update npm audit ==="; npm audit 2>&1 || true
|
|
echo; echo "=== post-update osv-scanner ==="
|
|
if [ -x "$OSV_BIN" ]; then "$OSV_BIN" scan source -L package-lock.json 2>&1 || true; fi
|
|
} | tee -a "$REPORT"
|
|
|
|
# ── gate: full tests + boot smoke ───────────────────────────────────────────
|
|
log "running tests + smoke…"
|
|
if npm test >"$STATE_DIR/test.log" 2>&1 && node scripts/smoke-test.mjs >"$STATE_DIR/smoke.log" 2>&1; then
|
|
if [ -n "$(git status --porcelain package.json package-lock.json)" ]; then
|
|
git add package.json package-lock.json
|
|
git commit -m "chore(deps): scheduled dependency update + audit ($(LC_ALL=C date +%F))" >/dev/null 2>&1 || true
|
|
if [ "$NO_PUSH" -eq 0 ]; then
|
|
# Bound the push: the git remote signs via the remote signer and can stall.
|
|
if timeout 180 git push origin main >"$STATE_DIR/push.log" 2>&1; then
|
|
log "pushed dependency update"
|
|
notify "Dependency update applied, tests pass, pushed. ${OSV_SUMMARY}"
|
|
else
|
|
log "push failed/skipped (see $STATE_DIR/push.log)"
|
|
notify "Dependency update committed locally but the push failed. ${OSV_SUMMARY}"
|
|
fi
|
|
else
|
|
log "committed locally (--no-push)"
|
|
notify "Dependency update applied and committed locally. ${OSV_SUMMARY}"
|
|
fi
|
|
else
|
|
log "no dependency changes to commit"
|
|
notify "Dependency scan complete: nothing to update. ${OSV_SUMMARY}"
|
|
fi
|
|
finish_timer
|
|
rm -f "$FAILURE_FILE"
|
|
log "done"
|
|
else
|
|
log "full update FAILED — saving diagnostics and trying the patch-only fallback"
|
|
FAIL_DIR="$FAILURE_DIR_BASE/$(date +%F-%H%M)"
|
|
mkdir -p "$FAIL_DIR"
|
|
cp "$STATE_DIR/test.log" "$STATE_DIR/smoke.log" "$FAIL_DIR"/ 2>/dev/null || true
|
|
cp package.json package-lock.json "$FAIL_DIR"/ 2>/dev/null || true
|
|
printf '%s\n' "full update failed at $(date -Is)" "see $FAIL_DIR/test.log" > "$FAIL_DIR/summary.txt"
|
|
echo "$(date -Is) full update failed — $FAIL_DIR" > "$FAILURE_FILE"
|
|
|
|
# Restore the original tree before trying the fallback.
|
|
cp "$STATE_DIR/package.json.bak" package.json 2>/dev/null || true
|
|
cp "$STATE_DIR/package-lock.json.bak" package-lock.json 2>/dev/null || true
|
|
npm i --silent >/dev/null 2>&1 || true
|
|
|
|
if patch_only_fallback \
|
|
&& npm test >"$STATE_DIR/test.log" 2>&1 \
|
|
&& node scripts/smoke-test.mjs >"$STATE_DIR/smoke.log" 2>&1; then
|
|
{
|
|
echo; echo "=== post-fallback npm audit ==="; npm audit 2>&1 || true
|
|
echo; echo "=== post-fallback osv-scanner ==="
|
|
if [ -x "$OSV_BIN" ]; then "$OSV_BIN" scan source -L package-lock.json 2>&1 || true; fi
|
|
} | tee -a "$REPORT"
|
|
OSV_SUMMARY="$(grep -E '^Total [0-9]+ packages affected' "$REPORT" | tail -1)"
|
|
[ -n "$OSV_SUMMARY" ] || OSV_SUMMARY="OSV scan: no summary"
|
|
|
|
if [ -n "$(git status --porcelain package.json package-lock.json)" ]; then
|
|
git add package.json package-lock.json
|
|
git commit -m "chore(deps): scheduled dependency update + audit ($(LC_ALL=C date +%F))" >/dev/null 2>&1 || true
|
|
if [ "$NO_PUSH" -eq 0 ]; then
|
|
if timeout 180 git push origin main >"$STATE_DIR/push.log" 2>&1; then
|
|
log "pushed patch-only dependency update"
|
|
notify "Dependency update applied via the patch-only fallback, tests pass, pushed. ${OSV_SUMMARY}"
|
|
else
|
|
log "push failed/skipped (see $STATE_DIR/push.log)"
|
|
notify "Patch-only dependency update committed locally but the push failed. ${OSV_SUMMARY}"
|
|
fi
|
|
else
|
|
log "committed locally (--no-push)"
|
|
notify "Patch-only dependency update applied and committed locally. ${OSV_SUMMARY}"
|
|
fi
|
|
else
|
|
log "patch-only fallback produced no package changes"
|
|
notify "Dependency scan complete: nothing safe to update. ${OSV_SUMMARY}"
|
|
fi
|
|
finish_timer
|
|
rm -f "$FAILURE_FILE"
|
|
log "done (patch-only fallback)"
|
|
exit 0
|
|
fi
|
|
|
|
# Both the full and the patch-only update failed: restore and retry in ~3 days.
|
|
mkdir -p "$FAIL_DIR/fallback"
|
|
cp "$STATE_DIR/test.log" "$STATE_DIR/smoke.log" "$FAIL_DIR/fallback"/ 2>/dev/null || true
|
|
cp package.json package-lock.json "$FAIL_DIR/fallback"/ 2>/dev/null || true
|
|
cp "$STATE_DIR/package.json.bak" package.json 2>/dev/null || true
|
|
cp "$STATE_DIR/package-lock.json.bak" package-lock.json 2>/dev/null || true
|
|
npm i --silent >/dev/null 2>&1 || true
|
|
RETRY_DAYS=3; [ "$INTERVAL_DAYS" -gt 4 ] || RETRY_DAYS=1
|
|
echo $(( $(date +%s) - (INTERVAL_DAYS - RETRY_DAYS) * 86400 )) > "$LAST"
|
|
notify "Dependency update REVERTED — full and patch fallback failed (see $FAIL_DIR). ${OSV_SUMMARY}"
|
|
timeout 90 "$REPO/scripts/send-ops-alert.sh" "The dependency update failed and was rolled back. The app is unchanged. The next retry is in about 3 days." || true
|
|
exit 1
|
|
fi
|