diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml index e217c03769..4de5212aa5 100644 --- a/.github/workflows/a11y.yml +++ b/.github/workflows/a11y.yml @@ -16,6 +16,10 @@ permissions: contents: read pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: axe-linter: runs-on: ubuntu-latest diff --git a/.github/workflows/agents-integration-tests.yml b/.github/workflows/agents-integration-tests.yml index 9454e8883b..4353e622b0 100644 --- a/.github/workflows/agents-integration-tests.yml +++ b/.github/workflows/agents-integration-tests.yml @@ -20,6 +20,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: agents_integration_tests: name: Integration Tests that use in-process MongoDB diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 67541f5b06..47e08d03fc 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: NODE_ENV: CI NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml index 3f6908921f..f98cff0c2f 100644 --- a/.github/workflows/cache-integration-tests.yml +++ b/.github/workflows/cache-integration-tests.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: cache_integration_tests: name: Integration Tests that use actual Redis Cache diff --git a/.github/workflows/config-review.yml b/.github/workflows/config-review.yml deleted file mode 100644 index b89c4e4c9f..0000000000 --- a/.github/workflows/config-review.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Config Migration Tests -on: - pull_request: - paths: - - 'config/**' - - 'api/models/**' - - 'api/db/**' - - 'packages/data-schemas/src/**' - - 'packages/data-provider/src/**' - - 'packages/api/src/acl/**' - - 'packages/api/src/shared-links/**' - - '!**.md' - -env: - NODE_ENV: CI - NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' - -jobs: - test-config: - name: 'Tests: config migrations' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v5 - - - name: Use Node.js 24.16.0 - uses: actions/setup-node@v5 - with: - node-version: '24.16.0' - - - name: Restore node_modules cache - id: cache-node-modules - uses: actions/cache@v5 - with: - path: | - node_modules - api/node_modules - packages/api/node_modules - packages/data-provider/node_modules - packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} - - - name: Install dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: npm ci - - - name: Restore data-provider build cache - id: cache-data-provider - uses: actions/cache@v5 - with: - path: packages/data-provider/dist - key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - - - name: Build data-provider - if: steps.cache-data-provider.outputs.cache-hit != 'true' - run: npm run build:data-provider - - - name: Restore data-schemas build cache - id: cache-data-schemas - uses: actions/cache@v5 - with: - path: packages/data-schemas/dist - key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - - - name: Build data-schemas - if: steps.cache-data-schemas.outputs.cache-hit != 'true' - run: npm run build:data-schemas - - - name: Restore api build cache - id: cache-api - uses: actions/cache@v5 - with: - path: packages/api/dist - key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} - - - name: Build api - if: steps.cache-api.outputs.cache-hit != 'true' - run: npm run build:api - - - name: Create empty auth.json file - run: | - mkdir -p api/data - echo '{}' > api/data/auth.json - - - name: Prepare .env.test file - run: cp api/test/.env.test.example api/test/.env.test - - - name: Run config migration tests - run: npm run test:config diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml deleted file mode 100644 index 47e8fbb970..0000000000 --- a/.github/workflows/eslint-ci.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: ESLint Code Quality Checks - -on: - pull_request: - branches: - - main - - dev - - dev-staging - - release/* - paths: - - 'api/**' - - 'client/**' - - 'packages/**' - - '.github/workflows/eslint-ci.yml' - - '!**.md' - -jobs: - eslint_checks: - name: Run ESLint Linting - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Node.js 24.16.0 - uses: actions/setup-node@v5 - with: - node-version: '24.16.0' - cache: npm - - - name: Install dependencies - run: npm ci - - # Run ESLint on changed files within the api/, client/, and packages/ directories. - - name: Run ESLint on changed files - run: | - # Extract the base commit SHA from the pull_request event payload. - BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") - echo "Base commit SHA: $BASE_SHA" - - # Get changed files (only JS/TS files in api/, client/, or packages/) - mapfile -d '' -t CHANGED_FILES < <( - git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | - grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true - ) - - # Debug output - echo "Changed files:" - printf '%s\n' "${CHANGED_FILES[@]}" - - # Ensure there are files to lint before running ESLint - if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then - echo "No matching files changed. Skipping ESLint." - exit 0 - fi - - # Run ESLint - # --no-warn-ignored: changed files under config-ignored paths - # (e.g. packages/data-schemas/misc/**) must not fail --max-warnings=0 - npx eslint --no-error-on-unmatched-pattern \ - --config eslint.config.mjs \ - --no-warn-ignored \ - --max-warnings=0 \ - -- "${CHANGED_FILES[@]}" - - # Run Prettier --check on the same set of changed files to catch - # formatting drift in PRs that bypassed the local pre-commit hook - # (e.g. GitHub UI edit-and-merge, `git commit --no-verify`). - - name: Run Prettier --check on changed files - run: | - BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") - mapfile -d '' -t CHANGED_FILES < <( - git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | - grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true - ) - - if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then - echo "No matching files changed. Skipping Prettier." - exit 0 - fi - - echo "Files to check:" - printf '%s\n' "${CHANGED_FILES[@]}" - - # `prettier --check` exits non-zero if any file would be reformatted. - # Suggest the local fix in the failure message so contributors aren't - # left guessing how to resolve. - if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then - echo "" - echo "::error::Prettier formatting drift detected. Fix locally with:" - echo "::error:: npx prettier --write " - echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)." - exit 1 - fi - - # Verify import ordering on the same set of changed files. The script - # only sorts files under known source roots, so unrelated changed files - # (configs, etc.) are ignored. Matches the lint-staged pre-commit hook. - - name: Check import sorting on changed files - run: | - BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") - mapfile -d '' -t CHANGED_FILES < <( - git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | - grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true - ) - - if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then - echo "No matching files changed. Skipping import-sort check." - exit 0 - fi - - echo "Files to check:" - printf '%s\n' "${CHANGED_FILES[@]}" - - # `--check` lists offending files and exits non-zero without writing. - if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then - echo "" - echo "::error::Import order drift detected. Fix locally with:" - echo "::error:: npm run sort-imports" - echo "::error::For specific files:" - echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts" - echo "::error::To check without writing files:" - echo "::error:: npm run sort-imports:check" - echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)." - exit 1 - fi diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index 1a2e505e7d..d3bd40ae47 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -12,6 +12,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' @@ -162,14 +166,14 @@ jobs: working-directory: packages/client test-ubuntu: - name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/4)' + name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/2)' needs: build runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2] steps: - uses: actions/checkout@v5 @@ -205,56 +209,8 @@ jobs: name: build-client-package path: packages/client/dist - - name: Run unit tests (shard ${{ matrix.shard }}/4) - run: npm run test:ci -- --shard=${{ matrix.shard }}/4 - working-directory: client - - test-windows: - name: 'Tests: Windows (shard ${{ matrix.shard }}/4)' - needs: build - runs-on: windows-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - steps: - - uses: actions/checkout@v5 - - - name: Use Node.js 24.16.0 - uses: actions/setup-node@v5 - with: - node-version: '24.16.0' - - - name: Restore node_modules cache - id: cache-node-modules - uses: actions/cache@v5 - with: - path: | - node_modules - client/node_modules - packages/client/node_modules - packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - - - name: Install dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: npm ci - - - name: Download data-provider build - uses: actions/download-artifact@v7 - with: - name: build-data-provider - path: packages/data-provider/dist - - - name: Download client-package build - uses: actions/download-artifact@v7 - with: - name: build-client-package - path: packages/client/dist - - - name: Run unit tests (shard ${{ matrix.shard }}/4) - run: npm run test:ci -- --shard=${{ matrix.shard }}/4 + - name: Run unit tests (shard ${{ matrix.shard }}/2) + run: npm run test:ci -- --shard=${{ matrix.shard }}/2 working-directory: client build-verify: diff --git a/.github/workflows/frontend-windows-nightly.yml b/.github/workflows/frontend-windows-nightly.yml new file mode 100644 index 0000000000..d8da19e7cd --- /dev/null +++ b/.github/workflows/frontend-windows-nightly.yml @@ -0,0 +1,139 @@ +name: Frontend Windows Tests + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' + +jobs: + build: + name: Build packages + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + source-sha: ${{ steps.source.outputs.sha }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'schedule' && 'dev' || github.sha }} + + - name: Capture source SHA + id: source + shell: bash + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Use Node.js 24.16.0 + uses: actions/setup-node@v5 + with: + node-version: '24.16.0' + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v5 + with: + path: | + node_modules + client/node_modules + packages/client/node_modules + packages/data-provider/node_modules + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Restore data-provider build cache + id: cache-data-provider + uses: actions/cache@v5 + with: + path: packages/data-provider/dist + key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-provider + if: steps.cache-data-provider.outputs.cache-hit != 'true' + run: npm run build:data-provider + + - name: Restore client-package build cache + id: cache-client-package + uses: actions/cache@v5 + with: + path: packages/client/dist + key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build client-package + if: steps.cache-client-package.outputs.cache-hit != 'true' + run: npm run build:client-package + + - name: Upload data-provider build + uses: actions/upload-artifact@v6 + with: + name: build-data-provider + path: packages/data-provider/dist + retention-days: 2 + + - name: Upload client-package build + uses: actions/upload-artifact@v6 + with: + name: build-client-package + path: packages/client/dist + retention-days: 2 + + test-windows: + name: 'Tests: Windows (shard ${{ matrix.shard }}/4)' + needs: build + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.build.outputs.source-sha }} + + - name: Use Node.js 24.16.0 + uses: actions/setup-node@v5 + with: + node-version: '24.16.0' + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v5 + with: + path: | + node_modules + client/node_modules + packages/client/node_modules + packages/data-provider/node_modules + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Download data-provider build + uses: actions/download-artifact@v7 + with: + name: build-data-provider + path: packages/data-provider/dist + + - name: Download client-package build + uses: actions/download-artifact@v7 + with: + name: build-client-package + path: packages/client/dist + + - name: Run unit tests (shard ${{ matrix.shard }}/4) + run: npm run test:ci -- --shard=${{ matrix.shard }}/4 + working-directory: client diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml deleted file mode 100644 index 33e47d58a7..0000000000 --- a/.github/workflows/i18n-unused-keys.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Detect Unused i18next Strings - -# This workflow checks for unused i18n keys in translation files. -# It has special handling for: -# - com_ui_special_var_* keys that are dynamically constructed -# - com_agents_category_* keys that are stored in the database and used dynamically - -on: - pull_request: - paths: - - "client/src/**" - - "api/**" - - "packages/data-provider/src/**" - - "packages/client/**" - - "packages/data-schemas/src/**" - - "!**.md" - -jobs: - detect-unused-i18n-keys: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Find unused i18next keys - id: find-unused - run: | - echo "🔍 Scanning for unused i18next keys..." - - # Define paths - I18N_FILE="client/src/locales/en/translation.json" - SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src") - - # Check if translation file exists - if [[ ! -f "$I18N_FILE" ]]; then - echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE" - exit 1 - fi - - # Extract all keys from the JSON file - KEYS=$(jq -r 'keys[]' "$I18N_FILE") - - # Track unused keys - UNUSED_KEYS=() - - # Check if each key is used in the source code - for KEY in $KEYS; do - FOUND=false - - # Special case for dynamically constructed special variable keys - if [[ "$KEY" == com_ui_special_var_* ]]; then - # Check if TSpecialVarLabel is used in the codebase - for DIR in "${SOURCE_DIRS[@]}"; do - if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then - FOUND=true - break - fi - done - - # Also check if the key is directly used somewhere - if [[ "$FOUND" == false ]]; then - for DIR in "${SOURCE_DIRS[@]}"; do - if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then - FOUND=true - break - fi - done - fi - # Special case for agent category keys that are dynamically used from database - elif [[ "$KEY" == com_agents_category_* ]]; then - # Check if agent category localization is being used - for DIR in "${SOURCE_DIRS[@]}"; do - # Check for dynamic category label/description usage - if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \ - # Check for the method that defines these keys - grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \ - # Check for direct usage in agentCategory.ts - grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \ - grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then - FOUND=true - break - fi - done - - # Also check if the key is directly used somewhere - if [[ "$FOUND" == false ]]; then - for DIR in "${SOURCE_DIRS[@]}"; do - if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then - FOUND=true - break - fi - done - fi - else - # Regular check for other keys - for DIR in "${SOURCE_DIRS[@]}"; do - if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then - FOUND=true - break - fi - done - fi - - if [[ "$FOUND" == false ]]; then - UNUSED_KEYS+=("$KEY") - fi - done - - # Output results - if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then - echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:" - echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV - for KEY in "${UNUSED_KEYS[@]}"; do - echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase." - done - else - echo "✅ No unused i18n keys detected!" - echo "unused_keys=[]" >> $GITHUB_ENV - fi - - - name: Post verified comment on PR - if: env.unused_keys != '[]' - run: | - PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") - - # Format the unused keys list as checkboxes for easy manual checking. - FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' ) - - COMMENT_BODY=$(cat <- github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && @@ -39,7 +39,7 @@ jobs: fail-fast: false matrix: stream_store: [memory, redis] - shard: [1, 2, 3, 4] + shard: [1, 2] services: redis: image: redis:7-alpine @@ -142,7 +142,7 @@ jobs: npx playwright install-deps chrome - name: Run mock-LLM Tier-1 e2e - run: npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }}/4 + run: npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }}/2 env: CI: 'true' diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml new file mode 100644 index 0000000000..fff4b8d0f3 --- /dev/null +++ b/.github/workflows/static-checks.yml @@ -0,0 +1,691 @@ +name: Static Checks + +on: + pull_request: + paths: + - 'api/**' + - 'client/**' + - 'config/**' + - 'packages/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/static-checks.yml' + - '!**.md' + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NODE_ENV: CI + NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' + +jobs: + static-checks: + name: Static checks + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Detect affected checks + id: paths + uses: dorny/paths-filter@v4 + with: + predicate-quantifier: 'some-with-excludes' + filters: | + eslint: + - 'api/**' + - 'client/**' + - 'packages/**' + - '.github/workflows/static-checks.yml' + - '!**.md' + config: + - 'api/**' + - 'config/**' + - 'packages/**' + - '.github/workflows/static-checks.yml' + - '!**.md' + i18n: + - 'api/**' + - 'client/src/**' + - 'packages/client/**' + - 'packages/data-provider/src/**' + - 'packages/data-schemas/src/**' + - '.github/workflows/static-checks.yml' + - '!**.md' + unused_packages: + - 'api/**' + - 'client/**' + - 'packages/api/**' + - 'packages/client/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/static-checks.yml' + - '!**.md' + + - name: Set up Node.js 24.16.0 + uses: actions/setup-node@v5 + with: + node-version: '24.16.0' + cache: npm + + - name: Install dependencies + id: install_dependencies + continue-on-error: true + run: npm ci + + # Run ESLint on changed files within the api/, client/, and packages/ directories. + - name: Run ESLint on changed files + id: eslint + if: always() && steps.paths.outputs.eslint == 'true' + continue-on-error: true + run: | + # Extract the base commit SHA from the pull_request event payload. + BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") + echo "Base commit SHA: $BASE_SHA" + + # Get changed files (only JS/TS files in api/, client/, or packages/) + mapfile -d '' -t CHANGED_FILES < <( + git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | + grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true + ) + + # Debug output + echo "Changed files:" + printf '%s\n' "${CHANGED_FILES[@]}" + + # Ensure there are files to lint before running ESLint + if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then + echo "No matching files changed. Skipping ESLint." + exit 0 + fi + + # Run ESLint + # --no-warn-ignored: changed files under config-ignored paths + # (e.g. packages/data-schemas/misc/**) must not fail --max-warnings=0 + npx eslint --no-error-on-unmatched-pattern \ + --config eslint.config.mjs \ + --no-warn-ignored \ + --max-warnings=0 \ + -- "${CHANGED_FILES[@]}" + + # Run Prettier --check on the same set of changed files to catch + # formatting drift in PRs that bypassed the local pre-commit hook + # (e.g. GitHub UI edit-and-merge, `git commit --no-verify`). + - name: Run Prettier --check on changed files + id: prettier + if: always() && steps.paths.outputs.eslint == 'true' + continue-on-error: true + run: | + BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") + mapfile -d '' -t CHANGED_FILES < <( + git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | + grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true + ) + + if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then + echo "No matching files changed. Skipping Prettier." + exit 0 + fi + + echo "Files to check:" + printf '%s\n' "${CHANGED_FILES[@]}" + + # `prettier --check` exits non-zero if any file would be reformatted. + # Suggest the local fix in the failure message so contributors aren't + # left guessing how to resolve. + if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then + echo "" + echo "::error::Prettier formatting drift detected. Fix locally with:" + echo "::error:: npx prettier --write " + echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)." + exit 1 + fi + + # Verify import ordering on the same set of changed files. The script + # only sorts files under known source roots, so unrelated changed files + # (configs, etc.) are ignored. Matches the lint-staged pre-commit hook. + - name: Check import sorting on changed files + id: import_sort + if: always() && steps.paths.outputs.eslint == 'true' + continue-on-error: true + run: | + BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") + mapfile -d '' -t CHANGED_FILES < <( + git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | + grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true + ) + + if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then + echo "No matching files changed. Skipping import-sort check." + exit 0 + fi + + echo "Files to check:" + printf '%s\n' "${CHANGED_FILES[@]}" + + # `--check` lists offending files and exits non-zero without writing. + if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then + echo "" + echo "::error::Import order drift detected. Fix locally with:" + echo "::error:: npm run sort-imports" + echo "::error::For specific files:" + echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts" + echo "::error::To check without writing files:" + echo "::error:: npm run sort-imports:check" + echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)." + exit 1 + fi + + - name: Restore data-provider build cache + if: always() && steps.paths.outputs.config == 'true' + id: cache-data-provider + continue-on-error: true + uses: actions/cache@v5 + with: + path: packages/data-provider/dist + key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-provider + id: config_data_provider + if: always() && steps.paths.outputs.config == 'true' && steps.cache-data-provider.outputs.cache-hit != 'true' + continue-on-error: true + run: npm run build:data-provider + + - name: Restore data-schemas build cache + if: always() && steps.paths.outputs.config == 'true' + id: cache-data-schemas + continue-on-error: true + uses: actions/cache@v5 + with: + path: packages/data-schemas/dist + key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-schemas + id: config_data_schemas + if: always() && steps.paths.outputs.config == 'true' && steps.cache-data-schemas.outputs.cache-hit != 'true' + continue-on-error: true + run: npm run build:data-schemas + + - name: Restore api build cache + if: always() && steps.paths.outputs.config == 'true' + id: cache-api + continue-on-error: true + uses: actions/cache@v5 + with: + path: packages/api/dist + key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} + + - name: Build api + id: config_api + if: always() && steps.paths.outputs.config == 'true' && steps.cache-api.outputs.cache-hit != 'true' + continue-on-error: true + run: npm run build:api + + - name: Create empty auth.json file + id: config_auth + if: always() && steps.paths.outputs.config == 'true' + continue-on-error: true + run: | + mkdir -p api/data + echo '{}' > api/data/auth.json + + - name: Prepare .env.test file + id: config_env + if: always() && steps.paths.outputs.config == 'true' + continue-on-error: true + run: cp api/test/.env.test.example api/test/.env.test + + - name: Run config migration tests + id: config_tests + if: always() && steps.paths.outputs.config == 'true' + continue-on-error: true + run: npm run test:config + + - name: Find unused i18next keys + id: find_unused_i18n + if: always() && steps.paths.outputs.i18n == 'true' + continue-on-error: true + run: | + echo "🔍 Scanning for unused i18next keys..." + + # Define paths + I18N_FILE="client/src/locales/en/translation.json" + SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src") + + # Check if translation file exists + if [[ ! -f "$I18N_FILE" ]]; then + echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE" + exit 1 + fi + + # Extract all keys from the JSON file + KEYS=$(jq -r 'keys[]' "$I18N_FILE") + + # Track unused keys + UNUSED_KEYS=() + + # Check if each key is used in the source code + for KEY in $KEYS; do + FOUND=false + + # Special case for dynamically constructed special variable keys + if [[ "$KEY" == com_ui_special_var_* ]]; then + # Check if TSpecialVarLabel is used in the codebase + for DIR in "${SOURCE_DIRS[@]}"; do + if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then + FOUND=true + break + fi + done + + # Also check if the key is directly used somewhere + if [[ "$FOUND" == false ]]; then + for DIR in "${SOURCE_DIRS[@]}"; do + if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then + FOUND=true + break + fi + done + fi + # Special case for agent category keys that are dynamically used from database + elif [[ "$KEY" == com_agents_category_* ]]; then + # Check if agent category localization is being used + for DIR in "${SOURCE_DIRS[@]}"; do + # Check for dynamic category label/description usage + if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \ + # Check for the method that defines these keys + grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \ + # Check for direct usage in agentCategory.ts + grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \ + grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then + FOUND=true + break + fi + done + + # Also check if the key is directly used somewhere + if [[ "$FOUND" == false ]]; then + for DIR in "${SOURCE_DIRS[@]}"; do + if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then + FOUND=true + break + fi + done + fi + else + # Regular check for other keys + for DIR in "${SOURCE_DIRS[@]}"; do + if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then + FOUND=true + break + fi + done + fi + + if [[ "$FOUND" == false ]]; then + UNUSED_KEYS+=("$KEY") + fi + done + + # Output results + if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then + echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:" + echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV + for KEY in "${UNUSED_KEYS[@]}"; do + echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase." + done + else + echo "✅ No unused i18n keys detected!" + echo "unused_keys=[]" >> $GITHUB_ENV + fi + + - name: Fail workflow if unused keys found + id: i18n + if: > + always() && + steps.paths.outputs.i18n == 'true' && + (steps.find_unused_i18n.outcome == 'failure' || env.unused_keys != '[]') + continue-on-error: true + run: exit 1 + + - name: Install depcheck + id: install_depcheck + if: always() && steps.paths.outputs.unused_packages == 'true' + continue-on-error: true + run: npm install -g depcheck + + - name: Validate JSON files + id: validate_package_json + if: always() && steps.paths.outputs.unused_packages == 'true' + continue-on-error: true + run: | + for FILE in package.json client/package.json api/package.json packages/client/package.json; do + if [[ -f "$FILE" ]]; then + jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1) + fi + done + + - name: Extract Dependencies Used in Scripts + if: always() && steps.paths.outputs.unused_packages == 'true' + id: extract-used-scripts + continue-on-error: true + run: | + extract_deps_from_scripts() { + local package_file=$1 + if [[ -f "$package_file" ]]; then + jq -r '.scripts | to_entries[].value' "$package_file" | \ + grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt + else + touch used_scripts.txt + fi + } + + extract_deps_from_scripts "package.json" + mv used_scripts.txt root_used_deps.txt + + extract_deps_from_scripts "client/package.json" + mv used_scripts.txt client_used_deps.txt + + extract_deps_from_scripts "api/package.json" + mv used_scripts.txt api_used_deps.txt + + - name: Extract Dependencies Used in Source Code + if: always() && steps.paths.outputs.unused_packages == 'true' + id: extract-used-code + continue-on-error: true + run: | + extract_deps_from_code() { + local folder=$1 + local output_file=$2 + + # Initialize empty output file + > "$output_file" + + if [[ -d "$folder" ]]; then + # Extract require() statements (use explicit includes for portability) + grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \ + --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ + sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true + + # Extract ES6 imports - import x from 'module' + grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ + --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ + sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true + + # import 'module' (side-effect imports) + grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ + --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ + sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true + + # export { x } from 'module' or export * from 'module' + grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ + --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ + sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true + + # import type { x } from 'module' (TypeScript) + grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ + --include='*.ts' --include='*.tsx' 2>/dev/null | \ + sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true + + # Remove subpath imports but keep the base package + # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg' + # For regular packages: 'pkg/subpath' -> 'pkg' + # Scoped packages (must keep @scope/package, strip anything after) + sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true + # Non-scoped packages (keep package name, strip subpath) + sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true + + sort -u "$output_file" -o "$output_file" + fi + } + + extract_deps_from_code "." root_used_code.txt + extract_deps_from_code "client" client_used_code.txt + extract_deps_from_code "api" api_used_code.txt + + # Extract dependencies used by workspace packages + # These packages are used in the workspace but dependencies are provided by parent package.json + extract_deps_from_code "packages/client" packages_client_used_code.txt + extract_deps_from_code "packages/api" packages_api_used_code.txt + + - name: Get @librechat/client dependencies + if: always() && steps.paths.outputs.unused_packages == 'true' + id: get-librechat-client-deps + continue-on-error: true + run: | + if [[ -f "packages/client/package.json" ]]; then + # Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies) + DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "") + DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "") + PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "") + + # Combine all dependencies + echo "$DEPS" > librechat_client_deps.txt + echo "$DEV_DEPS" >> librechat_client_deps.txt + echo "$PEER_DEPS" >> librechat_client_deps.txt + + # Also include dependencies that are imported in packages/client + cat packages_client_used_code.txt >> librechat_client_deps.txt + + # Remove empty lines and sort + grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt + mv temp_deps.txt librechat_client_deps.txt + else + touch librechat_client_deps.txt + fi + + - name: Get @librechat/api dependencies + if: always() && steps.paths.outputs.unused_packages == 'true' + id: get-librechat-api-deps + continue-on-error: true + run: | + if [[ -f "packages/api/package.json" ]]; then + # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies) + DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "") + DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "") + PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "") + + # Combine all dependencies + echo "$DEPS" > librechat_api_deps.txt + echo "$DEV_DEPS" >> librechat_api_deps.txt + echo "$PEER_DEPS" >> librechat_api_deps.txt + + # Also include dependencies that are imported in packages/api + cat packages_api_used_code.txt >> librechat_api_deps.txt + + # Remove empty lines and sort + grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt + mv temp_deps.txt librechat_api_deps.txt + else + touch librechat_api_deps.txt + fi + + - name: Extract Workspace Dependencies + if: always() && steps.paths.outputs.unused_packages == 'true' + id: extract-workspace-deps + continue-on-error: true + run: | + # Function to get dependencies from a workspace package that are used by another package + get_workspace_package_deps() { + local package_json=$1 + local output_file=$2 + + # Get all workspace dependencies (starting with @librechat/) + if [[ -f "$package_json" ]]; then + local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "") + + # For each workspace dependency, get its dependencies + for dep in $workspace_deps; do + # Convert @librechat/api to packages/api + local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//') + local workspace_package_json="${workspace_path}/package.json" + + if [[ -f "$workspace_package_json" ]]; then + # Extract all dependencies from the workspace package + jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file" + # Also extract peerDependencies + jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file" + fi + done + fi + + if [[ -f "$output_file" ]]; then + sort -u "$output_file" -o "$output_file" + else + touch "$output_file" + fi + } + + # Get workspace dependencies for each package + get_workspace_package_deps "package.json" root_workspace_deps.txt + get_workspace_package_deps "client/package.json" client_workspace_deps.txt + get_workspace_package_deps "api/package.json" api_workspace_deps.txt + + - name: Run depcheck for root package.json + if: always() && steps.paths.outputs.unused_packages == 'true' + id: check-root + continue-on-error: true + run: | + if [[ -f "package.json" ]]; then + UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") + # Exclude dependencies used in scripts, code, and workspace packages + UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "") + echo "ROOT_UNUSED<> $GITHUB_ENV + echo "$UNUSED" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + fi + + - name: Run depcheck for client/package.json + if: always() && steps.paths.outputs.unused_packages == 'true' + id: check-client + continue-on-error: true + run: | + if [[ -f "client/package.json" ]]; then + chmod -R 755 client + cd client + UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") + # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports + UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "") + # Filter out false positives + UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "") + echo "CLIENT_UNUSED<> $GITHUB_ENV + echo "$UNUSED" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + cd .. + fi + + - name: Run depcheck for api/package.json + if: always() && steps.paths.outputs.unused_packages == 'true' + id: check-api + continue-on-error: true + run: | + if [[ -f "api/package.json" ]]; then + chmod -R 755 api + cd api + UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") + # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports + UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "") + echo "API_UNUSED<> $GITHUB_ENV + echo "$UNUSED" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + cd .. + fi + + - name: Fail workflow if unused dependencies found + id: unused_packages + if: > + always() && + steps.paths.outputs.unused_packages == 'true' && + (env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != '') + continue-on-error: true + run: exit 1 + + - name: Summarize static check failures + if: always() + env: + INSTALL_DEPENDENCIES_OUTCOME: ${{ steps.install_dependencies.outcome }} + ESLINT_OUTCOME: ${{ steps.eslint.outcome }} + PRETTIER_OUTCOME: ${{ steps.prettier.outcome }} + IMPORT_SORT_OUTCOME: ${{ steps.import_sort.outcome }} + CACHE_DATA_PROVIDER_OUTCOME: ${{ steps.cache-data-provider.outcome }} + CONFIG_DATA_PROVIDER_OUTCOME: ${{ steps.config_data_provider.outcome }} + CACHE_DATA_SCHEMAS_OUTCOME: ${{ steps.cache-data-schemas.outcome }} + CONFIG_DATA_SCHEMAS_OUTCOME: ${{ steps.config_data_schemas.outcome }} + CACHE_API_OUTCOME: ${{ steps.cache-api.outcome }} + CONFIG_API_OUTCOME: ${{ steps.config_api.outcome }} + CONFIG_AUTH_OUTCOME: ${{ steps.config_auth.outcome }} + CONFIG_ENV_OUTCOME: ${{ steps.config_env.outcome }} + CONFIG_TESTS_OUTCOME: ${{ steps.config_tests.outcome }} + FIND_I18N_OUTCOME: ${{ steps.find_unused_i18n.outcome }} + I18N_OUTCOME: ${{ steps.i18n.outcome }} + INSTALL_DEPCHECK_OUTCOME: ${{ steps.install_depcheck.outcome }} + VALIDATE_PACKAGE_JSON_OUTCOME: ${{ steps.validate_package_json.outcome }} + EXTRACT_USED_SCRIPTS_OUTCOME: ${{ steps.extract-used-scripts.outcome }} + EXTRACT_USED_CODE_OUTCOME: ${{ steps.extract-used-code.outcome }} + GET_CLIENT_DEPS_OUTCOME: ${{ steps.get-librechat-client-deps.outcome }} + GET_API_DEPS_OUTCOME: ${{ steps.get-librechat-api-deps.outcome }} + EXTRACT_WORKSPACE_DEPS_OUTCOME: ${{ steps.extract-workspace-deps.outcome }} + CHECK_ROOT_OUTCOME: ${{ steps.check-root.outcome }} + CHECK_CLIENT_OUTCOME: ${{ steps.check-client.outcome }} + CHECK_API_OUTCOME: ${{ steps.check-api.outcome }} + UNUSED_PACKAGES_OUTCOME: ${{ steps.unused_packages.outcome }} + run: | + failures=() + + record_failure() { + if [[ "$2" == "failure" ]]; then + failures+=("$1") + fi + } + + record_failure "Dependency installation" "$INSTALL_DEPENDENCIES_OUTCOME" + record_failure "ESLint" "$ESLINT_OUTCOME" + record_failure "Prettier" "$PRETTIER_OUTCOME" + record_failure "Import sorting" "$IMPORT_SORT_OUTCOME" + record_failure "Config data-provider cache" "$CACHE_DATA_PROVIDER_OUTCOME" + record_failure "Config data-provider build" "$CONFIG_DATA_PROVIDER_OUTCOME" + record_failure "Config data-schemas cache" "$CACHE_DATA_SCHEMAS_OUTCOME" + record_failure "Config data-schemas build" "$CONFIG_DATA_SCHEMAS_OUTCOME" + record_failure "Config API cache" "$CACHE_API_OUTCOME" + record_failure "Config API build" "$CONFIG_API_OUTCOME" + record_failure "Config auth preparation" "$CONFIG_AUTH_OUTCOME" + record_failure "Config environment preparation" "$CONFIG_ENV_OUTCOME" + record_failure "Config migration tests" "$CONFIG_TESTS_OUTCOME" + record_failure "Unused i18n scan" "$FIND_I18N_OUTCOME" + record_failure "Unused i18n keys" "$I18N_OUTCOME" + record_failure "depcheck installation" "$INSTALL_DEPCHECK_OUTCOME" + record_failure "Package JSON validation" "$VALIDATE_PACKAGE_JSON_OUTCOME" + record_failure "Package script dependency extraction" "$EXTRACT_USED_SCRIPTS_OUTCOME" + record_failure "Source dependency extraction" "$EXTRACT_USED_CODE_OUTCOME" + record_failure "Client dependency collection" "$GET_CLIENT_DEPS_OUTCOME" + record_failure "API dependency collection" "$GET_API_DEPS_OUTCOME" + record_failure "Workspace dependency extraction" "$EXTRACT_WORKSPACE_DEPS_OUTCOME" + record_failure "Root depcheck" "$CHECK_ROOT_OUTCOME" + record_failure "Client depcheck" "$CHECK_CLIENT_OUTCOME" + record_failure "API depcheck" "$CHECK_API_OUTCOME" + record_failure "Unused NPM packages" "$UNUSED_PACKAGES_OUTCOME" + + if [[ "$UNUSED_PACKAGES_OUTCOME" == "failure" ]]; then + [[ -n "$ROOT_UNUSED" ]] && printf 'Root unused dependencies:\n%s\n' "$ROOT_UNUSED" + [[ -n "$CLIENT_UNUSED" ]] && printf 'Client unused dependencies:\n%s\n' "$CLIENT_UNUSED" + [[ -n "$API_UNUSED" ]] && printf 'API unused dependencies:\n%s\n' "$API_UNUSED" + fi + + if [[ ${#failures[@]} -eq 0 ]]; then + echo "All affected static checks passed." + exit 0 + fi + + echo "::error::Static checks failed:" + printf ' - %s\n' "${failures[@]}" + exit 1 diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml deleted file mode 100644 index 8836f32246..0000000000 --- a/.github/workflows/unused-packages.yml +++ /dev/null @@ -1,283 +0,0 @@ -name: Detect Unused NPM Packages - -on: - pull_request: - paths: - - 'package.json' - - 'package-lock.json' - - 'client/**' - - 'api/**' - - 'packages/client/**' - - 'packages/api/**' - - '!**.md' - -jobs: - detect-unused-packages: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - - steps: - - uses: actions/checkout@v5 - - - name: Use Node.js 24.16.0 - uses: actions/setup-node@v5 - with: - node-version: '24.16.0' - cache: 'npm' - - - name: Install depcheck - run: npm install -g depcheck - - - name: Validate JSON files - run: | - for FILE in package.json client/package.json api/package.json packages/client/package.json; do - if [[ -f "$FILE" ]]; then - jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1) - fi - done - - - name: Extract Dependencies Used in Scripts - id: extract-used-scripts - run: | - extract_deps_from_scripts() { - local package_file=$1 - if [[ -f "$package_file" ]]; then - jq -r '.scripts | to_entries[].value' "$package_file" | \ - grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt - else - touch used_scripts.txt - fi - } - - extract_deps_from_scripts "package.json" - mv used_scripts.txt root_used_deps.txt - - extract_deps_from_scripts "client/package.json" - mv used_scripts.txt client_used_deps.txt - - extract_deps_from_scripts "api/package.json" - mv used_scripts.txt api_used_deps.txt - - - name: Extract Dependencies Used in Source Code - id: extract-used-code - run: | - extract_deps_from_code() { - local folder=$1 - local output_file=$2 - - # Initialize empty output file - > "$output_file" - - if [[ -d "$folder" ]]; then - # Extract require() statements (use explicit includes for portability) - grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \ - --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ - sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true - - # Extract ES6 imports - import x from 'module' - grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ - --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ - sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true - - # import 'module' (side-effect imports) - grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ - --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ - sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true - - # export { x } from 'module' or export * from 'module' - grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ - --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \ - sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true - - # import type { x } from 'module' (TypeScript) - grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \ - --include='*.ts' --include='*.tsx' 2>/dev/null | \ - sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true - - # Remove subpath imports but keep the base package - # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg' - # For regular packages: 'pkg/subpath' -> 'pkg' - # Scoped packages (must keep @scope/package, strip anything after) - sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true - # Non-scoped packages (keep package name, strip subpath) - sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true - - sort -u "$output_file" -o "$output_file" - fi - } - - extract_deps_from_code "." root_used_code.txt - extract_deps_from_code "client" client_used_code.txt - extract_deps_from_code "api" api_used_code.txt - - # Extract dependencies used by workspace packages - # These packages are used in the workspace but dependencies are provided by parent package.json - extract_deps_from_code "packages/client" packages_client_used_code.txt - extract_deps_from_code "packages/api" packages_api_used_code.txt - - - name: Get @librechat/client dependencies - id: get-librechat-client-deps - run: | - if [[ -f "packages/client/package.json" ]]; then - # Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies) - DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "") - DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "") - PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "") - - # Combine all dependencies - echo "$DEPS" > librechat_client_deps.txt - echo "$DEV_DEPS" >> librechat_client_deps.txt - echo "$PEER_DEPS" >> librechat_client_deps.txt - - # Also include dependencies that are imported in packages/client - cat packages_client_used_code.txt >> librechat_client_deps.txt - - # Remove empty lines and sort - grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt - mv temp_deps.txt librechat_client_deps.txt - else - touch librechat_client_deps.txt - fi - - - name: Get @librechat/api dependencies - id: get-librechat-api-deps - run: | - if [[ -f "packages/api/package.json" ]]; then - # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies) - DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "") - DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "") - PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "") - - # Combine all dependencies - echo "$DEPS" > librechat_api_deps.txt - echo "$DEV_DEPS" >> librechat_api_deps.txt - echo "$PEER_DEPS" >> librechat_api_deps.txt - - # Also include dependencies that are imported in packages/api - cat packages_api_used_code.txt >> librechat_api_deps.txt - - # Remove empty lines and sort - grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt - mv temp_deps.txt librechat_api_deps.txt - else - touch librechat_api_deps.txt - fi - - - name: Extract Workspace Dependencies - id: extract-workspace-deps - run: | - # Function to get dependencies from a workspace package that are used by another package - get_workspace_package_deps() { - local package_json=$1 - local output_file=$2 - - # Get all workspace dependencies (starting with @librechat/) - if [[ -f "$package_json" ]]; then - local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "") - - # For each workspace dependency, get its dependencies - for dep in $workspace_deps; do - # Convert @librechat/api to packages/api - local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//') - local workspace_package_json="${workspace_path}/package.json" - - if [[ -f "$workspace_package_json" ]]; then - # Extract all dependencies from the workspace package - jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file" - # Also extract peerDependencies - jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file" - fi - done - fi - - if [[ -f "$output_file" ]]; then - sort -u "$output_file" -o "$output_file" - else - touch "$output_file" - fi - } - - # Get workspace dependencies for each package - get_workspace_package_deps "package.json" root_workspace_deps.txt - get_workspace_package_deps "client/package.json" client_workspace_deps.txt - get_workspace_package_deps "api/package.json" api_workspace_deps.txt - - - name: Run depcheck for root package.json - id: check-root - run: | - if [[ -f "package.json" ]]; then - UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") - # Exclude dependencies used in scripts, code, and workspace packages - UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "") - echo "ROOT_UNUSED<> $GITHUB_ENV - echo "$UNUSED" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - fi - - - name: Run depcheck for client/package.json - id: check-client - run: | - if [[ -f "client/package.json" ]]; then - chmod -R 755 client - cd client - UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") - # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports - UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "") - # Filter out false positives - UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "") - echo "CLIENT_UNUSED<> $GITHUB_ENV - echo "$UNUSED" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - cd .. - fi - - - name: Run depcheck for api/package.json - id: check-api - run: | - if [[ -f "api/package.json" ]]; then - chmod -R 755 api - cd api - UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") - # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports - UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "") - echo "API_UNUSED<> $GITHUB_ENV - echo "$UNUSED" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - cd .. - fi - - - name: Post comment on PR if unused dependencies are found - if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != '' - run: | - PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") - - ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}') - CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}') - API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}') - - COMMENT_BODY=$(cat <