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
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Keep the build context lean — deps are installed fresh (npm ci) and the bundle is built in the image.
node_modules
**/node_modules
dist
dist/*
!dist/server.mjs
dist-ssr
.output
.nitro
Expand Down
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ GITTENSORY_REVIEW_DRAFT=false
#
# Claude usage telemetry → OTEL collector → Prometheus → the Claude usage dashboard. OFF by default.
# CLAUDE_CODE_ENABLE_TELEMETRY=1 # enable; needs --profile observability (starts the otel-collector)

# --- Sentry error tracking (optional) ---
# SENTRY_DSN= # enables self-host Sentry capture; unset = complete no-op
# SENTRY_ENVIRONMENT=production
# SENTRY_TRACES_SAMPLE_RATE=0 # traces are off by default; errors still report
# SENTRY_RELEASE= # custom images only: set this ONLY when you uploaded source maps for
# # the exact built bundle under this exact release id. Future official
# # images bake GITTENSORY_VERSION=gittensory-selfhost@<version>, so do
# # not override SENTRY_RELEASE for those images.
# OTEL_METRIC_EXPORT_INTERVAL=10000 # ms between metric exports (default 10s here; CLI default is 60s)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # override only for an external collector
#
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/release-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,42 @@ jobs:
else
echo "v=${GITHUB_REF_NAME#selfhost-v}" >> "$GITHUB_OUTPUT"
fi
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${INPUT_VERSION}"
else
VERSION="${GITHUB_REF_NAME#selfhost-v}"
fi
echo "release=gittensory-selfhost@${VERSION}" >> "$GITHUB_OUTPUT"

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
cache: "npm"

- name: Install deps
run: npm ci --ignore-scripts

- name: Build release bundle
run: node scripts/build-selfhost.mjs --all

- name: Validate release source map
run: node scripts/validate-selfhost-sourcemap.mjs

- name: Upload Sentry source maps
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_RELEASE: ${{ steps.version.outputs.release }}
run: |
test -n "$SENTRY_AUTH_TOKEN"
test -n "$SENTRY_ORG"
test -n "$SENTRY_PROJECT"
npx -y @sentry/cli@latest releases new "$SENTRY_RELEASE"
npx -y @sentry/cli@latest releases set-commits "$SENTRY_RELEASE" --auto
npx -y @sentry/cli@latest sourcemaps inject dist
node scripts/validate-selfhost-sourcemap.mjs
npx -y @sentry/cli@latest sourcemaps upload --release="$SENTRY_RELEASE" dist

- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
Expand Down Expand Up @@ -70,13 +106,24 @@ jobs:
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
target: runtime-prebuilt
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GITTENSORY_VERSION=${{ steps.version.outputs.release }}
provenance: true
sbom: true

- name: Finalize Sentry release
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_RELEASE: ${{ steps.version.outputs.release }}
run: npx -y @sentry/cli@latest releases finalize "$SENTRY_RELEASE"

- name: GitHub Release
if: github.event_name == 'push'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- "src/selfhost/**"
- "src/server.ts"
- "scripts/build-selfhost.mjs"
- "scripts/validate-selfhost-sourcemap.mjs"
- "scripts/register-selfhost.mjs"
- "Dockerfile"
- "docker-compose.yml"
Expand All @@ -24,6 +25,7 @@ on:
- "src/selfhost/**"
- "src/server.ts"
- "scripts/build-selfhost.mjs"
- "scripts/validate-selfhost-sourcemap.mjs"
- "scripts/register-selfhost.mjs"
- "Dockerfile"
- "docker-compose.yml"
Expand Down Expand Up @@ -70,6 +72,9 @@ jobs:
- name: Build the self-host bundle
run: node scripts/build-selfhost.mjs

- name: Validate self-host source map
run: node scripts/validate-selfhost-sourcemap.mjs

- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Build the Docker image
Expand Down
24 changes: 18 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ COPY . .
# --all: bundle every dependency into one self-contained dist/server.mjs, so the runtime image needs no
# node_modules (≈10× smaller). The bundle has zero `cloudflare:*` imports (stubbed at build), so no loader.
RUN node scripts/build-selfhost.mjs --all
RUN node scripts/validate-selfhost-sourcemap.mjs

# --- runtime: slim, non-root ----------------------------------------------------------------------------
FROM public.ecr.aws/docker/library/node:24-slim AS runtime
# --- runtime base: slim, non-root -----------------------------------------------------------------------
FROM public.ecr.aws/docker/library/node:24-slim AS runtime-base
WORKDIR /app
ARG GITTENSORY_VERSION=
ENV NODE_ENV=production \
PLATFORM=self-hosted \
PORT=8787 \
DATABASE_PATH=/data/gittensory.sqlite \
MIGRATIONS_DIR=/app/migrations \
NPM_CONFIG_PREFIX=/home/node/.npm-global
COPY --from=build /app/dist ./dist
COPY --from=build /app/migrations ./migrations
NPM_CONFIG_PREFIX=/home/node/.npm-global \
GITTENSORY_VERSION=${GITTENSORY_VERSION}
# Optional: bake the Claude Code / Codex CLIs so the `claude-code` / `codex` subscription providers (#979)
# work in-image. Build with `--build-arg INSTALL_AI_CLIS=true`. No credentials are baked — operators mint
# CLAUDE_CODE_OAUTH_TOKEN (`claude setup-token`) / codex auth at run time and pass it via the env.
Expand All @@ -44,7 +45,7 @@ USER root
# Optional: enable visual review via an external Chrome sidecar (e.g. `browserless/chrome:latest`).
# Build with `--build-arg INSTALL_VISUAL_REVIEW=true` then set BROWSER_WS_ENDPOINT=<ws-url> at runtime.
ARG INSTALL_VISUAL_REVIEW=false
COPY --from=build /app/package*.json ./
COPY package*.json ./
RUN if [ "$INSTALL_VISUAL_REVIEW" = "true" ]; then npm install puppeteer-core@22.13.1 --ignore-scripts; fi
# Data dir (the SQLite file) — owned by the unprivileged node user; mount a volume here to persist.
RUN mkdir -p /data && chown -R node:node /data /app
Expand All @@ -59,3 +60,14 @@ EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8787)+'/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.mjs"]

# Maintainer release images are built from the already-built, Sentry-injected bundle in the workflow. The source map
# is uploaded to Sentry there and is deliberately not copied into the runtime image.
FROM runtime-base AS runtime-prebuilt
COPY --chown=node:node dist/server.mjs ./dist/server.mjs
COPY --chown=node:node migrations ./migrations

# Default local/operator builds still build the bundle inside Docker, but only the JS bundle reaches runtime.
FROM runtime-base AS runtime
COPY --from=build --chown=node:node /app/dist/server.mjs ./dist/server.mjs
COPY --from=build --chown=node:node /app/migrations ./migrations
17 changes: 15 additions & 2 deletions docs/self-host/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,20 @@ See [ai-providers.md](./ai-providers.md) for the full provider/model/effort/time
| `AI_EMBED_BASE_URL` / `_MODEL` / `_PROVIDER` | Dedicated RAG embed provider |
| `GITTENSORY_REPO_CONFIG_DIR` | Container-private per-repo config dir |

## Sentry environment variables

Sentry is optional and self-host-only. Unset `SENTRY_DSN` means no SDK import, no events, and no runtime overhead.

| Var | Purpose |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `SENTRY_DSN` | Enables self-host error reporting. Keep it in `.env` or a mounted secret. |
| `SENTRY_ENVIRONMENT` | Environment name, default `production`. |
| `SENTRY_TRACES_SAMPLE_RATE` | Trace sampling, default `0`; errors still report when tracing is off. |
| `SENTRY_RELEASE` | Custom images only, and only when source maps for that exact built bundle were uploaded under the same release id. |
| `GITTENSORY_VERSION` | Baked into future official images as `gittensory-selfhost@<version>` and used as the Sentry release when `SENTRY_RELEASE` is not explicitly set. |

## Secrets — never commit them

`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `INTERNAL_JOB_TOKEN`, `TOKEN_ENCRYPTION_SECRET`, the App private
key, and the webhook secret live in `.env` / mounted files **only** — keep your deploy directory out of any repo.
`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `INTERNAL_JOB_TOKEN`, `TOKEN_ENCRYPTION_SECRET`,
`SENTRY_DSN`, the App private key, and the webhook secret live in `.env` / mounted files **only** — keep your
deploy directory out of any repo.
47 changes: 47 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ content-lane are not yet per-repo toggleable and stay on the allowlist.)
in-flight job, checkpoints the WAL, and closes the DB before exiting.
- **Logs** are structured JSON (`selfhost_listening`, `selfhost_migrations_applied`, `selfhost_ai_provider`,
`selfhost_queue_recovered`, `selfhost_job_dead`, `selfhost_cron_error`, `selfhost_shutdown`, …).
- **Sentry error tracking.** Set `SENTRY_DSN` to capture self-host runtime errors. The SDK release is
`SENTRY_RELEASE` when set, otherwise the baked `GITTENSORY_VERSION` value. Future official images bake
`GITTENSORY_VERSION=gittensory-selfhost@<version>` and the maintainer release workflow uploads the matching
source maps before the image is pushed. Custom images should leave `SENTRY_RELEASE` unset unless you uploaded
source maps for that exact bundle under that exact release id.
- **Data + backup.** Everything is the single SQLite file on the `gittensory-data` volume (WAL mode). Back up
by snapshotting the volume or copying the `.sqlite` file. Migrations are idempotent and re-checked at boot.
For **continuous, point-in-time backup**, enable the optional [Litestream](https://litestream.io) sidecar in
Expand All @@ -253,6 +258,48 @@ content-lane are not yet per-repo toggleable and stay on the allowlist.)
- **App-level metrics.** Enable `GITTENSORY_REVIEW_OPS=true` for the read-only gate-block anomaly scan and the
bearer-gated `GET /v1/internal/ops/stats` aggregate.

### Sentry source maps and release tracking

This repo is not required to publish an official image on every self-host tweak. The source-map upload path is only
wired into the maintainer release workflow (`selfhost-v*` tags or manual `release-selfhost` runs), so normal PRs and
local operator builds do not upload anything to Sentry and do not need extra commands.

Set the Sentry GitHub code mapping for the Sentry project to:

| Sentry field | Value |
| ----------------- | ----- |
| Stack Trace Root | `/app` |
| Source Code Root | `.` |
| Branch | `main` |

The maintainer release workflow expects:

| GitHub setting | Value |
| --------------------------- | --------------------------------------------------------------------- |
| Secret `SENTRY_AUTH_TOKEN` | Sentry auth token allowed to create releases and upload source maps |
| Variable `SENTRY_ORG` | Sentry organization slug |
| Variable `SENTRY_PROJECT` | Sentry project slug |
| Sentry GitHub integration | Installed for `JSONbored/gittensory`, with the code mapping above |

The workflow builds `dist/server.mjs` with `dist/server.mjs.map`, validates the `sourceMappingURL` and embedded
`sourcesContent`, injects Sentry debug ids, creates release `gittensory-selfhost@<version>`, associates commits with
`set-commits --auto`, uploads the source maps, and then builds the image from that injected `dist/server.mjs`.
`dist/server.mjs.map` is **not** copied into the runtime image and is not served by the app; it only exists as a
private Sentry release artifact.

For a custom image, source maps only work when the deployed JS bundle is the exact post-injection bundle whose map was
uploaded. If you build locally and do not upload maps, leave `SENTRY_RELEASE` unset. Events still report to Sentry,
but stack frames can remain bundled at `/app/dist/server.mjs`.

If a new event still shows `/app/dist/server.mjs`:

1. Confirm the event's `release` exactly matches the release that has the uploaded artifact bundle.
2. Confirm the image was built from the injected `dist/server.mjs`, not from a later Docker-internal rebuild.
3. Confirm the Sentry code mapping is `/app` → `.` on branch `main`.
4. Confirm `dist/server.mjs` had `//# sourceMappingURL=server.mjs.map` before upload and the map includes
`sourcesContent`.
5. Trigger a fresh event after the upload; old events may need reprocessing before they pick up newly uploaded maps.

---

## 7. Scaling out — Postgres + Redis (multi-instance)
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-selfhost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ await esbuild.build({
format: "esm",
target: "node22",
outfile: resolve(root, "dist/server.mjs"),
sourcemap: true,
sourcesContent: true,
// External: nothing (bundle all) vs every package (external). node: builtins are always external on node.
...(bundleAll ? {} : { packages: "external" }),
// Bundling CJS deps into an ESM output needs require/__dirname/__filename shimmed (some deps call them).
Expand Down
63 changes: 63 additions & 0 deletions scripts/validate-selfhost-sourcemap.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";

const root = process.cwd();
const bundlePath = resolve(root, "dist/server.mjs");
const mapPath = resolve(root, "dist/server.mjs.map");

function fail(message) {
console.error(`self-host sourcemap validation failed: ${message}`);
process.exit(1);
}

if (!existsSync(bundlePath)) fail("dist/server.mjs is missing");
if (!existsSync(mapPath)) fail("dist/server.mjs.map is missing");

const bundle = readFileSync(bundlePath, "utf8");
if (!bundle.includes("//# sourceMappingURL=server.mjs.map")) {
fail("dist/server.mjs is missing the server.mjs.map sourceMappingURL");
}

let map;
try {
map = JSON.parse(readFileSync(mapPath, "utf8"));
} catch (error) {
fail(`dist/server.mjs.map is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
}

if (map.version !== 3) fail("dist/server.mjs.map is not a version 3 source map");
if (!Array.isArray(map.sources) || map.sources.length === 0) {
fail("dist/server.mjs.map has no original sources");
}
if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) {
fail("dist/server.mjs.map must include sourcesContent for every original source");
}
const serverSourceIndex = map.sources.findIndex((source) =>
String(source).endsWith("src/server.ts"),
);
if (serverSourceIndex === -1) {
fail("dist/server.mjs.map does not include src/server.ts");
}
if (map.sourcesContent[serverSourceIndex]?.trim() === "") {
fail("dist/server.mjs.map has empty source content for src/server.ts");
}
const repoSourceIndexes = map.sources
.map((source, index) => [String(source), index])
.filter(([source]) => source.startsWith("../src/"))
.map(([, index]) => index);
if (repoSourceIndexes.length === 0) {
fail("dist/server.mjs.map does not include repository sources");
}
if (
repoSourceIndexes.some(
(index) =>
typeof map.sourcesContent[index] !== "string" ||
map.sourcesContent[index].trim() === "",
)
) {
fail("dist/server.mjs.map is missing source content for a repository source");
}

console.log(
`self-host sourcemap validation passed (${map.sources.length} original sources)`,
);
13 changes: 12 additions & 1 deletion src/selfhost/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ let active = false;
const SECRET_KEY =
/(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)/i;

function nonBlank(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

export function resolveSentryRelease(
env: NodeJS.ProcessEnv,
): string | undefined {
return nonBlank(env.SENTRY_RELEASE) ?? nonBlank(env.GITTENSORY_VERSION);
}

/** beforeSend scrubber — redact anything token/secret-like before an event leaves the box (privacy boundary). */
export function scrubEvent<T>(event: T): T {
const redact = (obj: unknown, depth: number): void => {
Expand Down Expand Up @@ -41,7 +52,7 @@ export async function initSentry(env: NodeJS.ProcessEnv): Promise<boolean> {
Sentry.init({
dsn: env.SENTRY_DSN,
environment: env.SENTRY_ENVIRONMENT ?? "production",
release: env.SENTRY_RELEASE ?? env.GITTENSORY_VERSION,
release: resolveSentryRelease(env),
tracesSampleRate: Number(env.SENTRY_TRACES_SAMPLE_RATE ?? "0"),
serverName: env.PUBLIC_API_ORIGIN,
beforeSend: (e) => scrubEvent(e),
Expand Down
45 changes: 45 additions & 0 deletions test/unit/selfhost-sentry-release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

const read = (path: string) => readFileSync(path, "utf8");

describe("self-host Sentry release wiring", () => {
it("keeps source-map uploads in the maintainer release workflow only", () => {
const releaseWorkflow = read(".github/workflows/release-selfhost.yml");
expect(releaseWorkflow).toContain('sourcemaps inject dist');
expect(releaseWorkflow).toContain(
'sourcemaps upload --release="$SENTRY_RELEASE" dist',
);
expect(releaseWorkflow).toContain(
'releases set-commits "$SENTRY_RELEASE" --auto',
);
expect(releaseWorkflow).toContain("target: runtime-prebuilt");
expect(releaseWorkflow).toContain(
"GITTENSORY_VERSION=${{ steps.version.outputs.release }}",
);

for (const path of [
"scripts/build-selfhost.mjs",
"Dockerfile",
".github/workflows/selfhost.yml",
]) {
expect(read(path)).not.toContain("sourcemaps upload");
}
});

it("does not copy source maps into the runtime image", () => {
const dockerfile = read("Dockerfile");
expect(dockerfile).not.toContain("COPY --from=build /app/dist ./dist");
expect(dockerfile).toContain(
"COPY --from=build --chown=node:node /app/dist/server.mjs ./dist/server.mjs",
);
expect(dockerfile).toContain(
"COPY --chown=node:node dist/server.mjs ./dist/server.mjs",
);

const dockerignore = read(".dockerignore");
expect(dockerignore).toContain("dist/*");
expect(dockerignore).toContain("!dist/server.mjs");
expect(dockerignore).not.toContain("!dist/server.mjs.map");
});
});
Loading
Loading