Skip to content

Deploy

One repo per project, two sites per repo, zero Cloudflare work per project. Merging to main builds the public site (<repo>) and the internal site (<repo>-internal) and deploys both as Cloudflare Workers with static assets. What is public and what is internal is decided by where a file lives (D11): src/content/reports/ and public/data/bundles/ are public; src/content/internal/ and apps/site/private/data/bundles/ are internal and are never read by the public build. The internal site is sign-in only — an emailed one-time code (Cloudflare Access) or a shared password — and the gate is configured in deploy/audience.json, provisioned by the workflow. Authors never touch git, wrangler or the Cloudflare dashboard: /publish opens a PR, CI gates it, a merge deploys.

PublicInternal
Buildpnpm buildpnpm build:internal (CSDR_AUDIENCE=internal)
Dev serverpnpm devpnpm dev:internal
Contentsrc/content/reports/**+ src/content/internal/**
Data bundlespublic/data/bundles/**+ apps/site/private/data/bundles/** (copied into dist by integrations/audience.mjs)
Worker<repo> — assets only, world-readable<repo>-internalworker/internal.ts in front of the assets (run_worker_first), fails closed
Labellingnonemasthead badge + footer line Internal — not for distribution, title (internal), noindex, robots.txt Disallow: /; internal reports: orange banner, INTERNAL watermark, label in the PDF footer/Subject/Keywords and the HTML export’s first line
Gatedeploy/audience.json: email (Access one-time code) or password (Basic auth)
PreviewsPRs build the internal audience and upload a gated preview version

Rules that are checked, not remembered (pnpm checkscripts/check-audience.mjs; CI also runs it against both dists): no visibility: internal under src/content/reports/; no public report reads a private bundle; the internal Worker keeps its gate script and run_worker_first: true; a public dist contains no internal page or private file; an internal dist has the notice and the robots file. pnpm test:audience (Playwright) checks the labelling on the internal build.

{
"internal": {
"gate": "email",
"allow": { "emailDomains": ["your-org.edu"], "emails": ["partner@example.com"] },
"signIn": ["email", "github"],
"sessionDuration": "24h",
"protectPublicPreviews": true
}
}
  • gate: "email" (default) — Cloudflare Access is attached to the internal Worker (production, previews and any custom domain at once). A visitor enters an email address; if it matches allow, Access emails a six-digit code; that is the whole login — no password, no authenticator app. signIn lists the buttons the login page shows — email (the code) and optionally github, google, microsoft, okta, saml, oidc; each must exist as a login method on the Zero Trust team (Settings → Authentication), and exactly one provider per method is pinned, so duplicates on the team never show. A GitHub/Google sign-in still has to match allow by its verified email. scripts/provision-access.mjs creates or updates the Access application from this file on every deploy (--dry-run prints what it would send). With protectPublicPreviews, the public Worker’s preview URLs get the same policy; public production stays public. Prerequisites, once per Cloudflare account: Zero Trust enabled (dashboard → Zero Trust → choose a team name; the free plan covers 50 users) and the API token holding Access: Apps and Policies: Edit.
  • gate: "password" — the Worker enforces HTTP Basic auth against the INTERNAL_USER / INTERNAL_PASSWORD secrets, which the deploy workflow sets from the GitHub secrets CSDR_INTERNAL_USER (default csdr) / CSDR_INTERNAL_PASSWORD. No Zero Trust needed. One shared credential: simplest, weakest, fine for low-stakes drafts.
  • How the email gate is enforced. Workers with static assets don’t receive ctx.access, so apps/site/worker/internal.ts verifies the Access token on every request itself (worker/access-jwt.ts: RS256 signature against the team’s published keys, issuer, expiry, and this application’s audience tag). Provisioning stores the two values it needs — ACCESS_TEAM_DOMAIN and ACCESS_AUD — on the Worker after creating the application, so a fresh project is gated a few seconds after its first deploy. Several login methods of the same type on the team (say, four GitHub apps)? providers: { "github": "<name or id>" } picks one; otherwise the oldest is pinned. pnpm provision:access --report (or Actions → Access report) lists the team’s login methods and which application pins which, so duplicates can be removed with confidence.
  • Either way the Worker fails closed: no Access provisioned, no valid token, or no password secrets, and every request gets a 403/503 that says why — never the site.

pnpm build produces apps/site/dist (static). apps/site/wrangler.jsonc deploys it as a Cloudflare Worker with static assets — no SSR adapter, not_found_handling: "404-page"; env.internal adds the gate Worker.

.github/workflows/deploy.yml runs on every push to main: pnpm check → Chromium → build public (+ PDFs) → wrangler deploy --name <repo> → build internal → wrangler deploy --env internal --name <repo>-internal → set the password secrets or provision Access. Worker names come from the repository name (override with the WORKER_NAME variable); URLs default to https://<repo>.<account>.workers.dev and https://<repo>-internal.<account>.workers.dev.

Custom domains are two repository variables, nothing in the repo: SITE_URL=https://test.example.org and INTERNAL_SITE_URL=https://test-internal.example.org (hostnames on a zone in the same Cloudflare account). The workflow runs scripts/wrangler-ci-config.mjs, which writes apps/site/wrangler.ci.jsonc (= wrangler.jsonc + routes: [{ pattern, custom_domain: true }], gitignored), and wrangler deploy --config wrangler.ci.jsonc creates the DNS records and certificates. The token then also needs Zone · DNS · Edit and Zone · Workers Routes · Edit for that zone. Keep to one label under the apex (test-internal.example.org, not internal.test.example.org) — Free-plan Universal SSL covers *.example.org only. The checked-in wrangler.jsonc stays generic so every project made from the template can claim its own hostnames through its own variables.

By hand (platform engineers only; wrangler login once): pnpm build && pnpm deploy, pnpm build:internal && pnpm deploy:internal, pnpm provision:access --internal <name>-internal --public <name>.

  1. Cloudflare: an account; Zero Trust enabled if you want the email gate. Create an Account API token (Manage Account → Account API Tokens → Create → Custom; account-owned, so any admin can rotate it) with exactly: Account · Workers Scripts · Edit, Account · Account Settings · Read, Account · Access: Apps and Policies · Edit, Account · Access: Organizations, Identity Providers, and Groups · Read (lets provisioning pin one login button per signIn method and read the team domain; without it everything still works but the login page shows every method on the team), Zone · DNS · Edit, Zone · Workers Routes · Edit, Zone · Zone · Read — Zone Resources: All zones from an account (so future custom domains need no new token). Note the account ID (dashboard URL or wrangler whoami).
  2. GitHub: gh auth refresh -s admin:org once, then gh secret set CLOUDFLARE_ACCOUNT_ID --org <org> --visibility private --body <id> and gh secret set CLOUDFLARE_API_TOKEN --org <org> --visibility private (paste at the prompt). Org-level secrets flow into every private project repo created from the template (GitHub Team/Enterprise; on Free, the /setup-project skill sets them per repo). Turn on GitHub Actions for the org.
  3. Make this repository a template repository (Settings → Template repository).

Setting up a project (per repo — /setup-project does this)

Section titled “Setting up a project (per repo — /setup-project does this)”
  1. Create the repository from the template (private), clone, pnpm install.
  2. Name it: apps/site/src/site.config.ts (name, org, tagline, url); optionally WORKER_NAME.
  3. Decide the gate: edit deploy/audience.json (replace the placeholder domain!). For password, gh secret set CSDR_INTERNAL_PASSWORD.
  4. Push, open a PR, merge. Both Workers exist a few minutes later; the internal one is gated. That is the last time anyone thinks about deployment.

.github/workflows/preview.yml runs on every pull request (set the repository variable PREVIEW_VIA_ACTIONS=false to turn it off): it builds the internal audience (drafts and internal reports included, PDFs included) and uploads it as a version of the internal Worker with the alias pr-<n>, then comments the URL on the PR. Preview URLs are gated exactly like the internal site. The Worker must exist — merge to main once first.

Do not also connect the repository to Cloudflare Workers Builds: two deployers racing to the same Worker is the one way to make this messy, and Workers Builds has no Chromium so its previews would lack the PDFs.

Static assets are limited to 25 MiB per file (and 20 k / 100 k files per version). Data bundles above that go to R2 — set baseUrl in the manifest and keep only the manifest in the repo. The same applies to large PDF collections over time.

pnpm build writes two files next to every versioned report page (dist/reports/<slug>/v<version>/), controlled by the report’s outputs: frontmatter:

  • <slug>-v<version>.pdf — A4, tagged (structure tree), with a document outline, running header (title · version/date) and footer (organisation · URL · page n of N), a cover and contents page for type: report, a status watermark for drafts/reviews, and document metadata (title, authors, subject, keywords, language, dates) stamped from the page’s citation tags. Everything about the page comes from packages/theme/src/print.css (@page margin boxes on PatternFly tokens; static Red Hat instances from packages/theme/fonts/print/ so fonts embed as CID TrueType rather than Type 3); scripts/build-pdf.mjs only prints with Playwright Chromium and stamps the metadata with pdf-lib. It needs the browser: pnpm exec playwright install chromium. Without it the build logs a notice and skips; set CSDR_PDF=1 to make that fatal (CI does), CSDR_PDF=0 to skip on purpose. pnpm pdf [--only <slug>] re-renders against an existing dist.
  • <slug>-v<version>.html — one file: CSS and fonts inlined, chrome/TOC/actions removed, links made absolute, JSON-LD citation metadata kept, a provenance line at the top, and charts as PNG images at 2× (rendered by the same Chromium — Word, Google Docs and mail clients render PNG but not inline SVG; without Chromium the SVG stays). A short block of literal CSS values follows the token CSS so Word and Docs, which ignore var(), still get the type and colours. It replaces a DOCX pipeline for the common case. pnpm export:html re-runs it.

pnpm test includes tests/exports.spec.ts: both files present, structurally sound, the export accessible on its own, the PDF tagged and outlined with its metadata, no Type 3 fonts, paginated without orphaned captions, contents numbers pointing at the right pages (uses poppler’s pdftotext/pdffonts when installed). pnpm test:vr (Linux CI) additionally rasterises every PDF page with pdftoppm and compares it with the committed baseline — a caption that moves pages fails the build. Baselines are rendered on Linux CI like the other visual snapshots; download playwright-report/test-results from the first run and commit tests/__snapshots__/vr.spec.ts/*.png.

Exports live in dist and deploy with the Worker (25 MiB per file, well above any report PDF). If the collection grows or you want a stable assets host, mirror dist/reports/**/*.{pdf,html} to an R2 bucket after the build and point outputs links there:

Terminal window
for f in apps/site/dist/reports/*/v*/*.pdf apps/site/dist/reports/*/v*/*.html; do
pnpm exec wrangler r2 object put "csdr-exports/${f#apps/site/dist/}" --file "$f" --content-type "$( [[ $f == *.pdf ]] && echo application/pdf || echo 'text/html; charset=utf-8')"
done

Skip unchanged reports by comparing a hash of the report’s index.html (the source of both exports) rather than the export bytes: PDFs carry a modification date and are not byte-identical between builds. Keep the <slug>-v<version> names — the report page links, citation_pdf_url and the “Download” buttons all derive from them.

The Actions preview workflow installs Chromium, so previews carry the PDFs.