diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml new file mode 100644 index 0000000000..d1d203c6dc --- /dev/null +++ b/.github/workflows/builds.yml @@ -0,0 +1,202 @@ +name: Builds + +# Build packrat apps on every PR. Currently web only (guides + landing); native +# builds (Expo / macOS) may slot in here later under the same workflow. +# +# Gives us inspectable build signal (post counts, OG image counts, `out/` +# artifact) independent of the Cloudflare Pages dashboard, so a stale +# `lib/content.ts` or a missing OG image surfaces directly in PR checks. +# +# Each job: +# 1. Installs deps with the private @packrat-ai/nativewindui token. +# 2. Runs `bun run --cwd apps/ build`. +# 3. Surfaces post count + OG image count + `out/` size as a GitHub Step +# Summary so reviewers see it without clicking through to logs. +# 4. Uploads `out/` as a downloadable artifact (7-day retention). + +on: + pull_request: + branches: ['**'] + paths: + - 'apps/guides/**' + - 'apps/landing/**' + - 'packages/web-ui/**' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builds.yml' + push: + branches: ['main', 'development'] + paths: + - 'apps/guides/**' + - 'apps/landing/**' + - 'packages/web-ui/**' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builds.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + guides: + name: Builds (guides) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Build guides app + id: build + run: | + set -o pipefail + bun run --cwd apps/guides build 2>&1 | tee /tmp/guides-build.log + + - name: Summarize build output + if: always() + run: | + set -euo pipefail + GUIDES_DIR="apps/guides" + OUT_DIR="${GUIDES_DIR}/out" + OG_DIR="${GUIDES_DIR}/public/og" + CONTENT_FILE="${GUIDES_DIR}/lib/content.ts" + POSTS_DIR="${GUIDES_DIR}/content/posts" + + mdx_count=$(find "$POSTS_DIR" -maxdepth 1 -name '*.mdx' 2>/dev/null | wc -l | tr -d ' ') + content_count=0 + if [ -f "$CONTENT_FILE" ]; then + # Tolerate both `slug:` and `"slug":` shapes — build-content has emitted both. + content_count=$(grep -cE '^[[:space:]]+"?slug"?[[:space:]]*:' "$CONTENT_FILE" || true) + fi + og_count=0 + if [ -d "$OG_DIR" ]; then + og_count=$(find "$OG_DIR" -maxdepth 1 -name '*.png' 2>/dev/null | wc -l | tr -d ' ') + fi + out_size="(missing)" + out_html=0 + if [ -d "$OUT_DIR" ]; then + out_size=$(du -sh "$OUT_DIR" 2>/dev/null | cut -f1) + out_html=$(find "$OUT_DIR" -maxdepth 3 -name '*.html' 2>/dev/null | wc -l | tr -d ' ') + fi + root_og="missing" + if [ -f "${GUIDES_DIR}/public/og-image.png" ]; then + root_og="ok ($(stat -c%s "${GUIDES_DIR}/public/og-image.png" 2>/dev/null || stat -f%z "${GUIDES_DIR}/public/og-image.png") bytes)" + fi + + { + echo "## Guides build summary" + echo "" + echo "| Metric | Value |" + echo "|---|---|" + echo "| MDX source files (\`content/posts/*.mdx\`) | $mdx_count |" + echo "| Posts in \`lib/content.ts\` | $content_count |" + echo "| Per-post OG PNGs (\`public/og/*.png\`) | $og_count |" + echo "| Root \`public/og-image.png\` | $root_og |" + echo "| Static HTML pages in \`out/\` | $out_html |" + echo "| \`out/\` directory size | $out_size |" + echo "" + if [ "$mdx_count" -gt 0 ] && [ "$content_count" -lt "$mdx_count" ]; then + echo "> WARNING: \`lib/content.ts\` reports fewer posts than there are MDX files on disk." + echo "> This indicates \`build-content\` did not run (or ran before MDX changes were committed)." + echo "> See PR #2436." + fi + if [ "$mdx_count" -gt 0 ] && [ "$og_count" -lt "$mdx_count" ]; then + echo "> WARNING: fewer OG images than MDX files — \`generate-og-images\` may have read a stale \`lib/content.ts\`." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload guides static export + if: always() + uses: actions/upload-artifact@v7 + with: + name: guides-out + path: apps/guides/out + retention-days: 7 + if-no-files-found: warn + + - name: Upload guides build log + if: always() + uses: actions/upload-artifact@v7 + with: + name: guides-build-log + path: /tmp/guides-build.log + retention-days: 7 + if-no-files-found: ignore + + landing: + name: Builds (landing) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Build landing app + id: build + run: | + set -o pipefail + bun run --cwd apps/landing build 2>&1 | tee /tmp/landing-build.log + + - name: Summarize build output + if: always() + run: | + set -euo pipefail + OUT_DIR="apps/landing/out" + out_size="(missing)" + out_html=0 + if [ -d "$OUT_DIR" ]; then + out_size=$(du -sh "$OUT_DIR" 2>/dev/null | cut -f1) + out_html=$(find "$OUT_DIR" -maxdepth 3 -name '*.html' 2>/dev/null | wc -l | tr -d ' ') + fi + root_og="missing" + if [ -f "apps/landing/public/og-image.png" ]; then + root_og="ok ($(stat -c%s apps/landing/public/og-image.png 2>/dev/null || stat -f%z apps/landing/public/og-image.png) bytes)" + fi + { + echo "## Landing build summary" + echo "" + echo "| Metric | Value |" + echo "|---|---|" + echo "| Static HTML pages in \`out/\` | $out_html |" + echo "| \`out/\` directory size | $out_size |" + echo "| Root \`public/og-image.png\` | $root_og |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload landing static export + if: always() + uses: actions/upload-artifact@v7 + with: + name: landing-out + path: apps/landing/out + retention-days: 7 + if-no-files-found: warn + + - name: Upload landing build log + if: always() + uses: actions/upload-artifact@v7 + with: + name: landing-build-log + path: /tmp/landing-build.log + retention-days: 7 + if-no-files-found: ignore diff --git a/apps/guides/README.md b/apps/guides/README.md new file mode 100644 index 0000000000..22789b1837 --- /dev/null +++ b/apps/guides/README.md @@ -0,0 +1,59 @@ +# PackRat Guides + +Next.js 15 static-export site that ships the hiking/outdoor guides corpus. +Deployed to Cloudflare Pages from `apps/guides/out/`. + +## Build pipeline + +The `build` script in `package.json` runs three steps **in this order**: + +``` +bun run build-content → bun run generate-og-images → next build +``` + +### Why order matters + +- `scripts/build-content.ts` reads every `*.mdx` file in `content/posts/` and + writes the array of posts to **`lib/content.ts`** (committed; checked in for + fast cold builds). +- `scripts/generate-og-images.ts` **imports `lib/content.ts`** and renders one + OG PNG per post into `public/og/.png`, plus a root `public/og-image.png`. +- `next build` produces the static export in `out/`. + +If `generate-og-images` runs before `build-content`, it reads the previously +committed (stale) `lib/content.ts` and only generates OG images for that +older post set. That is exactly the bug PR #2436 fixed (39 OG images +generated for a corpus of 504 posts). + +Guards against re-inverting the order: + +1. The build script itself enforces order via `&&`. +2. `scripts/generate-og-images.ts` contains a runtime check + (`assertContentIsFresh`) that throws a clear error if `lib/content.ts` + looks suspiciously small compared to the number of MDX files on disk. +3. `__tests__/og-images.test.ts` exercises the full pipeline end-to-end and + asserts that `public/og/*.png` count equals `lib/content.ts` post count. + Run it with: + + ``` + bun run --cwd apps/guides test:og + ``` + +4. The `Builds` GitHub Actions workflow + (`.github/workflows/builds.yml`) builds the app on every PR and + surfaces the post / OG image counts in the GitHub Step Summary so + regressions are visible without depending on the Cloudflare Pages + dashboard. + +## Useful scripts + +| Script | Purpose | +|---|---| +| `bun run dev` | Local Next.js dev server | +| `bun run build-content` | Regenerate `lib/content.ts` from MDX | +| `bun run generate-og-images` | Render OG PNGs into `public/og/` | +| `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 lighthouse` | Build + run LHCI assertions | +| `bun run sync-to-r2` | Sync content to `packrat-guides` R2 bucket | diff --git a/apps/guides/__tests__/og-images.test.ts b/apps/guides/__tests__/og-images.test.ts new file mode 100644 index 0000000000..325e9161e0 --- /dev/null +++ b/apps/guides/__tests__/og-images.test.ts @@ -0,0 +1,91 @@ +/** + * End-to-end test for the OG image pipeline. + * + * This is the regression test for PR #2436: the bug was that + * `generate-og-images` ran BEFORE `build-content`, so OG images were generated + * for the 39 posts that happened to be committed in `lib/content.ts` instead + * of the 504 MDX files actually on disk. + * + * What this test does: + * 1. Asserts that the count of files in `public/og/*.png` matches the + * number of posts in `lib/content.ts`, and that the root + * `public/og-image.png` exists. + * 2. Spot-checks PNG validity (magic bytes + non-zero size). + * + * The actual `build-content` + `generate-og-images` pipeline is run by the + * `test:og` package script BEFORE invoking vitest. Doing the heavy work + * outside the test runner keeps vitest's RPC reporter from timing out on the + * ~500 lines of progress output and producing a spurious worker error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// This suite is gated behind `RUN_OG_PIPELINE_TEST=1` (set by the `test:og` +// script) so it does not bog down the regular `bun run test` flow. +const RUN = process.env.RUN_OG_PIPELINE_TEST === '1'; +const describeOrSkip = RUN ? describe : describe.skip; + +const APP_DIR = path.resolve(__dirname, '..'); +const PUBLIC_DIR = path.join(APP_DIR, 'public'); +const OG_DIR = path.join(PUBLIC_DIR, 'og'); +const ROOT_OG_PATH = path.join(PUBLIC_DIR, 'og-image.png'); +const POSTS_DIR = path.join(APP_DIR, 'content', 'posts'); + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function countMdxPosts(): number { + return fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith('.mdx')).length; +} + +function loadPostCountFromContentTs(): number { + // Read the auto-generated content.ts and count slug entries. We avoid + // importing the module here because that would pull in Next.js types via + // the alias chain; counting slug declarations is sufficient. The regex + // tolerates either `slug:` or `"slug":` since build-content has emitted + // both formats over time. + const contentPath = path.join(APP_DIR, 'lib', 'content.ts'); + const src = fs.readFileSync(contentPath, 'utf8'); + return (src.match(/^\s+"?slug"?\s*:/gm) ?? []).length; +} + +function assertPng(filePath: string): void { + const buf = fs.readFileSync(filePath); + expect(buf.length, `${path.basename(filePath)} non-empty`).toBeGreaterThan(0); + expect(buf.subarray(0, 8), `${path.basename(filePath)} PNG signature`).toEqual(PNG_SIGNATURE); +} + +describeOrSkip('OG image pipeline (build-content → generate-og-images)', () => { + it('lib/content.ts post count matches MDX file count', () => { + const mdxCount = countMdxPosts(); + const contentCount = loadPostCountFromContentTs(); + expect(mdxCount, 'no MDX posts found on disk').toBeGreaterThan(0); + expect(contentCount, 'lib/content.ts is stale vs content/posts/').toBe(mdxCount); + }); + + it('generates the root site OG image', () => { + expect(fs.existsSync(ROOT_OG_PATH), `${ROOT_OG_PATH} does not exist`).toBe(true); + assertPng(ROOT_OG_PATH); + }); + + it('generates exactly one PNG per post', () => { + const expectedCount = loadPostCountFromContentTs(); + expect(fs.existsSync(OG_DIR), `${OG_DIR} does not exist`).toBe(true); + + const generatedPngs = fs.readdirSync(OG_DIR).filter((f) => f.endsWith('.png')); + expect( + generatedPngs.length, + `Expected ${expectedCount} per-post OG images (one per post in lib/content.ts), ` + + `got ${generatedPngs.length}. This usually means generate-og-images ran ` + + `before build-content — see PR #2436.`, + ).toBe(expectedCount); + }); + + it('every per-post PNG is non-empty and starts with the PNG magic bytes', () => { + const generatedPngs = fs.readdirSync(OG_DIR).filter((f) => f.endsWith('.png')); + for (const name of generatedPngs) { + assertPng(path.join(OG_DIR, name)); + } + }); +}); diff --git a/apps/guides/package.json b/apps/guides/package.json index d8e0ffd7af..a3a55a1bc0 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -3,7 +3,7 @@ "version": "2.0.25", "private": true, "scripts": { - "build": "bun run generate-og-images && bun run build-content && next build", + "build": "bun run build-content && bun run generate-og-images && next build", "build-content": "bun run scripts/build-content.ts", "clean": "bunx rimraf .next node_modules out", "demo-enhancement": "bun run scripts/demo-enhancement.ts", @@ -16,6 +16,7 @@ "start": "next start", "sync-to-r2": "bun run scripts/sync-to-r2.ts", "test": "vitest run --config vitest.config.ts", + "test:og": "bun run build-content && bun run generate-og-images && RUN_OG_PIPELINE_TEST=1 vitest run --config vitest.config.ts __tests__/og-images.test.ts", "test-enhancement": "bun run scripts/test-enhancement.ts", "update-authors": "bun run scripts/update-authors.ts" }, diff --git a/apps/guides/scripts/generate-og-images.ts b/apps/guides/scripts/generate-og-images.ts index 104bd96ed6..f30297a2db 100644 --- a/apps/guides/scripts/generate-og-images.ts +++ b/apps/guides/scripts/generate-og-images.ts @@ -13,6 +13,15 @@ * public/og-image.png — root / site-level OG image * public/og/[slug].png — per-post OG images * + * IMPORTANT — build order matters: + * This script reads posts from `lib/content.ts`, which is auto-generated by + * `scripts/build-content.ts` from the MDX files in `content/posts/`. If you + * run this script BEFORE `build-content`, you will generate OG images for + * whatever post set was last committed to `lib/content.ts`, not the current + * set on disk. The `package.json` "build" script must therefore invoke + * `build-content` BEFORE `generate-og-images`. The guard below catches the + * order-inversion bug if it is ever reintroduced. See PR #2436 for context. + * * Run: `bun run scripts/generate-og-images.ts` */ @@ -25,6 +34,38 @@ import { getGuidesOgImageElement, getPostOgImageElement, OG_IMAGE_SIZE } from '. const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); const OG_DIR = path.join(PUBLIC_DIR, 'og'); +const POSTS_DIR = path.join(import.meta.dir, '..', 'content', 'posts'); + +/** + * Detect the build-order-inversion bug from PR #2436: if there are many MDX + * source files on disk but `lib/content.ts` only reports a small number of + * posts, the caller almost certainly forgot to run `build-content` first. + * Failing fast here is much cheaper than shipping a build with stale OG images. + */ +function assertContentIsFresh(postCount: number): void { + let mdxFileCount = 0; + try { + mdxFileCount = fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith('.mdx')).length; + } catch { + // If the posts directory is missing for some reason, skip the guard rather + // than blocking generation entirely — the build will fail downstream. + return; + } + + // Threshold chosen to be well below the current ~500 posts but well above + // the historical stale value (39). Adjust freely if the corpus shrinks. + const STALE_POST_THRESHOLD = 50; + const MIN_MDX_FILES_FOR_GUARD = 50; + + if (mdxFileCount > MIN_MDX_FILES_FOR_GUARD && postCount < STALE_POST_THRESHOLD) { + throw new Error( + `generate-og-images: only ${postCount} posts found in lib/content.ts but ` + + `${mdxFileCount} MDX files exist in content/posts/. ` + + `Did you forget to run \`bun run build-content\` first? ` + + `The \`build\` script must run build-content BEFORE generate-og-images — see PR #2436.`, + ); + } +} async function renderToPng(element: ReturnType): Promise { const response = new ImageResponse( @@ -35,6 +76,11 @@ async function renderToPng(element: ReturnType): } async function generateOgImages(): Promise { + // Load posts and assert freshness BEFORE doing any expensive work so that + // the build-order bug surfaces in seconds rather than minutes. + const posts = getAllPosts(); + assertContentIsFresh(posts.length); + fs.mkdirSync(OG_DIR, { recursive: true }); // Root site image @@ -44,7 +90,6 @@ async function generateOgImages(): Promise { console.log(`✓ Generated ${path.relative(process.cwd(), rootPath)} (${rootBuffer.length} bytes)`); // Per-post images - const posts = getAllPosts(); for (const post of posts) { const buffer = await renderToPng( getPostOgImageElement({ diff --git a/packages/env/scripts/no-raw-process-env.ts b/packages/env/scripts/no-raw-process-env.ts index af55366592..37dfae6f4a 100644 --- a/packages/env/scripts/no-raw-process-env.ts +++ b/packages/env/scripts/no-raw-process-env.ts @@ -49,6 +49,9 @@ const ALLOWED: string[] = [ 'packages/api/scripts/validate-cloudflare-api-env.ts', // One-off sync script, not app code 'apps/guides/scripts/sync-to-r2.ts', + // Test-only gate flag: reads RUN_OG_PIPELINE_TEST to opt into the heavy + // OG-image pipeline test from `bun run --cwd apps/guides test:og`. + 'apps/guides/__tests__/og-images.test.ts', // Test files that mutate process.env to exercise env-validation logic 'packages/api/src/utils/__tests__/', // Admin env shim — parses process.env once at module load