diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 610396959f..e7ef45f7c4 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -26,17 +26,14 @@ body: id: version-info attributes: label: Version Information - description: | - If using Docker, please run and provide the output of: - ```bash - docker images | grep librechat - ``` - - If running from source, please run and provide the output of: - ```bash - git rev-parse HEAD - ``` - placeholder: Paste the output here + description: | + In LibreChat, open **Settings → About** and click **Copy diagnostics**, then paste the result here. + This captures the exact version, commit, branch, and build date so maintainers can pinpoint the build you're running. + + If the About panel is unavailable (older version / self-hosted with it disabled), please provide as much of the following as possible instead: + - Docker: `docker images | grep librechat` (image tag) and `docker inspect | grep -i "\"Commit\\|BUILD_"` if build args were set + - Source: `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` + placeholder: Paste the diagnostics block here validations: required: true - type: textarea @@ -93,4 +90,4 @@ body: description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct - required: true \ No newline at end of file + required: true diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml index 464f6ce55a..f0e2ba54b5 100644 --- a/.github/workflows/dev-branch-images.yml +++ b/.github/workflows/dev-branch-images.yml @@ -9,6 +9,10 @@ on: - 'api/**' - 'client/**' - 'packages/**' + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'Dockerfile.multi' permissions: contents: read @@ -65,6 +69,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -79,3 +89,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/dev-images.yml b/.github/workflows/dev-images.yml index a9fbef8929..efdd202754 100644 --- a/.github/workflows/dev-images.yml +++ b/.github/workflows/dev-images.yml @@ -9,6 +9,10 @@ on: - 'api/**' - 'client/**' - 'packages/**' + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'Dockerfile.multi' permissions: contents: read @@ -61,6 +65,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -75,3 +85,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml index 7bb06e5298..6deb86205c 100644 --- a/.github/workflows/dev-staging-images.yml +++ b/.github/workflows/dev-staging-images.yml @@ -53,6 +53,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -67,3 +73,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/main-image-workflow.yml b/.github/workflows/main-image-workflow.yml index 348012de22..e5f76fe26e 100644 --- a/.github/workflows/main-image-workflow.yml +++ b/.github/workflows/main-image-workflow.yml @@ -38,6 +38,12 @@ jobs: fi printf 'LATEST_TAG=%s\n' "$LATEST_TAG" >> "$GITHUB_ENV" + - name: Compute build metadata + run: | + printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV" + printf 'BUILD_BRANCH=main\n' >> "$GITHUB_ENV" + printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV" + # Set up QEMU - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -80,3 +86,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml index 4477a89c13..a7d7ee7e9a 100644 --- a/.github/workflows/tag-images.yml +++ b/.github/workflows/tag-images.yml @@ -74,6 +74,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + - name: Resolve image tags id: image-tags env: @@ -104,3 +110,7 @@ jobs: tags: ${{ steps.image-tags.outputs.tags }} platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/Dockerfile b/Dockerfile index 809f167413..39efed4ca2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,18 @@ RUN \ npm prune --production; \ npm cache clean --force +# Optional build metadata surfaced in Settings -> About for support triage. +# Declared here (after the heavy install/build steps) so that commit/date +# changing on every CI run does not bust the cache for dependency install +# and frontend build layers. When unset, the backend falls back to local +# git resolution (if .git is present), and finally to empty values. +ARG BUILD_COMMIT= +ARG BUILD_BRANCH= +ARG BUILD_DATE= +ENV BUILD_COMMIT=${BUILD_COMMIT} +ENV BUILD_BRANCH=${BUILD_BRANCH} +ENV BUILD_DATE=${BUILD_DATE} + # Node API setup EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/Dockerfile.multi b/Dockerfile.multi index f392a51e40..30534a0182 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -4,6 +4,11 @@ # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 +# Optional build metadata surfaced in Settings -> About for support triage. +ARG BUILD_COMMIT= +ARG BUILD_BRANCH= +ARG BUILD_DATE= + # Base for all builds FROM node:20-alpine AS base-min ARG NPM_CI_TIMEOUT_SECONDS=1500 @@ -108,6 +113,15 @@ COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist COPY --from=client-build /app/client/dist ./client/dist +# Propagate build metadata into runtime env so /api/config can expose it. +# Declared here (after the heavy install/copy steps) so that commit/date +# changing on every CI run does not bust the cache for those layers. +ARG BUILD_COMMIT +ARG BUILD_BRANCH +ARG BUILD_DATE +ENV BUILD_COMMIT=${BUILD_COMMIT} +ENV BUILD_BRANCH=${BUILD_BRANCH} +ENV BUILD_DATE=${BUILD_DATE} WORKDIR /app/api EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 52a843116c..d7fbd04446 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -21,9 +21,16 @@ jest.mock('@librechat/data-schemas', () => ({ })); const mockGetCloudFrontConfig = jest.fn(() => null); +const mockResolveBuildInfo = jest.fn(() => ({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, +})); jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), getCloudFrontConfig: (...args) => mockGetCloudFrontConfig(...args), + resolveBuildInfo: (...args) => mockResolveBuildInfo(...args), })); const request = require('supertest'); @@ -63,6 +70,12 @@ const mockUser = { afterEach(() => { jest.resetAllMocks(); + mockResolveBuildInfo.mockReturnValue({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, + }); delete process.env.APP_TITLE; delete process.env.CHECK_BALANCE; delete process.env.START_BALANCE; @@ -446,4 +459,112 @@ describe('GET /api/config', () => { expect(response.body).toHaveProperty('error'); }); }); + + describe('buildInfo payload', () => { + const populatedBuildInfo = { + commit: 'abcdef1234567890abcdef1234567890abcdef12', + commitShort: 'abcdef1', + branch: 'dev', + buildDate: '2026-04-20T12:00:00Z', + }; + + it('includes buildInfo in authenticated response when interface flag is not explicitly disabled', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.buildInfo).toEqual(populatedBuildInfo); + }); + + it('omits buildInfo when interface.buildInfo is false', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('omits buildInfo when all resolver fields are null', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('includes buildInfo in unauthenticated response when flag is not disabled', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.buildInfo).toEqual(populatedBuildInfo); + }); + + it('omits buildInfo in unauthenticated response when interface.buildInfo is false', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('propagates interface.buildInfo=false in unauthenticated response so clients can hide About tab', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.interface).toBeDefined(); + expect(response.body.interface.buildInfo).toBe(false); + }); + + it('does not add interface.buildInfo=true to unauthenticated response (default stays implicit)', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { privacyPolicy: { externalUrl: 'https://x' }, buildInfo: true }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.interface).toBeDefined(); + expect(response.body.interface).not.toHaveProperty('buildInfo'); + }); + + it('includes interface block with only buildInfo=false when nothing else is set', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { buildInfo: false }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.interface).toEqual({ buildInfo: false }); + }); + }); }); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 33d1a8a325..28bdd762f7 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -3,6 +3,7 @@ const { isEnabled, getBalanceConfig, getCloudFrontConfig, + resolveBuildInfo, sanitizeModelSpecs, } = require('@librechat/api'); const { defaultSocialLogins } = require('librechat-data-provider'); @@ -25,6 +26,13 @@ const publicSharedLinksEnabled = const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); +/** + * Resolve build metadata eagerly at module load so the first `/api/config` + * request does not pay the cost of `execFileSync('git', ...)` on the hot path. + * The resolver caches its result after the first call. + */ +resolveBuildInfo(); + function isBirthday() { const today = new Date(); return today.getMonth() === 1 && today.getDate() === 11; @@ -105,6 +113,22 @@ function buildSharedPayload() { return payload; } +function buildBuildInfoPayload(interfaceConfig) { + if (interfaceConfig?.buildInfo === false) { + return undefined; + } + const info = resolveBuildInfo(); + if (!info.commit && !info.branch && !info.buildDate) { + return undefined; + } + return { + commit: info.commit, + commitShort: info.commitShort, + branch: info.branch, + buildDate: info.buildDate, + }; +} + function buildWebSearchConfig(appConfig) { const ws = appConfig?.webSearch; if (!ws) { @@ -159,7 +183,8 @@ router.get('/', async function (req, res) { }; const interfaceConfig = baseConfig?.interfaceConfig; - if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService) { + const buildInfoDisabled = interfaceConfig?.buildInfo === false; + if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) { payload.interface = {}; if (interfaceConfig.privacyPolicy) { payload.interface.privacyPolicy = interfaceConfig.privacyPolicy; @@ -167,6 +192,14 @@ router.get('/', async function (req, res) { if (interfaceConfig.termsOfService) { payload.interface.termsOfService = interfaceConfig.termsOfService; } + if (buildInfoDisabled) { + payload.interface.buildInfo = false; + } + } + + const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig); + if (unauthBuildInfo) { + payload.buildInfo = unauthBuildInfo; } return res.status(200).send(payload); @@ -205,6 +238,11 @@ router.get('/', async function (req, res) { payload.webSearch = webSearch; } + const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig); + if (buildInfo) { + payload.buildInfo = buildInfo; + } + if (!payload.allowAccountDeletion) { try { const userId = req.user.id ?? req.user._id?.toString(); diff --git a/client/src/components/Nav/Settings.spec.tsx b/client/src/components/Nav/Settings.spec.tsx new file mode 100644 index 0000000000..114b99ef97 --- /dev/null +++ b/client/src/components/Nav/Settings.spec.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Settings from './Settings'; + +const mockUseGetStartupConfig = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => mockUseGetStartupConfig(), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/hooks/usePersonalizationAccess', () => ({ + __esModule: true, + default: () => ({ + hasMemoryOptOut: false, + hasAnyPersonalizationFeature: false, + }), +})); + +jest.mock('@librechat/client', () => ({ + GearIcon: () =>