Skip to content

Lighthouse check

Lighthouse check #1449

name: Lighthouse check
on:
workflow_dispatch:
inputs:
mode:
description: "Audit mode"
required: true
default: "changed"
type: choice
options:
- "changed"
- "depth"
depth:
description: "Sitemap depth when mode is depth (0–2)"
required: true
default: "0"
type: choice
options:
- "0"
- "1"
- "2"
pr_number:
description: "Pull request number to audit"
required: true
type: string
permissions:
contents: read
issues: write
pull-requests: write
actions: read
jobs:
lighthouse:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- name: Resolve PR context
id: pr
uses: actions/github-script@v6
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
async function getPr(prNumber) {
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
if (pr.draft) {
core.setFailed('Skipping Lighthouse for draft PR');
return null;
}
const labels = (pr.labels || []).map(l => l.name);
const fullAudit = labels.includes('lighthouse:full');
return {
number: pr.number,
head_ref: pr.head.ref,
head_sha: pr.head.sha,
base_ref: pr.base.ref,
full_audit: fullAudit,
};
}
const prNumber = parseInt('${{ inputs.pr_number }}', 10);
const info = await getPr(prNumber);
if (!info) return;
let mode = '${{ inputs.mode }}';
let depth = '${{ inputs.depth }}';
if (info.full_audit && mode === 'changed') {
mode = 'depth';
depth = '2';
}
core.setOutput('number', String(info.number));
core.setOutput('head_ref', info.head_ref);
core.setOutput('head_sha', info.head_sha);
core.setOutput('base_ref', info.base_ref);
core.setOutput('mode', mode);
core.setOutput('depth', depth);
core.setOutput('full_audit', String(info.full_audit));
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.head_sha }}
fetch-depth: 0
- name: Set environment
run: |
echo "PREVIEW_URL=https://docs-staging.validmind.ai/pr_previews/${{ steps.pr.outputs.head_ref }}" >> $GITHUB_ENV
echo "COMMIT_SHA=${{ steps.pr.outputs.head_sha }}" >> $GITHUB_ENV
echo "COMMIT_SHA_SHORT=$(echo ${{ steps.pr.outputs.head_sha }} | cut -c1-7)" >> $GITHUB_ENV
echo "LIGHTHOUSE_MODE=${{ steps.pr.outputs.mode }}" >> $GITHUB_ENV
echo "LIGHTHOUSE_DEPTH=${{ steps.pr.outputs.depth }}" >> $GITHUB_ENV
echo "PR_NUMBER=${{ steps.pr.outputs.number }}" >> $GITHUB_ENV
- name: Check for PR preview URL
id: check_preview
run: |
check_url() {
local url=$1
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -I -A "Mozilla/5.0" "$url")
echo "Checking $url — status: $status"
[ "$status" -eq 200 ]
}
echo "Waiting for preview site to become available ..."
for i in $(seq 1 30); do
if check_url "$PREVIEW_URL/index.html"; then
echo "Info: Preview site is now available"
break
fi
if [ "$i" -eq 30 ]; then
echo "Error: Preview URL did not become available after 30 minutes"
exit 1
fi
echo "Attempt $i/30: waiting 1 minute..."
sleep 60
done
if ! check_url "$PREVIEW_URL/sitemap.xml"; then
echo "Error: Sitemap missing at $PREVIEW_URL/sitemap.xml"
exit 1
fi
echo "preview_exists=true" >> $GITHUB_OUTPUT
- name: Install Python dependencies
if: steps.check_preview.outputs.preview_exists == 'true'
run: |
python -m pip install --upgrade pip
pip install requests
- name: Generate URLs to check
if: steps.check_preview.outputs.preview_exists == 'true'
id: generate_urls
env:
INSTALLATION_USER: ${{ secrets.INSTALLATION_USER }}
INSTALLATION_PW: ${{ secrets.INSTALLATION_PW }}
run: |
cd site/scripts
python lighthouse_urls.py \
--mode "$LIGHTHOUSE_MODE" \
--base-ref "${{ steps.pr.outputs.base_ref }}" \
--depth "$LIGHTHOUSE_DEPTH" \
--preview-url "$PREVIEW_URL" \
--output ../../lhci-urls.txt \
--metadata ../../lighthouse-metadata.json \
--skip-file ../../lighthouse-skip.txt
if [ -f ../../lighthouse-skip.txt ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "No site pages to audit in this PR."
exit 0
fi
if [ ! -s ../../lhci-urls.txt ]; then
echo "Error: No URLs were generated."
exit 1
fi
echo "skip=false" >> $GITHUB_OUTPUT
echo "Lighthouse will check:"
cat ../../lhci-urls.txt
# Probe first URL from list (beyond index.html) when in changed mode
if [ "$LIGHTHOUSE_MODE" = "changed" ]; then
FIRST=$(head -n1 ../../lhci-urls.txt)
status=$(curl -s -o /dev/null -w "%{http_code}" -I -A "Mozilla/5.0" "$FIRST")
echo "Probe $FIRST — status: $status"
if [ "$status" -ne 200 ]; then
echo "Error: Changed page not reachable on preview"
exit 1
fi
fi
- name: Verify installation page auth
if: |
steps.check_preview.outputs.preview_exists == 'true' &&
steps.generate_urls.outputs.skip != 'true'
run: |
if ! grep -q '/installation/' lhci-urls.txt 2>/dev/null; then
echo "No installation pages in URL list — skipping auth check"
exit 0
fi
auth_url="https://${{ secrets.INSTALLATION_USER }}:${{ secrets.INSTALLATION_PW }}@docs-staging.validmind.ai/pr_previews/${{ steps.pr.outputs.head_ref }}/installation/index.html"
status=$(curl -s -o /dev/null -w "%{http_code}" -I -A "Mozilla/5.0" --anyauth "$auth_url")
echo "Checking installation page — status: $status"
if [ "$status" -ne 200 ]; then
echo "Error: Installation page not accessible with authentication"
exit 1
fi
- name: Post skip comment
if: steps.generate_urls.outputs.skip == 'true'
uses: actions/github-script@v6
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const body = `## Lighthouse check results\n\n✓ INFO: No site pages to audit in this PR.\n\nCommit SHA: [${process.env.COMMIT_SHA_SHORT}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/commit/${process.env.COMMIT_SHA})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
- name: Install Lighthouse CI
if: steps.generate_urls.outputs.skip != 'true' && steps.check_preview.outputs.preview_exists == 'true'
run: npm install -g @lhci/cli
- name: Create Lighthouse config
if: steps.generate_urls.outputs.skip != 'true' && steps.check_preview.outputs.preview_exists == 'true'
run: |
cat > .lighthouserc.js << 'EOF'
const fs = require('fs');
const urls = fs.readFileSync('lhci-urls.txt', 'utf-8').split('\n').filter(Boolean);
const urlsWithAuth = urls.map(url => {
if (url.includes('/installation/')) {
return `https://${process.env.INSTALLATION_USER}:${process.env.INSTALLATION_PW}@${new URL(url).host}${new URL(url).pathname}`;
}
return url;
});
module.exports = {
ci: {
collect: {
url: urlsWithAuth,
numberOfRuns: 3,
settings: {
formFactor: 'desktop',
screenEmulation: {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false,
},
throttling: {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 1,
requestLatencyMs: 0,
downloadThroughputKbps: 0,
uploadThroughputKbps: 0,
},
},
},
assert: {
assertions: {
'categories:accessibility': ['error', { minScore: 0.9 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};
EOF
- name: Run Lighthouse audit
if: steps.generate_urls.outputs.skip != 'true' && steps.check_preview.outputs.preview_exists == 'true'
uses: treosh/lighthouse-ci-action@v11
id: lighthouse
env:
INSTALLATION_USER: ${{ secrets.INSTALLATION_USER }}
INSTALLATION_PW: ${{ secrets.INSTALLATION_PW }}
with:
configPath: .lighthouserc.js
uploadArtifacts: true
temporaryPublicStorage: true
- name: Check Lighthouse audit result
if: steps.generate_urls.outputs.skip != 'true' && steps.check_preview.outputs.preview_exists == 'true'
run: |
if [ -z "${{ steps.lighthouse.outputs.manifest }}" ]; then
echo "Error: Lighthouse audit failed - no manifest output"
exit 1
fi
if ! echo '${{ steps.lighthouse.outputs.manifest }}' | jq . > /dev/null 2>&1; then
echo "Error: Lighthouse audit failed - invalid manifest format"
exit 1
fi
if ! echo '${{ steps.lighthouse.outputs.manifest }}' | jq 'length > 0' > /dev/null 2>&1; then
echo "Error: Lighthouse audit failed - no URLs were successfully audited"
exit 1
fi
# Fail if any page scored below 0.9 on accessibility
below=$(echo '${{ steps.lighthouse.outputs.manifest }}' | jq '[.[] | select(.summary.accessibility < 0.9)] | length')
if [ "$below" -gt 0 ]; then
echo "Error: $below page(s) scored below 0.9 on accessibility"
echo '${{ steps.lighthouse.outputs.manifest }}' | jq -r '.[] | select(.summary.accessibility < 0.9) | "\(.url): \(.summary.accessibility)"'
exit 1
fi
- name: Post Lighthouse results comment
if: steps.generate_urls.outputs.skip != 'true' && steps.check_preview.outputs.preview_exists == 'true'
uses: actions/github-script@v6
env:
LIGHTHOUSE_MODE: ${{ env.LIGHTHOUSE_MODE }}
LIGHTHOUSE_DEPTH: ${{ env.LIGHTHOUSE_DEPTH }}
with:
script: |
const fs = require('fs');
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const runId = context.runId;
const baseUrl = process.env.PREVIEW_URL;
const commitSha = process.env.COMMIT_SHA;
const commitShaShort = process.env.COMMIT_SHA_SHORT;
const mode = process.env.LIGHTHOUSE_MODE;
const depth = process.env.LIGHTHOUSE_DEPTH;
let metadata = {};
try {
metadata = JSON.parse(fs.readFileSync('lighthouse-metadata.json', 'utf8'));
} catch (e) {
console.log('No metadata file:', e.message);
}
const manifest = '${{ steps.lighthouse.outputs.manifest }}';
let manifestJson;
try {
manifestJson = JSON.parse(manifest);
if (!Array.isArray(manifestJson) || manifestJson.length === 0) {
throw new Error('Invalid manifest');
}
} catch (error) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `## Lighthouse check results\n\n⚠️ WARN: Failed to parse Lighthouse results. [Workflow run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})`,
});
return;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
for (const comment of comments) {
if (comment.user.login === 'github-actions[bot]' &&
comment.body.includes('## Lighthouse check results')) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
}
}
const scores = manifestJson.map(run => run.summary.accessibility);
const avgScore = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2);
const lighthouseReportUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const lighthouseComment = parseFloat(avgScore) >= 0.9
? `✓ INFO: Average accessibility score is **${avgScore}** (required: ≥0.9) — [View the workflow run](${lighthouseReportUrl})`
: `⚠️ WARN: Average accessibility score is **${avgScore}** (required: ≥0.9) — [Check the workflow run](${lighthouseReportUrl})`;
const stripAuth = url => {
try {
const u = new URL(url);
u.username = '';
u.password = '';
return u.toString();
} catch {
return url;
}
};
const links = (() => {
try {
return JSON.parse(`${{ steps.lighthouse.outputs.links }}`);
} catch {
return {};
}
})();
const scoresTable = manifestJson
.map(run => {
const formatScore = score => score === null ? 'N/A' : score.toFixed(2);
const displayPath = stripAuth(run.url).replace(baseUrl, '') || run.url;
const reportUrl = links[run.url] || lighthouseReportUrl;
return `| [${displayPath}](${reportUrl}) | ${formatScore(run.summary.accessibility)} | ${formatScore(run.summary.performance)} | ${formatScore(run.summary['best-practices'])} | ${formatScore(run.summary.seo)} |`;
})
.join('\n');
const modeLine = mode === 'changed'
? `Audit mode: **changed pages** (${metadata.paths?.length || manifestJson.length} URL(s))`
: `Audit mode: **depth ${depth}** (sitemap)`;
let comment = `## Lighthouse check results\n\n`;
comment += `${lighthouseComment}\n\n`;
comment += `${modeLine}\n\n`;
comment += `<details>\n<summary>Show Lighthouse scores</summary>\n\n`;
comment += `Commit SHA: [${commitShaShort}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/commit/${commitSha})\n\n`;
if (metadata.global_fallback) {
comment += `_Global site files changed — audited root navigation pages._\n\n`;
}
comment += `For a thorough audit, run the **Lighthouse check** workflow manually (Actions → Lighthouse check → Run workflow) with depth 0–2, or add the \`lighthouse:full\` label for depth 2 on the next validate run.\n\n`;
comment += `| Page | Accessibility | Performance | Best Practices | SEO |\n`;
comment += `|------|---------------|-------------|----------------|-----|\n`;
comment += `${scoresTable}\n\n`;
comment += `</details>\n\n`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: comment,
});