Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ Workflows are split by responsibility. **Node.js 24** is used in CI to match [`S

| Job | Purpose |
|-----|---------|
| **Formatting** | Prettier (`pnpm run format:check`) |
| **Lint** | ESLint |
| **Format and lint** | Prettier (`pnpm run format:check`) then ESLint (one setup; runs **in parallel** with **Build**). |
| **Build** | Single `pnpm run build`; uploads `SortVision/.next` as artifact `next-build` (no duplicate builds downstream). |
| **Test** | After **Build**: `pnpm install` + restore `next-build` tarball, **`next start`**, `pnpm test`, PR QA comment + `qa-pr-comment` artifact. Runs **in parallel** with **Lighthouse**. |
| **Lighthouse** | After **Build**: install + download `next-build`, **`next start`**, mobile + desktop Lighthouse ([`lighthouserc.json`](../../SortVision/lighthouserc.json), [`lighthouserc.desktop.json`](../../SortVision/lighthouserc.desktop.json)); job summary via [`lighthouse-ci-summary.cjs`](../../SortVision/scripts/lighthouse-ci-summary.cjs). |
| **Production validation** | On `main` / `master` only, after **Test** and **Lighthouse**: production smoke tests and HTTP checks |
| **Lighthouse** | After **Build**: install + download `next-build`, **`next start`**, mobile + desktop Lighthouse ([`lighthouserc.json`](../../SortVision/lighthouserc.json), [`lighthouserc.desktop.json`](../../SortVision/lighthouserc.desktop.json)); uploads `lighthouse-manifest-{mobile,desktop}` (manifest after the treosh action) for the summary job. |
| **Lighthouse summary** | Merges mobile/desktop manifests, **job summary** + **PR comment** (same pattern as QA), gates Lighthouse. |
| **Production validation** | On `main` / `master` only, after **Test** and **Lighthouse** (+ summary): production smoke tests and HTTP checks |

Shared setup: [`setup-sortvision`](../actions/setup-sortvision/action.yml) (pnpm, Node, `pnpm install`). Consumer jobs use [`restore-next-build`](../actions/restore-next-build/action.yml) after **Build** to unpack `next-build.tar.gz` into `SortVision/.next`.

Expand Down
139 changes: 94 additions & 45 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ defaults:
working-directory: ./SortVision

jobs:
formatting:
name: Formatting
# 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:
Expand All @@ -53,26 +54,12 @@ jobs:
- name: Prettier (check)
run: pnpm run format:check

lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (node24 runtime; fewer forced-runtime warnings)

- name: Setup SortVision
uses: ./.github/actions/setup-sortvision
with:
node-version: ${{ env.NODE_VERSION }}

- name: ESLint
run: pnpm run lint

# Single `next build`; consumers run in parallel with `next start` + tests or Lighthouse.
# 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
needs: [formatting, lint]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
Expand Down Expand Up @@ -190,11 +177,22 @@ jobs:
path: SortVision/.qa-pr-comment/
include-hidden-files: true

# Mobile + desktop run in parallel (matrix); combined table + gate in lighthouse-summary.
lighthouse:
name: 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (node24 runtime; fewer forced-runtime warnings)
Expand All @@ -214,59 +212,110 @@ jobs:
npx wait-on http://localhost:3000 --timeout 120000
sleep 2

- name: Lighthouse (mobile)
id: lighthouse-mobile
continue-on-error: true
- name: Lighthouse (${{ matrix.variant }})
uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12
with:
configPath: ./SortVision/lighthouserc.json
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

- name: Lighthouse (desktop)
id: lighthouse-desktop
continue-on-error: true
uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12
# 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@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
configPath: ./SortVision/lighthouserc.desktop.json
runs: 1
urls: |
http://localhost:3000
http://localhost:3000/algorithms/config/bubble
http://localhost:3000/es
http://localhost:3000/contributions/overview
uploadArtifacts: true
temporaryPublicStorage: true
name: lighthouse-manifest-${{ matrix.variant }}
path: .lighthouseci/manifest.json
if-no-files-found: error
retention-days: 7

- name: Print Lighthouse scores (job summary)
if: always()
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
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (node24 runtime; fewer forced-runtime warnings)

- name: Download Lighthouse manifests (mobile)
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: lighthouse-manifest-mobile
path: SortVision/lighthouse-mobile
if-no-files-found: ignore

- name: Download Lighthouse manifests (desktop)
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: lighthouse-manifest-desktop
path: SortVision/lighthouse-desktop
if-no-files-found: ignore

- name: Print Lighthouse scores (job summary + PR comment file)
working-directory: SortVision
env:
MANIFEST_MOBILE: ${{ steps.lighthouse-mobile.outputs.manifest }}
MANIFEST_DESKTOP: ${{ steps.lighthouse-desktop.outputs.manifest }}
MANIFEST_MOBILE_PATH: ${{ github.workspace }}/SortVision/lighthouse-mobile/manifest.json
MANIFEST_DESKTOP_PATH: ${{ github.workspace }}/SortVision/lighthouse-desktop/manifest.json
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 (mobile): ${{ steps.lighthouse-mobile.outcome }}"
echo "Lighthouse (desktop): ${{ steps.lighthouse-desktop.outcome }}"
if [ "${{ steps.lighthouse-mobile.outcome }}" != "success" ] || \
[ "${{ steps.lighthouse-desktop.outcome }}" != "success" ]; then
echo "::error::One or more Lighthouse runs failed (see steps above and job summary)."
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]
needs: [test, lighthouse, lighthouse-summary]
timeout-minutes: 10
defaults:
run:
Expand Down
8 changes: 0 additions & 8 deletions SortVision/lighthouserc.desktop.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@
"settings": {
"preset": "desktop"
}
},
"assert": {
"assertions": {
"categories:accessibility": ["warn", { "minScore": 0.85 }],
"categories:seo": ["warn", { "minScore": 0.85 }],
"categories:best-practices": ["warn", { "minScore": 0.8 }],
"categories:performance": ["warn", { "minScore": 0.75 }]
}
}
}
}
8 changes: 0 additions & 8 deletions SortVision/lighthouserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@
"ci": {
"collect": {
"numberOfRuns": 1
},
"assert": {
"assertions": {
"categories:accessibility": ["warn", { "minScore": 0.85 }],
"categories:seo": ["warn", { "minScore": 0.85 }],
"categories:best-practices": ["warn", { "minScore": 0.8 }],
"categories:performance": ["warn", { "minScore": 0.75 }]
}
}
}
}
77 changes: 59 additions & 18 deletions SortVision/scripts/lighthouse-ci-summary.cjs
Original file line number Diff line number Diff line change
@@ -1,37 +1,54 @@
#!/usr/bin/env node
/**
* Appends Lighthouse category scores to GITHUB_STEP_SUMMARY (mobile + desktop manifests from LHCI action).
* Lighthouse category scores: optional GITHUB_STEP_SUMMARY, optional PR comment file (CI).
*/
const fs = require('fs');
const path = require('path');

const out = process.env.GITHUB_STEP_SUMMARY;
if (!out) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
const commentDir = process.env.LIGHTHOUSE_COMMENT_DIR;

if (!summaryPath && !commentDir) {
process.exit(0);
}

/** Prefer file path (parallel CI jobs upload manifests); fall back to inline JSON from the same job. */
function loadManifest(envJsonKey, envPathKey) {
const p = process.env[envPathKey];
if (p) {
try {
if (fs.existsSync(p)) {
return fs.readFileSync(p, 'utf8');
}
} catch {
/* ignore */
}
}
return process.env[envJsonKey] || '';
}

function pct(n) {
if (n == null || Number.isNaN(n)) return '—';
return `${Math.round(Number(n) * 100)}`;
}

function table(title, manifestJson) {
/** Markdown table cell: escape backslashes first, then pipes (predictable table parsing). */
function escapeMarkdownTableCell(s) {
return String(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
}

function tableMarkdown(title, manifestJson) {
const lines = [`### ${title}`, ''];
let rows;
try {
rows = JSON.parse(manifestJson || '[]');
} catch {
lines.push('_Could not parse Lighthouse manifest._', '');
const text = lines.join('\n');
console.log(text);
fs.appendFileSync(out, text + '\n', 'utf8');
return;
return lines.join('\n');
}
if (!Array.isArray(rows) || rows.length === 0) {
lines.push('_No manifest rows (run may have failed before collection)._', '');
const text = lines.join('\n');
console.log(text);
fs.appendFileSync(out, text + '\n', 'utf8');
return;
return lines.join('\n');
}

lines.push(
Expand All @@ -41,17 +58,41 @@ function table(title, manifestJson) {

for (const r of rows) {
const s = r.summary || {};
const url = String(r.url || '—').replace(/\|/g, '\\|');
const url = escapeMarkdownTableCell(r.url || '—');
const bp = s['best-practices'] ?? s.bestPractices;
lines.push(
`| ${url} | ${pct(s.performance)} | ${pct(s.accessibility)} | ${pct(bp)} | ${pct(s.seo)} |`
);
}
lines.push('');
const text = lines.join('\n');
console.log(text);
fs.appendFileSync(out, text + '\n', 'utf8');
return lines.join('\n');
}

function buildReportBody() {
const mobile = loadManifest('MANIFEST_MOBILE', 'MANIFEST_MOBILE_PATH');
const desktop = loadManifest('MANIFEST_DESKTOP', 'MANIFEST_DESKTOP_PATH');
return [tableMarkdown('Lighthouse (mobile)', mobile), tableMarkdown('Lighthouse (desktop)', desktop)].join(
'\n'
);
}

table('Lighthouse (mobile)', process.env.MANIFEST_MOBILE);
table('Lighthouse (desktop)', process.env.MANIFEST_DESKTOP);
const body = buildReportBody();
console.log(body);

if (summaryPath) {
fs.appendFileSync(summaryPath, `${body}\n`, 'utf8');
}

if (commentDir) {
fs.mkdirSync(commentDir, { recursive: true });
const runUrl =
process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: '';
const headerLines = ['<!-- sortvision-lighthouse-report -->', '### Lighthouse (CI)', ''];
if (runUrl) {
headerLines.push(`[View workflow run](${runUrl})`, '');
}
const text = `${headerLines.join('\n')}${body}\n`;
fs.writeFileSync(path.join(commentDir, 'comment.md'), text, 'utf8');
}
Loading
Loading