Skip to content

Commit f9e109b

Browse files
lwwmanningclaude
andauthored
Add Lighthouse CI with calibrated thresholds and emoji-per-cell PR comment (#62)
Signed-off-by: Will Manning <will@willmanning.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ce26d52 commit f9e109b

7 files changed

Lines changed: 901 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,13 @@ jobs:
106106
if: failure()
107107
run: cat /tmp/server.log || true
108108

109-
# Hard-gates on vulnerable direct/transitive deps. One advisory is
110-
# ignored because it's upstream-blocked (uuid <14.0.0 via
111-
# resend svix@1.90.0, dev-/server-side, no exploitable code path) —
112-
# see CLAUDE.md "Audit advisories" for context and removal triggers.
113-
# Any new advisory fails the job.
109+
# Hard-gates on vulnerable direct/transitive deps. Two advisories are
110+
# ignored because they're upstream-blocked (both via @lhci/cli@0.15.1
111+
# and resend's transitive svix; both dev-/server-side with no
112+
# exploitable code path) — see CLAUDE.md "Audit advisories" for
113+
# context and removal triggers. Any new advisory fails the job.
114114
- name: Dependency audit
115-
run: bun audit --ignore=GHSA-w5hq-g745-h8pq
115+
run: bun audit --ignore=GHSA-w5hq-g745-h8pq --ignore=GHSA-52f5-9888-hmc6
116116

117117
# Runs only on PRs (no baseline diff to compute on a push to main).
118118
# Compares the PR's dependency manifest against main and flags

.github/workflows/lighthouse.yml

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
name: Lighthouse
2+
3+
on:
4+
pull_request:
5+
workflow_dispatch:
6+
7+
permissions:
8+
contents: read
9+
pull-requests: write
10+
11+
# Including `github.workflow` in the key keeps this from colliding with any
12+
# other workflow that happens to share a ref-based group prefix.
13+
concurrency:
14+
group: lighthouse-${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
lighthouse:
19+
name: Lighthouse audit
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 15
22+
steps:
23+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
24+
25+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
26+
with:
27+
bun-version: 1.3.13
28+
29+
# Bun is the package manager and script runner, but Next.js (and the
30+
# lhci binary) run on Node. Pin Node via .nvmrc so a future GitHub
31+
# bump can't break the audit silently.
32+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
33+
with:
34+
node-version-file: .nvmrc
35+
36+
# Cache Bun's resolved package store keyed on the lockfile hash.
37+
- name: Cache Bun install cache
38+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
39+
with:
40+
path: ~/.bun/install/cache
41+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
42+
restore-keys: |
43+
${{ runner.os }}-bun-
44+
45+
# Cache Next.js's incremental build output. Keyed on lockfile + commit
46+
# SHA so the exact-key match is always per-commit fresh; restore-keys
47+
# fall back to any previous build on the same lockfile so most CI runs
48+
# hit a cache and skip rebuilding unchanged webpack modules.
49+
- name: Cache Next.js build cache
50+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
51+
with:
52+
path: ${{ github.workspace }}/.next/cache
53+
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ github.sha }}
54+
restore-keys: |
55+
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
56+
${{ runner.os }}-nextjs-
57+
58+
- name: Install
59+
run: bun install --frozen-lockfile
60+
61+
- name: Build
62+
run: bun run build
63+
64+
# The action handles starting the server (per lighthouserc.cjs's
65+
# `startServerCommand`), running Lighthouse against each URL, asserting
66+
# the thresholds, and uploading reports to temporary public storage.
67+
# Threshold rationale lives in lighthouserc.cjs alongside the values.
68+
- name: Lighthouse CI
69+
id: lighthouse
70+
uses: treosh/lighthouse-ci-action@512cc908a55bfb0ad231facca52adf3d3a651df4 # v12
71+
with:
72+
configPath: ./lighthouserc.cjs
73+
uploadArtifacts: true
74+
temporaryPublicStorage: true
75+
76+
# Build a markdown table from the .lighthouseci/lhr-*.json reports.
77+
# `unique_by(.url)` collapses duplicate rows if `numberOfRuns > 1`
78+
# (the action writes the median LHR per URL, but be defensive);
79+
# `sort_by(.url)` keeps row order deterministic across runs.
80+
# `if: always()` so the PR comment still posts when assertions fail —
81+
# reviewers can see *which* category regressed without digging through
82+
# the workflow log.
83+
#
84+
# Per-cell emoji follows Lighthouse's own scoring buckets: 🟢 ≥90,
85+
# 🟡 50–89, 🔴 <50. Floor thresholds in lighthouserc.cjs are below
86+
# the 🟢 cutoff, so a cell can pass assertions and still show 🟡 — the
87+
# visual signals "we locked this in but it's improvable."
88+
- name: Format PR comment
89+
if: always() && github.event_name == 'pull_request' && steps.lighthouse.outputs.links != ''
90+
id: lh-comment
91+
env:
92+
LINKS_JSON: ${{ steps.lighthouse.outputs.links }}
93+
run: |
94+
{
95+
echo 'body<<COMMENT_EOF'
96+
echo '## 🔦 Lighthouse audit'
97+
echo ''
98+
echo '| URL | Perf | A11y | Best practices | SEO | Report |'
99+
echo '|---|:---:|:---:|:---:|:---:|---|'
100+
jq -s --argjson links "$LINKS_JSON" -r '
101+
def emo(s): if s >= 90 then "🟢" elif s >= 50 then "🟡" else "🔴" end;
102+
def cell(s): emo(s) + " " + (s | tostring);
103+
map({
104+
url: .finalUrl,
105+
perf: (.categories.performance.score * 100 | floor),
106+
a11y: (.categories.accessibility.score * 100 | floor),
107+
bp: (.categories["best-practices"].score * 100 | floor),
108+
seo: (.categories.seo.score * 100 | floor)
109+
})
110+
| unique_by(.url)
111+
| sort_by(.url)
112+
| map(
113+
"| `" + (.url | sub("^https?://[^/]+"; "")) + "`"
114+
+ " | " + cell(.perf)
115+
+ " | " + cell(.a11y)
116+
+ " | " + cell(.bp)
117+
+ " | " + cell(.seo)
118+
+ " | [report](" + ($links[.url] // "#") + ") |"
119+
)
120+
| join("\n")
121+
' .lighthouseci/lhr-*.json
122+
echo ''
123+
echo "_Run [\`${GITHUB_RUN_ID}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) · $(date -u +'%Y-%m-%d %H:%M UTC')_"
124+
echo 'COMMENT_EOF'
125+
} >> "$GITHUB_OUTPUT"
126+
127+
- name: Post or update PR comment
128+
if: github.event_name == 'pull_request' && steps.lh-comment.outputs.body != ''
129+
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
130+
with:
131+
header: lighthouse
132+
message: ${{ steps.lh-comment.outputs.body }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ public/static
4646

4747
# claude code local settings (per-machine tool allowlists)
4848
.claude
49+
50+
# lighthouse-ci output
51+
.lighthouseci

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ The site is optimized for performance with font optimization, analytics integrat
5151

5252
- **postcss `<8.5.10`** (GHSA-qx2v-qp2m-jg93, moderate XSS in CSS stringify). Multiple transitive resolutions — `next@16.2.4` pins `postcss@8.4.31` exactly, and `@tailwindcss/postcss@4.2.3` brings in `postcss@^8.5.6`. Resolved via `overrides.postcss = "8.5.10"` in `package.json`, which dedupes all transitives to the patched version. Drop the override after `next` and `@tailwindcss/postcss` ship releases that pull their transitives to ≥ 8.5.10.
5353
- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by three independent paths (shiki/rehype-pretty-code, react-markdown, velite/@mdx-js/mdx) — all parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1.
54-
- **uuid `<14.0.0`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` provided). **Upstream-blocked.** Comes exclusively from `resend@6.12.2 → svix@1.90.0 → uuid@^10.0.0`. svix's declared range `^10.0.0` doesn't admit a 14.x override without risking the parent's CJS imports. Exposure is theoretical: `/api/subscribe` invokes Resend's send-email endpoint, which doesn't exercise svix's webhook-signing path, and the vulnerable code (v3/v5/v6 with explicit `buf`) isn't called. Remove the `--ignore` when `svix` (or `resend`) ships a release that bumps uuid to `^14.0.0`.
54+
- **uuid `<14.0.0`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` provided). **Upstream-blocked.** Two parent paths: `resend@6.12.2 → svix@1.90.0 → uuid@^10.0.0` and `@lhci/cli@0.15.1 → uuid@8.3.2`. Neither parent admits a 14.x override without risking CJS imports. Exposure is theoretical on both: `/api/subscribe` uses Resend's send-email endpoint (not svix's webhook-signing path), `@lhci/cli` is dev-only and runs in CI on its own controlled inputs, and the vulnerable code (v3/v5/v6 with explicit `buf`) isn't called by either. Remove the `--ignore` when both parents ship releases bumping uuid to `^14.0.0`.
55+
- **tmp `<=0.2.3`** (GHSA-52f5-9888-hmc6, low symbolic-link path traversal in `dir` param). **Upstream-blocked.** Pulled exclusively by `@lhci/cli@0.15.1` (dev-only, runs in CI on controlled inputs). The symlink-traversal scenario doesn't apply. Remove the `--ignore` when `@lhci/cli` ships a release with patched transitives.
5556

56-
CI hard-gates on `bun audit` (`.github/workflows/ci.yml`) with `--ignore=GHSA-w5hq-g745-h8pq` for the upstream-blocked uuid advisory. Any new advisory fails the job. The `dependency-review-action` PR job is a separate gate (license/severity-focused) that remains `continue-on-error: true` while a baseline of acceptable findings is established.
57+
CI hard-gates on `bun audit` (`.github/workflows/ci.yml`) with `--ignore=GHSA-w5hq-g745-h8pq` and `--ignore=GHSA-52f5-9888-hmc6` for the upstream-blocked advisories. Any new advisory fails the job. The `dependency-review-action` PR job is a separate gate (license/severity-focused) that remains `continue-on-error: true` while a baseline of acceptable findings is established.

0 commit comments

Comments
 (0)