diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx index ec6e27260d..81ae1e6cfa 100644 --- a/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx +++ b/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx @@ -1088,6 +1088,26 @@ git merge --ff-only origin/main even when those three are present).
+
+ scripts/selfhost-pre-deploy-summary.sh (#5735) is a read-only preview of what{" "}
+ scripts/selfhost-update.sh would pull in — the commit range between the current
+ checkout (the last-deployed state) and the remote's tracked branch, plus a flag on any
+ incoming commit that touches a path with a history of breaking a deploy on this instance:{" "}
+ docker-compose*.yml, grafana/provisioning/**/
+ grafana/dashboards/**, migrations/**, Dockerfile*,
+ the deploy scripts themselves, and .env.example. It only runs{" "}
+ git fetch — never a merge or checkout — so it is safe to run anytime, including
+ with a dirty working tree, and takes the same SELFHOST_UPDATE_REMOTE/
+ SELFHOST_UPDATE_BRANCH overrides as selfhost-update.sh:
+
+ It is a skim tool, not a gate — it always exits 0 and never blocks{" "}
+ selfhost-update.sh from running; a flagged path is a prompt to read the actual
+ diff before deploying, not a hard stop.
+
scripts/selfhost-update.sh already runs the health probe below for you unless
diff --git a/scripts/selfhost-pre-deploy-summary.sh b/scripts/selfhost-pre-deploy-summary.sh
new file mode 100755
index 0000000000..fa6c8142c1
--- /dev/null
+++ b/scripts/selfhost-pre-deploy-summary.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# Pre-deploy diff summary for a self-host instance (#5735).
+#
+# A read-only preview of what `selfhost-update.sh` would pull in: the commit range between the
+# current checkout (the last-deployed state -- this checkout only ever advances via
+# selfhost-update.sh's own fast-forward merge) and the remote's tracked branch, plus a flag on any
+# incoming commit that touches a path with a history of breaking a deploy on THIS instance
+# (docker-compose service/volume definitions, Grafana provisioning, DB migrations, the deploy
+# scripts themselves). Never fetches destructively and never mutates the checkout -- safe to run
+# anytime, including with a dirty working tree, unlike selfhost-update.sh itself.
+#
+# ./scripts/selfhost-pre-deploy-summary.sh
+#
+# Optional knobs (same names as selfhost-update.sh, so one override works for both):
+# SELFHOST_UPDATE_REMOTE=upstream SELFHOST_UPDATE_BRANCH=main ./scripts/selfhost-pre-deploy-summary.sh
+set -euo pipefail
+
+REMOTE="${SELFHOST_UPDATE_REMOTE:-origin}"
+BRANCH="${SELFHOST_UPDATE_BRANCH:-main}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "error: required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+require_cmd git
+
+if ! git -C "$SCRIPT_DIR/.." rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "error: run this script from the loopover git checkout" >&2
+ exit 1
+fi
+
+cd "$SCRIPT_DIR/.."
+
+current_branch="$(git rev-parse --abbrev-ref HEAD)"
+if [ "$current_branch" = "HEAD" ]; then
+ echo "error: checkout is in a detached HEAD state, expected to be on '$BRANCH' -- checkout" \
+ "$BRANCH first (this script compares HEAD against $REMOTE/$BRANCH)" >&2
+ exit 1
+fi
+if [ "$current_branch" != "$BRANCH" ]; then
+ echo "error: currently on '$current_branch', expected '$BRANCH' -- checkout $BRANCH first, or" \
+ "set SELFHOST_UPDATE_BRANCH=$current_branch if that is deliberate" >&2
+ exit 1
+fi
+
+echo "pre-deploy summary: fetching $REMOTE"
+git fetch "$REMOTE" >/dev/null
+
+if ! git rev-parse --verify --quiet "$REMOTE/$BRANCH" >/dev/null; then
+ echo "error: $REMOTE/$BRANCH does not exist after fetching $REMOTE -- check" \
+ "SELFHOST_UPDATE_REMOTE/SELFHOST_UPDATE_BRANCH for a typo, or confirm $REMOTE actually has a" \
+ "'$BRANCH' branch" >&2
+ exit 1
+fi
+
+range="HEAD..$REMOTE/$BRANCH"
+commit_count="$(git rev-list --count "$range")"
+
+if [ "$commit_count" = "0" ]; then
+ echo "pre-deploy summary: up to date with $REMOTE/$BRANCH ($(git rev-parse --short=8 HEAD)) -- nothing to deploy"
+ exit 0
+fi
+
+if ! git merge-base --is-ancestor HEAD "$REMOTE/$BRANCH"; then
+ echo "pre-deploy summary: warning — HEAD is not an ancestor of $REMOTE/$BRANCH; selfhost-update.sh's" \
+ "fast-forward-only merge will refuse this until local history is resolved. Showing the diff against" \
+ "the merge-base instead of a clean incoming range." >&2
+ merge_base="$(git merge-base HEAD "$REMOTE/$BRANCH")"
+ range="$merge_base..$REMOTE/$BRANCH"
+ commit_count="$(git rev-list --count "$range")"
+fi
+
+echo "pre-deploy summary: $commit_count commit(s) from $(git rev-parse --short=8 HEAD) to $(git rev-parse --short=8 "$REMOTE/$BRANCH") on $REMOTE/$BRANCH"
+echo ""
+echo "commits:"
+git log --oneline "$range"
+echo ""
+echo "changed files ($(git diff --stat "$range" | tail -1 | sed 's/^ *//')):"
+git diff --stat "$range"
+
+# Historically-sensitive paths for THIS instance -- not the contributor-PR guardrail list
+# (src/review/guardrail-config.ts), which protects against a hostile/careless CONTRIBUTOR change.
+# This one is deploy-specific: every entry below has caused a real incident on this instance.
+# - docker-compose*.yml: an accidental `docker compose up -d grafana` without --no-deps
+# recreated postgres onto the wrong volume, causing a full outage (2026-07-13).
+# - grafana/provisioning/**, grafana/dashboards/**: disableDeletion:true orphaned 9 dashboards
+# under stale uids, and a $__all-prefixed SQL sentinel broke every dashboard filter (2026-07-13/14).
+# - migrations/**: a DB schema change that isn't also applied to the running instance's Postgres
+# leaves the app and the schema out of sync until the next deploy runs migrations.
+# - Dockerfile*: changes what's actually installed in the image (e.g. puppeteer-core /
+# INSTALL_VISUAL_REVIEW, codex/claude CLI binaries) -- a missing capability here silently
+# degrades a feature rather than failing loudly.
+# - scripts/selfhost-*.sh, scripts/lib/selfhost-*.sh, scripts/deploy-selfhost*.sh: the deploy
+# tooling itself -- a bug here affects every future deploy, not just this one.
+# - .env.example: a new required env var here with nothing set in the live .env degrades
+# silently rather than failing at boot.
+is_sensitive() {
+ case "$1" in
+ docker-compose*.yml | \
+ grafana/provisioning/* | grafana/dashboards/* | \
+ migrations/* | \
+ Dockerfile* | \
+ scripts/selfhost-*.sh | scripts/lib/selfhost-*.sh | scripts/deploy-selfhost*.sh | \
+ .env.example)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+sensitive_files=()
+while IFS= read -r file; do
+ if [ -n "$file" ] && is_sensitive "$file"; then
+ sensitive_files+=("$file")
+ fi
+done < <(git diff --name-only "$range")
+
+echo ""
+if [ "${#sensitive_files[@]}" -eq 0 ]; then
+ echo "pre-deploy summary: no historically-sensitive paths touched"
+else
+ echo "pre-deploy summary: ⚠ ${#sensitive_files[@]} historically-sensitive path(s) touched -- review before deploying:"
+ for file in "${sensitive_files[@]}"; do
+ echo " - $file"
+ done
+fi
diff --git a/test/unit/selfhost-pre-deploy-summary-script.test.ts b/test/unit/selfhost-pre-deploy-summary-script.test.ts
new file mode 100644
index 0000000000..9247109372
--- /dev/null
+++ b/test/unit/selfhost-pre-deploy-summary-script.test.ts
@@ -0,0 +1,227 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { spawnSync } from "node:child_process";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+// See selfhost-update-script.test.ts's own comment on this same timeout raise -- this file does the
+// identical real git/bash subprocess sandbox dance.
+vi.setConfig({ testTimeout: 60_000 });
+
+// Real end-to-end execution of scripts/selfhost-pre-deploy-summary.sh (#5735) against a throwaway
+// git remote, exercising the actual fetch/diff/sensitive-path-flag control flow rather than only
+// asserting on the script's source text.
+
+const REAL_SCRIPT = readFileSync("scripts/selfhost-pre-deploy-summary.sh", "utf8");
+
+const GIT_ENV = {
+ GIT_AUTHOR_NAME: "test",
+ GIT_AUTHOR_EMAIL: "test@example.invalid",
+ GIT_COMMITTER_NAME: "test",
+ GIT_COMMITTER_EMAIL: "test@example.invalid",
+};
+
+function git(args: string[], cwd: string) {
+ // -c commit.gpgsign=false: see selfhost-update-script.test.ts's own comment -- disposable sandbox
+ // commits must never wait on a contributor's personal signing setup.
+ const result = spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
+ cwd,
+ encoding: "utf8",
+ env: { ...process.env, ...GIT_ENV },
+ });
+ if (result.status !== 0) {
+ throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${result.stderr}`);
+ }
+ return result;
+}
+
+const sandboxDirs: string[] = [];
+
+afterEach(() => {
+ while (sandboxDirs.length > 0) {
+ const dir = sandboxDirs.pop();
+ if (dir) rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+function createSandbox() {
+ const base = mkdtempSync(join(tmpdir(), "gittensory-selfhost-pre-deploy-summary-"));
+ sandboxDirs.push(base);
+
+ const originDir = join(base, "origin.git");
+ const seedDir = join(base, "seed");
+ const checkoutDir = join(base, "checkout");
+
+ git(["init", "-q", "--bare", originDir], base);
+ git(["symbolic-ref", "HEAD", "refs/heads/main"], originDir);
+
+ mkdirSync(seedDir, { recursive: true });
+ writeFileSync(join(seedDir, "README.md"), "seed\n");
+ mkdirSync(join(seedDir, "scripts"), { recursive: true });
+ writeFileSync(join(seedDir, "scripts", "selfhost-pre-deploy-summary.sh"), REAL_SCRIPT);
+ git(["init", "-q", "-b", "main", seedDir], base);
+ git(["remote", "add", "origin", originDir], seedDir);
+ git(["add", "-A"], seedDir);
+ git(["commit", "-q", "-m", "initial"], seedDir);
+ git(["push", "-q", "origin", "main"], seedDir);
+
+ git(["clone", "-q", originDir, checkoutDir], base);
+
+ return { base, originDir, seedDir, checkoutDir };
+}
+
+function commitFile(dir: string, relPath: string, contents: string, message: string) {
+ const full = join(dir, relPath);
+ mkdirSync(join(full, ".."), { recursive: true });
+ writeFileSync(full, contents);
+ git(["add", "-A"], dir);
+ git(["commit", "-q", "-m", message], dir);
+}
+
+function run(checkoutDir: string, env: Record