Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,26 @@ git merge --ff-only origin/main
even when those three are present).
</p>

<h3>Pre-deploy: preview what&apos;s incoming</h3>
<p>
<code>scripts/selfhost-pre-deploy-summary.sh</code> (#5735) is a read-only preview of what{" "}
<code>scripts/selfhost-update.sh</code> would pull in — the commit range between the current
checkout (the last-deployed state) and the remote&apos;s tracked branch, plus a flag on any
incoming commit that touches a path with a history of breaking a deploy on this instance:{" "}
<code>docker-compose*.yml</code>, <code>grafana/provisioning/**</code>/
<code>grafana/dashboards/**</code>, <code>migrations/**</code>, <code>Dockerfile*</code>,
the deploy scripts themselves, and <code>.env.example</code>. It only runs{" "}
<code>git fetch</code> — never a merge or checkout — so it is safe to run anytime, including
with a dirty working tree, and takes the same <code>SELFHOST_UPDATE_REMOTE</code>/
<code>SELFHOST_UPDATE_BRANCH</code> overrides as <code>selfhost-update.sh</code>:
</p>
<CodeBlock lang="bash" code={`./scripts/selfhost-pre-deploy-summary.sh`} />
<p>
It is a skim tool, not a gate — it always exits 0 and never blocks{" "}
<code>selfhost-update.sh</code> from running; a flagged path is a prompt to read the actual
diff before deploying, not a hard stop.
</p>

<h3>Post-update checklist</h3>
<p>
<code>scripts/selfhost-update.sh</code> already runs the health probe below for you unless
Expand Down
133 changes: 133 additions & 0 deletions scripts/selfhost-pre-deploy-summary.sh
Original file line number Diff line number Diff line change
@@ -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
227 changes: 227 additions & 0 deletions test/unit/selfhost-pre-deploy-summary-script.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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");
});
});
Loading