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).

+

Pre-deploy: preview what's incoming

+

+ 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. +

+

Post-update checklist

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 = {}) { + return spawnSync("bash", [join(checkoutDir, "scripts", "selfhost-pre-deploy-summary.sh")], { + cwd: checkoutDir, + encoding: "utf8", + env: { ...process.env, ...GIT_ENV, ...env }, + }); +} + +describe("selfhost-pre-deploy-summary.sh", () => { + it("reports up to date and exits 0 when there is nothing new to deploy", () => { + const { checkoutDir } = createSandbox(); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("up to date"); + expect(result.stdout).toContain("nothing to deploy"); + }); + + it("summarizes incoming commits and file changes with no sensitive-path flag when none are touched", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "src/foo.ts", "export const foo = 1;\n", "add foo"); + commitFile(seedDir, "src/bar.ts", "export const bar = 2;\n", "add bar"); + git(["push", "-q", "origin", "main"], seedDir); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("2 commit(s)"); + expect(result.stdout).toContain("add foo"); + expect(result.stdout).toContain("add bar"); + expect(result.stdout).toContain("src/foo.ts"); + expect(result.stdout).toContain("src/bar.ts"); + expect(result.stdout).toContain("no historically-sensitive paths touched"); + }); + + it("flags a docker-compose.yml change as a historically-sensitive path", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "docker-compose.yml", "services:\n loopover:\n image: x\n", "bump image"); + git(["push", "-q", "origin", "main"], seedDir); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("1 historically-sensitive path(s) touched"); + expect(result.stdout).toContain("docker-compose.yml"); + }); + + it("flags grafana provisioning and migrations paths, and only the sensitive ones out of a mixed change", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "grafana/provisioning/dashboards/provider.yml", "disableDeletion: true\n", "grafana change"); + commitFile(seedDir, "migrations/0100_add_column.sql", "alter table x add column y text;\n", "migration"); + commitFile(seedDir, "src/harmless.ts", "export const ok = true;\n", "harmless change"); + git(["push", "-q", "origin", "main"], seedDir); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("2 historically-sensitive path(s) touched"); + expect(result.stdout).toContain("grafana/provisioning/dashboards/provider.yml"); + expect(result.stdout).toContain("migrations/0100_add_column.sql"); + expect(result.stdout).not.toMatch(/- src\/harmless\.ts/); + }); + + it("flags the deploy scripts themselves as sensitive", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "scripts/selfhost-update.sh", "#!/usr/bin/env bash\necho changed\n", "tweak deploy script"); + git(["push", "-q", "origin", "main"], seedDir); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("scripts/selfhost-update.sh"); + }); + + it("is read-only and safe to run against a dirty working tree", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "src/foo.ts", "export const foo = 1;\n", "add foo"); + git(["push", "-q", "origin", "main"], seedDir); + writeFileSync(join(checkoutDir, "README.md"), "local uncommitted edit\n"); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("1 commit(s)"); + }); + + it("refuses when the checkout is not on the expected branch", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "src/foo.ts", "export const foo = 1;\n", "add foo"); + git(["push", "-q", "origin", "main"], seedDir); + git(["checkout", "-q", "-b", "feature-x"], checkoutDir); + + const result = run(checkoutDir); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("currently on 'feature-x', expected 'main'"); + }); + + it("refuses a detached HEAD with a distinct message instead of the generic branch mismatch", () => { + const { checkoutDir } = createSandbox(); + git(["checkout", "-q", "--detach", "HEAD"], checkoutDir); + + const result = run(checkoutDir); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("detached HEAD state"); + }); + + it("gives a distinct error when SELFHOST_UPDATE_BRANCH names a branch the remote doesn't have", () => { + const { checkoutDir } = createSandbox(); + git(["checkout", "-q", "-b", "no-such-branch-upstream"], checkoutDir); + + const result = run(checkoutDir, { SELFHOST_UPDATE_BRANCH: "no-such-branch-upstream" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("origin/no-such-branch-upstream does not exist"); + }); + + it("accepts a non-default branch when SELFHOST_UPDATE_BRANCH names it explicitly", () => { + const { seedDir, checkoutDir } = createSandbox(); + git(["checkout", "-q", "-b", "release"], seedDir); + git(["push", "-q", "origin", "release"], seedDir); + git(["fetch", "-q", "origin"], checkoutDir); + git(["checkout", "-q", "-b", "release", "origin/release"], checkoutDir); + commitFile(seedDir, "src/foo.ts", "export const foo = 1;\n", "release commit"); + git(["push", "-q", "origin", "release"], seedDir); + + const result = run(checkoutDir, { SELFHOST_UPDATE_BRANCH: "release" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("1 commit(s)"); + }); + + it("warns and falls back to a merge-base range when local history has diverged from the remote", () => { + const { seedDir, checkoutDir } = createSandbox(); + commitFile(seedDir, "src/upstream-only.ts", "export const up = 1;\n", "upstream commit"); + git(["push", "-q", "origin", "main"], seedDir); + commitFile(checkoutDir, "src/local-only.ts", "export const local = 1;\n", "local-only commit"); + + const result = run(checkoutDir); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("is not an ancestor"); + expect(result.stdout).toContain("upstream commit"); + }); +});