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
202 changes: 202 additions & 0 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
@@ -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/<name> 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
Comment on lines +84 to +87
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
59 changes: 59 additions & 0 deletions apps/guides/README.md
Original file line number Diff line number Diff line change
@@ -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/<slug>.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) |
Comment on lines +56 to +57
| `bun run lighthouse` | Build + run LHCI assertions |
| `bun run sync-to-r2` | Sync content to `packrat-guides` R2 bucket |
91 changes: 91 additions & 0 deletions apps/guides/__tests__/og-images.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +76 to +82
});

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));
}
});
});
3 changes: 2 additions & 1 deletion apps/guides/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
Loading
Loading