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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ site/.vitepress/cache/
!migrations/*.sql
apps/gittensory-ui/public/downloads/gittensory-extension.zip
.worker-configuration.gen-check.d.ts
# Ad-hoc operator backup files (e.g. `cp file.yml file.yml.bak-notes-20260707`) -- general
# catch-alls so a stray manual snapshot never dirties `git status` on a Git-backed self-host
# checkout. Trailing on purpose: the narrower gittensory-config.backup-*/ and .deploy-backups/
# rules above already cover their specific directories, and nothing tracked in the repo matches
# either pattern (verified via `git ls-files | grep -E '\.bak-|\.backup-'`) (#1660).
*.bak-*
*.backup-*
69 changes: 60 additions & 9 deletions apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -882,8 +882,15 @@ SENTRY_RELEASE=gittensory-selfhost@2026.07.05
<Link to="/docs/self-hosting-backup-scaling">Backup and scaling</Link>.
</li>
<li>
Source path only: <code>git pull</code> and confirm <code>git status</code> is clean (no
uncommitted local changes the build would silently pick up).
Source path only: confirm <code>git status</code> is clean (no uncommitted local changes
the build would silently pick up). An ad-hoc snapshot like{" "}
<code>cp docker-compose.yml docker-compose.yml.bak-notes-20260707</code> does not count
against this — the trailing <code>*.bak-*</code>/<code>*.backup-*</code> patterns in{" "}
<code>.gitignore</code> keep stray manual backups out of <code>git status</code> entirely,
on top of the narrower <code>gittensory-config.backup-*/</code> and{" "}
<code>.deploy-backups/</code> patterns that already covered those specific directories.
<code>scripts/selfhost-update.sh</code> (below) checks this for you and refuses to
continue on a dirty tree.
</li>
<li>
Image path only: note the current tag or digest from <code>docker inspect</code> on the
Expand Down Expand Up @@ -921,19 +928,57 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl

<h3>Path 2: build from the current git checkout</h3>
<p>
<code>scripts/deploy-selfhost-prebuilt.sh</code> is for a source-based deploy (this is how{" "}
<code>GITTENSORY_VERSION</code> ends up as a short git SHA instead of an image tag). It
builds the bundle inside a Dockerized Node container — the host itself never needs Node or
npm installed — then restarts only the <code>gittensory</code> service the same way as the
image path.
<code>scripts/selfhost-update.sh</code> is the recommended entry point for a Git-backed
source checkout (#1660) — it is the single command that turns <code>git fetch</code> +
fast-forward + rebuild + verify into one flow, instead of an operator having to remember the
right order:
</p>
<CodeBlock lang="bash" code={`./scripts/selfhost-update.sh`} />
<p>
It refuses to continue, with a clear error and no side effects, on any of the three things
that make a plain <code>git pull</code> unsafe to script blindly: the working tree is not
clean, the checkout is not on the expected branch (<code>main</code> by default), or local
history has diverged from <code>origin/main</code> in a way that is not a fast-forward (
<code>git merge --ff-only</code> — it never rebases, force-merges, or picks a side for you).
Only once the fast-forward succeeds does it call{" "}
<code>scripts/deploy-selfhost-prebuilt.sh</code> (below) to rebuild and restart, then{" "}
<code>scripts/selfhost-post-update-check.sh</code> to verify health — so a normal update is
one command and a failure at any step stops before the next one runs.
</p>
<p>
None of this touches operator-owned state: <code>.env</code>, the{" "}
<code>gittensory-config/</code> mount, <code>.deploy-backups/</code>, any{" "}
<code>*.local</code> compose override or Alertmanager file, and every named data volume are
already gitignored or outside the source tree entirely, so a fetch-and-rebuild never touches
them. See the <Link to="/docs/self-hosting-quickstart">Quickstart</Link> for the initial
clone; this script assumes that checkout already exists and already tracks{" "}
<code>origin/main</code>.
</p>
<CodeBlock
lang="bash"
code={`# Point at a fork remote or a non-default branch (e.g. testing a release candidate branch)
SELFHOST_UPDATE_REMOTE=upstream SELFHOST_UPDATE_BRANCH=main ./scripts/selfhost-update.sh

# Skip the health probe step (e.g. you'll run it yourself, or right after a schema-changing release
# where you want to inspect logs before curling /ready)
SELFHOST_SKIP_POST_UPDATE_CHECK=1 ./scripts/selfhost-update.sh`}
/>
<p>
Want finer control — a pinned <code>SENTRY_RELEASE</code>, a Sentry source-map upload, or to
fetch and rebuild as separate manual steps? Call the two scripts it wraps directly:
</p>
<CodeBlock
lang="bash"
code={`git pull
code={`git fetch origin
git merge --ff-only origin/main
./scripts/deploy-selfhost-prebuilt.sh`}
/>
<p>
<code>SENTRY_RELEASE</code> defaults to{" "}
<code>scripts/deploy-selfhost-prebuilt.sh</code> is the actual rebuild step (this is how{" "}
<code>GITTENSORY_VERSION</code> ends up as a short git SHA instead of an image tag). It
builds the bundle inside a Dockerized Node container — the host itself never needs Node or
npm installed — then restarts only the <code>gittensory</code> service the same way as the
image path. <code>SENTRY_RELEASE</code> defaults to{" "}
<code>gittensory-selfhost@&lt;short git SHA of the current HEAD&gt;</code> unless you
override it, so each deploy from a new commit gets a distinct release id automatically. When{" "}
<code>SENTRY_AUTH_TOKEN</code>, <code>SENTRY_ORG</code>, and <code>SENTRY_PROJECT</code> are
Expand All @@ -943,6 +988,12 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl
</p>

<h3>Post-update checklist</h3>
<p>
<code>scripts/selfhost-update.sh</code> already runs the health probe below for you unless
you set <code>SELFHOST_SKIP_POST_UPDATE_CHECK=1</code>. Run it manually after the image
path, after calling the two wrapped scripts directly, or after any manual{" "}
<code>docker compose</code> update.
</p>
<ol>
<li>
Wait for the deploy script&apos;s health wait to finish (or run the helper below if you
Expand Down
82 changes: 82 additions & 0 deletions scripts/selfhost-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Git-backed self-host update flow: fetch, fast-forward-only, rebuild, verify (#1660).
#
# This is the single entry point for pulling upstream changes into a Git-backed self-host
# checkout. Before this script, an operator had to remember to run `git pull`, then
# deploy-selfhost-prebuilt.sh, then selfhost-post-update-check.sh, in that order, with no guard
# against a diverged local history silently creating a merge commit. This wraps all three into one
# command and refuses to proceed if the fast-forward is not clean:
#
# ./scripts/selfhost-update.sh
#
# What this preserves untouched (all already gitignored -- see .gitignore):
# - .env and any *_FILE secret mounts
# - gittensory-config/ (private per-repo .gittensory.yml policy)
# - .deploy-backups/ (operator deploy-backup snapshots)
# - any *.local compose override or alertmanager config files
# - named data volumes (gittensory-data, gittensory-pg, qdrant-data, gittensory-backups,
# grafana-data) -- untouched because this script only fetches source and rebuilds the
# gittensory app image; it never runs `docker volume` commands or touches compose profiles.
#
# Optional knobs:
# SELFHOST_UPDATE_REMOTE=upstream SELFHOST_UPDATE_BRANCH=main ./scripts/selfhost-update.sh
# SELFHOST_SKIP_POST_UPDATE_CHECK=1 ./scripts/selfhost-update.sh # skip the health probe step
set -euo pipefail

REMOTE="${SELFHOST_UPDATE_REMOTE:-origin}"
BRANCH="${SELFHOST_UPDATE_BRANCH:-main}"
SKIP_POST_UPDATE_CHECK="${SELFHOST_SKIP_POST_UPDATE_CHECK:-0}"

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 gittensory git checkout" >&2
exit 1
fi

cd "$SCRIPT_DIR/.."

current_branch="$(git rev-parse --abbrev-ref HEAD)"
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

if [ -n "$(git status --porcelain)" ]; then
echo "error: working tree is not clean -- commit, stash, or discard local changes before updating" >&2
git status --short >&2
exit 1
fi

echo "selfhost update: fetching $REMOTE"
git fetch "$REMOTE"

echo "selfhost update: fast-forwarding $BRANCH to $REMOTE/$BRANCH"
if ! git merge --ff-only "$REMOTE/$BRANCH"; then
echo "error: $BRANCH could not be fast-forwarded to $REMOTE/$BRANCH -- local history has" \
"diverged (unpushed commits or a manual edit). Resolve manually; this script never rebases" \
"or force-merges for you." >&2
exit 1
fi

echo "selfhost update: rebuilding from the updated checkout"
"$SCRIPT_DIR/deploy-selfhost-prebuilt.sh"

if [ "$SKIP_POST_UPDATE_CHECK" = "1" ]; then
echo "selfhost update: skipping post-update health check (SELFHOST_SKIP_POST_UPDATE_CHECK=1)"
else
echo "selfhost update: verifying health"
"$SCRIPT_DIR/selfhost-post-update-check.sh"
fi

echo "selfhost update: complete ($(git rev-parse --short=8 HEAD))"
88 changes: 88 additions & 0 deletions test/unit/docs-selfhost-git-deploy-hygiene.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";

// Drift guard (#1660): the Git-backed update flow -- the .gitignore backup-file catch-alls, the
// scripts/selfhost-update.sh wrapper, and the operations docs describing it -- must stay aligned
// so an operator following the docs actually gets the script's real safety behavior.

const GITIGNORE = ".gitignore";
const UPDATE_SCRIPT = "scripts/selfhost-update.sh";
const PREBUILT_SCRIPT = "scripts/deploy-selfhost-prebuilt.sh";
const POST_UPDATE_SCRIPT = "scripts/selfhost-post-update-check.sh";
const OPERATIONS = "apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx";

const gitignore = readFileSync(GITIGNORE, "utf8");
const updateScript = readFileSync(UPDATE_SCRIPT, "utf8");
const operations = readFileSync(OPERATIONS, "utf8");

describe("self-host git-deploy hygiene (#1660)", () => {
it(".gitignore catches ad-hoc operator backup files as trailing patterns", () => {
expect(gitignore).toContain("*.bak-*");
expect(gitignore).toContain("*.backup-*");
// Trailing: the general catch-alls must come after the narrower, already-shipped patterns
// they generalize, so this test fails loudly if a future edit reorders them.
const deployBackupsIndex = gitignore.indexOf(".deploy-backups/");
const generalBakIndex = gitignore.indexOf("*.bak-*");
const generalBackupIndex = gitignore.indexOf("*.backup-*");
expect(deployBackupsIndex).toBeGreaterThan(-1);
expect(generalBakIndex).toBeGreaterThan(deployBackupsIndex);
expect(generalBackupIndex).toBeGreaterThan(deployBackupsIndex);
});

it("does not shadow any file actually tracked in the repo", () => {
// The real regression concern: a future PR could add a legitimately-tracked file whose name
// happens to match `*.bak-*` or `*.backup-*`, which would silently untrack it the moment
// someone re-clones. Ask git itself, rather than approximating the glob in JS, since git's
// own matcher is the one that actually enforces these patterns.
const result = spawnSync("git", ["ls-files"], { encoding: "utf8" });
expect(result.status).toBe(0);
const trackedFiles = result.stdout.split("\n").filter(Boolean);
const bakLikeGlob = /(^|\/)[^/]*\.(bak|backup)-[^/]*$/;
const shadowed = trackedFiles.filter((path) => bakLikeGlob.test(path));
expect(shadowed).toEqual([]);
});

it("wraps fetch, fast-forward-only merge, rebuild, and the post-update check", () => {
expect(updateScript).toContain("#!/usr/bin/env bash");
expect(updateScript).toContain("set -euo pipefail");
expect(updateScript).toContain("git fetch");
expect(updateScript).toContain("git merge --ff-only");
expect(updateScript).toContain(PREBUILT_SCRIPT.replace("scripts/", ""));
expect(updateScript).toContain(POST_UPDATE_SCRIPT.replace("scripts/", ""));
});

it("refuses to proceed on a dirty tree, the wrong branch, or a non-fast-forward", () => {
expect(updateScript).toContain("git status --porcelain");
expect(updateScript).toContain("current_branch");
expect(updateScript).toMatch(/if\s*\[\s*-n\s*"\$\(git status --porcelain\)"\s*\]/);
});

it("never force-pushes, hard-resets, or force-merges on the operator's behalf", () => {
expect(updateScript).not.toContain("git push");
expect(updateScript).not.toContain("reset --hard");
expect(updateScript).not.toContain("--force");
expect(updateScript).not.toContain("clean -f");
expect(updateScript).not.toContain("merge --no-ff");
});

it("supports overriding the remote/branch and skipping the health probe", () => {
expect(updateScript).toContain("SELFHOST_UPDATE_REMOTE");
expect(updateScript).toContain("SELFHOST_UPDATE_BRANCH");
expect(updateScript).toContain("SELFHOST_SKIP_POST_UPDATE_CHECK");
});

it("operations docs point operators at the wrapper script and its safety guarantees", () => {
expect(operations).toContain("scripts/selfhost-update.sh");
expect(operations).toContain("*.bak-*");
expect(operations).toContain("*.backup-*");
expect(operations).toContain("git merge --ff-only");
expect(operations).toContain("SELFHOST_SKIP_POST_UPDATE_CHECK");
});

it("operations docs still name every operator-owned path the script must never touch", () => {
expect(operations).toContain("gittensory-config/");
expect(operations).toContain(".deploy-backups/");
expect(operations).toContain("*.local");
});
});
Loading
Loading