Files
hoelee-blog/docs/ops-runbook.md
T
hoelee f4008d9e8b
Deploy / build (push) Failing after 13m52s
feat(structure): compact archive + tag pages + hero/featured, and fix site search
Found a real bug while verifying the nav search: /pagefind/pagefind.js is an ES
module that defines no window.Pagefind, but Search.astro loaded it with a classic
<script src> and read window.Pagefind — so every query answered "Search is
unavailable." (confirmed live before the fix). Now a dynamic import; the specifier
must be a variable or Rollup resolves it at build time and the build fails.

Measured before/after in a real browser:

- archive /posts/ + /zh/posts/: 18,220px -> 3,788px (71 rows at 44px, grouped by
  year) with a client-side filter (title/tags/description text, category chips,
  14 tag chips, zero requests). Tested: "nginx" -> 6 rows, chip devops -> 28.
- tag pages for tags with >= 3 posts. Measured 235 distinct tags (an earlier brief
  claimed 74), 156 used exactly once — pages only where a topic exists: 40 tags ->
  80 pages, build 165 -> 245 pages. Pills link only when a page exists.
- homepage + ZH homepage: hero (288px) with the real scale claim, three CTAs, a
  fixed Featured 4 (scale -> automation -> observability -> a delivered result),
  Latest 10 -> 6. ZH hero had linked "关于我" to the English /about/.
- long code blocks collapse above 420px with an Expand button + gradient, copy
  button still copies the whole block (verified on the 1010px block).
- search is visible ("⌕ Search /" pill, "/" plus the existing Ctrl/Cmd+K) and the
  zero-result screen offers topic chips instead of a dead end.

Docs: project-state B3 entry, ops-runbook Pagefind gotcha.
2026-10-16 21:18:25 +08:00

119 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ops & Deploy Runbook — blog.hoelee.com
How this blog is built, deployed, and kept alive. Read when debugging CI/CD, deployment, or hosting.
---
## Pipeline
```
Markdown (src/content/posts) → Astro build (dist/) → Gitea Actions → act_runner on unRaid
→ nginx container → Cloudflare tunnel → Cloudflare CDN → blog.hoelee.com
```
- **Repo:** `git.hoelee.com/hoelee/hoelee-blog` (push here first, then GitHub — standing convention).
- **Runner:** `act_runner` on unRaid (verified working: Gitea 1.27.3 + runner 3.3.2, PAT-secret pipeline).
---
## Write & publish a post
1. Create `src/content/posts/<slug>.md` with frontmatter (see `content-guide.md` §6).
2. Push to `main`.
3. CI builds `dist/` and deploys to the nginx container automatically.
- **Before pushing a post that adds art**, generate its in-page derivatives:
`uv run --with pillow python scripts/optimize-images.py <slug>`
(banner `.webp` + `/og/thumb/<name>-{400,800}.webp`). The PNGs from
`banner-gen` / `og-gen` remain the `og:image` source of truth — see
`design-guide.md` §3 for the format policy and measured sizes.
4. Verify live: `curl -I https://blog.hoelee.com/<slug>` → expect 200.
---
## Build pitfalls (from prior Astro work — check these first)
- **Pagefind is an ES module and exposes no window global.** `dist/pagefind/pagefind.js`
ends with `export { search, debouncedSearch, … }`. Load it with a dynamic `import()`, never
a classic `<script src>` + `window.Pagefind` — that combination silently made every search
answer "Search is unavailable." for as long as the overlay existed. The specifier must be a
**variable** (`const url = '/pagefind/pagefind.js'; await import(/* @vite-ignore */ url)`)
because Rollup resolves a literal dynamic-import path at build time and fails on a file that
does not exist until after the search index is generated.
- **No JSX helper components in frontmatter** — inline JSX directly in `.map()` calls, or the build fails with `Expected ">" but found "class"`.
- **Import depth** — `src/pages/*.astro` use `../layouts/…`; subdirs use `../../`. Wrong level = `Could not resolve`.
- **Footer must render AFTER `<main>`** — a shared header+footer component renders footer above content otherwise.
- **`npm install` scripts blocked** can leave esbuild's binary missing → check `npm warn install-scripts` at install time.
---
## Verify before shipping a layout change
Headless-Chrome screenshot + vision check on **both** 390px and 1280px:
```bash
CHROME="/c/Program Files/Google/Chrome/Application/chrome.exe"
"$CHROME" --headless --disable-gpu --window-size=1280,2400 \
--screenshot="$(cygpath -w $LOCALAPPDATA/Temp/blog.png)" \
--virtual-time-budget=4000 "http://localhost:4321/"
```
Overflow diagnosis (real numbers, not screenshot guessing): check `scrollWidth > innerWidth` via CDP `Runtime.evaluate`.
---
## nginx layer (unRaid container, Portainer stack 247)
The container `nginx-hoelee-blog` (`nginx:1.29-alpine`, host port 8081) had a **stock
`default.conf`** until 2026-10-16, when the config became a bind mount:
- `/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf` → `/etc/nginx/conf.d/default.conf:ro`
- Edits take effect after `docker exec nginx-hoelee-blog nginx -t && docker exec nginx-hoelee-blog nginx -s reload`
(no restart needed). Validate a candidate file first in a throwaway container:
`docker run --rm -v /mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro nginx:1.29-alpine nginx -t`
- Stack: Portainer **DSM instance** (endpoint 3 = unRaid), stack id **247**. Read/update via
`GET|PUT /api/stacks/247?endpointId=3`; PUT recreates the service (a few seconds of downtime).
What the conf adds, and why:
| Rule | Why |
|---|---|
| `location = /posts/zh/ { return 301 /zh/posts/; }` | Chinese posts live at `/posts/zh/<slug>/`, so the bare directory had no index and answered **403**. The Chinese archive is `/zh/posts/`. |
| `error_page 404 /404.html;` + internal location | Astro builds `dist/404.html`. Without it, a broken link showed nginx's default page. Served with a 404 status so crawlers do not index it. |
| `location /_astro/` → `max-age=31536000, immutable` | Astro hashes these filenames, so they can never go stale. `/og/*.png` and `/banners/*.png` are **not** hashed and are regenerated in place, so they deliberately keep Cloudflare's default TTL. |
| HTML → `max-age=0, must-revalidate` | A deploy must be visible immediately; edge caching is controlled separately by the Cloudflare Cache Rule + the deploy purge step. |
Header note: the public `Strict-Transport-Security` and the default 16-day asset
`Cache-Control` come from **Cloudflare zone settings**, not from this nginx (the origin
sends no such headers). See `docs/project-state.md` §1 B2 for the open CF items.
## Hosting notes
- Static output = trivial to serve from any nginx/OpenLiteSpeed dir. Cloudflare in front gives CDN + HTTPS + DDoS.
- Cloudflare Pages **git integration supports GitHub/GitLab only — not Gitea**. Current setup (Gitea → self-hosted runner → nginx) sidesteps this entirely and is the reason the pipeline is the way it is.
- Full self-hosting also avoids Cloudflare Pages' 25MB/file, 20k-files limits — a decision already baked into this architecture.
---
## Health checks
- `curl -I https://blog.hoelee.com/` → 200, correct `content-type`.
- `curl https://blog.hoelee.com/sitemap.xml` → lists all published posts.
- `curl https://blog.hoelee.com/rss.xml` → non-empty.
- DNS: `blog.hoelee.com` resolves through Cloudflare (proxy enabled).
## Pitfall: premature poll caches a 16-day 404
The origin serves `Cache-Control: max-age=1382400` (16 days), and Cloudflare caches
negative (404) responses too. If you curl a freshly-deployed URL **before the CI deploy
finishes**, Cloudflare caches that 404 for 16 days — the page itself returns 200 but its
`og`/`banner` PNGs 404 with `cf-cache-status: HIT`.
- **Detect:** the bare URL 404s but `?v=<timestamp>` (cache-buster) returns 200 → origin is
fine, edge cache is stale.
- **Fix:** purge the Cloudflare zone cache. Zone-level token (`Zone.Cache Purge` perm, lives
in the cloudflare-pages-deploy skill) → `POST /zones/8c8b2359766ac853602b91dd851c630e/purge_cache`
with `{"purge_everything":true}`. See that skill for the exact curl.
- **Prevent:** after `git push`, `sleep` ~60–90s before the first bare-URL check, or validate
with `?cb=$(date +%s)` first so you never register a 404 into edge cache.