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
108 changes: 108 additions & 0 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,51 @@ jobs:
set -o pipefail
bun run --cwd apps/guides build 2>&1 | tee /tmp/guides-build.log

- name: Validate OG meta in built HTML
# Parse out/index.html + every out/guide/<slug>.html and assert the
# OG / Twitter meta tags expected by social previewers (LinkedIn,
# FB, microlink). A regression here — missing meta tag, relative
# og:image, wrong twitter:card — fails the workflow before the
# artifact is uploaded so reviewers see the failure on the PR check.
run: bun run --cwd apps/guides test:og-meta

- name: Lighthouse CI
# Runs `lhci autorun` against the already-built `out/` directory
# (staticDistDir in .lighthouserc.js). Error pages (404/500) are
# excluded via assertMatrix. Real failures fail the workflow so
# regressions are caught at PR time.
id: lhci
run: bun run --cwd apps/guides lighthouse:ci 2>&1 | tee /tmp/guides-lhci.log

- name: Summarize Lighthouse scores
if: always()
run: |
set -euo pipefail
LOG=/tmp/guides-lhci.log
{
echo ""
echo "## Guides Lighthouse CI"
echo ""
if [ -f "$LOG" ]; then
if grep -q "All results processed!" "$LOG" 2>/dev/null; then
echo '<details><summary>LHCI output (tail)</summary>'
echo ''
echo '```'
tail -n 80 "$LOG"
echo '```'
echo '</details>'
else
echo '> LHCI did not finish cleanly — see job log.'
echo ''
echo '```'
tail -n 60 "$LOG" 2>/dev/null || echo '(no log)'
echo '```'
fi
else
echo '> No LHCI log captured.'
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Summarize build output
if: always()
run: |
Expand Down Expand Up @@ -118,6 +163,15 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload Lighthouse reports (guides)
if: always()
uses: actions/upload-artifact@v7
with:
name: guides-lighthouse
path: apps/guides/.lighthouseci
retention-days: 14
if-no-files-found: warn

- name: Upload guides static export
if: always()
uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -158,6 +212,51 @@ jobs:
set -o pipefail
bun run --cwd apps/landing build 2>&1 | tee /tmp/landing-build.log

- name: Validate OG meta in built HTML
# Parse every out/*.html + out/<slug>/index.html and assert the
# OG / Twitter meta tags every social previewer (LinkedIn, FB,
# microlink, Slack) expects. Catches relative og:image URLs,
# missing twitter:card, and similar regressions before the
# artifact is uploaded.
run: bun run --cwd apps/landing test:og-meta

- name: Lighthouse CI
# Runs `lhci autorun` against the already-built `out/` directory.
# Budgets in .lighthouserc.js: perf >=0.8, a11y/best-practices/seo
# >=0.9, LCP <2500ms, CLS <0.1. Error pages (404/500) are excluded
# via assertMatrix. Real failures fail the workflow.
id: lhci
run: bun run --cwd apps/landing lighthouse:ci 2>&1 | tee /tmp/landing-lhci.log

- name: Summarize Lighthouse scores
if: always()
run: |
set -euo pipefail
LOG=/tmp/landing-lhci.log
{
echo ""
echo "## Landing Lighthouse CI"
echo ""
if [ -f "$LOG" ]; then
if grep -q "All results processed!" "$LOG" 2>/dev/null; then
echo '<details><summary>LHCI output (tail)</summary>'
echo ''
echo '```'
tail -n 80 "$LOG"
echo '```'
echo '</details>'
else
echo '> LHCI did not finish cleanly — see job log.'
echo ''
echo '```'
tail -n 60 "$LOG" 2>/dev/null || echo '(no log)'
echo '```'
fi
else
echo '> No LHCI log captured.'
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Summarize build output
if: always()
run: |
Expand All @@ -183,6 +282,15 @@ jobs:
echo "| Root \`public/og-image.png\` | $root_og |"
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload Lighthouse reports (landing)
if: always()
uses: actions/upload-artifact@v7
with:
name: landing-lighthouse
path: apps/landing/.lighthouseci
retention-days: 14
if-no-files-found: warn

- name: Upload landing static export
if: always()
uses: actions/upload-artifact@v7
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ apps/landing/public/og-image.png
apps/guides/public/og-image.png
apps/guides/public/og/

# Lighthouse CI output (produced by `lhci autorun`)
.lighthouseci/

# Git worktrees
.worktrees/
.worktrees
38 changes: 24 additions & 14 deletions apps/guides/.lighthouserc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,30 @@ module.exports = {
},
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.8 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'meta-description': 'error',
'document-title': 'error',
'html-has-lang': 'error',
'image-alt': 'error',
},
assertMatrix: [
{
// Next.js error pages (404/500) inherently lack <title>, <html lang>,
// and meta description. They're never user-shareable; skip them.
matchingUrlPattern: '.*/(404|500)\\.html$',
},
{
matchingUrlPattern: '.*\\.html$',
assertions: {
'categories:performance': ['error', { minScore: 0.8 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'meta-description': 'error',
'document-title': 'error',
'html-has-lang': 'error',
'image-alt': 'error',
},
},
],
},
upload: {
target: 'temporary-public-storage',
Expand Down
36 changes: 22 additions & 14 deletions apps/guides/.lighthouserc.mobile.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,28 @@ module.exports = {
},
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.8 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['error', { maxNumericValue: 3000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 4000 }],
'total-blocking-time': ['error', { maxNumericValue: 600 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'meta-description': 'error',
'document-title': 'error',
'html-has-lang': 'error',
'image-alt': 'error',
},
assertMatrix: [
{
matchingUrlPattern: '.*/(404|500)\\.html$',
},
{
matchingUrlPattern: '.*\\.html$',
assertions: {
'categories:performance': ['error', { minScore: 0.8 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['error', { maxNumericValue: 3000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 4000 }],
'total-blocking-time': ['error', { maxNumericValue: 600 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'meta-description': 'error',
'document-title': 'error',
'html-has-lang': 'error',
'image-alt': 'error',
},
},
],
},
upload: {
target: 'temporary-public-storage',
Expand Down
68 changes: 68 additions & 0 deletions apps/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,73 @@ Guards against re-inverting the order:
| `bun run build` | Full static build (`out/`) |
| `bun run test` | Lightweight vitest suite |
| `bun run test:og` | End-to-end OG image pipeline test (slow) |
| `bun run test:og-meta` | Parse built `out/**/index.html` and assert OG / Twitter meta tags |
| `bun run lighthouse` | Build + run LHCI assertions |
| `bun run sync-to-r2` | Sync content to `packrat-guides` R2 bucket |

## Open Graph metadata validation

All PackRat web apps (`apps/guides`, `apps/landing`) share the same OG
validation pattern, with per-app shapes:

- **Guides** has per-post images (`/og/<slug>.png`) and `og:type=article`.
- **Landing** has a single site-wide image (`/og-image.png`) and
`og:type=website`.

See [`apps/landing/README.md`](../landing/README.md) for the landing variant.

We do three layers of OG validation:

1. **Image generation** — `test:og` verifies one PNG per post in `public/og/`.
This catches the build-order bug (#2436) where OG images get generated
from a stale `lib/content.ts`.
2. **Static meta in built HTML** — `test:og-meta` runs `bun run build`
(if `out/` is missing) and then parses every `out/guide/<slug>.html`
plus the root `out/index.html` with cheerio. It asserts the required
tags (`og:title`, `og:description`, `og:image`, `og:image:width`,
`og:image:height`, `og:type`, `og:url`, `og:site_name`, `twitter:card`,
`twitter:title`, `twitter:description`, `twitter:image`) are present
on a 3-post random sample and that **every** post has an absolute
`https://` `og:image` URL pointing at `/og/<slug>.png`. The root page
gets the same shape with the site-wide image (`/og-image.png` or the
Next.js auto-generated `/opengraph-image` route — whichever wins).
This step runs in the `Builds` workflow on every PR.
3. **Live OG meta on a deployed URL** — opt-in via
`OG_LIVE_CHECK_URL=https://guides.packratai.com bun run test:og-meta`.
Hits the live origin via [`open-graph-scraper`][ogs] (the same parser
most platforms use under the hood) and asserts the same shape. Useful
after a deploy when you want to confirm CF transforms / caches didn't
eat any meta tags. Skipped by default.

### Manual validators

For one-off checks after a deploy, paste the URL into one of these:

- [opengraph.xyz](https://www.opengraph.xyz/) — quick visual preview
- [microlink.io](https://microlink.io/) — JSON view of every OG / Twitter tag
- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) — also flushes FB's cache for the URL
- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) — also flushes LI's cache

## Lighthouse CI

`.lighthouserc.js` (desktop) and `.lighthouserc.mobile.js` (mobile) drive
LHCI against the static `out/` directory. Budgets:

- Performance ≥ 0.8
- Accessibility / Best Practices / SEO ≥ 0.9
- LCP < 2500 ms (desktop) / 4000 ms (mobile)
- CLS < 0.1
- TBT < 300 ms (desktop) / 600 ms (mobile)

The `Builds` GitHub Actions workflow runs `lighthouse:ci` after the OG
meta test on every PR and surfaces the scores in the GitHub Step Summary.
The step is marked `continue-on-error: true` so perf regressions appear
as a yellow check on the PR rather than a hard block — keeps the cadence
fast while still surfacing the numbers to reviewers.

```
bun run --cwd apps/guides lighthouse # full: build + LHCI
bun run --cwd apps/guides lighthouse:ci # CI mode: requires out/ to exist
```

[ogs]: https://github.com/jshemas/openGraphScraper
Loading
Loading