refactor(chatbot): modularize assistant engine and harden moderation β¦ #109
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Continuous integration | |
| # If repo "Workflow permissions" is read-only, artifact uploads / PR comments are blocked (see workflows README). | |
| permissions: | |
| contents: read | |
| actions: write # upload/download Next.js build + QA / Lighthouse artifacts | |
| pull-requests: write | |
| issues: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| on: | |
| pull_request: | |
| branches: [main, master, develop] | |
| paths-ignore: | |
| - '**.md' | |
| - 'issue/**' | |
| push: | |
| branches: [main, master, develop] | |
| paths-ignore: | |
| - '**.md' | |
| - 'issue/**' | |
| merge_group: | |
| types: [checks_requested] | |
| workflow_dispatch: | |
| env: | |
| NODE_VERSION: '24' | |
| NEXT_PUBLIC_SITE_URL: https://www.sortvision.com | |
| # Use Node 24 for JS-based Actions (checkout, cache, artifact, etc.) β silences Node 20 deprecation warnings. | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| defaults: | |
| run: | |
| working-directory: ./SortVision | |
| jobs: | |
| # One checkout + install for both checks (faster than separate Formatting + Lint jobs). | |
| format-lint: | |
| name: Format and lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v6.0.2 (node24 runtime; fewer forced-runtime warnings) | |
| with: | |
| fetch-depth: 1 | |
| - name: Setup SortVision | |
| uses: ./.github/actions/setup-sortvision | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Prettier (check) | |
| run: pnpm run format:check | |
| - name: ESLint | |
| run: pnpm run lint | |
| - name: Unit tests (node:test) | |
| run: pnpm run test:unit | |
| # Runs in parallel with format-lint so wall time is max(static, build), not sum (update branch rules if you required separate "Formatting" / "Lint" checks). | |
| build: | |
| name: Build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v6.0.2 (node24 runtime; fewer forced-runtime warnings) | |
| with: | |
| fetch-depth: 1 | |
| - name: Cache Next.js build | |
| uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 | |
| with: | |
| path: SortVision/.next/cache | |
| key: nextjs-${{ runner.os }}-${{ hashFiles('SortVision/pnpm-lock.yaml', 'SortVision/package.json') }} | |
| restore-keys: nextjs-${{ runner.os }}- | |
| - name: Setup SortVision | |
| uses: ./.github/actions/setup-sortvision | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Build application | |
| run: pnpm run build | |
| env: | |
| NODE_ENV: production | |
| - name: Analyze bundle size | |
| run: | | |
| echo "=== Bundle Size Analysis ===" | |
| du -sh .next/static/chunks/* 2>/dev/null | sort -h | tail -10 || echo "No chunks found" | |
| TOTAL_SIZE=$(du -sb .next 2>/dev/null | awk '{print $1}') | |
| TOTAL_SIZE_MB=$((TOTAL_SIZE / 1024 / 1024)) | |
| echo "Total build size: ${TOTAL_SIZE_MB}MB" | |
| if [ "$TOTAL_SIZE_MB" -gt 50 ]; then | |
| echo "WARNING: Build size exceeds 50MB" | |
| fi | |
| - name: Generate and validate sitemap | |
| run: | | |
| pnpm run generate-sitemap | |
| if [ ! -f public/sitemap.xml ]; then | |
| echo "ERROR: Sitemap generation failed" | |
| exit 1 | |
| fi | |
| echo "Sitemap generated: $(wc -l < public/sitemap.xml) lines" | |
| # Tarball avoids upload-artifact v4 directory flattening (files must land under SortVision/.next for `next start`). | |
| - name: Pack Next.js build for CI artifact | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| set -euo pipefail | |
| test -f SortVision/.next/BUILD_ID | |
| tar czf "${RUNNER_TEMP}/next-build.tar.gz" -C SortVision .next | |
| - name: Upload Next.js build output | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: next-build | |
| path: ${{ runner.temp }}/next-build.tar.gz | |
| if-no-files-found: error | |
| retention-days: 7 | |
| test: | |
| name: Test | |
| needs: [build] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 35 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v6.0.2 (node24 runtime; fewer forced-runtime warnings) | |
| with: | |
| # Full history on PRs if needed for future git steps (push stays shallow). | |
| fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} | |
| - name: Setup SortVision | |
| uses: ./.github/actions/setup-sortvision | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Restore Next.js build from artifact | |
| uses: ./.github/actions/restore-next-build | |
| - name: Start production server | |
| run: | | |
| pnpm run start & | |
| echo $! > .server-pid | |
| pnpm exec wait-on http://localhost:3000 --timeout 120000 | |
| sleep 2 | |
| - name: Run complete test suite (core + extended sitemap, links, security) | |
| id: qa_tests | |
| run: pnpm run test:ci | |
| env: | |
| CI: 'true' | |
| GITHUB_ACTIONS: 'true' | |
| QA_COMMENT_DIR: ${{ github.event.pull_request && format('{0}/SortVision/.qa-pr-comment', github.workspace) || '' }} | |
| PR_NUMBER: ${{ github.event.pull_request.number || '' }} | |
| QA_METRICS_FILE: ${{ (github.event_name == 'push' || github.event_name == 'pull_request') && format('{0}/SortVision/qa-metrics.json', github.workspace) || '' }} | |
| - name: Prepare QA PR comment artifact | |
| if: github.event_name == 'pull_request' && always() | |
| run: | | |
| DIR="${GITHUB_WORKSPACE}/SortVision/.qa-pr-comment" | |
| mkdir -p "$DIR" | |
| if [ ! -s "$DIR/comment.md" ]; then | |
| echo "No QA report on disk; writing minimal placeholder for PR comment workflow." | |
| { | |
| echo '<!-- sortvision-qa-report -->' | |
| echo '### QA suite report' | |
| echo '' | |
| echo '**Result:** No report file was written (early failure or env issue). See **Test** job logs.' | |
| echo '' | |
| echo "[View run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" | |
| } > "$DIR/comment.md" | |
| fi | |
| if [ ! -s "$DIR/pr_number.txt" ]; then | |
| echo '${{ github.event.pull_request.number }}' > "$DIR/pr_number.txt" | |
| fi | |
| - name: Download base branch QA metrics (PR β compare to last green CI on base) | |
| if: github.event_name == 'pull_request' && always() | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| set +e | |
| BASE_REF="${{ github.event.pull_request.base.ref }}" | |
| PREV_RUN_ID=$(gh run list --repo "${GITHUB_REPOSITORY}" \ | |
| --workflow="continuous-integration.yml" \ | |
| --branch="${BASE_REF}" \ | |
| --status=success \ | |
| --json databaseId \ | |
| --limit 25 | jq -r --argjson rid "${GITHUB_RUN_ID}" '[.[] | select(.databaseId != $rid) | .databaseId] | first // empty') | |
| set -e | |
| if [ -z "${PREV_RUN_ID}" ]; then | |
| echo "No successful CI run on ${BASE_REF} with downloadable metrics yet." | |
| exit 0 | |
| fi | |
| mkdir -p "${GITHUB_WORKSPACE}/qa-base-dl" | |
| if gh run download "${PREV_RUN_ID}" -n qa-metrics -D "${GITHUB_WORKSPACE}/qa-base-dl"; then | |
| FOUND=$(find "${GITHUB_WORKSPACE}/qa-base-dl" -name qa-metrics.json -print -quit) | |
| if [ -n "${FOUND}" ]; then | |
| cp "${FOUND}" "${GITHUB_WORKSPACE}/SortVision/qa-metrics-base.json" | |
| echo "Base metrics from workflow run ${PREV_RUN_ID} (${BASE_REF})" | |
| fi | |
| else | |
| echo "Could not download qa-metrics from run ${PREV_RUN_ID}." | |
| fi | |
| - name: Append QA vs base branch to PR comment | |
| if: github.event_name == 'pull_request' && always() | |
| working-directory: ${{ github.workspace }} | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| QA_COMMENT_FILE: ${{ github.workspace }}/SortVision/.qa-pr-comment/comment.md | |
| QA_METRICS_CURRENT: ${{ github.workspace }}/SortVision/qa-metrics.json | |
| QA_METRICS_BASE: ${{ github.workspace }}/SortVision/qa-metrics-base.json | |
| run: node SortVision/scripts/ci-qa-pr-vs-base.cjs | |
| - name: Upload QA metrics artifact (for PR / push baselines) | |
| if: (github.event_name == 'push' || github.event_name == 'pull_request') && always() | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: qa-metrics | |
| path: SortVision/qa-metrics.json | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| - name: Find previous QA comment | |
| id: find_qa_comment | |
| if: github.event_name == 'pull_request' && always() | |
| uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 | |
| with: | |
| issue-number: ${{ github.event.pull_request.number }} | |
| body-includes: '<!-- sortvision-qa-report -->' | |
| - name: Post QA report to pull request | |
| if: github.event_name == 'pull_request' && always() | |
| uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 | |
| with: | |
| comment-id: ${{ steps.find_qa_comment.outputs.comment-id }} | |
| issue-number: ${{ github.event.pull_request.number }} | |
| body-path: SortVision/.qa-pr-comment/comment.md | |
| edit-mode: replace | |
| - name: Upload QA PR comment artifact | |
| if: github.event_name == 'pull_request' && always() | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: qa-pr-comment | |
| path: SortVision/.qa-pr-comment/ | |
| include-hidden-files: true | |
| # Mobile + desktop run in parallel (matrix); combined table + gate in lighthouse-summary. | |
| lighthouse: | |
| name: Lighthouse (${{ matrix.variant }}) | |
| needs: [build] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - variant: mobile | |
| config: lighthouserc.json | |
| artifact: lighthouse-results-mobile | |
| - variant: desktop | |
| config: lighthouserc.desktop.json | |
| artifact: lighthouse-results-desktop | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v6.0.2 (node24 runtime; fewer forced-runtime warnings) | |
| with: | |
| fetch-depth: 1 | |
| - name: Setup SortVision | |
| uses: ./.github/actions/setup-sortvision | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Restore Next.js build from artifact | |
| uses: ./.github/actions/restore-next-build | |
| - name: Start production server | |
| run: | | |
| pnpm run start & | |
| echo $! > .server-pid | |
| pnpm exec wait-on http://localhost:3000 --timeout 120000 | |
| sleep 2 | |
| - name: Lighthouse (${{ matrix.variant }}) | |
| uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12 | |
| with: | |
| configPath: ./SortVision/${{ matrix.config }} | |
| runs: 1 | |
| urls: | | |
| http://localhost:3000 | |
| http://localhost:3000/algorithms/config/bubble | |
| http://localhost:3000/es | |
| http://localhost:3000/contributions/overview | |
| uploadArtifacts: true | |
| artifactName: ${{ matrix.artifact }} | |
| temporaryPublicStorage: true | |
| # treosh uploads GitHub artifacts before `lhci upload --target=filesystem` writes manifest.json, so the zip | |
| # from uploadArtifacts does not include it. Upload manifest after the action for lighthouse-summary / PR comment. | |
| - name: Upload Lighthouse manifest (for summary) | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: lighthouse-manifest-${{ matrix.variant }} | |
| path: .lighthouseci/manifest.json | |
| if-no-files-found: error | |
| retention-days: 7 | |
| lighthouse-summary: | |
| name: Lighthouse summary | |
| needs: [lighthouse] | |
| # always(): run after matrix success or failure (still need manifest download + gate); skip if matrix never ran. | |
| if: ${{ always() && !cancelled() && needs.lighthouse.result != 'skipped' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Checkout repository (summary script only) | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v6.0.2 (node24 runtime; fewer forced-runtime warnings) | |
| with: | |
| fetch-depth: 1 | |
| sparse-checkout: | | |
| SortVision/scripts/lighthouse-ci-summary.cjs | |
| sparse-checkout-cone-mode: false | |
| - name: Download base branch Lighthouse manifests (PR β vs last green CI on base) | |
| if: github.event_name == 'pull_request' && always() | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| set +e | |
| BASE_REF="${{ github.event.pull_request.base.ref }}" | |
| PREV_RUN_ID=$(gh run list --repo "${GITHUB_REPOSITORY}" \ | |
| --workflow="continuous-integration.yml" \ | |
| --branch="${BASE_REF}" \ | |
| --status=success \ | |
| --json databaseId \ | |
| --limit 25 | jq -r --argjson rid "${GITHUB_RUN_ID}" '[.[] | select(.databaseId != $rid) | .databaseId] | first // empty') | |
| set -e | |
| if [ -z "${PREV_RUN_ID}" ]; then | |
| echo "No previous successful CI on ${BASE_REF} for Lighthouse baseline." | |
| exit 0 | |
| fi | |
| DL=$(mktemp -d) | |
| for name in lighthouse-manifest-mobile lighthouse-manifest-desktop; do | |
| if gh run download "${PREV_RUN_ID}" -n "${name}" -D "${DL}/${name}"; then | |
| FOUND=$(find "${DL}/${name}" -name manifest.json -print -quit) | |
| variant="${name##lighthouse-manifest-}" | |
| if [ -n "${FOUND}" ]; then | |
| mkdir -p "${GITHUB_WORKSPACE}/SortVision/lighthouse-${variant}-base" | |
| cp "${FOUND}" "${GITHUB_WORKSPACE}/SortVision/lighthouse-${variant}-base/manifest.json" | |
| echo "Loaded ${name} from run ${PREV_RUN_ID}" | |
| fi | |
| fi | |
| done | |
| - name: Download Lighthouse manifests (mobile) | |
| continue-on-error: true | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: lighthouse-manifest-mobile | |
| path: SortVision/lighthouse-mobile | |
| - name: Download Lighthouse manifests (desktop) | |
| continue-on-error: true | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: lighthouse-manifest-desktop | |
| path: SortVision/lighthouse-desktop | |
| - name: Print Lighthouse scores (job summary + PR comment file) | |
| working-directory: SortVision | |
| env: | |
| MANIFEST_MOBILE_PATH: ${{ github.workspace }}/SortVision/lighthouse-mobile/manifest.json | |
| MANIFEST_DESKTOP_PATH: ${{ github.workspace }}/SortVision/lighthouse-desktop/manifest.json | |
| MANIFEST_MOBILE_BASE_PATH: ${{ github.workspace }}/SortVision/lighthouse-mobile-base/manifest.json | |
| MANIFEST_DESKTOP_BASE_PATH: ${{ github.workspace }}/SortVision/lighthouse-desktop-base/manifest.json | |
| LIGHTHOUSE_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || '' }} | |
| LIGHTHOUSE_COMMENT_DIR: ${{ github.event.pull_request && format('{0}/SortVision/.lighthouse-pr-comment', github.workspace) || '' }} | |
| run: node scripts/lighthouse-ci-summary.cjs | |
| - name: Prepare Lighthouse PR comment (fallback) | |
| if: github.event_name == 'pull_request' && always() | |
| run: | | |
| DIR="${GITHUB_WORKSPACE}/SortVision/.lighthouse-pr-comment" | |
| mkdir -p "$DIR" | |
| if [ ! -s "$DIR/comment.md" ]; then | |
| { | |
| echo '<!-- sortvision-lighthouse-report -->' | |
| echo '### Lighthouse (CI)' | |
| echo '' | |
| echo '**Result:** Score table was not written (early failure). See **Lighthouse summary** job logs.' | |
| echo '' | |
| echo "[View run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" | |
| } > "$DIR/comment.md" | |
| fi | |
| - name: Find previous Lighthouse comment | |
| id: find_lighthouse_comment | |
| if: github.event_name == 'pull_request' && always() | |
| uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 | |
| with: | |
| issue-number: ${{ github.event.pull_request.number }} | |
| body-includes: '<!-- sortvision-lighthouse-report -->' | |
| - name: Post Lighthouse report to pull request | |
| if: github.event_name == 'pull_request' && always() | |
| uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 | |
| with: | |
| comment-id: ${{ steps.find_lighthouse_comment.outputs.comment-id }} | |
| issue-number: ${{ github.event.pull_request.number }} | |
| body-path: SortVision/.lighthouse-pr-comment/comment.md | |
| edit-mode: replace | |
| - name: Require Lighthouse audits to pass | |
| if: always() | |
| run: | | |
| echo "Lighthouse matrix: ${{ needs.lighthouse.result }}" | |
| if [ "${{ needs.lighthouse.result }}" != "success" ]; then | |
| echo "::error::One or more Lighthouse runs failed (see Lighthouse (mobile) / (desktop) logs and job summary)." | |
| exit 1 | |
| fi | |
| production-validation: | |
| name: Production validation | |
| runs-on: ubuntu-latest | |
| if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' | |
| needs: [test, lighthouse, lighthouse-summary] | |
| timeout-minutes: 10 | |
| defaults: | |
| run: | |
| working-directory: ./SortVision | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v6.0.2 (node24 runtime; fewer forced-runtime warnings) | |
| with: | |
| fetch-depth: 1 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Test production site | |
| run: node tests/quality-assurance.mjs --production | |
| - name: Validate production performance | |
| run: | | |
| echo "=== Production Site Health Check ===" | |
| RESPONSE_TIME=$(curl -o /dev/null -s -w '%{time_total}' https://www.sortvision.com) | |
| HTTP_CODE=$(curl -o /dev/null -s -w '%{http_code}' https://www.sortvision.com) | |
| echo "HTTP Status: $HTTP_CODE" | |
| echo "Response Time: ${RESPONSE_TIME}s" | |
| if [ "$HTTP_CODE" != "200" ]; then | |
| echo "ERROR: Production site returned $HTTP_CODE" | |
| exit 1 | |
| fi | |
| RESPONSE_MS=$(echo "$RESPONSE_TIME * 1000" | bc) | |
| if [ "$(echo "$RESPONSE_MS > 3000" | bc)" -eq 1 ]; then | |
| echo "WARNING: Response time exceeds 3 seconds" | |
| fi | |
| - name: Generate production report | |
| if: always() | |
| run: | | |
| echo "## Production Validation Results" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Production site: https://www.sortvision.com" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Check | Result |" >> $GITHUB_STEP_SUMMARY | |
| echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Site Accessibility | Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Production Tests | Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Response Time | Under 3s |" >> $GITHUB_STEP_SUMMARY |