---
name: site-deploy
description: Assemble an approved content tree into a live Cloudflare Pages deployment on its custom domain that survives the install device being offline. This is the skill behind "deploy this site", "put this landing page / investor site / microsite live on its own domain", "publish the waitlist site", and "the live site is stale / shows the wrong brand". It takes one canonical source tree, reuses the per-scope Pages-and-D1 token from the account secrets file, runs `wrangler pages deploy` to the production alias, attaches the custom domain, wires a contact or waitlist form to D1 when the tree carries one, and gates "done" on a cache-busted live `200` with correct content and Open Graph metadata on the production domain. Admin-run; it holds Bash and the account secrets surface. Distinct from `admin:publish-site` (device-tunnel hosting that dies when the device goes offline) and from `sitedesk:microsite` (single-page builder that stops before deploy).
---

# Deploy a site to Cloudflare Pages

Run by the admin/main session, the operator-infra surface that holds the `Bash` tool and reads the account secrets file. A content specialist authors and approves the site tree; the deploy itself is admin work because it touches the Cloudflare account credentials. When a `content-producer` finishes a site, it hands the deploy off to admin, which loads this skill.

This skill is an outcome contract, not a second copy of the Pages mechanics. The mechanics live in the cloudflare references; this skill names the canonical source convention, the auth discipline, the form-to-D1 wiring trigger, the Open Graph correctness check, and the live done-gate, then defers each command block to the matching reference. Read `references/hosting-sites.md` (build, `wrangler pages deploy`, custom domain) and `references/d1-data-capture.md` (form to Pages Function to D1) via `plugin-read` before issuing those commands; read `references/api.md` for the token discipline.

## When this skill, not another

| Situation | Skill |
|---|---|
| A standalone site (landing, investor, waitlist, microsite output) goes live on its own custom domain, hosted on Cloudflare's edge, surviving the install device being offline | **this skill** |
| Serve content that the install device itself renders, at a custom domain over the brand tunnel | `admin:publish-site` (device-tunnel `/sites/` path) |
| Build a single-page site from a brief | `sitedesk:microsite` (it stops before deploy; its output tree is then deployed by this skill) |

`admin:publish-site` serves from the install device through the brand tunnel: when the device is offline the site is unreachable. This skill puts the site on Cloudflare Pages, so it stays up independent of the device. Name the distinction when the operator's intent is ambiguous, and pick by whether the site must survive the device going offline.

## One canonical source tree

Each site is driven by exactly one source tree at:

```
<accountDir>/pages/<project>/
```

`<project>` is the slug of the site's graph node (the `:Organization` or site entity the site represents), so there is one deterministic path per site and no second copy to reconcile. The tree holds the framework source, `wrangler.toml`, the built output directory, and `functions/api/contact.ts` when the site captures form data.

There is never a `sites/<brand>/` versus `output/<brand>-site/` pair, a `.bak` clone, or a stale sibling. If more than one candidate tree exists when you start, reconcile to the single canonical path first: confirm which tree is current, move it to `<accountDir>/pages/<project>/`, and remove the others before deploying. Deploying from an unreconciled tree is the root cause of the dev/live drift this skill exists to prevent. `<accountDir>/pages/` is distinct from `<accountDir>/sites/`, which belongs to the device-tunnel `admin:publish-site` path; the two never share a directory.

## Auth: reuse the per-scope token, never mint ad hoc

> **Client accounts on a multi-tenant install do not use this section.** Post-1631 their secrets carry no master and `cf-token.sh` denies them the `pages` scope (`reason=not-house`). They publish with `storage-pages-deploy`, which deploys house-side against a project they own. For a **brand-new** project they first create and claim it with `storage-pages-create`, then deploy to it; a first `storage-pages-deploy` against a project with no owner on record fails at `reason=unregistered`, so the create comes first. `storage-pages-adopt` remains house admin only, for a project that **already exists on Cloudflare** that the client did not create. A `not-house` denial here is that boundary working, not a token fault, so do not try to mint around it. Everything below is the **house/single-tenant** path.

Resolve the Pages-and-D1 token with the deterministic helper **before** any token `curl`, never a hand-built one:

```bash
KEY=$(bash platform/plugins/cloudflare/bin/cf-token.sh pages <secrets-file>)
set -a; . <secrets-file>; set +a   # the active token is now in the variable named by $KEY
```

The helper reads the master's `cfat_`/`cfut_` prefix and routes verify, `permission_groups`, and mint to the matching endpoint family, mints once if absent (no expiry), persists, and prints only the secrets key name. A hand-built verify at the wrong endpoint returns a false-negative (`Invalid API Token` or `9109`) that reads as "cannot mint" but is not; when that happens the cause is the endpoint, not the token. See the `references/api.md` section on routing token endpoints by prefix.

A Pages deploy needs **Account · Cloudflare Pages · Edit**. A site with a D1-backed form also needs **Account · D1 · Edit** on the same token. The helper resolves the stable `<brand>-pages-d1` token (carrying both) from the account secrets file, minting it once only if absent and persisting it, exactly as `references/api.md` § Provisioning and reusing a stable per-scope token and `references/d1-data-capture.md` § 0 prescribe. The token is never written into the project tree, never committed, never echoed into chat. The master token stays in the secrets file. Surface every command as verb plus target ("deploying Pages project `<project>`"); `wrangler` prints the deployment URL, which is what gets relayed.

## Deploy and attach the custom domain

Follow `references/hosting-sites.md`: build the framework output, write `wrangler.toml` (the project name is `<project>`, the output dir is the framework default), and `wrangler pages deploy <output-dir> --project-name <project> --branch=main` to the production alias. Attach the custom domain to the Pages project via the Cloudflare API with the reused `<brand>-pages-d1` token (the operator clicks **Workers & Pages → project → Custom domains** in the dashboard only when they prefer to do it by hand), so the site answers on the operator's domain, not only on `<project>.pages.dev`.

Attaching the custom domain to the project is not the same as the domain resolving. The attach leaves validation **pending** until a DNS record in the zone points the hostname at the Pages site; until that record exists the domain returns `NXDOMAIN` and the page is unreachable. Confirm the record exists and create it when it does not:

- A **subdomain** custom domain (`investors.example.com`) needs a `CNAME` to `<project>.pages.dev`.
- An **apex** custom domain (`example.com`) cannot hold a literal CNAME, so it needs a **proxied (flattened) CNAME** at the apex to `<project>.pages.dev`, plus a `www` CNAME if `www` is also served. This is the case the attach most often leaves pending.

Creating that DNS record is a **DNS-scope** operation, not a Pages-scope one: the `<brand>-pages-d1` token returns a `10000 Authentication error` on the DNS endpoint. Use the DNS-scoped `<brand>-dns` token, exactly as `cloudflare/SKILL.md` § Apex hostnames and `references/api.md` § DNS records prescribe, minting it once if absent. The hostname's zone must be on the same Cloudflare account; a hostname whose zone lives in a different account is reached with that account's own `<brand>-dns` token, never by probing a drawer of ad-hoc `CF_DNS_TOKEN_*` strays (that token sprawl is what the `references/api.md` reconcile pass exists to clear). The custom domain is the surface the done-gate checks, so a missing record fails the gate rather than passing silently.

## Wire the form to D1 when one exists

When the canonical tree carries a contact or waitlist form, the deploy is not complete until that form records to D1. Follow `references/d1-data-capture.md`: create the database, add the `[[d1_databases]]` binding to `wrangler.toml`, create the table, and ship `functions/api/contact.ts`. The token from the auth step already carries D1 Edit, so the form does not silently 500 at the insert. A site with no form skips this section.

Name the database with the canonical `<accountId>-<purpose>` scheme from `bin/cf-store-name.sh`, and keep one store per capture purpose: a new capture kind is a discriminator column, not a second database. A document-acceptance form (works agreement, quote signing) uses the canonical `acceptances` shape declared in `references/d1-data-capture.md`, not a per-build table, so the watcher reads one known store.

## Bind the bucket when the tree binds one

When the canonical tree carries an `[[r2_buckets]]` block, the deploy is not complete until a Function can reach the bucket off `env`. Follow `references/r2-object-storage.md`: the bucket is created once by its owning account through the storage broker — not here, and not by this skill's token — the binding is added to `wrangler.toml`, and the Function reads `env.<binding>`.

The binding is declarative config carried in the deploy payload, so the Pages token from the auth step already covers it and no R2 scope is needed at deploy time. Only bucket *creation* needs `Workers R2 Storage Write`, which is the `storage` scope, and it happens outside this skill. An R2-bound Function that fails every store call with `result=failed` is the signature of a binding that did not deploy — check the block reached the tree with `bucket_name` filled, rather than reaching for a token the deploy never needed.

Name the bucket with the canonical `<accountId>-<purpose>` scheme from `bin/cf-store-name.sh`, and keep one bucket per purpose, exactly as the D1 section above requires for databases. A tree with no `[[r2_buckets]]` block skips this section.

## Open Graph correctness

The live page must carry brand-correct Open Graph metadata: `<meta property="og:title">`, `og:description`, `og:image`, and `og:url` reflecting this site and this brand, not a leftover from whatever the tree was cloned from. Stale OG (a live card still naming a previous brand) is a recorded failure mode of hand deployment. Confirm the `og:*` tags in the deployed HTML are correct before claiming done; if they are stale, fix them in the source tree, redeploy, and re-check.

## Done-gate: a live cache-busted 200 on the custom domain

"Done" is never "I ran deploy". It is a live behavioural signal on the **production custom domain**, surfaced verbatim in chat:

```bash
curl -s "https://<custom-domain>/?cb=$(date +%s)" -D - -o /tmp/site.html | head -1
grep -o '<meta property="og:[^>]*>' /tmp/site.html
```

The done-gate passes only when all hold:

- the domain resolves at all: a `NXDOMAIN` or "could not resolve host" means the custom-domain DNS record was never created, so go back to the custom-domain step and create the CNAME with the `<brand>-dns` token; a `522` is the certificate-provisioning window, so wait for it to clear with a bounded poll rather than treating it as done or re-curling by hand forever; `bash platform/plugins/cloudflare/bin/wait-for.sh --timeout 180 --interval 15 --expect 'HTTP/[0-9.]+ 200' -- curl -s --max-time 10 -D - -o /dev/null "https://<custom-domain>/?cb=$(date +%s)"` stops at the bound and reports the last status line, so a domain stuck off `200` becomes a reported finding instead of an endless wait,
- the status line is `HTTP/2 200` (or `HTTP/1.1 200 OK`),
- the body carries an expected content marker for this site (a brand string or headline you can name in advance), not a stale marker from a previous brand,
- the `og:*` tags are present and brand-correct,
- for a form-bearing site, a test POST appears in D1 unswept, per `references/d1-data-capture.md` § Outcome contract. This sub-check reuses the same `${PAGES_D1}` token and `<db-name>` already loaded when the form was wired, not a fresh token:

```bash
curl -sS -X POST -d "email=test@example.com&name=verify" "https://<custom-domain>/api/contact" -i | head -1
CLOUDFLARE_API_TOKEN="${PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
  "SELECT email FROM leads WHERE swept = 0;"
```

Any other status, a missing or stale content marker, an OG mismatch, or a form POST that does not land in D1 blocks "done" and is reported with the exact failing URL. Record the deployment alias and the cache-busted verification command in the result so a later operator re-runs the same check against the custom domain to confirm live state without reading source.

## Tool discipline

The permitted surface is `wrangler`, `curl`, and the Cloudflare API via Bash following the cloudflare references, using the reused per-scope token. The agent does not mint a fresh token per deploy, does not drive the dashboard with Playwright or Chrome DevTools, does not synthesise `wrangler` flags from web search, and never writes or echoes a token. When a step fails, report the exact output with secrets redacted, name the failing URL or command, and stop.
