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..83fb90a3ac --- /dev/null +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-release-checklist.tsx @@ -0,0 +1,245 @@ +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..1b8188e072 --- /dev/null +++ b/scripts/smoke-selfhost.sh @@ -0,0 +1,163 @@ +#!/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 }" +# 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}" +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 + if [ "$NETWORK_OWNED" = "1" ]; then + docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +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 + 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. +# 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 + [ -z "$line" ] && continue + EXTRA_ENV_ARGS+=(-e "$line") + 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 +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([]); + }); +});