GitNexus Deploy #170
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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/<name>/ 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 <host>` 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@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: Set up Docker Buildx | |
| if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' | |
| uses: docker/setup-buildx-action@v3 | |
| - 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: write # post deploy-complete comments on PR command dispatches | |
| steps: | |
| - name: Checkout deploy config | |
| uses: actions/checkout@v4 | |
| 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-<N>. 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@v7 | |
| 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 <host>` at bootstrap. | |
| if [ -z "$KNOWN_HOST" ]; then | |
| echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty. Run ssh-keyscan -H <host> 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/<pid>/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@v7 | |
| 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 |