LibreChat/.github/workflows/static-checks.yml
Danny Avila dfd4d9dd81
🧩 ci: Close Workflow Path-Filter Gaps (#14728)
* 🧩 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
2026-08-10 17:26:50 -04:00

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