From ccb8f4a81212a24679d8a6bea8c5eb8c0a38c1fb Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Thu, 2 Jul 2026 17:56:18 -0700
Subject: [PATCH 1/3] test(selfhost): add beta smoke matrix and release
checklist (#1944)
The existing CI "build + boot smoke test" only exercises one fixed configuration
(SQLite + Redis + direct-App mode) -- there was no repeatable way to validate
brokered mode, air-gapped mode, or any AI provider before cutting an RC image.
- scripts/smoke-selfhost.sh: a portable, mode-agnostic smoke script. Boots one
container against a fresh Redis on an isolated network, waits for health, and
asserts on /health, /ready, /metrics plus caller-supplied expected/forbidden
startup log events (SELFHOST_SMOKE_EXPECT_EVENTS / _FORBID_EVENTS). Always
asserts selfhost_migrations_applied regardless of mode.
- New docs page "Beta release checklist" walks through all six required
scenarios (direct App, brokered, air-gapped, each AI provider, SQLite vs.
Postgres, Redis + optional Qdrant RAG) with the exact env + smoke-script
invocation for each, an expected-events table, and a known-warnings table
distinguishing acceptable-in-beta from release-blocking (e.g. a failed relay
registration is a warning in pull mode but fatal in push mode).
- Linked from the Releases & images page's prerelease section and added to the
self-hosting nav under "Self-hosting: release & security".
- Drift-guard test (mirrors #1943's pattern): every selfhost_* event name cited
in the new doc and script is verified against the real console.log/error call
sites in src/server.ts and src/selfhost/ai.ts.
---
.../src/components/site/docs-nav.tsx | 1 +
apps/gittensory-ui/src/routeTree.gen.ts | 22 ++
.../docs.self-hosting-release-checklist.tsx | 241 ++++++++++++++++++
.../src/routes/docs.self-hosting-releases.tsx | 8 +-
scripts/smoke-selfhost.sh | 125 +++++++++
...host-release-checklist-event-names.test.ts | 32 +++
6 files changed, 428 insertions(+), 1 deletion(-)
create mode 100644 apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
create mode 100755 scripts/smoke-selfhost.sh
create mode 100644 test/unit/docs-selfhost-release-checklist-event-names.test.ts
diff --git a/apps/gittensory-ui/src/components/site/docs-nav.tsx b/apps/gittensory-ui/src/components/site/docs-nav.tsx
index 6ddd410859..0b33f14651 100644
--- a/apps/gittensory-ui/src/components/site/docs-nav.tsx
+++ b/apps/gittensory-ui/src/components/site/docs-nav.tsx
@@ -57,6 +57,7 @@ export const docsNav: DocsGroup[] = [
title: "Self-hosting: release & security",
items: [
{ to: "/docs/self-hosting-releases", label: "Releases & images" },
+ { to: "/docs/self-hosting-release-checklist", label: "Beta release checklist" },
{ to: "/docs/self-hosting-security", label: "Security" },
],
},
diff --git a/apps/gittensory-ui/src/routeTree.gen.ts b/apps/gittensory-ui/src/routeTree.gen.ts
index 8d8c8d2ed7..5b1a6cef2e 100644
--- a/apps/gittensory-ui/src/routeTree.gen.ts
+++ b/apps/gittensory-ui/src/routeTree.gen.ts
@@ -28,6 +28,7 @@ import { Route as DocsTroubleshootingRouteImport } from './routes/docs.troublesh
import { Route as DocsSelfHostingTroubleshootingRouteImport } from './routes/docs.self-hosting-troubleshooting'
import { Route as DocsSelfHostingSecurityRouteImport } from './routes/docs.self-hosting-security'
import { Route as DocsSelfHostingReleasesRouteImport } from './routes/docs.self-hosting-releases'
+import { Route as DocsSelfHostingReleaseChecklistRouteImport } from './routes/docs.self-hosting-release-checklist'
import { Route as DocsSelfHostingReesAnalyzersRouteImport } from './routes/docs.self-hosting-rees-analyzers'
import { Route as DocsSelfHostingReesRouteImport } from './routes/docs.self-hosting-rees'
import { Route as DocsSelfHostingRagRouteImport } from './routes/docs.self-hosting-rag'
@@ -162,6 +163,12 @@ const DocsSelfHostingReleasesRoute = DocsSelfHostingReleasesRouteImport.update({
path: '/self-hosting-releases',
getParentRoute: () => DocsRoute,
} as any)
+const DocsSelfHostingReleaseChecklistRoute =
+ DocsSelfHostingReleaseChecklistRouteImport.update({
+ id: '/self-hosting-release-checklist',
+ path: '/self-hosting-release-checklist',
+ getParentRoute: () => DocsRoute,
+ } as any)
const DocsSelfHostingReesAnalyzersRoute =
DocsSelfHostingReesAnalyzersRouteImport.update({
id: '/self-hosting-rees-analyzers',
@@ -405,6 +412,7 @@ export interface FileRoutesByFullPath {
'/docs/self-hosting-rag': typeof DocsSelfHostingRagRoute
'/docs/self-hosting-rees': typeof DocsSelfHostingReesRoute
'/docs/self-hosting-rees-analyzers': typeof DocsSelfHostingReesAnalyzersRoute
+ '/docs/self-hosting-release-checklist': typeof DocsSelfHostingReleaseChecklistRoute
'/docs/self-hosting-releases': typeof DocsSelfHostingReleasesRoute
'/docs/self-hosting-security': typeof DocsSelfHostingSecurityRoute
'/docs/self-hosting-troubleshooting': typeof DocsSelfHostingTroubleshootingRoute
@@ -460,6 +468,7 @@ export interface FileRoutesByTo {
'/docs/self-hosting-rag': typeof DocsSelfHostingRagRoute
'/docs/self-hosting-rees': typeof DocsSelfHostingReesRoute
'/docs/self-hosting-rees-analyzers': typeof DocsSelfHostingReesAnalyzersRoute
+ '/docs/self-hosting-release-checklist': typeof DocsSelfHostingReleaseChecklistRoute
'/docs/self-hosting-releases': typeof DocsSelfHostingReleasesRoute
'/docs/self-hosting-security': typeof DocsSelfHostingSecurityRoute
'/docs/self-hosting-troubleshooting': typeof DocsSelfHostingTroubleshootingRoute
@@ -519,6 +528,7 @@ export interface FileRoutesById {
'/docs/self-hosting-rag': typeof DocsSelfHostingRagRoute
'/docs/self-hosting-rees': typeof DocsSelfHostingReesRoute
'/docs/self-hosting-rees-analyzers': typeof DocsSelfHostingReesAnalyzersRoute
+ '/docs/self-hosting-release-checklist': typeof DocsSelfHostingReleaseChecklistRoute
'/docs/self-hosting-releases': typeof DocsSelfHostingReleasesRoute
'/docs/self-hosting-security': typeof DocsSelfHostingSecurityRoute
'/docs/self-hosting-troubleshooting': typeof DocsSelfHostingTroubleshootingRoute
@@ -579,6 +589,7 @@ export interface FileRouteTypes {
| '/docs/self-hosting-rag'
| '/docs/self-hosting-rees'
| '/docs/self-hosting-rees-analyzers'
+ | '/docs/self-hosting-release-checklist'
| '/docs/self-hosting-releases'
| '/docs/self-hosting-security'
| '/docs/self-hosting-troubleshooting'
@@ -634,6 +645,7 @@ export interface FileRouteTypes {
| '/docs/self-hosting-rag'
| '/docs/self-hosting-rees'
| '/docs/self-hosting-rees-analyzers'
+ | '/docs/self-hosting-release-checklist'
| '/docs/self-hosting-releases'
| '/docs/self-hosting-security'
| '/docs/self-hosting-troubleshooting'
@@ -692,6 +704,7 @@ export interface FileRouteTypes {
| '/docs/self-hosting-rag'
| '/docs/self-hosting-rees'
| '/docs/self-hosting-rees-analyzers'
+ | '/docs/self-hosting-release-checklist'
| '/docs/self-hosting-releases'
| '/docs/self-hosting-security'
| '/docs/self-hosting-troubleshooting'
@@ -851,6 +864,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsSelfHostingReleasesRouteImport
parentRoute: typeof DocsRoute
}
+ '/docs/self-hosting-release-checklist': {
+ id: '/docs/self-hosting-release-checklist'
+ path: '/self-hosting-release-checklist'
+ fullPath: '/docs/self-hosting-release-checklist'
+ preLoaderRoute: typeof DocsSelfHostingReleaseChecklistRouteImport
+ parentRoute: typeof DocsRoute
+ }
'/docs/self-hosting-rees-analyzers': {
id: '/docs/self-hosting-rees-analyzers'
path: '/self-hosting-rees-analyzers'
@@ -1184,6 +1204,7 @@ interface DocsRouteChildren {
DocsSelfHostingRagRoute: typeof DocsSelfHostingRagRoute
DocsSelfHostingReesRoute: typeof DocsSelfHostingReesRoute
DocsSelfHostingReesAnalyzersRoute: typeof DocsSelfHostingReesAnalyzersRoute
+ DocsSelfHostingReleaseChecklistRoute: typeof DocsSelfHostingReleaseChecklistRoute
DocsSelfHostingReleasesRoute: typeof DocsSelfHostingReleasesRoute
DocsSelfHostingSecurityRoute: typeof DocsSelfHostingSecurityRoute
DocsSelfHostingTroubleshootingRoute: typeof DocsSelfHostingTroubleshootingRoute
@@ -1218,6 +1239,7 @@ const DocsRouteChildren: DocsRouteChildren = {
DocsSelfHostingRagRoute: DocsSelfHostingRagRoute,
DocsSelfHostingReesRoute: DocsSelfHostingReesRoute,
DocsSelfHostingReesAnalyzersRoute: DocsSelfHostingReesAnalyzersRoute,
+ DocsSelfHostingReleaseChecklistRoute: DocsSelfHostingReleaseChecklistRoute,
DocsSelfHostingReleasesRoute: DocsSelfHostingReleasesRoute,
DocsSelfHostingSecurityRoute: DocsSelfHostingSecurityRoute,
DocsSelfHostingTroubleshootingRoute: DocsSelfHostingTroubleshootingRoute,
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
new file mode 100644
index 0000000000..cbf6ac03db
--- /dev/null
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
@@ -0,0 +1,241 @@
+import { createFileRoute, Link } from "@tanstack/react-router";
+
+import { DocsPage } from "@/components/site/docs-page";
+import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+
+export const Route = createFileRoute("/docs/self-hosting-release-checklist")({
+ head: () => ({
+ meta: [
+ { title: "Beta release checklist — Gittensory docs" },
+ {
+ name: "description",
+ content:
+ "The smoke matrix to run before publishing a self-host RC image: direct App, brokered, air-gapped, each AI provider, SQLite/Postgres, Redis/Qdrant. Portable commands, expected log events, known-warnings table.",
+ },
+ { property: "og:title", content: "Beta release checklist — Gittensory docs" },
+ {
+ property: "og:description",
+ content:
+ "The smoke matrix to run before publishing a self-host RC image: direct App, brokered, air-gapped, each AI provider, SQLite/Postgres, Redis/Qdrant.",
+ },
+ { property: "og:url", content: "/docs/self-hosting-release-checklist" },
+ ],
+ links: [{ rel: "canonical", href: "/docs/self-hosting-release-checklist" }],
+ }),
+ component: SelfHostingReleaseChecklist,
+});
+
+function SelfHostingReleaseChecklist() {
+ return (
+
+
+ Every scenario below shares the same core check — scripts/smoke-selfhost.sh{" "}
+ boots one container against a fresh Redis on an isolated network, waits for it to become
+ healthy, and asserts on /health, /ready, /metrics,
+ and startup log events. What changes per scenario is the env you pass in and which events
+ you expect (or forbid).
+
+
+
+
1. Direct GitHub App mode (default)
+
+ No ORB_ENROLLMENT_SECRET — the container uses its own GitHub App private key.
+ Telemetry export is always-on in this mode too; a clean run produces no export error.
+
+
+
+ selfhost_orb_relay_register must NOT appear here — relay registration is
+ brokered-only and silently skips in direct mode (see{" "}
+ GitHub App and Orb).
+
+
+
2. Brokered mode (private / managed-beta only)
+
+ ORB_ENROLLMENT_SECRET set — the container gets tokens from the central Orb
+ instead of its own App key. A working push-mode registration logs{" "}
+ selfhost_orb_relay_register; a broken one is fatal for push mode (logged at{" "}
+ error, not warn).
+
+
+
+
3. Air-gapped / no-telemetry mode
+
+ ORB_AIR_GAP=true disables the fleet-calibration export entirely. There is no
+ "air-gap confirmed" log event — the export function returns before doing anything, so
+ silence (no export error, no export attempt) is the signal. Confirm at the network level
+ too: no outbound request to the collector URL.
+
+
+
+
4. AI provider: Claude Code / Codex / both
+
+ Each provider choice must log selfhost_ai_provider and must NOT log{" "}
+ selfhost_ai_cli_missing (a CLI-subscription provider whose binary isn't on{" "}
+ PATH silently produces no review output — this must be caught here, not in
+ production).
+
+
+
+ These need real credentials to reach a genuinely healthy /ready (it probes the
+ configured AI provider). Where credentials aren't available for a given RC run, at minimum
+ confirm selfhost_ai_cli_missing does NOT appear — that alone catches the
+ release-blocking case (image built without INSTALL_AI_CLIS=true).
+
+
+
5. SQLite trial mode / Postgres production mode
+
+ SQLite is the default — the base smoke command above already covers it (no{" "}
+ DATABASE_URL set). For Postgres, boot a Postgres container on the same network
+ first and point DATABASE_URL at it.
+
+
+
+ SQLite is the trial/single-node default; recommend Postgres for production in release notes
+ whenever this mode is what beta testers actually validated.
+
+
+
6. Redis cache + optional Qdrant RAG
+
+ Redis is always-on in every scenario above (the base script already boots it) — confirm{" "}
+ selfhost_redis_ready appears with githubResponseCacheEnabled{" "}
+ matching whatever GITHUB_CACHE_TTL_SECONDS you set. For the optional Qdrant RAG
+ path, boot Qdrant on the same network and point QDRANT_URL at it.
+
+
+
+
Expected startup events
+
+
+
Known warnings: acceptable in beta vs. release-blocking
+
+
+
+ After every applicable scenario passes, continue with the normal{" "}
+ upgrade flow to cut the tag and publish the
+ image.
+
+
+ );
+}
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx
index 91e1fc1d98..ca02a0f1f3 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, Link } from "@tanstack/react-router";
import { DocsPage } from "@/components/site/docs-page";
import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
@@ -71,6 +71,12 @@ docker pull ghcr.io/jsonbored/gittensory-selfhost:latest`}
Stable release behavior is unchanged: a plain X.Y.Z tag still moves{" "}
latest and publishes an unmarked (non-prerelease) GitHub Release.
+
+ Before tagging any orb-v* release or prerelease, run the{" "}
+ beta release checklist against the
+ built image — CI only smoke-tests the plain SQLite + Redis + direct-App default, not
+ brokered mode, air-gapped mode, or any AI provider.
+
Upgrade flow
diff --git a/scripts/smoke-selfhost.sh b/scripts/smoke-selfhost.sh
new file mode 100755
index 0000000000..51917d2c73
--- /dev/null
+++ b/scripts/smoke-selfhost.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+# Portable self-host smoke test (#1944): boot one container from a given image, wait for it to become
+# healthy, and assert on its /health, /ready, /metrics output plus its startup log events. Mode-agnostic --
+# the caller supplies which env vars configure the mode under test and which log events that mode should
+# (or must not) produce. See docs/self-hosting-release-checklist for the beta smoke matrix built on this.
+#
+# Defaults to a plain SQLite + Redis + direct-App boot:
+# ./scripts/smoke-selfhost.sh gittensory:selfhost-ci
+#
+# Test a specific mode by passing extra env and the events it should produce:
+# SELFHOST_SMOKE_EXTRA_ENV="AI_PROVIDER=claude-code
+# CLAUDE_CODE_OAUTH_TOKEN=..." \
+# SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_ai_provider" \
+# ./scripts/smoke-selfhost.sh gittensory:selfhost-ci
+#
+# Assert an event must NOT appear (e.g. no AI-CLI-missing warning, no failed relay registration):
+# SELFHOST_SMOKE_FORBID_EVENTS="selfhost_ai_cli_missing" ./scripts/smoke-selfhost.sh gittensory:selfhost-ci
+set -euo pipefail
+
+IMAGE="${1:?usage: smoke-selfhost.sh }"
+NETWORK_NAME="${SELFHOST_SMOKE_NETWORK:-gt-smoke-$$}"
+REDIS_NAME="${SELFHOST_SMOKE_REDIS_NAME:-gt-smoke-redis-$$}"
+APP_NAME="${SELFHOST_SMOKE_APP_NAME:-gt-smoke-app-$$}"
+PORT="${SELFHOST_SMOKE_PORT:-8787}"
+HEALTH_TIMEOUT_SECONDS="${SELFHOST_SMOKE_HEALTH_TIMEOUT_SECONDS:-90}"
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "error: required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+require_cmd docker
+require_cmd curl
+
+cleanup() {
+ docker rm -f "$APP_NAME" "$REDIS_NAME" >/dev/null 2>&1 || true
+ docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+
+echo "smoke-selfhost: booting Redis + $IMAGE on an isolated network"
+docker network create "$NETWORK_NAME" >/dev/null
+docker run -d --name "$REDIS_NAME" --network "$NETWORK_NAME" redis:7-alpine >/dev/null
+for _ in $(seq 1 30); do
+ if docker exec "$REDIS_NAME" redis-cli ping | grep -q PONG; then break; fi
+ sleep 1
+done
+
+# Extra env, one KEY=VALUE per line -- turned into repeated -e flags. Deliberately whitespace/newline
+# separated (not comma) so values containing commas (e.g. AI_PROVIDER=claude-code,codex) are unambiguous.
+EXTRA_ENV_ARGS=()
+if [ -n "${SELFHOST_SMOKE_EXTRA_ENV:-}" ]; then
+ while IFS= read -r line; do
+ [ -z "$line" ] && continue
+ EXTRA_ENV_ARGS+=(-e "$line")
+ done <<<"$SELFHOST_SMOKE_EXTRA_ENV"
+fi
+
+docker run -d --name "$APP_NAME" --network "$NETWORK_NAME" -p "${PORT}:8787" \
+ -e "REDIS_URL=redis://${REDIS_NAME}:6379" \
+ -e "SELFHOST_SETUP_TOKEN=${SELFHOST_SMOKE_SETUP_TOKEN:-selfhost-smoke-setup-token}" \
+ -e "PUBLIC_API_ORIGIN=${SELFHOST_SMOKE_PUBLIC_API_ORIGIN:-https://selfhost-smoke.example}" \
+ "${EXTRA_ENV_ARGS[@]}" \
+ "$IMAGE" >/dev/null
+
+ok=0
+deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
+while [ "$SECONDS" -le "$deadline" ]; do
+ if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
+ ok=1
+ break
+ fi
+ sleep 2
+done
+if [ "$ok" != "1" ]; then
+ echo "::error::$APP_NAME did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s" >&2
+ docker logs "$APP_NAME" >&2 || true
+ exit 1
+fi
+
+echo "smoke-selfhost: checking /health, /ready, /metrics"
+curl -sf "http://127.0.0.1:${PORT}/health" | grep -q '"status":"ok"'
+curl -sf "http://127.0.0.1:${PORT}/ready" | grep -q '"ok":true'
+curl -sf "http://127.0.0.1:${PORT}/metrics" | grep -q 'gittensory_uptime_seconds'
+
+LOGS="$(docker logs "$APP_NAME" 2>&1)"
+
+if [ -n "${SELFHOST_SMOKE_EXPECT_EVENTS:-}" ]; then
+ IFS=',' read -ra EXPECT <<<"$SELFHOST_SMOKE_EXPECT_EVENTS"
+ for event in "${EXPECT[@]}"; do
+ event="$(echo "$event" | xargs)" # trim
+ [ -z "$event" ] && continue
+ if ! echo "$LOGS" | grep -q "\"event\":\"${event}\""; then
+ echo "::error::expected log event '$event' did not appear" >&2
+ echo "$LOGS" >&2
+ exit 1
+ fi
+ echo "smoke-selfhost: found expected event '$event'"
+ done
+fi
+
+if [ -n "${SELFHOST_SMOKE_FORBID_EVENTS:-}" ]; then
+ IFS=',' read -ra FORBID <<<"$SELFHOST_SMOKE_FORBID_EVENTS"
+ for event in "${FORBID[@]}"; do
+ event="$(echo "$event" | xargs)" # trim
+ [ -z "$event" ] && continue
+ if echo "$LOGS" | grep -q "\"event\":\"${event}\""; then
+ echo "::error::forbidden log event '$event' appeared" >&2
+ echo "$LOGS" >&2
+ exit 1
+ fi
+ echo "smoke-selfhost: confirmed absent forbidden event '$event'"
+ done
+fi
+
+# Always required: migrations must have applied on every mode, every boot.
+if ! echo "$LOGS" | grep -q '"event":"selfhost_migrations_applied"'; then
+ echo "::error::selfhost_migrations_applied did not appear" >&2
+ echo "$LOGS" >&2
+ exit 1
+fi
+
+echo "smoke-selfhost: passed"
diff --git a/test/unit/docs-selfhost-release-checklist-event-names.test.ts b/test/unit/docs-selfhost-release-checklist-event-names.test.ts
new file mode 100644
index 0000000000..548674df99
--- /dev/null
+++ b/test/unit/docs-selfhost-release-checklist-event-names.test.ts
@@ -0,0 +1,32 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+// Drift guard (#1944): the beta release checklist and its smoke script reference exact selfhost_* log
+// event names. If an event is ever renamed/removed in src/, this test fails instead of the checklist
+// silently going stale -- mirrors docs-selfhost-troubleshooting-metric-names.test.ts's approach (#1943).
+
+const DOC_PATH = "apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx";
+const SCRIPT_PATH = "scripts/smoke-selfhost.sh";
+const doc = readFileSync(DOC_PATH, "utf8");
+const script = readFileSync(SCRIPT_PATH, "utf8");
+
+// The exact source files that emit every selfhost_* event referenced in the checklist/script, per an
+// audit against the real console.log/console.error({ event: "selfhost_..." }) call sites.
+const EVENT_SOURCE_FILES = ["src/server.ts", "src/selfhost/ai.ts"];
+const eventSource = EVENT_SOURCE_FILES.map((path) => readFileSync(path, "utf8")).join("\n");
+
+describe("self-hosting-release-checklist doc + smoke script: event names match source (#1944)", () => {
+ it("every selfhost_* event name referenced in the checklist doc is actually emitted by the code", () => {
+ const names = [...new Set([...doc.matchAll(/selfhost_[a-z_]+/g)].map((m) => m[0]))];
+ expect(names.length).toBeGreaterThan(5); // sanity: the extraction found the checklist's real content
+ const missing = names.filter((name) => !eventSource.includes(`event: "${name}"`) && !eventSource.includes(`event": "${name}"`));
+ expect(missing).toEqual([]);
+ });
+
+ it("every selfhost_* event name referenced in the smoke script is actually emitted by the code", () => {
+ const names = [...new Set([...script.matchAll(/selfhost_[a-z_]+/g)].map((m) => m[0]))];
+ expect(names.length).toBeGreaterThan(0);
+ const missing = names.filter((name) => !eventSource.includes(`event: "${name}"`) && !eventSource.includes(`event": "${name}"`));
+ expect(missing).toEqual([]);
+ });
+});
From 323ff36fbee49b32351145a2324a90adef51bbc3 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:07:49 -0700
Subject: [PATCH 2/3] fix(selfhost): don't manage caller-supplied networks in
smoke-selfhost.sh
Gate review caught two real bugs: (1) the script unconditionally created the
network even when SELFHOST_SMOKE_NETWORK was set, breaking the doc's own
Postgres/Qdrant examples (network already exists); cleanup then unconditionally
removed it too, which would have torn down the caller's network out from under
their other containers. (2) the Redis readiness loop fell through silently on
timeout instead of failing with a clear error. Also pinned the Qdrant image tag
to match docker-compose.yml instead of :latest.
Verified live with Docker: a pre-created network now survives the full
create-skip + cleanup-skip path, and the default (owned) path still cleans up
with no orphaned networks.
---
.../docs.self-hosting-release-checklist.tsx | 2 +-
scripts/smoke-selfhost.sh | 34 ++++++++++++++++---
2 files changed, 30 insertions(+), 6 deletions(-)
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
index cbf6ac03db..3429958ba6 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
@@ -167,7 +167,7 @@ docker rm -f gt-pg && docker network rm gt-pg-smoke`}
# With Qdrant RAG:
docker network create gt-rag-smoke
-docker run -d --name gt-qdrant --network gt-rag-smoke qdrant/qdrant:latest
+docker run -d --name gt-qdrant --network gt-rag-smoke qdrant/qdrant:v1.18.2
SELFHOST_SMOKE_NETWORK=gt-rag-smoke \\
SELFHOST_SMOKE_EXTRA_ENV="QDRANT_URL=http://gt-qdrant:6333" \\
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_vectorize" \\
diff --git a/scripts/smoke-selfhost.sh b/scripts/smoke-selfhost.sh
index 51917d2c73..5e749811ec 100755
--- a/scripts/smoke-selfhost.sh
+++ b/scripts/smoke-selfhost.sh
@@ -18,7 +18,16 @@
set -euo pipefail
IMAGE="${1:?usage: smoke-selfhost.sh }"
-NETWORK_NAME="${SELFHOST_SMOKE_NETWORK:-gt-smoke-$$}"
+# A caller-supplied network (e.g. one already holding a Postgres or Qdrant container for a cross-service
+# scenario) is joined, not owned -- this script neither creates nor removes it. Only the default
+# self-generated network is created/removed here.
+NETWORK_OWNED=1
+if [ -n "${SELFHOST_SMOKE_NETWORK:-}" ]; then
+ NETWORK_NAME="$SELFHOST_SMOKE_NETWORK"
+ NETWORK_OWNED=0
+else
+ NETWORK_NAME="gt-smoke-$$"
+fi
REDIS_NAME="${SELFHOST_SMOKE_REDIS_NAME:-gt-smoke-redis-$$}"
APP_NAME="${SELFHOST_SMOKE_APP_NAME:-gt-smoke-app-$$}"
PORT="${SELFHOST_SMOKE_PORT:-8787}"
@@ -36,17 +45,32 @@ require_cmd curl
cleanup() {
docker rm -f "$APP_NAME" "$REDIS_NAME" >/dev/null 2>&1 || true
- docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
+ if [ "$NETWORK_OWNED" = "1" ]; then
+ docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
+ fi
}
trap cleanup EXIT
-echo "smoke-selfhost: booting Redis + $IMAGE on an isolated network"
-docker network create "$NETWORK_NAME" >/dev/null
+if [ "$NETWORK_OWNED" = "1" ]; then
+ echo "smoke-selfhost: booting Redis + $IMAGE on an isolated network ($NETWORK_NAME)"
+ docker network create "$NETWORK_NAME" >/dev/null
+else
+ echo "smoke-selfhost: booting Redis + $IMAGE on caller-supplied network ($NETWORK_NAME)"
+fi
docker run -d --name "$REDIS_NAME" --network "$NETWORK_NAME" redis:7-alpine >/dev/null
+redis_ok=0
for _ in $(seq 1 30); do
- if docker exec "$REDIS_NAME" redis-cli ping | grep -q PONG; then break; fi
+ if docker exec "$REDIS_NAME" redis-cli ping | grep -q PONG; then
+ redis_ok=1
+ break
+ fi
sleep 1
done
+if [ "$redis_ok" != "1" ]; then
+ echo "::error::$REDIS_NAME never responded to PING" >&2
+ docker logs "$REDIS_NAME" >&2 || true
+ exit 1
+fi
# Extra env, one KEY=VALUE per line -- turned into repeated -e flags. Deliberately whitespace/newline
# separated (not comma) so values containing commas (e.g. AI_PROVIDER=claude-code,codex) are unambiguous.
From 92b06c09e0d3d8a33669d179d926a9089a90782e Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:16:37 -0700
Subject: [PATCH 3/3] fix(selfhost): mount multiline secrets as files instead
of env in smoke-selfhost.sh
Gate review caught a real bug: SELFHOST_SMOKE_EXTRA_ENV is line-delimited, so a
multiline PEM private key passed through it (exactly what the direct-App
checklist scenario documented) gets truncated at the first internal newline and
the remaining PEM lines become bogus separate env entries -- the container ends
up without a valid private key for the one scenario that most needs it.
Added SELFHOST_SMOKE_EXTRA_VOLUMES (same line-delimited -v passthrough pattern)
and switched the direct-App doc example to mount the key file + set
GITHUB_APP_PRIVATE_KEY_FILE, which src/server.ts already resolves into
GITHUB_APP_PRIVATE_KEY at startup like every other *_FILE variable. Verified
live with Docker that a multiline file mounts with content intact.
---
.../routes/docs.self-hosting-release-checklist.tsx | 8 ++++++--
scripts/smoke-selfhost.sh | 14 ++++++++++++++
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
index 3429958ba6..83fb90a3ac 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx
@@ -53,8 +53,12 @@ docker buildx build --load -t gittensory:rc-candidate .
diff --git a/scripts/smoke-selfhost.sh b/scripts/smoke-selfhost.sh
index 5e749811ec..1b8188e072 100755
--- a/scripts/smoke-selfhost.sh
+++ b/scripts/smoke-selfhost.sh
@@ -74,6 +74,8 @@ fi
# Extra env, one KEY=VALUE per line -- turned into repeated -e flags. Deliberately whitespace/newline
# separated (not comma) so values containing commas (e.g. AI_PROVIDER=claude-code,codex) are unambiguous.
+# NOT for multiline secrets like a PEM private key -- a newline inside a value is indistinguishable from
+# an entry boundary here. Use SELFHOST_SMOKE_EXTRA_VOLUMES + a _FILE env var instead (see below).
EXTRA_ENV_ARGS=()
if [ -n "${SELFHOST_SMOKE_EXTRA_ENV:-}" ]; then
while IFS= read -r line; do
@@ -82,11 +84,23 @@ if [ -n "${SELFHOST_SMOKE_EXTRA_ENV:-}" ]; then
done <<<"$SELFHOST_SMOKE_EXTRA_ENV"
fi
+# Extra volumes, one "host_path:container_path[:opts]" per line -- turned into repeated -v flags. This is
+# how a multiline secret (e.g. GITHUB_APP_PRIVATE_KEY_FILE) reaches the container safely: mount the file,
+# then set the *_FILE env var to its container path via SELFHOST_SMOKE_EXTRA_ENV (a single-line value).
+EXTRA_VOLUME_ARGS=()
+if [ -n "${SELFHOST_SMOKE_EXTRA_VOLUMES:-}" ]; then
+ while IFS= read -r line; do
+ [ -z "$line" ] && continue
+ EXTRA_VOLUME_ARGS+=(-v "$line")
+ done <<<"$SELFHOST_SMOKE_EXTRA_VOLUMES"
+fi
+
docker run -d --name "$APP_NAME" --network "$NETWORK_NAME" -p "${PORT}:8787" \
-e "REDIS_URL=redis://${REDIS_NAME}:6379" \
-e "SELFHOST_SETUP_TOKEN=${SELFHOST_SMOKE_SETUP_TOKEN:-selfhost-smoke-setup-token}" \
-e "PUBLIC_API_ORIGIN=${SELFHOST_SMOKE_PUBLIC_API_ORIGIN:-https://selfhost-smoke.example}" \
"${EXTRA_ENV_ARGS[@]}" \
+ "${EXTRA_VOLUME_ARGS[@]}" \
"$IMAGE" >/dev/null
ok=0