Deploy
The model in one paragraph
Section titled “The model in one paragraph”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.
Audiences: public and internal
Section titled “Audiences: public and internal”| Public | Internal | |
|---|---|---|
| Build | pnpm build | pnpm build:internal (CSDR_AUDIENCE=internal) |
| Dev server | pnpm dev | pnpm dev:internal |
| Content | src/content/reports/** | + src/content/internal/** |
| Data bundles | public/data/bundles/** | + apps/site/private/data/bundles/** (copied into dist by integrations/audience.mjs) |
| Worker | <repo> — assets only, world-readable | <repo>-internal — worker/internal.ts in front of the assets (run_worker_first), fails closed |
| Labelling | none | masthead 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 |
| Gate | — | deploy/audience.json: email (Access one-time code) or password (Basic auth) |
| Previews | — | PRs build the internal audience and upload a gated preview version |
Rules that are checked, not remembered (pnpm check → scripts/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.
The gate — deploy/audience.json
Section titled “The gate — deploy/audience.json”{ "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 matchesallow, Access emails a six-digit code; that is the whole login — no password, no authenticator app.signInlists the buttons the login page shows —email(the code) and optionallygithub,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 matchallowby its verified email.scripts/provision-access.mjscreates or updates the Access application from this file on every deploy (--dry-runprints what it would send). WithprotectPublicPreviews, 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 theINTERNAL_USER/INTERNAL_PASSWORDsecrets, which the deploy workflow sets from the GitHub secretsCSDR_INTERNAL_USER(defaultcsdr) /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, soapps/site/worker/internal.tsverifies 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_DOMAINandACCESS_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.
Production
Section titled “Production”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>.
Setting up an organisation (once)
Section titled “Setting up an organisation (once)”- 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
signInmethod 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 orwrangler whoami). - GitHub:
gh auth refresh -s admin:orgonce, thengh secret set CLOUDFLARE_ACCOUNT_ID --org <org> --visibility private --body <id>andgh 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-projectskill sets them per repo). Turn on GitHub Actions for the org. - 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)”- Create the repository from the template (private), clone,
pnpm install. - Name it:
apps/site/src/site.config.ts(name, org, tagline, url); optionallyWORKER_NAME. - Decide the gate: edit
deploy/audience.json(replace the placeholder domain!). Forpassword,gh secret set CSDR_INTERNAL_PASSWORD. - 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.
Previews
Section titled “Previews”.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.
Big files
Section titled “Big files”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.
Exports: PDF and self-contained HTML
Section titled “Exports: PDF and self-contained HTML”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 fortype: 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 frompackages/theme/src/print.css(@pagemargin boxes on PatternFly tokens; static Red Hat instances frompackages/theme/fonts/print/so fonts embed as CID TrueType rather than Type 3);scripts/build-pdf.mjsonly 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; setCSDR_PDF=1to make that fatal (CI does),CSDR_PDF=0to skip on purpose.pnpm pdf [--only <slug>]re-renders against an existingdist.<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 ignorevar(), still get the type and colours. It replaces a DOCX pipeline for the common case.pnpm export:htmlre-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.
Mirroring exports to R2
Section titled “Mirroring exports to R2”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:
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')"doneSkip 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.