diff --git a/.github/workflows/leave-comment.yml b/.github/workflows/leave-comment.yml new file mode 100644 index 00000000..b40d8f79 --- /dev/null +++ b/.github/workflows/leave-comment.yml @@ -0,0 +1,74 @@ +# Adapted from https://github.com/nodejs/nodejs.org/blob/main/.github/workflows/leave-comment.yml +name: Leave Comment + +on: + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: [Lighthouse] + types: [completed] + +permissions: + contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +jobs: + leave-comment: + name: Leave Comment + runs-on: ubuntu-latest + permissions: + actions: read + pull-requests: write + + steps: + - name: Download Comment Artifact + id: download + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pr-comment + path: pr-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve Pull Request Number + id: pr + if: steps.download.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const run = context.payload.workflow_run; + + if (run.pull_requests && run.pull_requests.length) { + core.setOutput('number', run.pull_requests[0].number); + return; + } + + const match = await github.rest.pulls + .list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${run.head_repository.owner.login}:${run.head_branch}`, + sort: 'updated', + direction: 'desc', + per_page: 1, + }) + .then(r => r.data[0]); + + if (!match) { + core.info(`No open pull request found for HEAD ${run.head_sha}`); + return; + } + + core.setOutput('number', match.number); + + - name: Add Comment to PR + if: steps.download.outcome == 'success' && steps.pr.outputs.number != '' + uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 # v3.0.0 + with: + file-path: pr-comment/comment.md + comment-tag: lighthouse_audit + pr-number: ${{ steps.pr.outputs.number }} diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 00000000..62ff58ed --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,77 @@ +# Adapted from https://github.com/nodejs/nodejs.org/blob/main/.github/workflows/lighthouse.yml +name: Lighthouse + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lighthouse: + name: Lighthouse Report + if: startsWith(github.event.pull_request.head.ref, 'dependabot/') == false + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Wait for Vercel preview deployment + id: preview + uses: patrickedqvist/wait-for-vercel-preview@06c79330064b0e6ef7a2574603b62d3c98789125 # v1.3.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + max_timeout: 300 + check_interval: 10 + + - name: Audit preview URLs with Lighthouse + id: lighthouse + uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12 + with: + configPath: ./.lighthouserc.json + uploadArtifacts: true + temporaryPublicStorage: true + urls: | + ${{ steps.preview.outputs.url }}/ + ${{ steps.preview.outputs.url }}/about + ${{ steps.preview.outputs.url }}/about/sponsors + ${{ steps.preview.outputs.url }}/docs + ${{ steps.preview.outputs.url }}/guides + ${{ steps.preview.outputs.url }}/blog + + - name: Format Lighthouse report + id: format + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + LIGHTHOUSE_RESULT: ${{ steps.lighthouse.outputs.manifest }} + LIGHTHOUSE_LINKS: ${{ steps.lighthouse.outputs.links }} + VERCEL_PREVIEW_URL: ${{ steps.preview.outputs.url }} + with: + script: | + const { formatLighthouseResults } = await import( + '${{ github.workspace }}/scripts/lighthouse/index.mjs' + ); + await formatLighthouseResults({ core }); + + - name: Prepare PR comment + env: + COMMENT: ${{ steps.format.outputs.comment }} + run: | + set -euo pipefail + mkdir -p pr-comment + printf '%s' "$COMMENT" > pr-comment/comment.md + + - name: Upload PR comment + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pr-comment + path: pr-comment/ + retention-days: 1 diff --git a/.lighthouserc.json b/.lighthouserc.json new file mode 100644 index 00000000..be61cf83 --- /dev/null +++ b/.lighthouserc.json @@ -0,0 +1,19 @@ +{ + "ci": { + "collect": { + "numberOfRuns": 1, + "settings": { + "preset": "desktop", + "skipAudits": ["is-crawlable"] + } + }, + "assert": { + "assertions": { + "categories:performance": ["warn", { "minScore": 0.9 }], + "categories:accessibility": ["warn", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.9 }], + "categories:seo": ["warn", { "minScore": 0.9 }] + } + } + } +} diff --git a/scripts/lighthouse/index.mjs b/scripts/lighthouse/index.mjs new file mode 100644 index 00000000..a26e7949 --- /dev/null +++ b/scripts/lighthouse/index.mjs @@ -0,0 +1,62 @@ +// Adapted from https://github.com/nodejs/nodejs.org/blob/main/apps/site/scripts/lighthouse/index.mjs + +const CATEGORIES = [ + ['performance', 'Performance'], + ['accessibility', 'Accessibility'], + ['best-practices', 'Best Practices'], + ['seo', 'SEO'], +]; + +const formatScore = score => { + if (score === undefined || score === null) { + return 'n/a'; + } + + const value = Math.round(score * 100); + const emoji = value >= 90 ? '🟢' : value >= 75 ? '🟠' : '🔴'; + + return `${emoji} ${value}`; +}; + +export const formatLighthouseResults = ({ core }) => { + const previewUrl = process.env.VERCEL_PREVIEW_URL ?? ''; + + const results = JSON.parse(process.env.LIGHTHOUSE_RESULT || '[]'); + const links = JSON.parse(process.env.LIGHTHOUSE_LINKS || '{}'); + + const runs = results.filter(result => result.isRepresentativeRun ?? true); + + const header = + '| URL | ' + + CATEGORIES.map(([, label]) => label).join(' | ') + + ' | Report |'; + const divider = `|${' --- |'.repeat(CATEGORIES.length + 2)}`; + + const rows = runs.map(({ url, summary }) => { + const path = previewUrl ? url.replace(previewUrl, '') || '/' : url; + const scores = CATEGORIES.map(([key]) => formatScore(summary?.[key])).join( + ' | ' + ); + const report = links[url] ? `[🔗](${links[url]})` : 'n/a'; + + return `| [${path}](${url}) | ${scores} | ${report} |`; + }); + + const comment = [ + '## ⚡ Lighthouse report', + '', + previewUrl + ? `Audited the Vercel preview deployment: ${previewUrl}` + : 'Audited the Vercel preview deployment.', + '', + header, + divider, + ...rows, + '', + '> Scores are advisory (warn at 90); they do not fail the build. See `.lighthouserc.json`.', + ].join('\n'); + + core.setOutput('comment', comment); + + return comment; +};