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
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const SECTION_LINKS = [
{
title: "Operations",
description:
"Health checks, logs, metrics, dashboards, jobs, queues, and daily operator routines.",
"Health checks, logs, metrics, safe update/rollback checklists, deploy scripts, and daily operator routines.",
to: "/docs/self-hosting-operations",
},
{
Expand Down
153 changes: 104 additions & 49 deletions apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -607,18 +607,74 @@ volumes:

<h2>Updating and rolling back</h2>
<p>
Both update paths below only ever restart the <code>gittensory</code> app service (
<code>--no-deps</code>) — they never touch other compose-profile services or their state
(Postgres, Redis, Qdrant, and Grafana&apos;s own <code>grafana-data</code> volume), and they
never touch <code>.env</code> keys other than the one they persist for next time. That means{" "}
<code>.env</code>, the <code>gittensory-config/</code> mount, every data volume — including
the app&apos;s own <code>/data</code> volume where Codex/Claude Code auth material lives —
and any <code>docker-compose.override.yml</code> are preserved automatically across an
update. You don&apos;t need to back those up or re-supply them just to run either script,
and you only need to recreate a profile service yourself if you&apos;re deliberately
upgrading that service (its own image tag in <code>docker-compose.yml</code>, or a
Postgres/Redis/Qdrant major-version bump) rather than the app.
</p>
Day-two operator flow: pull or build a new app image, restart only the{" "}
<code>gittensory</code> service, verify <code>/ready</code>, and confirm the release id. Use{" "}
<Link to="/docs/self-hosting-releases">Releases and images</Link> to pick a tag; use the
checklists below so updates never overwrite operator-owned secrets, config, or data.
</p>

<Callout variant="safety" title="Operator-owned — deploy scripts never overwrite these">
<ul>
<li>
<code>.env</code> and any <code>*_FILE</code> secret mounts — deploy scripts only write
back <code>GITTENSORY_IMAGE</code> (image path) or <code>SENTRY_RELEASE</code> /{" "}
<code>GITTENSORY_VERSION</code> (source path).
</li>
<li>
<code>./gittensory-config/</code> bind mount — private per-repo{" "}
<code>.gittensory.yml</code> policy.
</li>
<li>
Named data volumes — especially <code>gittensory-data</code> (SQLite DB, Codex/Claude
auth under <code>/data</code>), <code>gittensory-pg</code>, <code>qdrant-data</code>,{" "}
<code>gittensory-backups</code>, and Grafana&apos;s <code>grafana-data</code>.
</li>
<li>
Optional <code>docker-compose.override.yml</code> — still loaded via{" "}
<code>SELFHOST_COMPOSE_FILES</code> when set, or automatically when present beside{" "}
<code>docker-compose.yml</code>.
</li>
</ul>
</Callout>

<FeatureRow
items={[
{
title: "Restart gittensory only (normal app update)",
description:
"Both deploy-selfhost-image.sh and deploy-selfhost-prebuilt.sh run docker compose up -d --no-deps gittensory. Redis, Postgres, Qdrant, Grafana, backup sidecars, and every volume stay running with their existing data.",
},
{
title: "Recreate a profile service (separate step)",
description:
"Only when you deliberately change that service's image or major version — e.g. docker compose --profile postgres pull postgres && docker compose --profile postgres up -d postgres. Never required just to ship a new gittensory app build.",
},
]}
/>

<h3>Preflight checklist</h3>
<ol>
<li>
Read release notes for migration or env changes — migrations are forward-only (see
Rollback below).
</li>
<li>
Take a fresh backup when the release may change schema — see{" "}
<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).
</li>
<li>
Image path only: note the current tag or digest from <code>docker inspect</code> on the
running <code>gittensory</code> container so rollback has a known-good target.
</li>
<li>
Confirm routine health is green before you start —{" "}
<code>curl http://localhost:8787/ready</code> and a quick <code>docker compose ps</code>.
</li>
</ol>

<h3>Path 1: pull a published image</h3>
<p>
Expand Down Expand Up @@ -667,7 +723,42 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl
even when those three are present).
</p>

<h3>Rollback: no dedicated command today</h3>
<h3>Post-update checklist</h3>
<ol>
<li>
Wait for the deploy script&apos;s health wait to finish (or run the helper below if you
updated manually with plain <code>docker compose</code>).
</li>
<li>
<code>curl http://localhost:8787/ready</code> returns HTTP 200.
</li>
<li>
<code>docker compose ps gittensory</code> shows <code>healthy</code>.
</li>
<li>
Tail logs for <code>selfhost_listening</code> and, on first boot after a schema bump,{" "}
<code>selfhost_migrations_applied</code> — not <code>selfhost_job_dead</code>.
</li>
<li>
Confirm the release id — neither <code>/health</code> nor <code>/ready</code> exposes a
version string; check <code>.env</code> and the running container image instead.
</li>
</ol>
<CodeBlock
lang="bash"
code={`./scripts/selfhost-post-update-check.sh
# equivalent manual checks:
curl -sf http://localhost:8787/ready
docker compose ps gittensory
grep -E '^(GITTENSORY_IMAGE|GITTENSORY_VERSION|SENTRY_RELEASE)=' .env
docker inspect --format '{{.Config.Image}}' "$(docker compose ps -q gittensory)"`}
/>
<p>
If any check fails, see <Link to="/docs/self-hosting-troubleshooting">Troubleshooting</Link>
.
</p>

<h3>Rollback: no dedicated command</h3>
<p>
There is no <code>rollback</code> script. Rolling back means re-running one of the two
scripts above pointed at an older target:
Expand Down Expand Up @@ -699,42 +790,6 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl
the same on the live instance.
</Callout>

<h3>Before and after any update</h3>
<p>Before updating:</p>
<ul>
<li>
Source-based deploys: <code>git status</code> is clean (no uncommitted local changes the
build would silently pick up or drop).
</li>
<li>
A current, verified backup exists if the update includes schema changes — see{" "}
<Link to="/docs/self-hosting-backup-scaling">Backup and scaling</Link>.
</li>
</ul>
<p>
After updating, work through the same checks as any other health pass — see{" "}
<strong>Health endpoints</strong> and <strong>Useful commands</strong> above: confirm{" "}
<code>/ready</code> returns 200, <code>docker compose ps</code> shows the service{" "}
<code>healthy</code>, and tail recent logs for startup errors or an unexpected absence of{" "}
<code>selfhost_listening</code> / <code>selfhost_migrations_applied</code>.
</p>
<p>
Neither <code>/health</code> nor <code>/ready</code> reports a version, so confirm the
deployed release directly — <code>GITTENSORY_IMAGE</code> or <code>SENTRY_RELEASE</code> in{" "}
<code>.env</code> records what the deploy script just resolved, and{" "}
<code>docker inspect</code> confirms what the running container actually has:
</p>
<CodeBlock
lang="bash"
code={`grep -E '^(GITTENSORY_IMAGE|GITTENSORY_VERSION|SENTRY_RELEASE)=' .env
docker inspect --format '{{.Config.Image}}' "$(docker compose ps -q gittensory)"`}
/>

<p>
If an operating check fails, go to{" "}
<Link to="/docs/self-hosting-troubleshooting">Self-host troubleshooting</Link>.
</p>

<h2>Uninstalling and decommissioning</h2>
<p>
Tearing an instance down cleanly touches four independent things: the GitHub App
Expand Down
2 changes: 2 additions & 0 deletions scripts/deploy-selfhost-image.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Pull and deploy a published self-host image without rebuilding on the host.
#
# After a successful run, verify with: ./scripts/selfhost-post-update-check.sh
#
# Defaults to the latest official image:
# ./scripts/deploy-selfhost-image.sh
#
Expand Down
2 changes: 2 additions & 0 deletions scripts/deploy-selfhost-prebuilt.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Build and deploy the self-host runtime from a prebuilt bundle without relying on host Node/npm.
#
# After a successful run, verify with: ./scripts/selfhost-post-update-check.sh
#
# Defaults are intentionally operator-friendly:
# ./scripts/deploy-selfhost-prebuilt.sh
#
Expand Down
56 changes: 56 additions & 0 deletions scripts/selfhost-post-update-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Post-update verification for a self-host instance (#1823).
#
# Run after deploy-selfhost-image.sh, deploy-selfhost-prebuilt.sh, or any manual
# `docker compose up -d --no-deps gittensory` that ships a new app image.
#
# Checks /ready, compose health, .env release metadata, and the running container image.
# Does not modify .env, volumes, gittensory-config/, or any profile service.
set -euo pipefail

ENV_FILE="${SELFHOST_ENV_FILE:-.env}"
SERVICE="${SELFHOST_SERVICE:-gittensory}"
PORT="${PORT:-8787}"
READY_URL="${SELFHOST_READY_URL:-http://127.0.0.1:${PORT}/ready}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/selfhost-deploy-common.sh
. "$SCRIPT_DIR/lib/selfhost-deploy-common.sh"

require_cmd docker
require_cmd curl
docker compose version >/dev/null

mapfile -t compose_args < <(compose_file_args)

container_id="$(docker compose "${compose_args[@]}" ps -q "$SERVICE" 2>/dev/null || true)"
if [ -z "$container_id" ]; then
echo "error: $SERVICE is not running" >&2
docker compose "${compose_args[@]}" ps "$SERVICE" >&2 || true
exit 1
fi

echo "selfhost post-update check: probing $READY_URL"
if ! curl -sf "$READY_URL" >/dev/null; then
echo "error: $READY_URL did not return HTTP 2xx" >&2
exit 1
fi

status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)"
echo "selfhost post-update check: $SERVICE container status=$status"
if [ "$status" != "healthy" ] && [ "$status" != "running" ]; then
echo "error: expected healthy or running, got $status" >&2
docker compose "${compose_args[@]}" ps "$SERVICE" >&2 || true
exit 1
fi

if [ -f "$ENV_FILE" ]; then
echo "selfhost post-update check: release metadata from $ENV_FILE"
grep -E '^(GITTENSORY_IMAGE|GITTENSORY_VERSION|SENTRY_RELEASE)=' "$ENV_FILE" || true
else
echo "selfhost post-update check: warning — $ENV_FILE not found (skipping release metadata grep)" >&2
fi

running_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")"
echo "selfhost post-update check: running image=$running_image"
echo "selfhost post-update check: ok"
43 changes: 43 additions & 0 deletions test/unit/docs-selfhost-update-rollback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

// Drift guard (#1823): the self-host update/rollback docs must stay aligned with the shipped deploy
// scripts and the post-update verification helper.

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

const operations = readFileSync(OPERATIONS, "utf8");
const imageScript = readFileSync(IMAGE_SCRIPT, "utf8");
const prebuiltScript = readFileSync(PREBUILT_SCRIPT, "utf8");
const postUpdateScript = readFileSync(POST_UPDATE_SCRIPT, "utf8");

describe("self-host update + rollback docs (#1823)", () => {
it("documents both deploy paths, operator-owned paths, and the post-update helper", () => {
expect(operations).toContain("deploy-selfhost-image.sh");
expect(operations).toContain("deploy-selfhost-prebuilt.sh");
expect(operations).toContain("selfhost-post-update-check.sh");
expect(operations).toContain("Preflight checklist");
expect(operations).toContain("Post-update checklist");
expect(operations).toContain("Operator-owned");
expect(operations).toContain("gittensory-config/");
expect(operations).toContain("gittensory-data");
expect(operations).toContain("Migrations are forward-only");
});

it("deploy scripts restart only gittensory with --no-deps", () => {
expect(imageScript).toContain('up -d --no-build --no-deps "$SERVICE"');
expect(prebuiltScript).toContain('up -d --no-deps "$SERVICE"');
});

it("post-update script probes /ready without mutating operator-owned state", () => {
expect(postUpdateScript).toContain("/ready");
expect(postUpdateScript).toContain("GITTENSORY_IMAGE");
expect(postUpdateScript).toContain("GITTENSORY_VERSION");
expect(postUpdateScript).toContain("SENTRY_RELEASE");
expect(postUpdateScript).not.toContain("env_put");
expect(postUpdateScript).not.toContain("docker compose down");
});
});
Loading