Deploy / build (push) Failing after 13m52s
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.
119 lines
6.4 KiB
Markdown
119 lines
6.4 KiB
Markdown
# 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.
|