mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 19:41:32 +00:00
* 🧩 ci: Close Workflow Path-Filter Gaps Six trigger-filter gaps found by reading all workflows against the live dependency graph (AI-1755, codegraph FINDINGS §6l): - backend-review/frontend-review: root package.json/package-lock.json now trigger unit tests — a lockfile-only dependency bump previously ran zero backend or frontend unit tests while every test job installs from it - agents-integration-tests: widen to the three package src trees it builds and imports (was only src/agents/**) - cache-integration-tests: same shape — verified live that packages/api/src/flow/manager.ts (imported by mcp/oauth) matched neither integration filter - docker-smoke: plain Dockerfile had no PR-time validation despite shipping via dev-images/tag-images; new node-image-smoke job builds it, gated by paths-filter to Dockerfile/.dockerignore changes - dev-images/dev-branch-images: add config/**, skill/**, .dockerignore — the single-stage image COPYs the full build context - static-checks: eslint.config.mjs now re-triggers the lint job (gap carried over from eslint-ci.yml in the #14716 consolidation) - delete generate_embeddings.yml: fired on docs/**, which no longer exists, and its docs-root-path pointed at the same missing directory * 🧩 ci: Address Codex Review Findings - Build caches: all 26 build-* keys across 8 workflows now lead with root package.json + package-lock.json so manifest-only bumps cannot restore stale dists (data-provider embeds the root version); unifies the split key families (playwright already hashed the lockfile) - static-checks: config changes now gate on the ESLint config loading and applying to representative files, plus a report-only full-tree sweep (70 pre-existing errors at dev HEAD block a hard gate for now) - docker-smoke: the workflow file itself triggers the plain-Dockerfile build so job edits are validated - dev-images/dev-branch-images: re-include skill/**/*.md after !**.md so shipped deployment-skill Markdown rebuilds images * 🧩 ci: Gate Config Lint Sweep on Regression vs Base Config Second-round codex finding: the report-only sweep swallowed config-wide breakage in scoped blocks the representative files don't exercise. The sweep now lints the same tree under the PR's config and the base ref's config and fails only when the PR's config produces more diagnostics for some (file, rule) pair — pre-existing debt never fails the gate, and fixes are never penalized. Base-config unavailability degrades to the load gate with a notice. Outcome surfaced in the failure summary. * 🧩 ci: Harden Config Lint Gate per External Review - Coverage direction: fail when the PR config stops linting files the base config covered (set difference on linted files) — a mis-scoped ignores previously only removed diagnostics and passed both gates - Severity-aware fingerprints: (file, rule, severity) so warn->error escalations gate on a clean tree for that rule; downgrades still free - Hard-fail when the base commit is missing so a future shallow-checkout change cannot silently disable the gate; annotate fetch-depth: 0 - Tab-separated fingerprint keys (space-in-path proof), --config on both sweeps, EXIT trap for the base config copy, comment on why it must live at the repo root (flat-config pattern base paths) - Narrow skill md re-include with !skill/README.md: top-level README is documentation-only; runtime skill Markdown still rebuilds images
795 lines
36 KiB
YAML
795 lines
36 KiB
YAML
name: Static Checks
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- 'api/**'
|
|
- 'client/**'
|
|
- 'config/**'
|
|
- 'packages/**'
|
|
- 'package.json'
|
|
- 'package-lock.json'
|
|
- 'eslint.config.mjs'
|
|
- '.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:
|
|
# Full history is load-bearing: changed-file steps diff against the
|
|
# PR base and the ESLint regression gate reads the base ref's
|
|
# config via git show — a shallow checkout breaks both.
|
|
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/**'
|
|
- 'eslint.config.mjs'
|
|
- '.github/workflows/static-checks.yml'
|
|
- '!**.md'
|
|
eslint_config:
|
|
- 'eslint.config.mjs'
|
|
- '.github/workflows/static-checks.yml'
|
|
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 <files>"
|
|
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
|
|
|
|
# The changed-file lint above never loads a changed root config: a
|
|
# config-only PR matches no lintable files, so even a malformed
|
|
# eslint.config.mjs would pass. When the config changes, gate on it
|
|
# loading and applying cleanly to representative sources, then run the
|
|
# full-tree regression gate below.
|
|
# Directory args, not `npm run lint`: the root brace-expansion@^5
|
|
# override breaks minimatch@3's brace expansion, so that script's
|
|
# braced glob crashes on a clean install; dir args never brace-expand.
|
|
- name: Validate ESLint config on config changes
|
|
id: eslint_config
|
|
if: always() && steps.paths.outputs.eslint_config == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
npx eslint --config eslint.config.mjs \
|
|
api/server/index.js client/src/main.jsx packages/api/src/index.ts
|
|
|
|
# Full-tree sweep that gates on regression, not cleanliness: the tree
|
|
# carries a pre-existing lint backlog (70 errors at time of wiring), so
|
|
# requiring a clean sweep would fail config PRs on unrelated debt.
|
|
# Instead, lint the same tree under the PR's config and under the base
|
|
# ref's config and fail when the PR's config (a) stops linting files
|
|
# the base config covered — the signature of a mis-scoped ignores — or
|
|
# (b) produces more diagnostics for some (file, rule, severity) triple.
|
|
# On an identical tree, any delta is attributable to the config change
|
|
# alone. Severity is part of the key so a warn->error escalation must
|
|
# land with the tree clean for that rule; downgrades and fixes are
|
|
# never penalized.
|
|
- name: ESLint full-sweep regression gate on config changes
|
|
id: eslint_sweep
|
|
if: always() && steps.paths.outputs.eslint_config == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
npx eslint --config eslint.config.mjs api client packages -f json -o "$RUNNER_TEMP/eslint-head.json" || true
|
|
if [ ! -s "$RUNNER_TEMP/eslint-head.json" ]; then
|
|
echo "::error title=ESLint sweep::Head-config sweep produced no report — ESLint likely crashed under the new config."
|
|
exit 1
|
|
fi
|
|
|
|
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
|
if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
|
echo "::error title=ESLint sweep::Base commit is unavailable — this gate requires the checkout above to keep fetch-depth: 0."
|
|
exit 1
|
|
fi
|
|
# The base config is written to the repo root, not $RUNNER_TEMP:
|
|
# flat-config files/ignores patterns and plugin imports resolve
|
|
# relative to the config's own directory, so a temp-dir copy would
|
|
# scope to nothing and the comparison would pass vacuously.
|
|
trap 'rm -f eslint.config.base.mjs' EXIT
|
|
if ! git show "$BASE_SHA:eslint.config.mjs" > eslint.config.base.mjs 2>/dev/null; then
|
|
echo "::notice title=ESLint sweep::No eslint.config.mjs at base ref; skipping regression comparison."
|
|
exit 0
|
|
fi
|
|
npx eslint --config eslint.config.base.mjs api client packages -f json -o "$RUNNER_TEMP/eslint-base.json" || true
|
|
if [ ! -s "$RUNNER_TEMP/eslint-base.json" ]; then
|
|
echo "::notice title=ESLint sweep::Base config cannot run against this tree; skipping regression comparison."
|
|
exit 0
|
|
fi
|
|
|
|
jq -r '.[].filePath' "$RUNNER_TEMP/eslint-head.json" | sort > "$RUNNER_TEMP/head.files"
|
|
jq -r '.[].filePath' "$RUNNER_TEMP/eslint-base.json" | sort > "$RUNNER_TEMP/base.files"
|
|
LOST=$(comm -23 "$RUNNER_TEMP/base.files" "$RUNNER_TEMP/head.files")
|
|
if [ -n "$LOST" ]; then
|
|
LOST_COUNT=$(printf '%s\n' "$LOST" | wc -l)
|
|
echo "::error title=ESLint coverage regression::The config change stops linting $LOST_COUNT file(s) the base config covered (showing up to 20):"
|
|
printf '%s\n' "$LOST" | head -20
|
|
exit 1
|
|
fi
|
|
|
|
fingerprints() {
|
|
jq -r '.[] | .filePath as $f | .messages[] | "\($f)\t\(.ruleId // "parse-error")\t\(.severity)"' "$1" |
|
|
sort | uniq -c | sed -E 's/^ *([0-9]+) /\1\t/'
|
|
}
|
|
fingerprints "$RUNNER_TEMP/eslint-head.json" > "$RUNNER_TEMP/head.fp"
|
|
fingerprints "$RUNNER_TEMP/eslint-base.json" > "$RUNNER_TEMP/base.fp"
|
|
|
|
REGRESSIONS=$(awk -F'\t' '
|
|
NR==FNR { base[$2 FS $3 FS $4] = $1; next }
|
|
{
|
|
if ($1 > base[$2 FS $3 FS $4] + 0) {
|
|
sev = ($4 == 2) ? "error" : "warn"
|
|
printf "%s %s (%s): %d -> %d\n", $2, $3, sev, base[$2 FS $3 FS $4] + 0, $1
|
|
}
|
|
}
|
|
' "$RUNNER_TEMP/base.fp" "$RUNNER_TEMP/head.fp")
|
|
|
|
if [ -n "$REGRESSIONS" ]; then
|
|
echo "::error title=ESLint config regression::The config change introduces new diagnostics (file rule (severity): base -> head):"
|
|
echo "$REGRESSIONS"
|
|
exit 1
|
|
fi
|
|
echo "No coverage loss and no new diagnostics versus the base config."
|
|
|
|
- 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('package.json', 'package-lock.json', '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('package.json', 'package-lock.json', '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('package.json', 'package-lock.json', '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<<EOF" >> $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<<EOF" >> $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<<EOF" >> $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 }}
|
|
ESLINT_CONFIG_OUTCOME: ${{ steps.eslint_config.outcome }}
|
|
ESLINT_SWEEP_OUTCOME: ${{ steps.eslint_sweep.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 "ESLint config validation" "$ESLINT_CONFIG_OUTCOME"
|
|
record_failure "ESLint config regression sweep" "$ESLINT_SWEEP_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
|