## Overview

zudo-doc is a fully static site generator — the output of `pnpm build` is a directory of HTML, CSS, and JavaScript files that can be deployed anywhere. The default deployment target is **Cloudflare Workers** (static assets), using GitHub Actions for CI/CD automation.

This guide documents the zudo-doc **showcase repository's** host-owned Worker and workflows. A
fresh downstream scaffold does not include these workflow files, the live AI handler,
`worker-entry.ts`, or `AiChatDailySpendCap`; use this as a reference when adding those host pieces.

The CI pipeline is split into parallel jobs to keep total build time low. Site generation and document history extraction run as separate jobs and are merged before deployment.

## Cloudflare Workers Static Assets

The built site is deployed using `wrangler deploy`. The deploy artifact includes:

- The zfb build output (`dist/`)
- Pre-generated doc history JSON files (if `docHistory` is enabled)
- `worker-entry.ts` — the source-owned Wrangler entry; it imports generated `dist/_worker.js`, which retains `dist/_zfb_inner.mjs`

The site is served at root (`base = "/"`) via the Workers static asset layer. Dynamic routes (e.g. `/api/ai-chat`) are handled by `dist/_worker.js`; all other GET requests are served as static assets from `dist/`.

[Dynamic content unavailable in text export]

The site URL is configured via `[[routes]]` in `wrangler.toml` (custom domain binding). Live AI
chat also uses the `ANTHROPIC_API_KEY` secret, `DOCS_SITE_URL`, soft per-IP `RATE_LIMIT` KV, and
the `AI_CHAT_DAILY_SPEND_CAP` namespace bound to exported class `AiChatDailySpendCap`. Its Worker
migration is `v1-ai-chat-daily-spend-cap` with
`new_sqlite_classes = ["AiChatDailySpendCap"]`; production `wrangler deploy` applies it.

The deployment entry is custom `worker-entry.ts`, not the generated adapter entry alone. The build
must run before Wrangler so `dist/_worker.js` and `dist/_zfb_inner.mjs` exist; the custom entry
preserves both the adapter handler and Durable Object export. PR CI restores that exact build
artifact, runs Workers-runtime SQLite tests, then dry-runs Wrangler against the same files.



## CI Pipeline Structure

The project uses three GitHub Actions workflows: production deploys (`main-deploy.yml`), PR checks and previews (`pr-checks.yml`), and branch previews (`preview-deploy.yml`).

### Production (`main-deploy.yml`)

Triggered on push to `main`. Runs five jobs:

1. **build-site** — full clone (`fetch-depth: 0`), builds the zfb site with `pnpm build` (no `SKIP_DOC_HISTORY` — the preBuild step needs full git history to populate the SSG meta block)
2. **html-validate** — validates the built `dist/` for HTML element errors
3. **build-history** — full clone (`fetch-depth: 0`), runs `@takazudo/zudo-doc-history-server generate` to pre-extract git history for all content files
4. **deploy** — merges the site and history artifacts, then deploys via `wrangler deploy` (production)
5. **notify** — sends an IFTTT webhook notification with the deploy status

**Concurrency:** `group: production-deploy, cancel-in-progress: false` — earlier deploys are allowed to finish rather than being cancelled.

### PR Checks (`pr-checks.yml`)

Triggered on pull requests targeting `main`. Runs twenty jobs:

1. **check-template-drift** — verifies no drift between source files and generator templates
2. **check-no-host-alias-in-package** — rejects host-only import aliases from package sources
3. **check-pin-parity** — verifies `@takazudo/zfb` pin is consistent across all package sources
4. **check-fixture-settings-drift** — verifies every key in `src/config/settings.ts` is present in all e2e fixture settings files (or allowlisted)
5. **check-package-wind-manifest** — verify the public `wind.json` manifest and authored class coverage
6. **check-b4push-ci-parity** — verifies every lightweight guard gate in `scripts/run-b4push.sh` has a matching CI job
7. **check-e2e-spec-naming** — verifies all e2e spec filenames use known fixture prefixes so Playwright picks them up
8. **check-flaky-tracking-issue** — requires tracked issues for flaky/local-only tests
9. **check-wait-debt** — rejects unannotated fixed waits in E2E tests
10. **check-component-tokens** — verifies generated component-token CSS is current
11. **lint-gates** — runs tag, compatibility, resolution, and design-token guards
12. **typecheck** — runs root and workspace TypeScript checks
13. **worker-contract** — restores the `build-site` artifact, runs Workers-runtime Durable Object tests, and verifies the Wrangler dry-run
14. **package-tests** — runs all package-scoped vitest suites
15. **root-tests** — runs the root unit test suite
16. **build-site** — full clone (`fetch-depth: 0`), `pnpm build`, then `check:links` (no `SKIP_DOC_HISTORY` — same rationale as production: preBuild needs full history for SSG meta dates)
17. **html-validate** — validates the built `dist/` for HTML element errors
18. **build-history** — full clone, doc history generation
19. **e2e** — runs Playwright end-to-end tests
20. **preview** — merges artifacts and uploads a preview via `wrangler versions upload --preview-alias`, then posts the URL as a PR comment

**Concurrency:** `group: pr-checks-{pr-number}, cancel-in-progress: true` — outdated runs are cancelled to save CI minutes.

### Preview Deployments

Each PR gets a **stable preview-alias** URL via `wrangler versions upload --preview-alias <alias>`. The generated adapter-only preview config keeps `preview_urls = true` while leaving the primary `workers_dev` route disabled, just like production. The alias is `pr-<number>` for PR checks and the branch slug for branch previews. The URL is on `*.workers.dev` (not the production custom domain) and its subdomain starts with the alias:

```
https://<alias>-zudo-doc-preview.<your-subdomain>.workers.dev
```

CI parses the exact host from wrangler's output and posts it automatically as a comment on the PR by the **preview** job.
When the isolated service is first created, CI explicitly enables only its preview-URL subdomain setting through the Cloudflare API, then retries the aliased upload.
Its entry sends GET/HEAD directly to `ASSETS` with an internal hostname and delegates other methods to the generated adapter. Cloudflare's asset binding matches only the pathname; this avoids treating the preview alias as a same-zone Worker fetch while retaining the API POST smoke gate. Production continues to export the generated adapter directly.

Cloudflare does not issue preview URLs for versions implementing Durable Objects. Preview workflows
therefore generate an adapter-only config/entry for a separate preview service after the site build.
The preview validates SSR and assets, but does not bind `AI_CHAT_DAILY_SPEND_CAP` or apply its
migration, production `RATE_LIMIT` KV, or production's custom-domain route; the live AI admission and
rate-limit path is production-only.

## SKIP_DOC_HISTORY

When `SKIP_DOC_HISTORY=1` is set, the doc-history zfb plugin skips all git history calls and writes an empty manifest, causing the visible Created/Updated/Author block to be absent from every SSG page. In standard CI this flag is **not** set on the `build-site` job — the preBuild step needs full git access to populate real dates.

| Context | Value | Why |
|---|---|---|
| CI `build-site` job | unset | preBuild needs full git history to populate the SSG meta block (Created/Updated/Author) |
| CI `e2e` job | unset | E2E tests verify history features inline |
| Local `pnpm build` | unset | The Created/Updated/Author meta block is embedded (preBuild). The per-page history dropdown JSON is **not** generated locally by default — see GEN_DOC_HISTORY below |

[Dynamic content unavailable in text export]

`SKIP_DOC_HISTORY=1` is an escape hatch for shallow-clone or custom CI variants where git history is unavailable. Setting it causes the Created/Updated/Author block to be absent from every page.



The JSON decision table below assumes `docHistory: true` and `docHistoryUi: true`. With `docHistoryUi: false`, the plugin skips JSON generation and the dev proxy regardless of environment variables. The preBuild manifest is retained unless `SKIP_DOC_HISTORY=1` blanks it; `DOC_HISTORY_SKIP_POSTBUILD=1` is redundant. Remove a host’s separate `build-history` job and artifact handoff steps too: the standalone generator CLI is not disabled by this setting. See the [Document History guide](https://zudo-doc.takazudomodular.com/docs/guides/doc-history) for configuration and the full interaction table.

## GEN_DOC_HISTORY

`GEN_DOC_HISTORY=1` (introduced in #1986) gates the doc-history plugin's **postBuild** hook — the step that writes the per-page revision/diff JSON into `dist/doc-history/` feeding the DocHistory dropdown island. The plugin's **preBuild** hook (the visible Created/Updated/Author meta block) is unaffected and is governed by `SKIP_DOC_HISTORY` alone. postBuild now follows this decision table:

| Context | postBuild JSON | Why |
|---|---|---|
| `SKIP_DOC_HISTORY=1` | skipped | overrides everything below — wins over `DOC_HISTORY_SKIP_POSTBUILD`, `GEN_DOC_HISTORY`, and CI |
| `DOC_HISTORY_SKIP_POSTBUILD=1` | skipped | overrides `GEN_DOC_HISTORY` and CI, loses only to `SKIP_DOC_HISTORY` |
| `GEN_DOC_HISTORY=1` | generated | explicit local opt-in |
| `CI` / `GITHUB_ACTIONS` set | generated | keeps the `build-site` artifact byte-identical to before |
| plain local `pnpm build` | skipped | the #1986 default — see Tip below |

[Dynamic content unavailable in text export]

postBuild is off by default locally because its per-content-file `git log --follow` chain exceeds zfb's 120s postBuild lifecycle budget on a large corpus and fails a plain `pnpm build`. It is redundant locally anyway: dev reads history live from the standalone `:4322` server, and CI generates the JSON in the dedicated parallel `build-history` job. To make the dropdown work when previewing a locally-built `dist/` (or building the Tauri offline reader), run `GEN_DOC_HISTORY=1 pnpm build`. Note this gates only the zfb plugin's postBuild — the standalone `@takazudo/zudo-doc-history-server generate` CLI used by the `build-history` job is never gated and always generates when invoked.



## DOC_HISTORY_SKIP_POSTBUILD

`DOC_HISTORY_SKIP_POSTBUILD=1` (introduced in #2927) skips only the postBuild per-page dropdown JSON step from the decision table above — the same step `GEN_DOC_HISTORY` opts into. Unlike `SKIP_DOC_HISTORY=1`, it does **not** blank the preBuild manifest, so the Created/Updated/Author block still shows real git dates.

Use it for a shallow-clone CI variant: one with enough git history for the cheap preBuild `--name-status` walk, but that wants to explicitly opt out of the heavier per-file postBuild `git log --follow` chain rather than relying on the plain-local-build default (which would not apply if that variant also sets `CI=true`, since CI otherwise generates).

[Dynamic content unavailable in text export]

The name deliberately does not contain the substring `SKIP_DOC_HISTORY`, so it is a distinct marker for `scripts/check-compatibility-contract.ts`'s literal survivor scan and needs no allowlist changes.



## Inter-Job Data Sharing

Build jobs tar their output and publish it with **`actions/upload-artifact`**. Consumer jobs use
`actions/download-artifact` and extract the archives from `${{ runner.temp }}` before validation
or deployment:

```yaml
name: site-dist       # contains site-dist.tar
name: doc-history     # contains doc-history.tar
retention-days: 1
```

The **deploy** and **preview** jobs restore both archives and merge doc history into `dist/` before
Wrangler. The **worker-contract** job restores the same `site-dist` artifact, so runtime tests and
the dry-run verify the exact build later used by preview/deploy jobs.

[Dynamic content unavailable in text export]

Tar keeps arbitrary route filenames intact during artifact upload. One-day retention bounds
storage while preserving a run-scoped paper trail; this handoff does not rely on mutable cache
keys or cross-run cache state.



## Required Secrets

Configure these in your repository's **Settings → Secrets and variables → Actions**:

| Secret | Description |
|---|---|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Workers deployment permissions |
| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID |
| `IFTTT_PROD_NOTIFY` | IFTTT webhook URL for deploy notifications (optional) |

[Dynamic content unavailable in text export]

The `CLOUDFLARE_API_TOKEN` needs the **Workers: Edit** permission (and **KV Storage: Edit** if you use KV namespaces). Scoping it to the specific Worker is recommended over granting account-wide access.