From c14b8c54ebf2bd11c0217d31b5a068b07f8d3bfe Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Aug 2026 12:16:32 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=AD=20ci:=20Codegraph=20Test-Selection?= =?UTF-8?q?=20Probe=20(Observe-Only)=20(#14936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: codegraph test-selection probe (observe-only) Asks the codegraph service which test files and matrix jobs the PR needs and writes the decision to the job summary. Gates nothing — every path exits 0, forks without secrets no-op. Companion to the shadow-mode evaluation: the decision CI would act on, made visible next to the runs it would have replaced. * chore: remove GitNexus CI and deployment configs Superseded by the codegraph service: the index workflow spent ~45min per invocation building an artifact the PR flow never served, while the replacement indexes incrementally in ~1.4s per commit server-side. Removes the four workflows (index, deploy, cleanup-pr, pr-command) and the .do/gitnexus deployment bundle. No remaining references. * ci: render playwright spec tiers in the codegraph probe summary --- .do/gitnexus/Caddyfile | 25 - .do/gitnexus/Dockerfile | 46 -- .do/gitnexus/docker-compose.yml | 87 ---- .do/gitnexus/entrypoint.sh | 48 -- .do/gitnexus/install-extensions.js | 46 -- .github/workflows/codegraph-select.yml | 72 +++ .github/workflows/gitnexus-cleanup-pr.yml | 91 ---- .github/workflows/gitnexus-deploy.yml | 583 ---------------------- .github/workflows/gitnexus-index.yml | 323 ------------ .github/workflows/gitnexus-pr-command.yml | 141 ------ 10 files changed, 72 insertions(+), 1390 deletions(-) delete mode 100644 .do/gitnexus/Caddyfile delete mode 100644 .do/gitnexus/Dockerfile delete mode 100644 .do/gitnexus/docker-compose.yml delete mode 100644 .do/gitnexus/entrypoint.sh delete mode 100644 .do/gitnexus/install-extensions.js create mode 100644 .github/workflows/codegraph-select.yml delete mode 100644 .github/workflows/gitnexus-cleanup-pr.yml delete mode 100644 .github/workflows/gitnexus-deploy.yml delete mode 100644 .github/workflows/gitnexus-index.yml delete mode 100644 .github/workflows/gitnexus-pr-command.yml diff --git a/.do/gitnexus/Caddyfile b/.do/gitnexus/Caddyfile deleted file mode 100644 index 3c5dac2c6f..0000000000 --- a/.do/gitnexus/Caddyfile +++ /dev/null @@ -1,25 +0,0 @@ -# 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/.do/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile deleted file mode 100644 index 8b7e538726..0000000000 --- a/.do/gitnexus/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -# 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.16.0-slim - -ARG GITNEXUS_VERSION=1.6.7 -# Pin the native DB to match the index workflow; gitnexus's ^0.17.0 range -# would otherwise let the served image drift from the CI-produced index. -ARG LADYBUG_VERSION=0.17.1 - -# 1. Build native addons with Bookworm toolchain, then remove build tools. -# curl stays for the docker healthcheck; Caddy lives in its own container. -# LadybugDB is pinned nested under gitnexus so step 3's require() resolves it. -RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 make g++ curl \ - && npm install -g gitnexus@${GITNEXUS_VERSION} \ - && npm install --no-save --prefix /usr/local/lib/node_modules/gitnexus "@ladybugdb/core@${LADYBUG_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. -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 ~/.kuzu/extension/ -# is baked into the image. gitnexus serve loads extensions with a -# load-only policy and never installs them at runtime, so the cache -# must already exist. (GitNexus loads the vector extension itself -# via loadVectorExtension — no adapter patch needed.) -COPY install-extensions.js /tmp/install-extensions.js -RUN node /tmp/install-extensions.js && rm -rf /tmp/install-extensions.js /tmp/lbug-ext-install - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 4747 - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/.do/gitnexus/docker-compose.yml b/.do/gitnexus/docker-compose.yml deleted file mode 100644 index 7761a89a30..0000000000 --- a/.do/gitnexus/docker-compose.yml +++ /dev/null @@ -1,87 +0,0 @@ -# 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 deleted file mode 100644 index a5f0e7e54a..0000000000 --- a/.do/gitnexus/entrypoint.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/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/.do/gitnexus/install-extensions.js b/.do/gitnexus/install-extensions.js deleted file mode 100644 index 231741e949..0000000000 --- a/.do/gitnexus/install-extensions.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Pre-install LadybugDB extensions (FTS + vector) into the Docker image's - * extension cache (~/.kuzu/extension/). Without this, gitnexus serve's - * 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. - * - * 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. -// 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 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); - -(async () => { - try { - await conn.query('INSTALL fts'); - console.log('FTS extension installed'); - } catch (err) { - console.error('FTS install failed:', err.message); - process.exit(1); - } - try { - await conn.query('INSTALL vector'); - console.log('Vector extension installed'); - } catch (err) { - console.error('Vector install failed:', err.message); - process.exit(1); - } -})(); diff --git a/.github/workflows/codegraph-select.yml b/.github/workflows/codegraph-select.yml new file mode 100644 index 0000000000..e74c6ee213 --- /dev/null +++ b/.github/workflows/codegraph-select.yml @@ -0,0 +1,72 @@ +# Codegraph test selection — OBSERVE-ONLY. +# +# Asks the codegraph service which test files / matrix jobs this PR actually needs and writes +# the answer to the job summary. It gates NOTHING: no workflow reads its outputs yet, it cannot +# fail the PR (every path exits 0), and forks without secrets no-op silently. This is the +# production probe for the shadow-mode evaluation: the same decision CI would act on, made +# visible next to the runs it would have replaced. +# +# Requires repo secrets: CODEGRAPH_URL (https endpoint), CODEGRAPH_TOKEN (bearer). +name: Codegraph Select (observe) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + select: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Ask codegraph, render, never fail + env: + URL: ${{ secrets.CODEGRAPH_URL }} + TOKEN: ${{ secrets.CODEGRAPH_TOKEN }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set +e + note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; } + note "### Codegraph select — observe-only" + if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_secrets not configured; skipped_"; exit 0; fi + + gh api "repos/$REPO/pulls/$PR/files" --paginate \ + --jq '.[] | {path: .filename, status, patch}' | jq -s . > files.json + if [ ! -s files.json ]; then note "_could not fetch changed files; skipped_"; exit 0; fi + + jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" \ + '{files: ., lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json + RESP=$(curl -sS -m 45 -H "Authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select") + if [ -z "$RESP" ] || ! echo "$RESP" | jq -e .selected >/dev/null 2>&1; then + note "_codegraph unavailable (${RESP:0:120}); skipped — full CI runs as always_" + exit 0 + fi + + echo "$RESP" | jq -r ' + "graph `\(.gate.head[0:12] // "?")` · \(.engine) · \(.mode) · \(.ms)ms · reached \(.reached)", + "", + "| workspace | decision |", + "|---|---|", + (.selected | to_entries[] | + "| \(.key) | " + (if .value.mode == "FULL" then "FULL — \(.value.why)" + elif .value.mode == "NONE" then "no tests" + else "\(.value.files | length) test files" end) + " |"), + "", + "matrix: " + ([.matrix | to_entries[] | .key as $wf | .value | to_entries[] | + "\($wf)/\(.key)=" + (if .value then "run" else "SKIP" end)] | join(" ")), + (if .shards then "shards: " + (.shards | to_json) else empty end), + (if .lock_workspaces then "lockfile → " + (.lock_workspaces | to_json) else empty end), + (if .e2e and (.e2e.error | not) then + "e2e tiers: must \(.e2e.must_run | length) · floor \(.e2e.floor | length) · skippable \(.e2e.skippable | length)" + + (if (.e2e.must_run | length) > 0 then " — must: " + (.e2e.must_run[:4] | join(", ")) else "" end) + else empty end) + ' >> "$GITHUB_STEP_SUMMARY" + exit 0 diff --git a/.github/workflows/gitnexus-cleanup-pr.yml b/.github/workflows/gitnexus-cleanup-pr.yml deleted file mode 100644 index dfed28426e..0000000000 --- a/.github/workflows/gitnexus-cleanup-pr.yml +++ /dev/null @@ -1,91 +0,0 @@ -# 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@v8 - 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.yml b/.github/workflows/gitnexus-deploy.yml deleted file mode 100644 index 998af79fdd..0000000000 --- a/.github/workflows/gitnexus-deploy.yml +++ /dev/null @@ -1,583 +0,0 @@ -# Deploys GitNexus indexes to a droplet via SSH + rsync. -# -# Architecture: -# GitHub Actions (deploy) -# 1. Resolves latest successful index runs for main and dev -# 2. Downloads each matching .gitnexus/ artifact -# 3. Rsyncs them into /opt/gitnexus/indexes// on the droplet -# 4. Removes any stale folders on the droplet that are not main/dev -# 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 - -on: - workflow_run: - workflows: ['GitNexus Index'] - types: [completed] - workflow_dispatch: - inputs: - pr_number: - description: 'Optional PR number for status comments from bot-triggered dispatches' - type: string - default: '' - -permissions: - actions: read - contents: read - pull-requests: write # post status comments on PR command dispatches - -# Global serialization. Earlier versions used per-ref concurrency with -# cancel-in-progress so rapid pushes to the same ref coalesced but deploys -# targeting different refs ran in parallel. That had a data race: the -# prune-stale-indexes step computes its active_names up front, so if -# deploy A is rsyncing /opt/gitnexus/indexes/LibreChat-pr-12580 while -# deploy B (started slightly later with a different ref) prunes, B can -# rm -rf a folder A is still uploading into. -# -# All deploys now queue behind a single group. cancel-in-progress is -# false so a running rsync/docker-compose restart never gets killed -# mid-operation (which would leave the droplet in a partial state). -# The 20-minute job timeout bounds total queue depth. -concurrency: - group: gitnexus-deploy - cancel-in-progress: false - -env: - GITNEXUS_VERSION: '1.6.7' - 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' && - github.event.workflow_run.event == 'push' && - (github.event.workflow_run.head_branch == 'main' || - github.event.workflow_run.head_branch == 'dev') - ) - 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@v5 - 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: Set up Docker Buildx - if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' - uses: docker/login-action@v4 - 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@v7 - 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: write # post deploy-complete comments on PR command dispatches - steps: - - name: Checkout deploy config - uses: actions/checkout@v5 - with: - sparse-checkout: .do/gitnexus - fetch-depth: 1 - - # Resolve every index to serve. All resolutions go through - # listArtifactsForRepo keyed by the expected artifact name, so a - # run's branch or event type doesn't matter — we always pick the - # freshest artifact that actually exists. - # - # Why this matters: a /gitnexus index command dispatches - # gitnexus-index.yml with ref=main and an input pr_number, which - # produces a run whose head_branch is "main" but whose artifact - # is gitnexus-index-pr-. listWorkflowRuns(branch='main') would - # happily return that run, and we'd then try to download a - # nonexistent gitnexus-index-main artifact from it. Querying by - # artifact name directly avoids the whole mess. - - name: Resolve indexes to serve - id: resolve - uses: actions/github-script@v8 - with: - script: | - const serve = []; // [{ name, artifactName, runId }] - - // Helper — pick the newest non-expired artifact matching a name. - const latestArtifact = async (artifactName) => { - const { data } = await github.rest.actions.listArtifactsForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - name: artifactName, - per_page: 10, - }); - return data.artifacts - .filter((a) => !a.expired) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; - }; - - // --- main and dev branches --- - for (const [branch, name] of [ - ['main', 'LibreChat'], - ['dev', 'LibreChat-dev'], - ]) { - const artifactName = `gitnexus-index-${branch}`; - const fresh = await latestArtifact(artifactName); - if (!fresh) { - core.warning(`No artifact found for ${branch} (expected ${artifactName})`); - continue; - } - serve.push({ - name, - artifactName, - runId: fresh.workflow_run.id, - }); - core.info(`${branch}: run ${fresh.workflow_run.id} -> ${name}`); - } - - core.info('PR index deploys are paused; serving main and dev only.'); - - 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: Prune stale indexes then sync fresh ones - env: - SSH_USER: ${{ secrets.GITNEXUS_DO_USER }} - SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }} - ACTIVE_NAMES: ${{ steps.resolve.outputs.active_names }} - run: | - set -e - # ── Step 1: prune FIRST ──────────────────────────────── - # Remove any folders on the droplet that aren't in the active set. - # This frees disk BEFORE rsyncing new data, which matters on a - # 10GB disk where each current index is ~400MB. - 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 - 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 - echo "Disk after prune:" - df -h / | tail -1 - REMOTE - - # ── Step 2: rsync-then-swap ───────────────────────────── - # Upload each index to a temp directory, then atomically swap - # it into place. If rsync fails, the old index survives intact - # and the partial temp dir is cleaned up — no production data - # is lost. The brief period where both old + new exist costs - # ~400MB of extra disk, but the prune step already freed - # space from evicted indexes so this fits on a 10GB disk. - for dir in staging/*/; do - [ -d "$dir" ] || continue - name=$(basename "$dir") - echo "Syncing $name (rsync-then-swap)" - ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ - "mkdir -p /opt/gitnexus/indexes/${name}.new" - if rsync -az -e "ssh -i ~/.ssh/deploy_key" \ - "$dir" \ - "$SSH_USER@$SSH_HOST:/opt/gitnexus/indexes/${name}.new/"; then - # Swap: remove old, rename new into place - ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ - "rm -rf /opt/gitnexus/indexes/$name && mv /opt/gitnexus/indexes/${name}.new /opt/gitnexus/indexes/$name" - echo " $name swapped successfully" - else - # Clean up the partial temp dir - ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ - "rm -rf /opt/gitnexus/indexes/${name}.new" - # main/dev are critical — abort the deploy so the failure - # is visible and the container isn't restarted with stale - # or missing data. PR indexes are best-effort. - case "$name" in - LibreChat|LibreChat-dev) - echo "::error::rsync failed for critical index $name — aborting deploy" - exit 1 - ;; - *) - echo "::warning::rsync failed for PR index $name — keeping previous index" - ;; - esac - fi - done - - - 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 - - # ── Disk cleanup ────────────────────────────────────── - # Docker accumulates old image layers, dangling images, and - # build cache across deploys. This droplet is only ~8.7GB - # usable with a 700MB+ gitnexus image, so disk pressure is - # constant. Prune everything not used by currently-running - # containers BEFORE pulling the new image so the extract has - # room; the post-recreate prune below reclaims the old image. - echo "Disk before cleanup:" - df -h / | tail -1 - # Omit --volumes: Caddy's caddy-data and caddy-config volumes - # hold TLS certificates and ACME state. If Caddy happens to be - # stopped when this runs (the workflow handles that case later), - # --volumes would wipe them, forcing Let's Encrypt re-issuance - # and risking rate-limit lockout (5 certs/domain/week). - docker system prune -af 2>/dev/null || true - echo "Disk after cleanup:" - df -h / | tail -1 - - # Fail fast if disk is critically low even after prune. The - # gitnexus image is ~700MB and shares most layers with the - # running one, so an incremental pull needs well under 1GB. - # 1536MB leaves headroom on this small droplet without the - # over-conservative 2GB guard aborting on a healthy box. - AVAIL_MB=$(df --output=avail -m / | tail -1 | tr -d ' ') - if [ "$AVAIL_MB" -lt 1536 ]; then - echo "::error::Disk critically low (${AVAIL_MB}MB free). Aborting deploy." - exit 1 - fi - - docker compose pull gitnexus - docker compose up -d --force-recreate gitnexus - - # The previous gitnexus image is now dangling (the running - # container was recreated onto the freshly pulled image). The - # pre-pull prune above couldn't touch it because it was still - # in use at that point. Reclaim it now so the old generation - # doesn't accumulate — critical on this 10GB droplet. - docker image prune -f 2>/dev/null || true - - # 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 - - # When the deploy was triggered by a PR command path, post a - # terminal status comment on that one PR only. Two sub-cases: - # - # 1. workflow_run trigger: the PR's native auto-index run fired - # workflow_run, so github.event.workflow_run.id is the trigger. - # Find the matching PR via the matrix entry whose runId matches. - # - # 2. workflow_dispatch trigger with inputs.pr_number set: the - # index workflow's bot-fallback path dispatched us directly - # because workflow_run is suppressed for GITHUB_TOKEN triggers. - # Use inputs.pr_number as the comment target. - # - # Broadcast-commenting on every active PR would be noise — only the - # PR that asked for a fresh index gets a reply. - - name: Comment on PR — deploy complete - if: always() - uses: actions/github-script@v8 - env: - MATRIX: ${{ steps.resolve.outputs.matrix }} - TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} - DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }} - DEPLOY_STATUS: ${{ job.status }} - with: - script: | - const deployUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const matrix = JSON.parse(process.env.MATRIX || '[]'); - let prNum = null; - - // Case 1: dispatched directly with pr_number (bot-fallback path) - if (process.env.DISPATCH_PR_NUMBER && process.env.DISPATCH_PR_NUMBER !== '') { - const dispatchPrRaw = process.env.DISPATCH_PR_NUMBER; - if (!/^\d+$/.test(dispatchPrRaw)) { - core.setFailed(`Invalid PR number: ${dispatchPrRaw}`); - return; - } - - const dispatchPrNum = Number(dispatchPrRaw); - const servedPr = matrix.some((m) => m.name === `LibreChat-pr-${dispatchPrNum}`); - - if (!servedPr) { - const body = [ - '### GitNexus: PR deploy skipped', - '', - 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.', - `[Deploy run](${deployUrl})`, - ].join('\n'); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: dispatchPrNum, - body, - }); - return; - } - - prNum = dispatchPrNum; - } - // Case 2: workflow_run trigger from a PR index run - else if (context.eventName === 'workflow_run') { - const triggerRunId = Number(process.env.TRIGGER_RUN_ID); - const match = matrix.find( - (m) => m.runId === triggerRunId && m.name.startsWith('LibreChat-pr-'), - ); - if (match) { - prNum = parseInt(match.name.replace('LibreChat-pr-', ''), 10); - } - } - - if (!prNum) { - core.info('No PR to comment on (trigger was not a PR-scoped index); skipping.'); - return; - } - - const ok = process.env.DEPLOY_STATUS === 'success'; - const body = [ - `### GitNexus: ${ok ? '🚀 deployed' : '❌ deploy failed'}`, - '', - ok - ? `The \`LibreChat-pr-${prNum}\` index is now live on the MCP server.` - : `The deploy failed — the previous index (if any) continues to be served.`, - `[Deploy run](${deployUrl})`, - ].join('\n'); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNum, - body, - }); - - - name: Cleanup SSH key - if: always() - run: rm -f ~/.ssh/deploy_key diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml deleted file mode 100644 index 21edd34e52..0000000000 --- a/.github/workflows/gitnexus-index.yml +++ /dev/null @@ -1,323 +0,0 @@ -name: GitNexus Index - -on: - # PR branches are NOT auto-indexed — an embeddings run is too slow to - # spend on every PR push. Only main/dev are indexed automatically; - # individual PRs are indexed on demand via the /gitnexus command or a - # manual workflow_dispatch. - push: - branches: [main, dev] - paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**'] - workflow_dispatch: - inputs: - embeddings: - description: 'Enable embedding generation (slow, increases index size)' - type: boolean - default: false - force: - 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: 'Optional PR head ref to check out; defaults to refs/pull//head when pr_number is set' - type: string - default: '' - deploy_after: - description: 'Dispatch GitNexus Deploy after a successful index run' - type: boolean - default: false - -permissions: - contents: read - -concurrency: - # 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: - GITNEXUS_VERSION: '1.6.7' - -jobs: - index: - permissions: - contents: read - pull-requests: read # read changed files to decide whether embeddings are needed - # Push + dispatch run unconditionally. The pull_request trigger is - # disabled (see `on:` above), so this never runs automatically on a - # PR. PRs are indexed on demand instead: - # - /gitnexus index (PR comment command, contributor-gated) - # - workflow_dispatch (manual dispatch from Actions UI) - # Both arrive as workflow_dispatch. The pull_request guard is kept as - # a safety net should the trigger ever be re-added. - if: | - github.event_name != 'pull_request' || - github.event.pull_request.user.login == 'danny-avila' - runs-on: ubuntu-latest - # Embedding generation dominates the budget: ~45 min worst case on - # standard runners since the 1.6.x graph (~23k nodes) doubled vs 1.5.x. - timeout-minutes: 60 - # Best-effort index: a tool-internal crash must not block PRs. Fail soft on - # PR events; push/dispatch runs still fail loudly so regressions stay visible. - continue-on-error: ${{ github.event_name == 'pull_request' }} - steps: - - name: Validate dispatch inputs - if: github.event_name == 'workflow_dispatch' - env: - PR_NUMBER: ${{ inputs.pr_number }} - PR_REF: ${{ inputs.pr_ref }} - run: | - set -euo pipefail - if [ -n "$PR_NUMBER" ]; then - if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then - echo "::error::pr_number must be numeric" - exit 1 - fi - EXPECTED_REF="refs/pull/${PR_NUMBER}/head" - if [ -n "$PR_REF" ] && [ "$PR_REF" != "$EXPECTED_REF" ]; then - echo "::error::pr_ref must match ${EXPECTED_REF}" - exit 1 - fi - elif [ -n "$PR_REF" ]; then - echo "::error::pr_ref requires pr_number" - exit 1 - fi - - - name: Resolve GitNexus flags - id: flags - env: - EVENT_NAME: ${{ github.event_name }} - ENABLE_EMBEDDINGS_INPUT: ${{ inputs.embeddings }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUM: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - # Decide whether to generate embeddings. Rules: - # push (main/dev) -> always embed - # pull_request -> embed ONLY when the PR changes files - # under paths that also trigger backend - # or frontend unit tests (api/, client/, - # packages/). Docs/config-only PRs skip - # embeddings to save ~3-5 min of CI. - # workflow_dispatch -> respect the explicit `embeddings` input - # (default false). This also covers the - # /gitnexus index [embeddings] command. - ENABLE_EMBEDDINGS=false - case "$EVENT_NAME" in - workflow_dispatch) - [ "$ENABLE_EMBEDDINGS_INPUT" = "true" ] && ENABLE_EMBEDDINGS=true - ;; - push) - ENABLE_EMBEDDINGS=true - ;; - pull_request) - CHANGED=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUM/files" \ - --paginate --jq '.[].filename' 2>/dev/null || echo "") - if printf '%s\n' "$CHANGED" | grep -qE '^(api/|client/|packages/)'; then - echo "PR #$PR_NUM touches unit-test paths (api|client|packages) — enabling embeddings" - ENABLE_EMBEDDINGS=true - else - echo "PR #$PR_NUM does not touch unit-test paths — graph-only index" - fi - ;; - esac - - if [ "$ENABLE_EMBEDDINGS" = "true" ]; then - echo "enable_embeddings=true" >> "$GITHUB_OUTPUT" - else - echo "enable_embeddings=false" >> "$GITHUB_OUTPUT" - fi - - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: '24.16.0' - - - name: Install GitNexus CLI - working-directory: ${{ runner.temp }} - env: - NPM_CONFIG_AUDIT: false - NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache - NPM_CONFIG_FUND: false - NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/gitnexus-cli/global-npmrc - NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/gitnexus-cli/.npmrc - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/gitnexus-cli" "$RUNNER_TEMP/gitnexus-npm-cache" - : > "$RUNNER_TEMP/gitnexus-cli/global-npmrc" - printf '%s\n' \ - 'registry=https://registry.npmjs.org/' \ - 'audit=false' \ - 'fund=false' \ - > "$RUNNER_TEMP/gitnexus-cli/.npmrc" - # Keep GitNexus' native DB dependency deterministic in fresh CI installs. - npm install \ - --prefix "$RUNNER_TEMP/gitnexus-cli" \ - --no-save \ - --no-package-lock \ - "gitnexus@${{ env.GITNEXUS_VERSION }}" \ - "@ladybugdb/core@0.17.1" - test -x "$RUNNER_TEMP/gitnexus-cli/node_modules/.bin/gitnexus" - - - name: Checkout repository - uses: actions/checkout@v5 - with: - # When the /gitnexus command dispatches us with a pr_ref, it's - # a refs/pull//head ref that GitHub mirrors into the base - # repo for every PR, so checkout works for fork PRs too. When - # pr_ref is empty (native push/pull_request), fall back to the - # default ref actions/checkout would use. - ref: ${{ inputs.pr_ref || (inputs.pr_number != '' && format('refs/pull/{0}/head', inputs.pr_number) || '') }} - fetch-depth: 1 - persist-credentials: false - - # HuggingFace throttles anonymous model downloads from shared GHA - # runner IPs (429s or stalled transfers). Cache the embedding model - # across runs so warm runs never touch HF at all. - - name: Cache HuggingFace embedding model - if: steps.flags.outputs.enable_embeddings == 'true' - uses: actions/cache@v5 - with: - path: ${{ runner.temp }}/hf-cache - key: hf-model-snowflake-arctic-embed-xs-v1 - - - name: Run GitNexus Analyze - working-directory: ${{ runner.temp }} - env: - ENABLE_EMBEDDINGS: ${{ steps.flags.outputs.enable_embeddings }} - FORCE: ${{ inputs.force }} - GITNEXUS_BIN: ${{ runner.temp }}/gitnexus-cli/node_modules/.bin/gitnexus - # Fail soft in ~2 min on stalled downloads instead of eating the - # 25-min job budget; HF_TOKEN lifts the anonymous rate limit on - # cold-cache runs (empty when the secret is unset — safe no-op). - HF_DOWNLOAD_TIMEOUT_MS: '60000' - HF_HOME: ${{ runner.temp }}/hf-cache - HF_MAX_ATTEMPTS: '2' - HF_TOKEN: ${{ secrets.HF_TOKEN }} - NPM_CONFIG_AUDIT: false - NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache - NPM_CONFIG_FUND: false - NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/gitnexus-cli/global-npmrc - NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/gitnexus-cli/.npmrc - run: | - set -euo pipefail - FLAGS=(--skip-agents-md --verbose) - - if [ "$ENABLE_EMBEDDINGS" = "true" ]; then - FLAGS+=(--embeddings) - fi - if [ "$FORCE" = "true" ]; then - FLAGS+=(--force) - fi - "$GITNEXUS_BIN" analyze "$GITHUB_WORKSPACE" "${FLAGS[@]}" - - - name: Verify index - run: | - if [ ! -d ".gitnexus" ] || [ ! -f ".gitnexus/meta.json" ]; then - echo "::error::GitNexus index was not created" - exit 1 - fi - echo "::group::Index metadata" - cat .gitnexus/meta.json - echo "" - echo "::endgroup::" - - - name: Upload GitNexus index - uses: actions/upload-artifact@v6 - 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-${{ - 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 - retention-days: 30 - - post-index: - needs: index - if: | - always() && - (inputs.pr_number != '' || - inputs.deploy_after) - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - actions: write # dispatch gitnexus-deploy.yml when deploy_after is set - pull-requests: write # post completion comments for /gitnexus command runs - steps: - # GitHub suppresses workflow_run events for workflow runs triggered - # by GITHUB_TOKEN (to prevent recursive chaining). Dispatches without - # a PR number can still opt into a deploy by setting deploy_after=true. - - name: Trigger deploy workflow after non-PR dispatches - if: inputs.deploy_after && inputs.pr_number == '' && needs.index.result == 'success' - uses: actions/github-script@v8 - with: - script: | - core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.'); - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'gitnexus-deploy.yml', - ref: 'main', - inputs: { - pr_number: '', - }, - }); - - # Reply on the PR when the /gitnexus command path runs so the - # requester knows the index step finished. This fires when - # inputs.pr_number is set and reports the index job result. - - name: Comment on PR — index complete - if: inputs.pr_number != '' - uses: actions/github-script@v8 - env: - EMBEDDINGS_INPUT: ${{ inputs.embeddings }} - INDEX_RESULT: ${{ needs.index.result }} - PR_NUMBER: ${{ inputs.pr_number }} - with: - script: | - const indexSucceeded = process.env.INDEX_RESULT === 'success'; - const outcome = indexSucceeded ? '✅ indexed' : '❌ index failed'; - const prNum = parseInt(process.env.PR_NUMBER || '', 10); - if (!Number.isSafeInteger(prNum)) { - core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`); - return; - } - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const embeddingsFlag = process.env.EMBEDDINGS_INPUT === 'true' ? 'with embeddings' : 'graph-only'; - const body = [ - `### GitNexus: ${outcome}`, - ``, - `PR #${prNum} was indexed ${embeddingsFlag}.`, - `[Index run](${runUrl})`, - '', - indexSucceeded - ? 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.' - : '_Index run failed — the previous index (if any) continues to be served._', - ].join('\n'); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNum, - body, - }); diff --git a/.github/workflows/gitnexus-pr-command.yml b/.github/workflows/gitnexus-pr-command.yml deleted file mode 100644 index a4efcfe37d..0000000000 --- a/.github/workflows/gitnexus-pr-command.yml +++ /dev/null @@ -1,141 +0,0 @@ -# Responds to `/gitnexus index` comments on pull requests. -# -# Gated to the same author_association roles (OWNER, MEMBER, COLLABORATOR) -# as the automatic PR index trigger, but applied to the COMMENTER, not -# the PR author. This intentionally lets a contributor index a PR from -# a non-contributor / first-time fork author — the contributor takes -# responsibility for the trust boundary by typing the command. -# -# When a matching comment lands on a PR, this workflow dispatches -# `gitnexus-index.yml` with the PR number and the `refs/pull//head` -# ref so indexing works for fork PRs too (GitHub mirrors every PR's -# head ref into the base repo regardless of which fork it originated -# from, so actions/checkout can always resolve it). -# -# 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 -# - Index a non-contributor (fork) PR that the auto-trigger skipped -# - Re-run a failed index -# -# Supported commands: -# /gitnexus index — index the PR with embeddings (default) -# /gitnexus index embeddings — explicit form of the above; same effect -# /gitnexus index fast — graph-only index (skip embeddings), for -# a quick re-index without waiting ~5 min -# of embedding generation - -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 - # commenters. Intentionally checks the COMMENTER's association so a - # contributor can index a non-contributor's PR on demand. - 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 ref - id: parse - uses: actions/github-script@v8 - 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 [fast]`); - return; - } - const [, subcommand, modifier] = match; - if (subcommand !== 'index') { - core.setFailed(`Unknown subcommand: ${subcommand}. Only 'index' is supported.`); - return; - } - // Default to embeddings on — a contributor typing the command - // has already decided they want a full re-index. The `fast` - // modifier is the explicit opt-out for graph-only runs. - // `embeddings` is accepted as a no-op alias for backwards - // compat with the previous command form. - let embeddings = 'true'; - if (modifier === 'fast' || modifier === 'graph-only' || modifier === 'no-embeddings') { - embeddings = 'false'; - } - - // Use refs/pull//head instead of the raw head SHA. GitHub - // mirrors every PR's head into the base repo as this ref, so - // actions/checkout can always resolve it — even for PRs from - // forks whose raw SHAs don't exist in the base repo. - const prNum = context.payload.issue.number; - core.setOutput('pr_number', String(prNum)); - core.setOutput('pr_ref', `refs/pull/${prNum}/head`); - core.setOutput('embeddings', embeddings); - core.info( - `Dispatching index for PR #${prNum} at refs/pull/${prNum}/head (embeddings=${embeddings}, modifier=${modifier || '(none)'})`, - ); - - - name: Dispatch gitnexus-index workflow - uses: actions/github-script@v8 - env: - EMBEDDINGS: ${{ steps.parse.outputs.embeddings }} - PR_NUMBER: ${{ steps.parse.outputs.pr_number }} - PR_REF: ${{ steps.parse.outputs.pr_ref }} - with: - script: | - const prNumber = process.env.PR_NUMBER || ''; - const prRef = process.env.PR_REF || ''; - const embeddings = process.env.EMBEDDINGS || 'false'; - if (!/^[0-9]+$/.test(prNumber)) { - core.setFailed(`Invalid PR number: ${prNumber}`); - return; - } - if (prRef !== `refs/pull/${prNumber}/head`) { - core.setFailed(`Invalid PR ref: ${prRef}`); - return; - } - if (!['true', 'false'].includes(embeddings)) { - core.setFailed(`Invalid embeddings value: ${embeddings}`); - return; - } - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'gitnexus-index.yml', - ref: 'main', - inputs: { - pr_number: prNumber, - pr_ref: prRef, - embeddings, - force: 'false', - deploy_after: 'true', - }, - }); - - - name: React to the comment - uses: actions/github-script@v8 - with: - script: | - await github.rest.reactions.createForIssueComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: context.payload.comment.id, - content: 'rocket', - });