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>-internal — worker/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
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.

{
"internal": {
"gate": "email",
"allow": { "emailDomains": ["your-org.edu"], "emails": ["partner@example.com"], "githubOrgs": ["your-github-org"] },
"signIn": ["email", "github"],
"sessionDuration": "24h",
"protectPublicPreviews": true
}
}

Which gate? The email code is the default and the one to take unless a line below rules it out:

Email code ("email", default)Shared password ("password")
Who can get inThe people named in allow — an organisation’s domain, individual addresses, a GitHub organisation. Each signs in as themselves.Anyone who has the one username + password. Nobody is named; a forwarded link and password is a forwarded key.
What you have to doNothing. The deploy after the merge creates the sign-in itself from the file.Set the CSDR_INTERNAL_PASSWORD secret by hand before the merge, and rotate it yourself when someone leaves.
What can go wrongThe organisation’s Cloudflare account has never turned on Zero Trust (once, by an admin) — the internal site stays locked until it is. The deploy’s preflight says so.The secret is missing at merge time (the internal site answers 503 until it is set and Deploy is re-run), or the password leaks — it is one secret for everyone, and there is no list of who has it.
Take it whenAlmost always.The organisation cannot enable Zero Trust, or the site genuinely needs a link + password to hand around.
  • 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 — unless the account belongs to an organisation in allow.githubOrgs ("org" or "org/team"): membership is checked at login and admits the member with no email match. That needs github in signIn, and the GitHub organisation must have authorised the sign-in OAuth app (GitHub → organisation Settings → Third-party access) — until it does, membership is invisible and members are refused. 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.

  • Adding features beyond the template? The token may need more. The permission list above covers exactly what this template’s pipeline does. Projects that bolt on common extras hit predictable gaps — ask the org admin for these when the feature arrives, not after it fails in CI:

    If a project adds…The token also needs
    A comment/feedback endpoint or rate limiting (Workers KV)Account · Workers KV Storage · Edit
    File or asset storage, downloadable documents (R2)Account · Workers R2 Storage · Edit
    Anything hosted on Cloudflare PagesAccount · Cloudflare Pages · Edit

    Every deploy starts with a token preflight (scripts/preflight-token.mjs) that probes what the deploy is about to do and names any missing permission in the run’s summary — so a gap costs one glance at the failed run, not a support round-trip.

  • Adding a Worker secret by hand? The dashboard can silently not apply it. The template’s own secret paths avoid this (CI sets secrets during deploys), but any secret added through the Cloudflare dashboard (Worker → Settings → Runtime variables and secrets) can attach to a non-active version of the Worker — no Deploy button appears, the list shows the secret as saved, and the running Worker still doesn’t have it. The reliable fix: trigger any fresh deploy of that Worker (re-run its deploy workflow) — a new version activates carrying all stored secrets. The one-minute diagnostic when a Worker claims to be unconfigured: send it a request that passes validation with a deliberately bad token/credential — a “not configured” response means the active version lacks the secret; an “invalid credential” response means configuration is complete and the problem is elsewhere. (Cost an hour on a live project before it was named; it doesn’t get to cost that twice.)

  • Setting a Worker secret from a script? --name only, never with --env. wrangler secret put (unlike wrangler deploy) appends the environment to an explicit name: --env internal --name my-site-internal writes the secret to a Worker called my-site-internal-internal — created on the spot, no script, holding your password — while the real Worker keeps answering 503. The deploy workflow uses --name alone. A project that hit this before the fix (September 2026) has such a stray Worker to delete in the dashboard, and a password worth rotating.

  • A fresh project’s file is the template’s, and nothing is published until it is rewritten. The template ships deploy/audience.json with its own allow list plus a top-level templateDefault key naming the template repository. In any other repository the deploy stays green but holds back: sign-in is not provisioned (the internal site stays locked instead of admitting the template’s people) and the public Worker is a being set up holding page instead of the template’s example reports under your project’s name. Both Workers exist from that first run, so the names and addresses are claimed; the job summary says what to do next. /setup-project rewrites allow and deletes the key; pnpm check warns while it remains.

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 <name> → build internal → wrangler deploy --env internal --name <name>-internal → set the password secrets or provision Access.

Where the sites live is a file: deploy/site.json — the companion of deploy/audience.json (who may sign in). Three fields, all optional:

{ "name": "bbnj-brief", "publicUrl": "https://bbnj-brief.example.org", "internalUrl": "https://bbnj-brief-internal.example.org" }
  • name — the Worker name; the internal Worker is <name>-internal. Blank = the repository name. Decide it before the first merge: renaming later creates new Workers and leaves the old ones for an admin to delete.
  • publicUrl / internalUrl — blank = the automatic addresses https://<name>.<account>.workers.dev and https://<name>-internal.<account>.workers.dev. Set them to hostnames on a zone in the same Cloudflare account and the next deploy attaches them as Worker custom domains: scripts/wrangler-ci-config.mjs 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 (token: Zone · DNS · Edit + Zone · Workers Routes · Edit). One label under the apex (bbnj-brief-internal.example.org, not internal.bbnj-brief.example.org) — Free-plan Universal SSL covers *.example.org only.
  • On a shared zone, the address is the name. Every project in the organisation can deploy to the same zone from its own repository, so two rules keep them from treading on each other, enforced by the deploy preflight before any build minute is spent: (1) a new address must be <name>.<apex> for the public site and <name>-internal.<apex> for the internal one (an internal-only project with no public address may take <name>) — want reports.example.org? Name the project reports; (2) an address that is already another Worker’s stops the deploy with the holder named. That second rule matters because wrangler deploy in CI has no terminal to ask on and answers its own “already exists — take it over?” prompt with yes; without the check, the second project to merge the same hostname would silently capture it. An address a project already holds is kept whatever it is called (the template’s own test.csdr.global predates the convention), and a hand-made DNS record at a new address stops the deploy instead of being replaced. pnpm check says which addresses the convention would refuse before the PR is opened.

scripts/deploy-config.mjs resolves the file (run it locally to see exactly what a merge would create; pnpm check validates it: usable name, https:// hostnames without paths, distinct public/internal). Repository variables WORKER_NAME / SITE_URL / INTERNAL_SITE_URL are still read, but only for fields the file leaves blank — the file is the record, and it travels with the repo, is reviewed in the PR and needs no gh variable set from anyone. The checked-in wrangler.jsonc stays generic so every project made from the template claims its own names and hostnames through its own deploy/site.json.

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, or the organisation’s secrets do not reach it — clone, pnpm install. Its initial commit already ran Deploy once: green, both Workers created, nothing published (holding page + locked internal site) until step 4.
  2. Name it: apps/site/src/site.config.ts (name, org, tagline); deploy/site.json (Worker name and, if wanted, the two custom addresses). Delete tests/__snapshots__ — the template’s visual-regression screenshots carry its wordmark and would fail against the renamed site; the project’s own arrive by themselves after step 4.
  3. Decide the gate: edit deploy/audience.json (replace the placeholder domain, delete templateDefault). For password, gh secret set CSDR_INTERNAL_PASSWORD before step 4.
  4. Push, open a PR, merge. Both sites are live a few minutes later; the internal one is gated. A small bot PR follows with the project’s screenshots (test(vr): this project’s visual-regression baselines) — merge it. That is the last time anyone thinks about deployment.

Keeping a project current with the template

Section titled “Keeping a project current with the template”

Projects are created from the template with GitHub’s Use this template, which copies the tree without the history — so later template improvements do not arrive by themselves. pnpm template:sync (the /update-template skill runs it and opens the PR) closes that gap without turning projects into forks:

  1. pnpm template:sync fetches the template named in .template-sync.json and reports the commits and files that changed since this project’s starting commit (found once by matching the repository’s initial commit against the template’s history, then recorded as sha).
  2. pnpm template:sync --apply stages all of it as one change: a squash of the template’s commits, three-way merged against that starting commit (git is briefly told the project’s root commit descends from it — git replace --graft, local, removed afterwards), so the project’s own edits survive. Paths the project owns — reports, internal reports, bundles, analyses, deploy/*.json, site.config.ts, README — keep the project’s version when both sides changed them, and the script says so. Genuine conflicts elsewhere are left for review; --continue finishes after they are resolved.
  3. pnpm check, commit as infra: sync with template <sha>, PR, merge on green — the next Deploy ships it.

Details that matter in practice: a repository that shares the template’s history (a plain clone) uses the git merge base instead of the graft; generated files (pnpm-lock.yaml, palettes.generated.ts, palettes/*.css, patternfly-entries.json) and the sync tooling itself take the template’s side on conflict and the script tells you what to regenerate; exit code 2 means “conflicts, then --continue”, 3 means “this is the template — nothing to do”.

Automatic: .github/workflows/template-sync.yml runs the same thing as a bot — Actions → Template sync → Run workflow, or weekly with the repository variable TEMPLATE_SYNC_AUTO=true — and opens the PR (or an issue when a person must resolve conflicts). It wants the secret TEMPLATE_SYNC_TOKEN (fine-grained PAT: Contents read on the template; Contents, Pull requests, Workflows read/write on the projects); with only GITHUB_TOKEN it cannot read a private template or touch .github/workflows/, and its PRs do not start CI.

Older projects that predate the tooling bootstrap it once: git remote add template https://github.com/SustainableDevelopmentReform/csdr-project.git && git fetch template main && git checkout template/main -- scripts/template-sync.mjs .template-sync.json .claude/skills/update-template/SKILL.md .github/workflows/template-sync.yml.

Three levers, from smallest to largest — none needs the Cloudflare dashboard, because CI holds the token; every one is a pull request someone can read.

NeedLeverReversible?
One report must gostatus: withdrawn + withdrawn: { date, reason } on its versions → a tombstone page (no body, no exports, noindex, still at its address so citations resolve). See Reports.Yes — change the status back
The site (public and/or internal) must stop showing content — for a while, or for goodThe offline switch in deploy/site.json: "offline": { "public": true, "internal": false, "permanent": false, "message": "…" } → merge → that Worker is redeployed as a plain holding page (apps/site/worker/offline.ts): every path answers 503 + Retry-After while permanent is false, 410 Gone when true; noindex, no-store, robots.txt disallow. The Worker keeps its name, address, custom domain and (internal) sign-in gate — only the assets are not shipped. pnpm check warns loudly while the switch is on.Yes — set it back to false and merge; the site is rebuilt from the repo
The project is finished: remove the Cloudflare footprintActions → Decommission (.github/workflows/decommission.yml): type the repository name, choose both / public / internal. It refuses unless the offline switch is already on for what you remove (so the record is a reviewed PR and the next merge cannot quietly recreate the site), then deletes the Access applications (provision-access.mjs --remove), the Worker(s) (wrangler delete) and any custom domains still attached, and prints what — if anything — is left for an org owner (a DNS record that still resolves the next day). Then archive the repository so nothing deploys again.Reversal = offline back to false + merge (everything is recreated)

What an org owner may still be asked to do: delete a lingering DNS record in the zone; revoke the API token if the project’s credentials should die with it; delete the Zero Trust login methods no other project uses (Actions → Access report shows who pins what).

.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.