# Deploy — Cloudflare Pages (public URL while the repo stays private)

The public static site (see [site.md](site.md)) can be published to **Cloudflare Pages**
by the [`.github/workflows/deploy-cloudflare.yml`](../.github/workflows/deploy-cloudflare.yml)
workflow, in addition to the GitHub Pages path in [deploy.md](deploy.md).

## Why a second deploy path

GitHub Pages ties "the site is live" to "the repo is public" — Pages on a private repo
needs a paid plan, and otherwise you must make the repo public first (itself a separate,
explicit decision). Cloudflare Pages does **not**: `wrangler pages deploy` uploads the
already-built `_site/` directory to Cloudflare with a scoped API token, and Cloudflare
serves it at a public URL **regardless of the GitHub repo's visibility**.

That is exactly what the demonstrator's *convince-me* package needs: a **stable public URL
you can open on a phone** to see the real site — the incredible UI, the knowledge graph, the
weekly briefs — **while the repo stays private** until the public-flip decision is made
separately. The local `build_site.py --serve` preview is `127.0.0.1`-only (and the box's
watchdog-served copy is LAN-only and restarts on a schedule); Cloudflare Pages is the durable
off-network URL.

> **The two paths are not exclusive.** Cloudflare Pages is the preview-while-private URL now,
> and can stay the canonical host after the flip; GitHub Pages ([deploy.md](deploy.md)) remains
> the repo-native option that lights up automatically once the repo is public. Neither
> publishes anything until its own gate is satisfied.

## Published URL

Once configured (below), the site serves at:

> **https://azimuth.pages.dev/**

(the default `<project>.pages.dev` domain for a Pages project named `azimuth`; a custom domain
can be attached later in the Cloudflare dashboard, which changes this URL accordingly).

## How it works

On every push to `main` (and on manual `workflow_dispatch`), the workflow:

1. **preflight-gates** on the two Cloudflare secrets **and** on the `azimuth` Pages project
   actually existing (it probes the CF API `…/pages/projects/azimuth`) — if the secrets are
   missing *or* the project has not been created yet, it marks the run *not configured* and
   skips every real step, so the job stays green doing nothing. Probing the project (not just
   the secrets) is what stops the old "Project not found [code: 8000007]" red-flood: `wrangler
   pages deploy` cannot publish to a project that does not exist, so the secrets-only gate
   red-failed every push once the secrets were present but the project was not yet created;
2. checks out the repo and installs Python 3.12 + the `site` and `ld` extras
   (`uv pip install --system -e ".[site,ld]"`);
3. builds the site with `python scripts/build_site.py --out _site` (this also renders the
   cross-channel knowledge graph, `graph.json` + `graph.html`, and the autonomy counters);
4. exports the RDF graph beside it with `python scripts/build_rdf.py --out _site`
   (`schema.ttl` + `data.ttl`, the Vault-LD OKF lift);
5. copies [`cloudflare/_headers`](../cloudflare/_headers) into `_site/_headers` so Cloudflare
   applies the site's security response headers;
6. deploys with `wrangler pages deploy _site --project-name=azimuth`
   (`cloudflare/wrangler-action@v3`, authenticated with the API token).

The build output (`_site/`, like `site/`) is gitignored — it is regenerated by the workflow on
every run, never committed. The deploy config is [`wrangler.toml`](../wrangler.toml).

## READY-TO-DEPLOY gate (Michael's manual step, ~15 min, one time)

**Nothing publishes until both Cloudflare secrets are set AND the `azimuth` Pages project
exists.** The workflow file existing in the repo does not deploy on its own — the preflight
step skips the whole job (green, no red X) until both are true. To turn the Cloudflare Pages
site on (Michael only, when ready):

1. **Create the Pages project.** In the Cloudflare dashboard → *Workers & Pages* → *Create* →
   *Pages* → *Direct upload* (or, from a machine with Node:
   `npx wrangler pages project create azimuth --production-branch main`). Name it **`azimuth`**
   so it matches `--project-name=azimuth`. Choose *Direct upload* (not the Git integration) to
   keep Cloudflare from needing access to the private GitHub repo.
2. **Mint a scoped API token.** Dashboard → *My Profile* → *API Tokens* → *Create Token* →
   template **"Cloudflare Pages — Edit"** (Account · Cloudflare Pages · Edit). Copy the token.
3. **Find the account id.** Dashboard → any domain / *Workers & Pages* overview → *Account ID*
   in the right sidebar.
4. **Add both as GitHub repo secrets.** Repo → *Settings* → *Secrets and variables* → *Actions*
   → *New repository secret*:
   - `CLOUDFLARE_API_TOKEN` = the token from step 2
   - `CLOUDFLARE_ACCOUNT_ID` = the id from step 3
5. **Trigger a deploy.** *Actions* tab → *Deploy site to Cloudflare Pages* → *Run workflow*
   (or push any commit to `main`). The published URL appears in the run's deploy-step log and at
   `https://azimuth.pages.dev/`.

To take it offline: delete the Pages project in the Cloudflare dashboard, or remove the two
repo secrets (the next run then skips the deploy).

## Hardening (follow-up)

[`cloudflare/_headers`](../cloudflare/_headers) sets `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY`, a `Referrer-Policy`, and a `Permissions-Policy`. It deliberately does
**not** set a `Content-Security-Policy` yet: the site uses inline progressive-enhancement
scripts (the graph canvas, the build-time sparklines, the live autonomy/graph-count fetches),
so a strict `script-src` would break them. Adding a nonce- or hash-based CSP is a clean
follow-up once those inline blocks are refactored — tracked, not shipped, to avoid breaking the
live convince-me site.

## Related

- [deploy.md](deploy.md) — the GitHub Pages path (repo-native, lights up at the public flip).
- [site.md](site.md) — how the static site is built and structured.
- [operations.md](operations.md) — the on-call runbook for the engine that feeds the site.
- [security/public-flip-readiness.md](security/public-flip-readiness.md) — the go/no-go
  checklist for making the **repo** public (a separate decision from making the **site** live).
