🛤️ ci: Limit GitNexus Deploys To Main And Dev Only (#13799)

This commit is contained in:
Danny Avila 2026-06-16 15:00:22 -04:00 committed by GitHub
parent 4cb35945dc
commit c820dfb9a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 87 deletions

View file

@ -2,14 +2,10 @@
#
# 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)
# 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 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)
# 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.
@ -58,14 +54,14 @@ on:
workflow_dispatch:
inputs:
pr_number:
description: 'Optional PR number to post completion comment on (set by bot-triggered dispatches from gitnexus-index.yml)'
description: 'Optional PR number for status comments from bot-triggered dispatches'
type: string
default: ''
permissions:
actions: read
contents: read
pull-requests: write # post completion comments on served PR indexes
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
@ -93,7 +89,12 @@ jobs:
build-image:
if: |
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
(
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:
@ -158,7 +159,7 @@ jobs:
permissions:
actions: read
contents: read
pull-requests: write # post deploy-complete comments on served PR indexes
pull-requests: write # post deploy-complete comments on PR command dispatches
steps:
- name: Checkout deploy config
uses: actions/checkout@v4
@ -217,62 +218,7 @@ jobs:
core.info(`${branch}: run ${fresh.workflow_run.id} -> ${name}`);
}
// --- open PRs with at least one successful index run ---
// github.paginate handles the 100-per-page ceiling automatically
// so the resolution works on repos with 200+ concurrent open PRs.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
core.info(`Found ${openPrs.length} open PRs`);
// Parallelize artifact lookups in fixed-size batches so the
// resolve step runs in seconds instead of minutes on big repos,
// without burning the GitHub API rate limit all at once.
const BATCH_SIZE = 10;
const prMatches = [];
for (let i = 0; i < openPrs.length; i += BATCH_SIZE) {
const batch = openPrs.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
batch.map(async (pr) => {
const artifactName = `gitnexus-index-pr-${pr.number}`;
const fresh = await latestArtifact(artifactName);
return fresh ? { pr, artifactName, fresh } : null;
}),
);
for (const hit of results) {
if (hit) prMatches.push(hit);
}
}
// Cap to the N most recent PR indexes by artifact creation time.
// On a 10GB droplet each index is ~130MB; 3 PRs + main + dev ≈
// 650MB of index data, leaving headroom for the ~700MB Docker image
// and OS. Older PR indexes are evicted by the prune step.
const MAX_PR_INDEXES = 3;
prMatches.sort(
(a, b) => new Date(b.fresh.created_at) - new Date(a.fresh.created_at),
);
const keptPrs = prMatches.slice(0, MAX_PR_INDEXES);
const evictedPrs = prMatches.slice(MAX_PR_INDEXES);
for (const { pr, artifactName, fresh } of keptPrs) {
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 (evictedPrs.length) {
core.info(
`Evicted ${evictedPrs.length} older PR indexes (cap=${MAX_PR_INDEXES}): ` +
evictedPrs.map((e) => `#${e.pr.number}`).join(', '),
);
}
core.info(`Serving ${keptPrs.length} PR indexes out of ${prMatches.length} with artifacts (${openPrs.length} open PRs total)`);
core.info('PR index deploys are paused; serving main and dev only.');
if (!serve.length) {
core.setFailed('No indexes to serve');
@ -387,7 +333,7 @@ jobs:
# ── 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 index is ~130MB.
# 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'
@ -415,8 +361,8 @@ jobs:
# 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
# ~130MB of extra disk, but the prune step already freed
# space from evicted PR indexes so this fits on a 10GB disk.
# ~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")
@ -567,15 +513,41 @@ jobs:
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 !== '') {
prNum = parseInt(process.env.DISPATCH_PR_NUMBER, 10);
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 matrix = JSON.parse(process.env.MATRIX || '[]');
const triggerRunId = Number(process.env.TRIGGER_RUN_ID);
const match = matrix.find(
(m) => m.runId === triggerRunId && m.name.startsWith('LibreChat-pr-'),
@ -590,7 +562,6 @@ jobs:
return;
}
const deployUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const ok = process.env.DEPLOY_STATUS === 'success';
const body = [
`### GitNexus: ${ok ? '🚀 deployed' : '❌ deploy failed'}`,

View file

@ -267,35 +267,27 @@ jobs:
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). Command-triggered
# index runs opt into a deploy by setting deploy_after=true.
- name: Trigger deploy workflow after command-triggered runs
if: inputs.deploy_after && needs.index.result == 'success'
# 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@v7
env:
PR_NUMBER: ${{ inputs.pr_number }}
with:
script: |
core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.');
// Pass pr_number through so the deploy workflow knows which
// PR to post its completion comment on (for /gitnexus
// command runs this will be set; for other bot dispatches
// it's empty and the deploy step falls back to matrix match).
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'gitnexus-deploy.yml',
ref: 'main',
inputs: {
pr_number: process.env.PR_NUMBER || '',
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. A
# separate comment posts from the deploy workflow when the live
# server has the fresh index.
# 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@v7
@ -321,7 +313,7 @@ jobs:
`[Index run](${runUrl})`,
'',
indexSucceeded
? '⏳ Waiting for deploy to serve the fresh index…'
? '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({