Skip to content

refactor(chatbot): modularize assistant engine and harden moderation … #109

refactor(chatbot): modularize assistant engine and harden moderation …

refactor(chatbot): modularize assistant engine and harden moderation … #109

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