diff --git a/.do/gitnexus/Caddyfile b/.do/gitnexus/Caddyfile new file mode 100644 index 0000000000..3c5dac2c6f --- /dev/null +++ b/.do/gitnexus/Caddyfile @@ -0,0 +1,25 @@ +# Caddy reverse proxy with bearer token auth and automatic HTTPS. +# The domain is supplied via environment variable GITNEXUS_DOMAIN, +# and the auth token via API_TOKEN. Both are set in docker-compose.yml. + +{$GITNEXUS_DOMAIN} { + # Health check — unauthenticated so monitoring can probe it + @health path /health + handle @health { + reverse_proxy gitnexus:4747 { + rewrite /api/info + } + } + + # All other routes require bearer token + @authed { + header Authorization "Bearer {$API_TOKEN}" + } + + handle @authed { + reverse_proxy gitnexus:4747 + } + + # Reject unauthenticated requests + respond "Unauthorized" 401 +} diff --git a/.fly/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile similarity index 52% rename from .fly/gitnexus/Dockerfile rename to .do/gitnexus/Dockerfile index 3362eb658d..a1266d5a13 100644 --- a/.fly/gitnexus/Dockerfile +++ b/.do/gitnexus/Dockerfile @@ -1,62 +1,45 @@ +# Long-lived GitNexus image for DigitalOcean droplet deployment. +# +# This image does NOT bake in the index data. Indexes are mounted from +# the host at /indexes//.gitnexus/ and registered at container +# startup. A fresh index only requires rsync + container restart — no +# image rebuild on every push. + FROM node:24-slim ARG GITNEXUS_VERSION=1.5.3 -# 1. Build native addons with Bookworm toolchain, then remove build tools +# 1. Build native addons with Bookworm toolchain, then remove build tools. +# curl stays for the docker healthcheck; Caddy lives in its own container. RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 make g++ caddy curl \ + && apt-get install -y --no-install-recommends python3 make g++ curl \ && npm install -g gitnexus@${GITNEXUS_VERSION} \ && apt-get purge -y --auto-remove python3 make g++ \ && rm -rf /var/lib/apt/lists/* /root/.npm # 2. Upgrade libstdc++ from Trixie — @ladybugdb/core prebuilt binary needs # GLIBCXX_3.4.32 which Bookworm (3.4.31) doesn't ship. -# Done AFTER removing g++ to avoid libc6-dev version conflict. RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list \ && apt-get update \ && apt-get install -y -t trixie libstdc++6 \ && rm /etc/apt/sources.list.d/trixie.list \ && rm -rf /var/lib/apt/lists/* -# 3. Pre-install LadybugDB FTS + vector extensions so the cache in -# ~/.kuzu/extension/ is baked into the image. Workaround for upstream -# bug: gitnexus serve's pool-adapter calls LOAD EXTENSION but never -# INSTALL, so without this step, query()'s BM25 + semantic search -# silently return empty. +# 3. Pre-install LadybugDB FTS + vector extensions so ~/.kuzu/extension/ +# is baked into the image. Workaround for upstream GitNexus 1.5.3 bug. COPY install-extensions.js /tmp/install-extensions.js RUN node /tmp/install-extensions.js && rm -rf /tmp/install-extensions.js /tmp/lbug-ext-install -# 4. Patch mcp/core/lbug-adapter.js to also LOAD EXTENSION vector after FTS. -# Upstream only loads FTS at serve time; without vector extension -# loaded, semantic search's CALL QUERY_VECTOR_INDEX fails silently. -# Note: the published npm package compiles the source pool-adapter.ts -# to dist/mcp/core/lbug-adapter.js (not a 'pool-adapter.js' file). +# 4. Patch lbug-adapter.js to also LOAD EXTENSION vector after FTS. RUN LBUG_ADAPTER=/usr/local/lib/node_modules/gitnexus/dist/mcp/core/lbug-adapter.js \ && grep -q "LOAD EXTENSION fts" "$LBUG_ADAPTER" \ && sed -i "s|await available\[0\]\.query('LOAD EXTENSION fts');|await available[0].query('LOAD EXTENSION fts'); try { await available[0].query('LOAD EXTENSION vector'); } catch (e) { /* vector extension may not be installed */ }|g" "$LBUG_ADAPTER" \ && grep -c "LOAD EXTENSION vector" "$LBUG_ADAPTER" \ && echo "lbug-adapter.js patched to load vector extension" -# Copy pre-built GitNexus indexes (one per branch) and register each. -# The directory name becomes the repo name in `list_repos` output, so -# each branch lands in /LibreChat or /LibreChat-dev etc. -COPY indexes/ /indexes/ -RUN set -e && \ - for dir in /indexes/*/; do \ - name=$(basename "$dir"); \ - target="/$name"; \ - mkdir -p "$target"; \ - cp -r "$dir.gitnexus" "$target/.gitnexus"; \ - gitnexus index "$target" --allow-non-git; \ - done && \ - rm -rf /indexes - -# Caddy reverse proxy: bearer token auth in front of gitnexus serve -COPY Caddyfile /etc/caddy/Caddyfile - COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 8080 +EXPOSE 4747 ENTRYPOINT ["/entrypoint.sh"] diff --git a/.do/gitnexus/docker-compose.yml b/.do/gitnexus/docker-compose.yml new file mode 100644 index 0000000000..7761a89a30 --- /dev/null +++ b/.do/gitnexus/docker-compose.yml @@ -0,0 +1,87 @@ +# GitNexus stack for the DigitalOcean droplet. +# +# Two services: the gitnexus server (bound to an internal network only) +# and a Caddy reverse proxy that handles TLS + auth. +# +# Index data lives on the host at /opt/gitnexus/indexes/ and is +# bind-mounted read-write into the gitnexus container. The deploy +# workflow rsyncs fresh indexes into that directory and restarts +# only the gitnexus container — Caddy keeps running undisturbed. +# +# Break-glass: if gitnexus is stuck unhealthy and you need to restart +# just Caddy (e.g. to push an emergency Caddyfile fix), the +# `depends_on: condition: service_healthy` would block: +# docker compose up -d caddy +# Use --no-deps to bypass the dependency check: +# docker compose up -d --no-deps caddy + +name: gitnexus + +# Shared logging defaults applied to both services so the droplet's +# disk doesn't fill up with unbounded json-file logs. +x-logging: &default-logging + driver: json-file + options: + max-size: '50m' + max-file: '3' + +services: + gitnexus: + # Override via GITNEXUS_IMAGE in /opt/gitnexus/.env to use a fork or + # a pinned version tag like :v1.5.3 for reproducible rollbacks. + image: ${GITNEXUS_IMAGE:-ghcr.io/danny-avila/librechat-gitnexus:latest} + container_name: gitnexus + restart: unless-stopped + networks: + - gitnexus-net + volumes: + - /opt/gitnexus/indexes:/indexes + # memswap_limit equal to mem_limit disables swap for this container. + # Without it, Docker lets the process silently swap onto host disk, + # turning sub-second graph queries into multi-second ones. Hard + # OOM-kill is preferable — the container restarts via unless-stopped, + # the deploy health poll catches it, and the failure is explicit. + mem_limit: 1792m + memswap_limit: 1792m + logging: *default-logging + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:4747/api/info'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + caddy: + image: caddy:2-alpine + container_name: gitnexus-caddy + restart: unless-stopped + # service_healthy (not just service_started) ensures Caddy doesn't + # start routing traffic until gitnexus passes its initial healthcheck + # on a cold `compose up`. This only governs initial startup ordering — + # during force-recreates of gitnexus, Caddy stays up and may briefly + # return 502 while the new gitnexus container binds its port. The + # deploy workflow's health poll catches any sustained failure. + depends_on: + gitnexus: + condition: service_healthy + ports: + - '80:80' + - '443:443' + networks: + - gitnexus-net + volumes: + - /opt/gitnexus/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + logging: *default-logging + environment: + GITNEXUS_DOMAIN: ${GITNEXUS_DOMAIN} + API_TOKEN: ${API_TOKEN} + +networks: + gitnexus-net: + driver: bridge + +volumes: + caddy-data: + caddy-config: diff --git a/.do/gitnexus/entrypoint.sh b/.do/gitnexus/entrypoint.sh new file mode 100644 index 0000000000..a5f0e7e54a --- /dev/null +++ b/.do/gitnexus/entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -e + +# Cap Node heap below the container's cgroup limit (1792m in compose), +# leaving room for @ladybugdb/core's C++ heap and OS overhead. Native +# allocations happen outside V8's view, so a slim V8 budget is the only +# thing between a heavy query and a cgroup OOM-kill. Without this cap, +# gitnexus defaults to --max-old-space-size=8192 and reserves memory +# the container doesn't have. +export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1280}" + +# Register every index mounted under /indexes//.gitnexus/. +# This is idempotent — re-registering an existing repo updates the +# metadata pointer without touching the index data. +# +# Registration failure handling: +# - main (LibreChat) and dev (LibreChat-dev) are critical. If either +# fails to register, exit 1 so docker marks the container unhealthy +# and the deploy workflow's readiness check surfaces the error. +# - PR indexes (LibreChat-pr-*) are best-effort. A corrupt PR index +# shouldn't take the whole server down. +if [ -d /indexes ]; then + for dir in /indexes/*/; do + [ -d "$dir" ] || continue + name=$(basename "$dir") + [ -d "$dir.gitnexus" ] || continue + echo "Registering index: $name" + if ! gitnexus index "$dir" --allow-non-git; then + case "$name" in + LibreChat|LibreChat-dev) + echo "ERROR: failed to register critical index $name" >&2 + exit 1 + ;; + *) + echo "WARN: failed to register PR index $name — skipping" >&2 + ;; + esac + fi + done +else + echo "WARN: /indexes directory not mounted" >&2 +fi + +# Bind 0.0.0.0 inside the container so Caddy (in a separate container +# on the same docker network) can reach gitnexus at gitnexus:4747. +# docker-compose.yml intentionally does NOT expose port 4747 on the +# host — only Caddy's 80/443 are published. +exec gitnexus serve --host 0.0.0.0 --port 4747 diff --git a/.fly/gitnexus/install-extensions.js b/.do/gitnexus/install-extensions.js similarity index 65% rename from .fly/gitnexus/install-extensions.js rename to .do/gitnexus/install-extensions.js index f8cb1b5183..231741e949 100644 --- a/.fly/gitnexus/install-extensions.js +++ b/.do/gitnexus/install-extensions.js @@ -1,27 +1,30 @@ /** * Pre-install LadybugDB extensions (FTS + vector) into the Docker image's * extension cache (~/.kuzu/extension/). Without this, gitnexus serve's - * pool-adapter calls LOAD EXTENSION fts at runtime but fails silently + * lbug-adapter calls LOAD EXTENSION fts at runtime but fails silently * because the extension was never installed, causing all BM25 and * semantic queries via the query() tool to return empty. * - * This is a workaround for an upstream GitNexus 1.5.3 bug: - * pool-adapter.ts loads extensions but never installs them, and the - * CI-produced .gitnexus/ artifact doesn't include the extension cache. + * Workaround for upstream GitNexus 1.5.3 bug where the CI-produced + * .gitnexus/ artifact doesn't include the extension cache. */ const path = require('path'); const fs = require('fs'); -// @ladybugdb/core lives under the globally-installed gitnexus package +// @ladybugdb/core lives under the globally-installed gitnexus package. +// This path is stable across gitnexus versions because npm always nests +// transitive deps under the installed package's node_modules. const lbugPath = '/usr/local/lib/node_modules/gitnexus/node_modules/@ladybugdb/core'; const lbug = require(lbugPath); const tmpDir = '/tmp/lbug-ext-install'; fs.mkdirSync(tmpDir, { recursive: true }); -// Open a throwaway database to run INSTALL against. The extension cache -// persists in ~/.kuzu/extension/ regardless of which database was used. +// Open a throwaway database just to run INSTALL against. The extension +// cache persists in ~/.kuzu/extension/ regardless of which database was +// used to install it, so the throwaway db and tmpDir are deleted in the +// Dockerfile after this script finishes. const db = new lbug.Database(path.join(tmpDir, 'db'), 0, false, false); const conn = new lbug.Connection(db); diff --git a/.fly/gitnexus/Caddyfile b/.fly/gitnexus/Caddyfile deleted file mode 100644 index ac69e6e775..0000000000 --- a/.fly/gitnexus/Caddyfile +++ /dev/null @@ -1,21 +0,0 @@ -:8080 { - # Health check — unauthenticated so Fly.io can probe it - @health path /health - handle @health { - reverse_proxy localhost:4747 { - rewrite /api/info - } - } - - # All other routes require bearer token - @authed { - header Authorization "Bearer {env.API_TOKEN}" - } - - handle @authed { - reverse_proxy localhost:4747 - } - - # Reject unauthenticated requests - respond "Unauthorized" 401 -} diff --git a/.fly/gitnexus/entrypoint.sh b/.fly/gitnexus/entrypoint.sh deleted file mode 100644 index 42ada8499c..0000000000 --- a/.fly/gitnexus/entrypoint.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -set -e - -if [ -z "$API_TOKEN" ]; then - echo "ERROR: API_TOKEN secret is not set." - echo "Run: flyctl secrets set API_TOKEN=" - exit 1 -fi - -# Cap Node heap to match the Fly machine (leaves headroom for Caddy + OS). -# Without this, gitnexus defaults to --max-old-space-size=8192 which over-commits -# and gets killed by the OOM killer on small machines. -export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=768}" - -# Start gitnexus serve in background, pipe output to stdout/stderr -gitnexus serve --host 127.0.0.1 --port 4747 2>&1 & -GITNEXUS_PID=$! - -# Wait for gitnexus to be ready (up to 30s) -echo "Waiting for gitnexus serve to start..." -for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:4747/api/info > /dev/null 2>&1; then - echo "gitnexus serve is ready (pid $GITNEXUS_PID)" - break - fi - # Check if process died - if ! kill -0 "$GITNEXUS_PID" 2>/dev/null; then - echo "ERROR: gitnexus serve exited prematurely" - exit 1 - fi - sleep 1 -done - -# Final check -if ! curl -sf http://127.0.0.1:4747/api/info > /dev/null 2>&1; then - echo "ERROR: gitnexus serve failed to start within 30s" - exit 1 -fi - -# Start caddy auth proxy in foreground -exec caddy run --config /etc/caddy/Caddyfile diff --git a/.fly/gitnexus/fly.toml b/.fly/gitnexus/fly.toml deleted file mode 100644 index 65417bc3db..0000000000 --- a/.fly/gitnexus/fly.toml +++ /dev/null @@ -1,27 +0,0 @@ -app = 'librechat-gitnexus' -primary_region = 'iad' - -[build] - dockerfile = 'Dockerfile' - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = 'stop' - auto_start_machines = true - min_machines_running = 1 - -[[vm]] - size = 'shared-cpu-1x' - memory = '1gb' - -# 512MB swap file to absorb transient memory spikes -swap_size_mb = 512 - -[checks] - [checks.health] - type = 'http' - port = 8080 - path = '/health' - interval = '30s' - timeout = '5s' diff --git a/.github/workflows/gitnexus-cleanup-pr.yml b/.github/workflows/gitnexus-cleanup-pr.yml new file mode 100644 index 0000000000..d3c9628321 --- /dev/null +++ b/.github/workflows/gitnexus-cleanup-pr.yml @@ -0,0 +1,91 @@ +# Removes a PR's GitNexus index from the droplet when the PR is closed +# (merged or not). The deploy workflow also prunes stale folders as a +# safety net, but this gives us immediate cleanup without waiting for +# the next deploy trigger. + +name: GitNexus Cleanup PR + +on: + pull_request: + types: [closed] + +permissions: + contents: read + actions: read + +concurrency: + group: gitnexus-cleanup-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + cleanup: + # Skip fork PRs entirely. GitHub withholds repository secrets from + # pull_request events originating on forks, so an SSH deploy job run + # from a fork close would fail noisily. The deploy workflow's stale- + # folder pruning step catches any fork-contributor indexes that + # actually made it onto the droplet. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Skip the SSH round-trip entirely when no index artifact was ever + # built for this PR (docs-only PRs, paths-ignored PRs, PRs closed + # before indexing finished, etc). Eliminates ~95% of no-op SSH + # sessions on a busy repo. + - name: Check for index artifact + id: check + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.actions.listArtifactsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + name: `gitnexus-index-pr-${context.payload.pull_request.number}`, + per_page: 1, + }); + const hasArtifact = data.total_count > 0; + core.info(`Artifact exists: ${hasArtifact}`); + core.setOutput('has_artifact', hasArtifact ? 'true' : 'false'); + + - name: Setup SSH + if: steps.check.outputs.has_artifact == 'true' + env: + SSH_KEY: ${{ secrets.GITNEXUS_DO_SSH_KEY }} + KNOWN_HOST: ${{ secrets.GITNEXUS_DO_KNOWN_HOST }} + run: | + set -e + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + if [ -z "$KNOWN_HOST" ]; then + echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty" + exit 1 + fi + printf '%s\n' "$KNOWN_HOST" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Remove PR index from droplet + if: steps.check.outputs.has_artifact == 'true' + env: + SSH_USER: ${{ secrets.GITNEXUS_DO_USER }} + SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }} + PR_NUM: ${{ github.event.pull_request.number }} + run: | + ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" PR_NUM="$PR_NUM" bash <<'REMOTE' + set -e + TARGET="/opt/gitnexus/indexes/LibreChat-pr-$PR_NUM" + if [ -d "$TARGET" ]; then + echo "Removing $TARGET" + rm -rf "$TARGET" + cd /opt/gitnexus + docker compose up -d --force-recreate gitnexus + echo "GitNexus restarted without PR #$PR_NUM" + else + echo "No index to clean up for PR #$PR_NUM (artifact existed but droplet folder did not)" + fi + REMOTE + + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/deploy_key diff --git a/.github/workflows/gitnexus-deploy-do.yml b/.github/workflows/gitnexus-deploy-do.yml new file mode 100644 index 0000000000..875ff34105 --- /dev/null +++ b/.github/workflows/gitnexus-deploy-do.yml @@ -0,0 +1,431 @@ +# Deploys GitNexus indexes to a DigitalOcean droplet via SSH + rsync. +# +# Architecture: +# GitHub Actions (deploy) +# 1. Resolves latest successful index runs for main, dev, and every +# open PR that already has an index artifact (contributor-gated +# upstream by the index workflow's author_association check) +# 2. Downloads each matching .gitnexus/ artifact +# 3. Rsyncs them into /opt/gitnexus/indexes// on the droplet +# 4. Removes any stale folders on the droplet for PRs that closed +# (even though gitnexus-cleanup-pr.yml also handles that path, +# this is a safety net in case the close event was missed) +# 5. Pulls latest image, force-recreates gitnexus, reloads Caddy, +# and polls docker health until the container reports healthy +# The caddy container is untouched — no TLS churn. +# +# First-time droplet bootstrap (run once, manually): +# 1. Create 2GB+ Ubuntu 24.04 droplet, add SSH key +# 2. Point DNS A record for your subdomain at the droplet IP +# 3. SSH in and run: +# curl -fsSL https://get.docker.com | sh +# systemctl enable --now docker +# mkdir -p /opt/gitnexus/indexes +# useradd -m -s /bin/bash deploy +# usermod -aG docker deploy +# mkdir -p /home/deploy/.ssh +# # Add deploy pubkey to /home/deploy/.ssh/authorized_keys +# chown -R deploy:deploy /home/deploy/.ssh /opt/gitnexus +# chmod 700 /home/deploy/.ssh +# ufw allow 22,80,443/tcp +# ufw --force enable +# 4. Copy .do/gitnexus/docker-compose.yml and Caddyfile into /opt/gitnexus/ +# 5. Create /opt/gitnexus/.env with: GITNEXUS_DOMAIN=... and API_TOKEN=... +# 6. cd /opt/gitnexus && docker compose up -d +# +# Then capture the droplet's SSH host key from your workstation and +# save it as the GITNEXUS_DO_KNOWN_HOST secret (below) so CI can pin it: +# ssh-keyscan -H gitnexus.yourdomain.com +# +# GHCR image: the workflow runs `docker login ghcr.io` on the droplet +# on every deploy using GITHUB_TOKEN, so the package can stay private. +# If you'd rather not have CI manage droplet auth, make the package +# public under repo Settings -> Packages. +# +# Required GitHub secrets: +# GITNEXUS_DO_HOST — droplet IP or hostname +# GITNEXUS_DO_USER — SSH user (e.g. "deploy") +# GITNEXUS_DO_SSH_KEY — private key matching the authorized pubkey +# GITNEXUS_DO_KNOWN_HOST — output of `ssh-keyscan -H ` pinning the +# droplet's host keys (prevents MITM/TOFU risk) + +name: GitNexus Deploy (DigitalOcean) + +on: + workflow_run: + workflows: ['GitNexus Index'] + types: [completed] + workflow_dispatch: + +permissions: + actions: read + contents: read + pull-requests: read + +# Per-ref concurrency: rapid pushes to the same branch/PR coalesce, but +# deploys targeting different refs can still run in parallel. Safe because +# each deploy only rsync's its own folder; final step always reflects the +# latest state for all refs discovered at resolve time. +concurrency: + group: gitnexus-deploy-do-${{ github.event.workflow_run.head_branch || github.ref }} + cancel-in-progress: true + +env: + GITNEXUS_VERSION: '1.5.3' + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/librechat-gitnexus + +jobs: + # Rebuilds the long-lived image only when Dockerfile/entrypoint/extensions + # change. Skipped on every other run, so index-only deploys are fast. + build-image: + if: | + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write # push image to GHCR + outputs: + image_tag: ${{ steps.tag.outputs.value }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect image changes + id: changes + run: | + # Default to rebuild when we can't cleanly diff (first commit, + # workflow_run from a PR branch where HEAD isn't the trigger, etc). + # Rebuild on miss > skip when we should have rebuilt. + if git rev-parse --verify HEAD~1 >/dev/null 2>&1 && \ + git diff --quiet HEAD~1 HEAD -- .do/gitnexus/Dockerfile .do/gitnexus/entrypoint.sh .do/gitnexus/install-extensions.js; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Compute image tag + id: tag + run: echo "value=v${{ env.GITNEXUS_VERSION }}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' + uses: docker/build-push-action@v5 + with: + context: .do/gitnexus + file: .do/gitnexus/Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.value }} + build-args: | + GITNEXUS_VERSION=${{ env.GITNEXUS_VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-image + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Checkout deploy config + uses: actions/checkout@v4 + with: + sparse-checkout: .do/gitnexus + fetch-depth: 1 + + # Resolve every index to serve. For main/dev this is simple: latest + # successful run per branch. For PRs, we list artifacts across recent + # workflow runs, match gitnexus-index-pr-, then cross-reference + # GitHub PR state and only keep artifacts whose PR is still open. + - name: Resolve indexes to serve + id: resolve + uses: actions/github-script@v7 + with: + script: | + const serve = []; // [{ name, artifactName, runId }] + + // --- main and dev branches --- + for (const branch of ['main', 'dev']) { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'gitnexus-index.yml', + branch, + status: 'success', + per_page: 1, + }); + if (data.workflow_runs.length) { + const runId = data.workflow_runs[0].id; + const name = branch === 'main' ? 'LibreChat' : `LibreChat-${branch}`; + serve.push({ name, artifactName: `gitnexus-index-${branch}`, runId }); + core.info(`${branch}: run ${runId} -> ${name}`); + } else { + core.warning(`No successful index run found for ${branch}`); + } + } + + // --- open PRs with at least one successful index run --- + const { data: openPrs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + core.info(`Found ${openPrs.length} open PRs`); + if (openPrs.length === 100) { + core.warning( + 'Open PR list was truncated at 100 (GitHub API maximum). ' + + 'Some PR indexes may be skipped. Add pagination if the repo ' + + 'regularly exceeds 100 concurrent open PRs.', + ); + } + + for (const pr of openPrs) { + // PR branches live on forks too. listWorkflowRuns for a fork + // branch name doesn't return anything useful, so we instead + // query artifacts directly filtered by name. + const artifactName = `gitnexus-index-pr-${pr.number}`; + const { data: arts } = await github.rest.actions.listArtifactsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + name: artifactName, + per_page: 5, + }); + // Pick the most recent non-expired artifact + const fresh = arts.artifacts + .filter((a) => !a.expired) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; + if (!fresh) continue; + serve.push({ + name: `LibreChat-pr-${pr.number}`, + artifactName, + runId: fresh.workflow_run.id, + }); + core.info(`PR #${pr.number}: run ${fresh.workflow_run.id} -> LibreChat-pr-${pr.number}`); + } + + if (!serve.length) { + core.setFailed('No indexes to serve'); + return; + } + + core.setOutput('matrix', JSON.stringify(serve)); + core.setOutput('active_names', serve.map((s) => s.name).join(',')); + + - name: Download each index artifact + env: + MATRIX: ${{ steps.resolve.outputs.matrix }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + mkdir -p staging + # main/dev artifact download failures are fatal — a missing + # main/dev index is a real deploy failure. PR artifact failures + # are soft — a PR artifact deleted mid-deploy shouldn't abort + # the whole deploy and take main/dev down with it. + echo "$MATRIX" | jq -c '.[]' | while read -r entry; do + name=$(echo "$entry" | jq -r '.name') + artifact=$(echo "$entry" | jq -r '.artifactName') + runId=$(echo "$entry" | jq -r '.runId') + target="staging/${name}/.gitnexus" + echo "Downloading $artifact from run $runId -> $target" + mkdir -p "$target" + if ! gh run download "$runId" \ + --repo "${{ github.repository }}" \ + --name "$artifact" \ + --dir "$target"; then + case "$name" in + LibreChat|LibreChat-dev) + echo "::error::Failed to download critical artifact $artifact" + exit 1 + ;; + *) + # The name stays in active_names so the prune step + # won't remove the droplet's existing copy. The old + # index keeps being served instead of being wiped to + # nothing — stale beats empty — but observability + # requires an explicit notice since this path is + # invisible in the happy-path deploy log. + echo "::warning::Failed to download PR artifact $artifact — skipping fresh sync; previous index (if any) will continue being served from the droplet" + rm -rf "staging/${name}" + ;; + esac + fi + done + echo "" + echo "Staged for rsync:" + du -sh staging/*/.gitnexus/ 2>/dev/null || echo "(none)" + + - name: Setup SSH + env: + SSH_KEY: ${{ secrets.GITNEXUS_DO_SSH_KEY }} + KNOWN_HOST: ${{ secrets.GITNEXUS_DO_KNOWN_HOST }} + run: | + set -e + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + # Pin the droplet's SSH host key from a repository secret instead + # of trusting whatever ssh-keyscan returns at deploy time. The + # secret is populated from `ssh-keyscan -H ` at bootstrap. + if [ -z "$KNOWN_HOST" ]; then + echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty. Run ssh-keyscan -H and paste the output as this secret." + exit 1 + fi + printf '%s\n' "$KNOWN_HOST" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Authenticate droplet with GHCR + # GHCR packages pushed by GITHUB_TOKEN start private. The droplet + # pulls the image on every deploy, so we re-authenticate it here + # using the same short-lived token. If the package is public, this + # step is redundant but harmless. + # + # The token MUST travel through SSH stdin (not as a command arg) + # so it's never visible in the droplet's process table via + # /proc//cmdline. `printf '%s'` is preferred over `echo` + # so the exact byte sequence sent is explicit — docker login + # tolerates a trailing newline but `printf` makes the intent + # obvious and portable across shells. + env: + SSH_USER: ${{ secrets.GITNEXUS_DO_USER }} + SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_ACTOR: ${{ github.actor }} + run: | + printf '%s' "$GH_TOKEN" | ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ + "docker login ghcr.io -u '$GH_ACTOR' --password-stdin" + + - name: Upload config files + env: + SSH_USER: ${{ secrets.GITNEXUS_DO_USER }} + SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }} + run: | + rsync -az -e "ssh -i ~/.ssh/deploy_key" \ + .do/gitnexus/docker-compose.yml \ + .do/gitnexus/Caddyfile \ + "$SSH_USER@$SSH_HOST:/opt/gitnexus/" + + - name: Rsync indexes and prune stale ones + env: + SSH_USER: ${{ secrets.GITNEXUS_DO_USER }} + SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }} + ACTIVE_NAMES: ${{ steps.resolve.outputs.active_names }} + run: | + set -e + # Push every active index up + for dir in staging/*/; do + [ -d "$dir" ] || continue + name=$(basename "$dir") + echo "Syncing $name" + ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ + "mkdir -p /opt/gitnexus/indexes/$name" + rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" \ + "$dir" \ + "$SSH_USER@$SSH_HOST:/opt/gitnexus/indexes/$name/" + done + + # Prune any folders on the droplet that aren't in the active set. + # This cleans up closed PRs the cleanup workflow might have missed, + # and is safe because main/dev/PR- are always present if active. + echo "Pruning stale indexes (keeping: $ACTIVE_NAMES)" + ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ + ACTIVE_NAMES="$ACTIVE_NAMES" bash <<'REMOTE' + set -e + cd /opt/gitnexus/indexes || exit 0 + # nullglob makes `for dir in */` expand to nothing when the + # directory is empty (first deploy), instead of the literal + # string "*/". Explicit no-op > relying on rm -f to silently + # tolerate a nonexistent file named "*". + shopt -s nullglob + IFS=',' read -ra ACTIVE <<< "$ACTIVE_NAMES" + for dir in */; do + dir="${dir%/}" + keep=false + for a in "${ACTIVE[@]}"; do + if [ "$dir" = "$a" ]; then keep=true; break; fi + done + if [ "$keep" = false ]; then + echo "Removing stale index: $dir" + rm -rf "$dir" + fi + done + REMOTE + + - name: Pull image, restart gitnexus, reload Caddy, wait for healthy + env: + SSH_USER: ${{ secrets.GITNEXUS_DO_USER }} + SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }} + run: | + ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" bash <<'REMOTE' + set -e + cd /opt/gitnexus + docker compose pull gitnexus + docker compose up -d --force-recreate gitnexus + + # Reload Caddy in-place so a changed Caddyfile takes effect + # without losing TLS certs or restarting connections. If caddy + # isn't running yet (first-time bootstrap), bring it up. + if docker compose ps --status running caddy 2>/dev/null | grep -q caddy; then + echo "Reloading Caddy config" + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || { + echo "Caddy reload failed — forcing restart" + docker compose up -d --force-recreate caddy + } + else + echo "Caddy not running — starting" + docker compose up -d caddy + fi + + # Poll gitnexus health until ready or timeout. Docker's own + # unhealthy detection takes up to 150s (start_period 60s + + # retries 3 * interval 30s), so the poll ceiling must clear + # that to avoid false negatives when gitnexus legitimately + # takes ~2.5 min to warm up. + # Max wait = 36 sleeps * 5s = 180s (final iteration exits + # before its sleep on failure, so 37 iterations is the + # correct upper bound for a true 180s ceiling). + echo "Waiting for gitnexus to report healthy..." + for i in $(seq 1 37); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' gitnexus 2>/dev/null || echo unknown) + echo "[$i/37] gitnexus health: $STATUS" + if [ "$STATUS" = "healthy" ]; then + echo "gitnexus is healthy" + break + fi + if [ "$i" -eq 37 ]; then + echo "ERROR: gitnexus failed to become healthy after 180s" + docker compose ps + docker compose logs --tail 80 gitnexus + exit 1 + fi + sleep 5 + done + + docker compose ps + echo "--- Caddy logs (last 20 lines) ---" + docker compose logs --tail 20 caddy || true + echo "--- GitNexus logs (last 30 lines) ---" + docker compose logs --tail 30 gitnexus || true + REMOTE + + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/deploy_key diff --git a/.github/workflows/gitnexus-deploy.yml b/.github/workflows/gitnexus-deploy.yml deleted file mode 100644 index 4a647d787b..0000000000 --- a/.github/workflows/gitnexus-deploy.yml +++ /dev/null @@ -1,122 +0,0 @@ -# Deploys the GitNexus indexes for both main and dev to Fly.io as a -# persistent MCP + REST server, serving both branches simultaneously. -# -# Endpoints available after deploy: -# /api/mcp — MCP-over-HTTP (StreamableHTTP transport) -# /api/query — Search execution flows (specify repo: LibreChat or LibreChat-dev) -# /api/search — Hybrid BM25 + semantic search -# /api/repos — List indexed repositories -# /api/info — Server version and status -# -# First-time setup: -# 1. flyctl apps create librechat-gitnexus -# 2. flyctl tokens create deploy -x 999999h -# 3. flyctl secrets set API_TOKEN=$(openssl rand -hex 32) -# 4. Add FLY_API_TOKEN as a GitHub repo secret -# -# All requests (except /health) require: Authorization: Bearer - -name: GitNexus Deploy - -on: - workflow_run: - workflows: ['GitNexus Index'] - branches: [main, dev] - types: [completed] - workflow_dispatch: - -permissions: - actions: read - -concurrency: - group: gitnexus-deploy - cancel-in-progress: false - -env: - GITNEXUS_VERSION: '1.5.3' - -jobs: - deploy: - if: | - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout deploy config - uses: actions/checkout@v4 - with: - sparse-checkout: .fly/gitnexus - fetch-depth: 1 - - - name: Resolve latest successful index runs per branch - id: resolve - uses: actions/github-script@v7 - with: - script: | - const branches = ['main', 'dev']; - const result = {}; - for (const branch of branches) { - const { data } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'gitnexus-index.yml', - branch, - status: 'success', - per_page: 1, - }); - if (data.workflow_runs.length) { - result[branch] = data.workflow_runs[0].id; - core.info(`${branch}: run ${result[branch]}`); - } else { - core.warning(`No successful index run found for ${branch}`); - } - } - if (!Object.keys(result).length) { - core.setFailed('No successful index runs found for any branch'); - return; - } - core.setOutput('main_run', result.main || ''); - core.setOutput('dev_run', result.dev || ''); - - - name: Download main index - if: steps.resolve.outputs.main_run != '' - uses: actions/download-artifact@v4 - with: - name: gitnexus-index-main - path: deploy/indexes/LibreChat/.gitnexus - run-id: ${{ steps.resolve.outputs.main_run }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Download dev index - if: steps.resolve.outputs.dev_run != '' - uses: actions/download-artifact@v4 - with: - name: gitnexus-index-dev - path: deploy/indexes/LibreChat-dev/.gitnexus - run-id: ${{ steps.resolve.outputs.dev_run }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Prepare deploy context - run: | - cp .fly/gitnexus/Dockerfile deploy/Dockerfile - cp .fly/gitnexus/Caddyfile deploy/Caddyfile - cp .fly/gitnexus/entrypoint.sh deploy/entrypoint.sh - cp .fly/gitnexus/install-extensions.js deploy/install-extensions.js - echo "Deploy context:" - ls -la deploy/ - echo "Indexes staged:" - ls -la deploy/indexes/ || echo "(none)" - - - name: Setup Fly - uses: superfly/flyctl-actions/setup-flyctl@master - - - name: Deploy to Fly.io - working-directory: deploy - run: | - flyctl deploy \ - --config ../.fly/gitnexus/fly.toml \ - --build-arg GITNEXUS_VERSION=${{ env.GITNEXUS_VERSION }} \ - --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml index bd395247b9..914b0a35f9 100644 --- a/.github/workflows/gitnexus-index.yml +++ b/.github/workflows/gitnexus-index.yml @@ -17,12 +17,25 @@ on: description: 'Force full re-index' type: boolean default: false + # When invoked from the /gitnexus index PR command, the command + # workflow fills these so the index is built from the PR's head + # ref and uploaded under the PR-numbered artifact name. + pr_number: + description: 'PR number to index (set by /gitnexus command)' + type: string + default: '' + pr_ref: + description: 'PR head SHA or ref to check out (set by /gitnexus command)' + type: string + default: '' permissions: contents: read concurrency: - group: gitnexus-${{ github.ref }} + # When triggered by the /gitnexus command, group by PR number so rapid + # re-runs coalesce. Otherwise group by git ref as before. + group: gitnexus-${{ inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.ref }} cancel-in-progress: true env: @@ -30,7 +43,10 @@ env: jobs: index: - # Allow push + dispatch unconditionally; filter PRs to contributors only + # Allow push + dispatch unconditionally; filter native pull_request + # events to contributors only. The /gitnexus command workflow does + # its own contributor check before it dispatches this workflow, so + # workflow_dispatch is always trusted here. if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'OWNER' || @@ -42,6 +58,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + # When the /gitnexus command dispatches us with a pr_ref, check + # out that exact SHA. Otherwise fall back to the default ref. + ref: ${{ inputs.pr_ref || '' }} fetch-depth: 1 - name: Setup Node.js @@ -83,11 +102,17 @@ jobs: - name: Upload GitNexus index uses: actions/upload-artifact@v4 with: + # Artifact naming order of precedence: + # 1. /gitnexus command dispatch: inputs.pr_number -> pr- + # 2. Native pull_request event: github.event.pull_request.number + # 3. Push or manual dispatch without pr_number: github.ref_name name: >- gitnexus-index-${{ - github.event_name == 'pull_request' - && format('pr-{0}', github.event.pull_request.number) - || github.ref_name + inputs.pr_number != '' + && format('pr-{0}', inputs.pr_number) + || (github.event_name == 'pull_request' + && format('pr-{0}', github.event.pull_request.number) + || github.ref_name) }} path: .gitnexus/ include-hidden-files: true diff --git a/.github/workflows/gitnexus-pr-command.yml b/.github/workflows/gitnexus-pr-command.yml new file mode 100644 index 0000000000..4ce58bb2b8 --- /dev/null +++ b/.github/workflows/gitnexus-pr-command.yml @@ -0,0 +1,100 @@ +# Responds to `/gitnexus index` comments on pull requests. +# +# Gated to the same author_association roles (OWNER, MEMBER, COLLABORATOR) +# as the automatic PR index trigger. When a matching comment lands on a +# PR, this workflow dispatches `gitnexus-index.yml` with the PR number +# and head SHA so the existing indexing pipeline does the actual work. +# +# Use cases: +# - Re-index a PR after a rebase without pushing a new commit +# - Index a docs-only PR that was skipped by paths-ignore +# - Re-run a failed index +# +# Supported commands: +# /gitnexus index — index with defaults (no embeddings) +# /gitnexus index embeddings — index with --embeddings enabled + +name: GitNexus PR Command + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + actions: write # needed to dispatch gitnexus-index.yml + +concurrency: + group: gitnexus-pr-command-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + dispatch: + # Only run for PR comments that start with /gitnexus from trusted users + if: | + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/gitnexus') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Parse command and resolve PR head + id: parse + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.comment.body.trim(); + const match = body.match(/^\/gitnexus\s+(\w+)(?:\s+(\w+))?/); + if (!match) { + core.setFailed(`Unrecognized command: ${body}. Try: /gitnexus index [embeddings]`); + return; + } + const [, subcommand, modifier] = match; + if (subcommand !== 'index') { + core.setFailed(`Unknown subcommand: ${subcommand}. Only 'index' is supported.`); + return; + } + const embeddings = modifier === 'embeddings' ? 'true' : 'false'; + + // Resolve the PR's head SHA — listWorkflowDispatchRun needs a real ref. + const prNum = context.payload.issue.number; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNum, + }); + core.setOutput('pr_number', String(prNum)); + core.setOutput('pr_ref', pr.head.sha); + core.setOutput('embeddings', embeddings); + core.info(`Dispatching index for PR #${prNum} at ${pr.head.sha} (embeddings=${embeddings})`); + + - name: Dispatch gitnexus-index workflow + uses: actions/github-script@v7 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'gitnexus-index.yml', + ref: 'main', + inputs: { + pr_number: '${{ steps.parse.outputs.pr_number }}', + pr_ref: '${{ steps.parse.outputs.pr_ref }}', + embeddings: '${{ steps.parse.outputs.embeddings }}', + force: 'false', + }, + }); + + - name: React to the comment + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket', + });