SkateSpots/scripts/update-deps.sh
SkateSpots ac5ad39515 chore(ops): 16-day deps gate, weekly CVE scan, overrides, release helper
- Dependency job is now 16 days (service env + script default); a separate
  weekly scan-only timer reports npm/OSV findings without changing the lockfile.
- TimeoutStartSec=30min on the deps job and 20min on the deploy job (systemd's
  90s default could kill a legitimate run); both scripts resolve the newest nvm
  Node instead of hardcoding v22.23.1.
- npm override pins @tanstack/query-core to $@tanstack/react-query so a blanket
  npm update cannot create a second copy (the Oct 26 tsc failure).
- Failed runs keep test/smoke logs + attempted package files in failures/
  (rotated, 8 kept) and a last-failure marker; reports rotate (12 kept).
- scripts/release.sh: notes check -> npm test -> bump -> Android build ->
  zsp publish -> relay verify -> commit/push + tag.
- Deploy/deps systemd units are tracked under scripts/systemd/.
2026-10-27 09:09:45 +07:00

169 lines
7.5 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"
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"
# 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"; }
# ── 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"
notify "Vulnerability scan: $OSV_SUMMARY (report: $REPORT)"
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
# ── 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 "tests FAILED — saving diagnostics and restoring the previous lockfile"
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' "tests failed at $(date -Is)" "see $FAIL_DIR/test.log" > "$FAIL_DIR/summary.txt"
echo "$(date -Is) tests failed — $FAIL_DIR" > "$FAILURE_FILE"
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
# A failed run must NOT consume the whole interval: retry in ~3 days instead.
RETRY_DAYS=3; [ "$INTERVAL_DAYS" -gt 4 ] || RETRY_DAYS=1
echo $(( $(date +%s) - (INTERVAL_DAYS - RETRY_DAYS) * 86400 )) > "$LAST"
notify "Dependency update REVERTED — tests failed (see $FAIL_DIR). ${OSV_SUMMARY}"
exit 1
fi