🗼 fix: Serialize MCP OAuth Token Refresh Across Replicas #9166
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
| name: Docker Build Smoke Tests | |
| on: | |
| workflow_dispatch: | |
| pull_request: | |
| paths: | |
| - '.github/workflows/docker-smoke.yml' | |
| - '.dockerignore' | |
| - 'Dockerfile' | |
| - 'Dockerfile.multi' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'api/**' | |
| - 'client/**' | |
| - 'config/**' | |
| - 'skill/**' | |
| - 'packages/api/**' | |
| - 'packages/client/**' | |
| - 'packages/data-provider/**' | |
| - 'packages/data-schemas/**' | |
| - '!**.md' | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: docker-smoke-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Stage 2 of codegraph gating (stage 1 = backend jest in backend-review.yml). Two of the three | |
| # smokes are graph-decidable: the client package build only matters when the change reaches the | |
| # client build context, and the production-image boot only when it reaches the api image's build | |
| # context (Dockerfile.multi's api-build stage never builds client). Monotone and fail-open: a | |
| # smoke is dropped ONLY on an explicit `false`; unavailable/unconfigured/non-synchronize events | |
| # run everything. Lock attribution rides along so a dependency bump keeps the image smoke. | |
| # Kill switch: repo variable CODEGRAPH_GATING=off. | |
| codegraph_select: | |
| name: Codegraph select | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'synchronize' && | |
| vars.CODEGRAPH_GATING != 'off' | |
| outputs: | |
| decided: ${{ steps.sel.outputs.decided }} | |
| client_run: ${{ steps.sel.outputs.client_run }} | |
| api_run: ${{ steps.sel.outputs.api_run }} | |
| steps: | |
| - name: Select smokes, fail open on any doubt | |
| id: sel | |
| 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 }} | |
| CHANGED: ${{ github.event.pull_request.changed_files }} | |
| run: | | |
| set +e | |
| note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; } | |
| note "### Codegraph select — GATING (docker smokes)" | |
| if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_no codegraph config; running FULL_"; exit 0; fi | |
| # A failed or truncated page must not become a shorter file list: the pipeline would hide | |
| # gh's exit status behind jq, and a partial list can turn a required lane off. Check the | |
| # fetch status AND the count against the PR's own changed_files (Codex P1, #15136). | |
| if ! gh api "repos/$REPO/pulls/$PR/files" --paginate \ | |
| --jq '.[] | {path: .filename, status, patch}' > files.ndjson; then | |
| note "_could not fetch changed files; running FULL_"; exit 0 | |
| fi | |
| jq -s . files.ndjson > files.json | |
| N=$(jq 'length' files.json) | |
| if [ "$N" -eq 0 ] || { [ -n "$CHANGED" ] && [ "$N" -ne "$CHANGED" ]; }; then | |
| note "_changed-file list incomplete ($N of ${CHANGED:-?}); running FULL_"; exit 0 | |
| fi | |
| jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" \ | |
| '{files: ., mode: "safe", lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json | |
| # curl's status is checked explicitly: a transfer that times out or truncates after a | |
| # parseable body must fail open, not be honoured (Codex P1, #15136). --fail-with-body | |
| # also turns HTTP errors into a failure while keeping the error text for the summary. | |
| RESP=$(curl -sS --fail-with-body -m 45 -H "Authorization: Bearer $TOKEN" \ | |
| -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select"); RC=$? | |
| if [ "$RC" -ne 0 ] || [ -z "$RESP" ] || ! echo "$RESP" | jq -e '.matrix["docker-smoke"]' >/dev/null 2>&1; then | |
| note "_codegraph unavailable (curl exit $RC: ${RESP:0:120}); running FULL_" | |
| exit 0 | |
| fi | |
| # A smoke is skipped only on the JSON boolean false — tested inside jq, because `jq -r` | |
| # prints the string "false" and the boolean identically (Codex P1, #15136). Anything | |
| # else (true, null, a string, missing) runs. | |
| if echo "$RESP" | jq -e '.e2e.fail_open == true' >/dev/null 2>&1; then | |
| note "_fail-open decision (root/workflow/lockfile change or stale graph): everything runs_" | |
| fi | |
| note "| smoke | decision |" | |
| note "|---|---|" | |
| emit() { | |
| key="$1"; hint="$2"; label="$3" | |
| if echo "$RESP" | jq -e --arg h "$hint" '.matrix["docker-smoke"][$h] == false' >/dev/null 2>&1; then | |
| echo "${key}_run=false" >> "$GITHUB_OUTPUT" | |
| note "| $label | skip (no reach into its build context) |" | |
| else | |
| echo "${key}_run=true" >> "$GITHUB_OUTPUT" | |
| note "| $label | run |" | |
| fi | |
| } | |
| emit client client_package_target "client package build" | |
| emit api api_runtime_smoke "api runtime smoke" | |
| echo "codegraph-select: $(echo "$RESP" | jq -c '.matrix["docker-smoke"]')" | |
| echo "decided=true" >> "$GITHUB_OUTPUT" | |
| note "" | |
| note "node image smoke keeps its own path filter · kill switch: repo variable \`CODEGRAPH_GATING=off\` · everything runs on PR open" | |
| exit 0 | |
| client-package-target: | |
| name: Build Docker client package target | |
| needs: [codegraph_select] | |
| if: ${{ !cancelled() && needs.codegraph_select.outputs.client_run != 'false' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Build client package target | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: Dockerfile.multi | |
| platforms: linux/amd64 | |
| push: false | |
| target: client-package-build | |
| # The plain single-stage Dockerfile ships via dev-images/tag-images but had no | |
| # PR-time validation. The npm build pipeline itself is already smoked on every | |
| # matching PR by the Dockerfile.multi jobs above, so the full build here is | |
| # gated to changes of the Dockerfile or the build-context definition. | |
| node-image-smoke: | |
| name: Node image smoke (plain Dockerfile builds) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Detect plain Dockerfile changes | |
| id: paths | |
| if: github.event_name == 'pull_request' | |
| uses: dorny/paths-filter@v4 | |
| with: | |
| filters: | | |
| dockerfile: | |
| - 'Dockerfile' | |
| - '.dockerignore' | |
| - '.github/workflows/docker-smoke.yml' | |
| - name: Set up Docker Buildx | |
| if: github.event_name == 'workflow_dispatch' || steps.paths.outputs.dockerfile == 'true' | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Build node image | |
| if: github.event_name == 'workflow_dispatch' || steps.paths.outputs.dockerfile == 'true' | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: Dockerfile | |
| platforms: linux/amd64 | |
| push: false | |
| target: node | |
| cache-from: type=gha,scope=docker-smoke-node | |
| cache-to: type=gha,mode=max,scope=docker-smoke-node | |
| api-runtime-smoke: | |
| name: API runtime smoke (production image boots) | |
| needs: [codegraph_select] | |
| if: ${{ !cancelled() && needs.codegraph_select.outputs.api_run != 'false' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| # Build the real production image (final `api-build` stage), which installs | |
| # with `npm ci --omit=dev` — the same prune that, in prod, exposed runtime | |
| # dependencies the tsdown bundle externalizes but were never declared. | |
| - name: Build production image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: Dockerfile.multi | |
| platforms: linux/amd64 | |
| push: false | |
| load: true | |
| tags: librechat-api-smoke:ci | |
| cache-from: type=gha,scope=docker-smoke-api | |
| cache-to: type=gha,mode=max,scope=docker-smoke-api | |
| # Loads the entire externalized require graph of the built @librechat/api | |
| # bundle inside the pruned production image. A missing or ESM-incompatible | |
| # runtime dependency (e.g. the `get-stream` regression) fails here with a | |
| # non-zero exit — deterministically, with no database required. | |
| - name: Verify production image resolves all runtime modules | |
| run: | | |
| docker run --rm librechat-api-smoke:ci \ | |
| node -e "require('@librechat/api'); require('@librechat/api/telemetry'); console.log('module resolution OK')" | |
| # Boot the real entrypoint against a real MongoDB so the *entire* server | |
| # require graph loads (api/db throws at module scope without MONGO_URI, and | |
| # is imported before models/services/routes), then gate on /readyz AND the | |
| # container staying alive. /readyz only returns 200 after the post-listen | |
| # startup (initializeMCPs + checkMigrations) sets serverReady, and those | |
| # steps process.exit(1) on failure — so ANY startup crash (missing module, | |
| # ReferenceError, bad config, post-listen failure) fails the smoke. | |
| - name: Boot production image against MongoDB and poll /readyz | |
| run: | | |
| set -u | |
| docker network create lc-smoke | |
| docker run -d --name lc-mongo --network lc-smoke mongo:8.0.20 | |
| docker run -d --name lc-api --network lc-smoke -p 3080:3080 \ | |
| -e HOST=0.0.0.0 -e PORT=3080 \ | |
| -e NODE_ENV=production \ | |
| -e MONGO_URI=mongodb://lc-mongo:27017/LibreChat \ | |
| -e CREDS_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ | |
| -e CREDS_IV=0123456789abcdef0123456789abcdef \ | |
| -e JWT_SECRET=docker-smoke-jwt-secret \ | |
| -e JWT_REFRESH_SECRET=docker-smoke-jwt-refresh-secret \ | |
| -e SEARCH=false \ | |
| librechat-api-smoke:ci | |
| healthy="" | |
| for i in $(seq 1 60); do | |
| if [ "$(docker inspect -f '{{.State.Running}}' lc-api 2>/dev/null)" != "true" ]; then | |
| echo "::error::API container exited during startup (exit code $(docker inspect -f '{{.State.ExitCode}}' lc-api 2>/dev/null))" | |
| break | |
| fi | |
| if [ "$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:3080/readyz 2>/dev/null || true)" = "200" ]; then | |
| healthy="yes" | |
| echo "/readyz returned 200 — server fully booted (post-listen startup complete)." | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| echo "----- last 100 lines of api container logs -----" | |
| docker logs lc-api 2>&1 | tail -100 || true | |
| echo "------------------------------------------------" | |
| docker rm -f lc-api lc-mongo >/dev/null 2>&1 || true | |
| docker network rm lc-smoke >/dev/null 2>&1 || true | |
| if [ -z "$healthy" ]; then | |
| echo "::error::Production image failed to reach a ready /readyz within timeout" | |
| exit 1 | |
| fi |