chore(ops): pre-push/pre-deploy test gate + patch-only deps fallback
- scripts/preflight.sh: app+server tsc, eslint, i18n, vitest; installed as .githooks/pre-push (scripts/install-hooks.sh) and run by auto-deploy before Netlify. Failures record preflight-failed and ops-health flags them. - update-deps.sh: if the blanket npm update fails, restore the lock and retry patch-level packages one at a time, keeping those that pass a fast gate; the final npm test + smoke still gates the result, otherwise revert + 3-day retry. - release.sh/deps set SKIP_PREFLIGHT=1 (they run the full npm test themselves).
This commit is contained in:
parent
16ad6ad882
commit
39c388a6d7
8 changed files with 195 additions and 5 deletions
6
.githooks/pre-push
Executable file
6
.githooks/pre-push
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run the fast gate before every manual push. Skip with:
|
||||
# SKIP_PREFLIGHT=1 git push (or git push --no-verify)
|
||||
set -euo pipefail
|
||||
[ "${SKIP_PREFLIGHT:-0}" = "1" ] && exit 0
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh"
|
||||
12
AGENTS.md
12
AGENTS.md
|
|
@ -2390,6 +2390,18 @@ Rules:
|
|||
`scripts/systemd/`, and `.nvmrc` pins Node 22. A 2026-10-27 scan-only run
|
||||
reports **3 packages / 4 OSV findings** (0 critical; `xlsx` is write-only and
|
||||
the rest is dev build tooling).
|
||||
- **Pre-push + pre-deploy gate:** `scripts/preflight.sh` runs the app + server
|
||||
typechecks → ESLint → i18n → vitest. It is installed as `.githooks/pre-push`
|
||||
via `scripts/install-hooks.sh`, and `auto-deploy.sh` runs it before every
|
||||
Netlify deploy; a failure records `preflight-failed` and `ops-health.sh`
|
||||
flags it. Skip one push with `SKIP_PREFLIGHT=1` (or `git push --no-verify`).
|
||||
The dependency and release scripts set `SKIP_PREFLIGHT=1` because they already
|
||||
run the full `npm test` themselves.
|
||||
- **Patch-only dependency fallback:** if the blanket `npm update` fails, the
|
||||
script restores the lock and retries the **patch-level** outdated packages one
|
||||
at a time, keeping those that pass the fast gate and skipping the rest; a
|
||||
final `npm test` + boot smoke gates the result. If both the full and fallback
|
||||
paths fail, it restores the original tree and schedules the ~3-day retry.
|
||||
|
||||
### Community relay — self-hosted Newlay (2026-10-12)
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,19 @@ if [ "$LOCK_HASH" != "$(cat "$LOCK_FILE" 2>/dev/null || true)" ]; then
|
|||
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"
|
||||
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
|
||||
|
|
|
|||
9
scripts/install-hooks.sh
Executable file
9
scripts/install-hooks.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the repo's git hooks (currently the pre-push test gate).
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO"
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-push scripts/preflight.sh
|
||||
echo "pre-push hook installed (bypass: SKIP_PREFLIGHT=1 or git push --no-verify)"
|
||||
|
|
@ -149,6 +149,9 @@ else
|
|||
smoke-failed)
|
||||
p " ${R}→ the built bundle failed the smoke test (real breakage — check the journal).${N}"
|
||||
problems+=("smoke test failed during deploy"); signature+="smoke;" ;;
|
||||
preflight-failed)
|
||||
p " ${R}→ the test gate failed — fix typecheck/lint/i18n/tests before it can deploy.${N}"
|
||||
problems+=("deploy blocked: preflight failed"); signature+="preflight;" ;;
|
||||
diverged)
|
||||
p " ${R}→ local main diverged from origin/main — a manual merge is needed.${N}"
|
||||
problems+=("local main diverged from origin/main"); signature+="diverged;" ;;
|
||||
|
|
|
|||
32
scripts/preflight.sh
Executable file
32
scripts/preflight.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Fast gate for manual pushes and the auto-deploy timer:
|
||||
# typecheck (app + servers) → lint → i18n → unit tests. Does NOT build; the
|
||||
# deploy script builds and boots the bundle afterwards.
|
||||
#
|
||||
# Run manually: scripts/preflight.sh
|
||||
# Bypass once: SKIP_PREFLIGHT=1 git push (or git push --no-verify)
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO"
|
||||
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"
|
||||
|
||||
[ -d node_modules ] || { echo "preflight: node_modules missing — run npm ci" >&2; exit 1; }
|
||||
|
||||
echo "preflight: typecheck"
|
||||
npx tsc --noEmit
|
||||
npx tsc -p cvm-server/tsconfig.json
|
||||
npx tsc -p ai-proxy/tsconfig.json
|
||||
npx tsc -p push-service/tsconfig.json
|
||||
echo "preflight: lint"
|
||||
npx eslint
|
||||
echo "preflight: i18n"
|
||||
node scripts/check-i18n.mjs
|
||||
echo "preflight: unit tests"
|
||||
npx vitest run --reporter=dot --silent
|
||||
echo "preflight: OK"
|
||||
|
|
@ -130,6 +130,8 @@ log "committing the release…"
|
|||
git add package.json package-lock.json release-notes.txt
|
||||
git commit -m "release: $VERSION" >/dev/null
|
||||
git tag -a "v$VERSION" -m "SkateSpots $VERSION"
|
||||
# `npm test` already ran above; don't make the pre-push hook repeat it.
|
||||
export SKIP_PREFLIGHT=1
|
||||
git push origin main
|
||||
if ! git push origin "v$VERSION" 2>/dev/null; then
|
||||
log "warning: tag v$VERSION was created locally but the push failed"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ if [ -d "$HOME/.nvm/versions/node" ]; then
|
|||
[ -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}"
|
||||
|
|
@ -38,6 +40,7 @@ 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}"
|
||||
|
|
@ -57,6 +60,68 @@ notify() {
|
|||
}
|
||||
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)
|
||||
|
|
@ -111,6 +176,9 @@ 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
|
||||
|
|
@ -151,19 +219,64 @@ if npm test >"$STATE_DIR/test.log" 2>&1 && node scripts/smoke-test.mjs >"$STATE_
|
|||
rm -f "$FAILURE_FILE"
|
||||
log "done"
|
||||
else
|
||||
log "tests FAILED — saving diagnostics and restoring the previous lockfile"
|
||||
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' "tests failed at $(date -Is)" "see $FAIL_DIR/test.log" > "$FAIL_DIR/summary.txt"
|
||||
echo "$(date -Is) tests failed — $FAIL_DIR" > "$FAILURE_FILE"
|
||||
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
|
||||
# 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}"
|
||||
notify "Dependency update REVERTED — full and patch fallback failed (see $FAIL_DIR). ${OSV_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
Loading…
Reference in a new issue