diff --git a/.github/workflows/cron-rebuild.yml b/.github/workflows/cron-rebuild.yml index 30d8f45..59ff1af 100644 --- a/.github/workflows/cron-rebuild.yml +++ b/.github/workflows/cron-rebuild.yml @@ -7,9 +7,8 @@ name: cron-rebuild # # Cadence: twice daily at 02:00 and 14:00 UTC — roughly 7am and 7pm PT -# during PDT, 6am and 6pm during PST. 60 builds/month, well under -# Cloudflare Pages Free's 500/month ceiling. Tighten the cron if events -# start changing more often than morning/evening. +# during PDT, 6am and 6pm during PST. Tighten the cron if events start +# changing more often than morning/evening. # # Pipeline doc: docs/explanation/events-pipeline.md diff --git a/docs/explanation/events-pipeline.md b/docs/explanation/events-pipeline.md index 9f2bb72..bd0277a 100644 --- a/docs/explanation/events-pipeline.md +++ b/docs/explanation/events-pipeline.md @@ -85,9 +85,9 @@ If you rename a calendar event, the URL changes. That's working as intended — ## Rebuild cadence -A GitHub Actions (GitHub's built-in automation that runs scripts on a schedule or on each push) scheduled workflow — a "cron" job, meaning it runs on a fixed timetable — at [`.github/workflows/cron-rebuild.yml`](../../.github/workflows/cron-rebuild.yml) fires twice a day (roughly morning and evening PT) and dispatches the `Deploy production` workflow. The Pages build re-fetches the calendar on its way through. +A GitHub Actions (GitHub's built-in automation that runs scripts on a schedule or on each push) scheduled workflow — a "cron" job, meaning it runs on a fixed timetable — at [`.github/workflows/cron-rebuild.yml`](../../.github/workflows/cron-rebuild.yml) fires twice a day (roughly morning and evening PT) and dispatches the `Deploy production` workflow. The build re-fetches the calendar before the Worker version is deployed. -Why twice a day: event metadata changes a few times a week at most; a morning and an evening rebuild keep the site current without burning Cloudflare Pages Free's 500-builds/month budget (twice daily = 60/month). Tighten the cron in the workflow file if events start moving faster than that. +Why twice a day: event metadata changes a few times a week at most; morning and evening rebuilds keep the site current without unnecessary deployments. Tighten the cron in the workflow file if events start moving faster than that. Why GitHub Actions and not a Cloudflare Worker: the trigger needs zero long-lived credentials this way. The workflow uses the auto-issued `GITHUB_TOKEN`, scope-limited to `actions: write` on this repo. No PATs, no Worker secrets, no API token rotation. Logs surface in the Actions UI alongside every other deploy. diff --git a/docs/guides/add-a-project.md b/docs/guides/add-a-project.md index 2285d86..da68bbb 100644 --- a/docs/guides/add-a-project.md +++ b/docs/guides/add-a-project.md @@ -42,4 +42,4 @@ Steps: 5. **Goals render automatically** at the end of the page from the frontmatter `goals:` array via `src/components/ProjectGoals.astro`. Do **not** add a `## Goals` section in the body — it would duplicate the auto-render. 6. **Three statuses, kept simple.** `planned` (committed but not started), `in-progress` (working on it), `done` (achieved). If a goal genuinely changes scope, edit the text or remove it. Misses and scope changes go in `## Updates`, not in new status types. -7. Commit. Push to `main`. Cloudflare Pages deploys in ~60 seconds. +7. Commit. Push to `main`. GitHub Actions builds the site and deploys the verified Worker version. diff --git a/docs/guides/add-an-event.md b/docs/guides/add-an-event.md index e7ef3a4..105c34b 100644 --- a/docs/guides/add-an-event.md +++ b/docs/guides/add-an-event.md @@ -17,7 +17,7 @@ Events live in the LVBT (Las Vegans for Better Transit) Google Calendar, not in - **Date / time** — in Pacific Time (Las Vegas's time zone, UTC−8/−7). Always set an end time. - **Location** — a meeting URL (virtual), a full physical address (in-person), or both (hybrid — put the address in Location and the meeting URL in Description). For physical events, use the full Google-style address so the site can publish a real postal address in structured data. - **Description** — the first paragraph becomes the card / lede summary (keep it to one sentence). Everything after that paragraph becomes the body on the detail page; format it however you want with GCal's rich-text editor (the toolbar for bold, lists, links — like a mini word processor). Add `RSVP: https://…` if registration goes through an external sign-up form. Add `ADMISSION: https://…` or `TICKETS: https://…` only when the link is where people get admission or tickets, even for a free event. -3. Save. The next scheduled rebuild (within ~1 hour) picks it up. To rush it, trigger a redeploy from the Cloudflare Pages dashboard. +3. Save. The scheduled rebuild runs twice a day. For a same-day correction, run the `cron-rebuild` workflow from GitHub Actions; it dispatches the production build. ## When an event needs more than what GCal can hold diff --git a/docs/guides/add-transit-news.md b/docs/guides/add-transit-news.md index 156bd91..4651794 100644 --- a/docs/guides/add-transit-news.md +++ b/docs/guides/add-transit-news.md @@ -73,7 +73,7 @@ Claude will search, show you a list, ask for confirmation, then run the script o ## Path 3 — Notion form (anyone, no CLI) A public Notion form where anyone — volunteers, the public — pastes an article -URL. A Cloudflare Pages Function (a small backend script that runs on Cloudflare — see [glossary](../reference/glossary.md#pages-function)) (`/api/transit-news-intake`) enriches the +URL. An API function compiled into the production Worker (`/api/transit-news-intake`) enriches the submission automatically: it fetches the URL and fills in headline, date, publication, topics, location, and the full article body. @@ -104,7 +104,7 @@ A _webhook_ is an automated message one service sends another when something hap - Run `pnpm bootstrap --phase secrets`. If `LVBT_TRANSIT_NEWS_INTAKE_SECRET` is not set anywhere yet, bootstrap generates a random value, stores it on the - Pages project, the Worker and GitHub, and offers to show it once. Copy it for + production Worker, Pages fallback and GitHub, and offers to show it once. Copy it for step 3. It also asks for `LVBT_NOTION_API_KEY` if that is missing. - If the secret is already set but nobody has the value, run `pnpm bootstrap --phase secrets --rotate LVBT_TRANSIT_NEWS_INTAKE_SECRET` to diff --git a/docs/guides/connect-the-membership-form.md b/docs/guides/connect-the-membership-form.md index c3ee5a3..e5a2d59 100644 --- a/docs/guides/connect-the-membership-form.md +++ b/docs/guides/connect-the-membership-form.md @@ -11,7 +11,7 @@ submission fails, is in > **Before you start.** You need edit access to the form and the value of > `LVBT_MEMBERSHIP_INTAKE_SECRET` that the live site uses. If the form is > being connected for the first time, make a new value (see [membership -> intake](../reference/membership-intake.md#required-cloudflare-pages-secrets)) +> intake](../reference/membership-intake.md#required-cloudflare-secrets)) > and store it with `pnpm bootstrap --phase secrets`. Never use the value in > your own `.env.local`; it is a local test value only. @@ -26,7 +26,7 @@ submission fails, is in | Property | Value | | ------------------------------- | ------------------------------------------------------ | | `LVBT_MEMBERSHIP_INTAKE_URL` | `https://lasvegasfortransit.org/api/membership-intake` | - | `LVBT_MEMBERSHIP_INTAKE_SECRET` | same value as the Cloudflare Pages secret | + | `LVBT_MEMBERSHIP_INTAKE_SECRET` | same value as the production Worker secret | 6. Open **Triggers** (clock icon) → **Add Trigger** and set: diff --git a/docs/guides/edit-a-long-form-doc.md b/docs/guides/edit-a-long-form-doc.md index 8eed4a4..1a6a889 100644 --- a/docs/guides/edit-a-long-form-doc.md +++ b/docs/guides/edit-a-long-form-doc.md @@ -19,4 +19,4 @@ These are intentional, considered documents — meaning each was deliberately wr 1. **Required:** read [explanation/voice-and-tone.md](../explanation/voice-and-tone.md) before drafting, so your edit matches the house voice. 2. **Required when you touch numbers** (ridership, dates, dollar amounts): cross-reference [reference/key-facts.md](../reference/key-facts.md) — the same numbers anchor multiple files, so they must stay in sync. 3. Commit messages should explain **why** the change was made, not what changed. -4. Push to `main`. Cloudflare Pages deploys in ~60 seconds. +4. Push to `main`. GitHub Actions builds the site and deploys the verified Worker version. diff --git a/docs/guides/test-the-workers-candidate.md b/docs/guides/test-the-workers-candidate.md index 78c169e..b035515 100644 --- a/docs/guides/test-the-workers-candidate.md +++ b/docs/guides/test-the-workers-candidate.md @@ -1,6 +1,6 @@ # Test the Workers candidate -The Workers candidate runs beside the Pages production site. These checks establish equivalence without changing DNS or the production route. +A candidate is a version of the production Worker that receives a versioned preview URL before deployment. It uses the production bindings, so browser tests use non-destructive data. The public hostnames stay on the previously deployed version until candidate checks pass. ## Check locally @@ -31,12 +31,13 @@ Under the `worker-preview` environment's **Environment secrets**, click **Add en Set the repository variable `CLOUDFLARE_WORKERS_PREVIEW_ENABLED` to `true` after `pnpm worker:upload --env preview` succeeds for `lvbt-website-preview`, the separate Worker that pull request previews use. Re-run the pull request workflow and open the `Worker candidate` link in its comment. -The preview workflow compares the Worker with the Pages production origin, then runs the complete Playwright suite against the Worker URL. The same checks run locally against an uploaded candidate: +The preview workflow runs the complete Playwright suite against the Worker URL. The same checks run locally against an uploaded candidate: ```sh pnpm worker:test:live \ --pages https://lasvegasfortransit.org \ - --worker https://-lvbt-website-preview..workers.dev + --worker https://-lvbt-website-preview..workers.dev \ + --skip-api PLAYWRIGHT_BASE_URL=https://-lvbt-website-preview..workers.dev \ pnpm worker:test:browser ``` @@ -47,43 +48,28 @@ Inspect the navigation at phone and desktop widths. Check the browser console, r Create a separate `worker-candidate` GitHub environment the same way: **Settings → Environments → New environment**, type `worker-candidate`, **Configure environment**. It needs its own `CLOUDFLARE_WORKERS_API_TOKEN` environment secret — create a second custom token exactly as above (**Account · Workers Scripts · Edit**, scoped to the LVBT account; name it `lvbt-website candidate (GitHub Actions)` so it reads differently from the preview one in the token list) and add it under this environment's **Environment secrets**. It reads the same `CLOUDFLARE_ACCOUNT_ID` repository variable created above — do not make a second copy. Set the repository variable `CLOUDFLARE_WORKERS_CANDIDATE_ENABLED` to `true` only after the preview workflow passes. -Each successful Pages production run then starts `Deploy Worker candidate` for the same commit. The -workflow uploads a version with the stable `candidate` preview alias, compares it with Pages, and -runs the browser suite. It does not attach a route. Use **Run workflow** on `main` to repeat the check -without publishing Pages again. +Each successful `Deploy production` build starts `Deploy Worker candidate` for the same commit. The workflow uploads a version with the stable `candidate` preview alias and runs the browser suite. With `LVBT_WORKERS_PRODUCTION_ENABLED=true`, it deploys that version only after confirming that `main` still points at the tested commit. Use **Run workflow** on `main` to repeat the release without another build trigger. -Copy the commit, version, and preview URL from the workflow summary into the cutover change. +Record the commit, version, and preview URL from the workflow summary with the release. -## Switch production +## Check production -Confirm the current `main` commit has a passing candidate run and record the Pages deployment ID. -Keep the Pages custom domains and DNS records in place during the first switch. Set the repository -variable `LVBT_WORKERS_PRODUCTION_ENABLED` to `true`, then run `Deploy Worker candidate` on `main`. -The workflow deploys the verified Worker version and compares it with the production hostname. +After `Deploy Worker candidate` succeeds, compare `https://lasvegasfortransit.org` with the version URL recorded in its workflow summary. Check `https://www.lasvegasfortransit.org` redirects to the apex over valid TLS. Follow a content link, refresh a nested page, inspect an unknown path, and check an event calendar file. Confirm that production includes the Cloudflare Web Analytics beacon while version previews do not. -Attach `lasvegasfortransit.org/*` and `www.lasvegasfortransit.org/*` to `lvbt-website` as Worker -routes. Check both hostnames over HTTPS, including `/`, a content page, an unknown path, redirects, -calendar files, and the intake endpoints. Confirm analytics appears on the production hostname and -not on the version preview. Keep the Pages project available as the fallback until these checks -pass. Remove either route to send that hostname back to Pages if the Worker fails live checks. - -After the route overlay proves stable, replace the Pages CNAMEs and domain attachments with Worker -custom domains. Check TLS and the same HTTP contract again before retiring the Pages deployment. -Cloudflare requires the Pages CNAME to be removed before a Worker custom domain can use that -hostname. +The `lvbt-website` Worker owns both public hostnames as custom domains. The Pages project has no custom-domain attachment. Its `lvbt-website-5zh.pages.dev` address remains available for emergency rollback; an ordinary release regression rolls back the Worker version without changing DNS. The [deployment pipeline](../reference/deployment-pipeline.md#rollback) records the recovery path. ## Record acceptance -Record the commit, Worker version, Pages deployment, preview URL, and check time in the cutover change. Compare these behaviors before attaching the production hostname: +Record the commit, Worker version, preview URL, and check time in the release record. Compare these behaviors before and after deployment: -| Surface | Required result | -| --------------------- | ------------------------------------------------------------- | -| `/` and content pages | Status, HTML, canonical metadata, and navigation match Pages | -| unknown path | Branded `404.html` with status 404 | -| `_headers` | Security and cache headers match Pages | -| `_redirects` | Every permanent redirect returns 301 to the same location | -| `/events/*.ics` | `text/calendar; charset=utf-8` | -| `/api/*` | Status, CORS, validation, and downstream behavior match Pages | -| analytics | Production hostname included; preview hostname excluded | +| Surface | Required result | +| --------------------- | --------------------------------------------------------------------- | +| `/` and content pages | Status, HTML, canonical metadata, and navigation match the candidate | +| unknown path | Branded `404.html` with status 404 | +| `_headers` | Security and cache headers match the candidate | +| `_redirects` | Every permanent redirect returns 301 to the same location | +| `/events/*.ics` | `text/calendar; charset=utf-8` | +| `/api/*` | Status, CORS, validation, and downstream behavior match the candidate | +| analytics | Production hostname included; preview hostname excluded | -Keep the Pages deployment and its hostname attachment intact until the post-cutover production check passes. +Keep the previous Worker version and the Pages fallback available until the production check passes. diff --git a/docs/reference/bootstrap.md b/docs/reference/bootstrap.md index ca4a706..db3f4d7 100644 --- a/docs/reference/bootstrap.md +++ b/docs/reference/bootstrap.md @@ -1,18 +1,16 @@ # Bootstrap CLI reference -The bootstrap CLI is the one command that sets up the whole project for you. It exists so a new contributor doesn't have to run a dozen manual steps (install tools, create accounts, wire up GitHub and Cloudflare) by hand and in the right order — it does them in sequence and checks what's already done. It's a multi-phase CLI (command-line tool, run in your terminal) written in TypeScript (JavaScript with type labels — see [glossary](./glossary.md#typescript)) that walks the LVBT website from a fresh checkout to a deployed site. Source: `scripts/bootstrap/`. +`pnpm bootstrap` checks the local toolchain, the website repository, and the production Cloudflare resources in sequence. It provisions missing resources after confirmation and leaves existing deployments alone. The implementation lives in `scripts/bootstrap/`. For the narrative walk-through, see [tutorials/first-time-setup.md](../tutorials/first-time-setup.md). ## Before you start -The full setup (through the `deploy` and `domain` phases) needs a few accounts and -tools. The `install` and `auth` phases check these for you, but it's smoother to -have them ready: +The remote phases require access to the LVBT GitHub organization and Cloudflare account. The `install` and `auth` phases check the required tools and sign-ins: - A **GitHub account** with an [SSH key set up](./glossary.md#ssh) — the `repo` phase pushes over SSH. -- A **Cloudflare account** — the `deploy` and `domain` phases use it. +- Access to the **LVBT Cloudflare account** — the `deploy` and `domain` phases use it. - [`gh`](./glossary.md#gh) (GitHub's CLI) and [`wrangler`](./glossary.md#wrangler) (Cloudflare's CLI), installed and logged in. @@ -35,34 +33,34 @@ pnpm bootstrap --phase secrets --rotate LVBT_SIGN_IN_SECRET # replace secrets ## Running it again is safe -The bootstrap is [idempotent](./glossary.md#idempotent): every step checks what already exists, then does only what is missing. You can run it on a finished setup at any time. It changes nothing and reports every phase as ready. In particular, a re-run: +The bootstrap is [idempotent](./glossary.md#idempotent): each phase reads current state before making a change. On a finished setup, a second run reports every phase as ready. It: - never asks for, generates or replaces a secret that is already stored; -- never creates a second Pages project, domain attachment or DNS record; +- never creates a duplicate Worker deployment or domain attachment; - never pushes the site to production again; - never rewrites `.env.local` or `wrangler.jsonc` when nothing in them changes. Here is what each phase checks, and what it does only when something is missing: -| Phase | Checks first | Changes only when missing | -| ----------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `install` | Whether each tool is installed and new enough | Offers to install the missing tool | -| `auth` | `gh auth status` and `wrangler whoami` | Offers to sign in | -| `workspace` | Nothing remote | Always runs `pnpm install --frozen-lockfile` and a `pnpm build` smoke test | -| `env` | Which `.env.local` values are still empty or placeholders | Asks only for those, and writes only the ones you fill in | -| `repo` | Whether `origin` is already set | Creates or connects the GitHub repository and pushes | -| `deploy` | Whether the Pages project exists and has a production deployment | Creates the project and pushes `./dist` as its first deployment | -| `domain` | Which hosts are attached to the Pages project, and which CNAMEs already exist | Attaches only unattached hosts and writes only missing CNAMEs | -| `secrets` | Which secrets each target already has | Asks for each missing secret once and stores it only where it is missing | +| Phase | Checks first | Changes only when missing | +| ----------- | --------------------------------------------------------- | -------------------------------------------------------------------------- | +| `install` | Whether each tool is installed and new enough | Offers to install the missing tool | +| `auth` | `gh auth status` and `wrangler whoami` | Offers to sign in | +| `workspace` | Nothing remote | Always runs `pnpm install --frozen-lockfile` and a `pnpm build` smoke test | +| `env` | Which `.env.local` values are empty or placeholders | Asks only for those, and writes only the ones you fill in | +| `repo` | Whether `origin` is already set | Creates or connects the GitHub repository and pushes | +| `deploy` | Whether `lvbt-website` has a production Worker deployment | Builds and deploys the Worker after confirmation | +| `domain` | Whether apex and `www` route to the production Worker | Attaches missing Worker custom domains after confirmation | +| `secrets` | Which secrets each target already has | Asks for each missing secret once and stores it only where it is missing | Replacing something that already exists is always your explicit choice, never a default: -- `--redeploy` builds the site and pushes `./dist` to the Pages production branch even though a production deployment exists. Day to day you don't need it: every push to `main` deploys through the "Deploy production" GitHub Actions workflow. +- `--redeploy` builds and deploys this checkout to the production Worker even when it already has a deployment. Routine releases go through the GitHub Actions pipeline instead. - `--rotate NAME[,NAME]` replaces the named secrets everywhere they are stored. See [replace a secret on purpose](./platform-secrets.md#replace-a-secret-on-purpose). ### Picking up after a partial run -If a run stops partway, because you pressed Ctrl+C, skipped a secret, or a command failed, run it again. Each phase checks the real state of GitHub, Cloudflare and your files, not a record of what it meant to do, so the next run does exactly the work that is left. For example, if the deploy failed after the project was created, the next run sees the project, does not create it again, and only pushes the deployment. If you skipped one secret, the next run asks for that secret alone. +If a run stops partway, run it again. Each phase checks GitHub, Cloudflare, or local state before acting. A failed Worker deployment is retried without reattaching domains; a skipped secret is asked for on the next run. The summary at the end lists any phase that is not finished as `partial`, with the `pnpm bootstrap --phase ` command that finishes it. @@ -77,40 +75,34 @@ The setup runs as a sequence of _phases_ — self-contained steps that each get | `workspace` | Runs `pnpm install --frozen-lockfile` (installs the exact pinned versions from the [lockfile](./glossary.md#lockfile); fails instead of updating it) and a `pnpm build` smoke test | | `env` | Creates `.env.local` from `.env.example`; prompts for values that are still placeholders. These are for your machine only | | `repo` | Creates a GitHub repo via `gh repo create` and wires `origin` to the [SSH URL](./glossary.md#ssh) | -| `deploy` | Creates the Cloudflare Pages project and its first production deployment, if they don't exist | -| `domain` | Attaches the [apex](./glossary.md#apex-domain) domain and any extra hosts to the Pages project; creates missing [DNS](./glossary.md#dns) records via the Cloudflare API | -| `secrets` | Reports every server-side secret missing from the Worker, Pages and GitHub, asks for each once, and stores it everywhere — see [platform secrets](./platform-secrets.md) | +| `deploy` | Checks for a production `lvbt-website` Worker deployment and builds and deploys one when absent | +| `domain` | Confirms that the [apex](./glossary.md#apex-domain) and `www` hostnames belong to that Worker; attaches missing custom domains through the Cloudflare API | +| `secrets` | Reports server-side secrets missing from the Worker, Pages fallback and GitHub, asks for each once, and stores it where needed — see [platform secrets](./platform-secrets.md) | The `env` phase never touches production. The server-side values in `.env.local` (Beehiiv, Notion, and a random intake secret for testing) are only for `pnpm dev` and local scripts. The live site gets its secrets from the `secrets` phase, and its public `PUBLIC_LVBT_*` values from GitHub Actions variables (the repository's Settings → Secrets and variables → Actions → Variables tab). -### The DNS token the domain phase may ask for +### Production domains -Wrangler's sign-in cannot write DNS records. Only when a CNAME record is missing, the domain phase asks for a Cloudflare API token that can: +The checked-in `scripts/bootstrap/config/production-hosting.json` selects Worker hosting. Bootstrap stops before any phase if this file is missing or invalid. The domain phase checks `lasvegasfortransit.org` and `www.lasvegasfortransit.org` through Cloudflare's Worker-domain API. It leaves a hostname owned by another Worker untouched. Cloudflare creates the DNS record and certificate when a missing custom domain is attached; the phase never writes a Pages CNAME. -1. Open `https://dash.cloudflare.com//api-tokens` (Manage Account → API Tokens for the LVBT account, "Las Vegans for Better Transit"). The phase opens it for you. -2. Click **Create Token**. Next to **Edit zone DNS**, click **Use template**. -3. Name it `lasvegasfortransit.org DNS (bootstrap)`. -4. Keep the one permission row the template adds: **Zone · DNS · Edit**. -5. Under Zone Resources, choose **Include · Specific zone · lasvegasfortransit.org**. -6. Click **Continue to summary**, then **Create Token**, and copy the token. Cloudflare shows it only once. -7. Paste it at the prompt. It is saved as `CLOUDFLARE_API_TOKEN` in `.env.local` on your machine (readable only by you, never committed), so later runs reuse it. It is not the deploy token GitHub Actions uses, and bootstrap never passes it to wrangler. +The prior Pages deployment remains reachable at its `pages.dev` address for emergency recovery. Restoring its public hostnames is a separate, deliberate [rollback operation](./deployment-pipeline.md#rollback). ## State file The bootstrap keeps a record of its last run in `.lvbt/dev-readiness.json` (a local, git-ignored file in the `.lvbt/` folder). It holds per-phase status (`complete | partial | failed | skipped`), per-tool readiness, setup steps you confirmed that bootstrap cannot check for itself, such as creating the staff console's Google Group, so it asks about each of those only once, and the last value it stored for each platform secret that is not a credential (an ID, a domain or a public key), so the `secrets` phase can show it back. It never holds a credential. `--resume` reads this file and skips phases marked `complete`. The file is rewritten at the end of every run with fresh timestamps; that is expected, because it is a run record, not configuration. -`.env.local` doubles as the cross-phase persistence layer for values that need to survive between phases (e.g. `CLOUDFLARE_PAGES_PROJECT`, `CLOUDFLARE_ACCOUNT_ID`). `run.ts` hydrates `process.env` from it at startup, and the bootstrap writes to it only when a value actually changes. +`.env.local` keeps local values that survive between phases, including the selected `CLOUDFLARE_ACCOUNT_ID`. `run.ts` loads it at startup and writes a value only when it changes. Hosting mode comes from the tracked production-hosting configuration, not from a local environment variable. ## Defaults -| Knob | Default | Override | -| ------------------------ | ----------------------------------------- | -------------------------------------------- | -| GitHub repo | `/` (filesystem-derived) | Prompt accepts `/` | -| GitHub visibility | public | Prompt | -| Cloudflare Pages project | `lvbt-website` | `CLOUDFLARE_PAGES_PROJECT` env var or prompt | -| Production branch | `main` | `CLOUDFLARE_PAGES_BRANCH` env var or prompt | -| Apex domain | `lasvegasfortransit.org` | `LVBT_DOMAIN` env var or prompt | -| Cloudflare account | auto-selected if only one | `CLOUDFLARE_ACCOUNT_ID` env var or prompt | +| Knob | Default | Override | +| ------------------ | ----------------------------------------- | -------------------------------------------------- | +| GitHub repo | `/` (filesystem-derived) | Prompt accepts `/` | +| GitHub visibility | public | Prompt | +| Production Worker | `lvbt-website` | Set in `scripts/bootstrap/lib/defaults.ts` | +| Production branch | `main` | GitHub Actions workflow | +| Public hostnames | apex and `www.lasvegasfortransit.org` | Set in `scripts/bootstrap/phases/worker-domain.ts` | +| Cloudflare account | auto-selected if only one | `CLOUDFLARE_ACCOUNT_ID` env var or prompt | ## Adding a new phase diff --git a/docs/reference/deployment-pipeline.md b/docs/reference/deployment-pipeline.md index 4953495..c588a68 100644 --- a/docs/reference/deployment-pipeline.md +++ b/docs/reference/deployment-pipeline.md @@ -2,7 +2,7 @@ GitHub Actions owns builds and deployment. A change is built from a clean checkout, validated, and sent to Cloudflare with the package and Wrangler versions recorded in the repository. -Cloudflare Pages remains the production origin during the Workers acceptance period. The Workers candidate uses the same Astro output, Pages Functions, headers, redirects, and hostname contract. No production route points to the Worker before the live checklist passes. +The `lvbt-website` Worker serves `lasvegasfortransit.org` and `www.lasvegasfortransit.org` through Cloudflare custom domains. The former Pages project stays available at `lvbt-website-5zh.pages.dev` as a rollback artifact; it owns neither public hostname. ## Build contract @@ -15,13 +15,13 @@ Cloudflare Pages remains the production origin during the Workers acceptance per - the permanent `/get-involved` redirect; - execution of the compiled subscription API. -The checked configuration lives in `wrangler.jsonc`. It exposes no custom domain or route, so uploading a candidate cannot move production traffic. +The checked Worker configuration lives in `wrangler.jsonc`. Production custom domains are attached to the existing Worker in Cloudflare. Version uploads do not change those domains, and the deployment token cannot edit DNS or routes. ## Pull requests -Every pull request receives ordinary validation. Same-repository pull requests also receive the existing Pages preview. +Every pull request receives ordinary validation. Same-repository pull requests also receive Pages and Worker previews. -The `Deploy Worker preview` workflow runs when the repository variable `CLOUDFLARE_WORKERS_PREVIEW_ENABLED` is `true`. Its token comes only from the `worker-preview` GitHub environment. The workflow uploads a version of the separate `lvbt-website-preview` Worker without deploying it. That Worker is the `preview` environment in `wrangler.jsonc` and uses the preview platform database, so test data never reaches the production database. The workflow verifies the versioned preview URL, compares its HTTP contract with Pages, runs the Playwright accessibility and visual suites against the edge deployment, and updates one pull request comment. Forks never receive the token. +The `Deploy Worker preview` workflow runs when the repository variable `CLOUDFLARE_WORKERS_PREVIEW_ENABLED` is `true`. Its token comes only from the `worker-preview` GitHub environment. The workflow uploads a version of the separate `lvbt-website-preview` Worker without deploying it. That Worker is the `preview` environment in `wrangler.jsonc` and uses the preview platform database, so test data never reaches the production database. The workflow verifies the versioned preview URL, runs the Playwright accessibility and visual suites against the edge deployment, and updates one pull request comment. Forks never receive the token. The Pages comparison runs only when Workers production is disabled. The preview environment contains: @@ -48,45 +48,19 @@ Both databases are on the free plan in Western North America. `pnpm dev` and `pn ## Production -`Deploy production` builds `main`. While `LVBT_WORKERS_PRODUCTION_ENABLED` is unset, it also publishes `dist/` to the `lvbt-website` Pages project. Pages remains the fallback origin during Worker acceptance. +`Deploy production` builds `main`. With `LVBT_WORKERS_PRODUCTION_ENABLED=true`, its Pages job is skipped. The Pages project remains online at its `pages.dev` address but receives no new production builds. -Its `deploy` job declares `environment: production`, but that only makes the deployment show up under the repository's **Environments** tab with its own history — add required reviewers under **Settings → Environments → production** if you want a manual approval gate there. The two values it needs are plain repository-level settings, not scoped to that one environment, because `deploy-preview.yml`'s fork-safety job (which has no environment of its own) also reads them: +After a successful build, `Deploy Worker candidate` uploads the same `main` commit as a versioned Worker. It runs the browser acceptance suite, records the commit, version, and preview URL, and confirms that `main` still points at the verified commit before deploying that version. The final check compares the production hostname with the version preview. A manual run is accepted only from `main`. -| Setting | Kind | Purpose | -| ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CLOUDFLARE_ACCOUNT_ID` | repository variable | Selects the LVBT Cloudflare account; not secret, so its value is shown in plain text wherever GitHub lists variables | -| `CLOUDFLARE_API_TOKEN` | repository secret | Deploys to Cloudflare Pages (`wrangler pages deploy`); shared with `deploy-preview.yml`'s `preview` environment job, so it must stay a repository secret rather than move into either environment | +The `worker-candidate` GitHub environment contains `CLOUDFLARE_WORKERS_API_TOKEN`, scoped to the LVBT account with Workers Scripts Edit permission. It reads the account ID from the repository-level `CLOUDFLARE_ACCOUNT_ID` variable. The separate `worker-preview` environment has its own token. Neither token can change zone DNS or Worker routes. The repository-level `CLOUDFLARE_API_TOKEN` remains for Pages pull request previews; it is not used for production Worker deployment. -Create the token: open `https://dash.cloudflare.com//api-tokens`, click **Create Token**, then **Create Custom Token** → **Get started** (Cloudflare has no ready-made template for Pages alone). Under **Permissions**, add one row: **Account · Cloudflare Pages · Edit**. Under **Account Resources**, choose **Include** and the LVBT account, not "All accounts". Leave **Zone Resources** at its default — this token needs no zone permission. Leave the TTL empty so deploys keep working. Click **Continue to summary**, then **Create Token**, and copy it: Cloudflare shows it only once. Store it with `gh secret set CLOUDFLARE_API_TOKEN` (paste the value at the prompt; never pass it as a command-line argument). Store the account ID, which is not secret, with `gh variable set CLOUDFLARE_ACCOUNT_ID`. - -After a successful production build, `Deploy Worker candidate` uploads the same `main` commit as a -versioned Worker when `CLOUDFLARE_WORKERS_CANDIDATE_ENABLED` is `true`. Before cutover it compares -the candidate with Pages. It then runs the browser acceptance suite and records the commit, Worker -version, and preview URL in the workflow summary. With `LVBT_WORKERS_PRODUCTION_ENABLED` unset, the -workflow only uploads a version and does not edit a route or create a deployment. - -With `LVBT_WORKERS_PRODUCTION_ENABLED` set to `true`, the workflow skips the comparison with the -older Pages fallback, confirms that `main` still points at the verified commit, and deploys that -exact Worker version. It then compares the production hostname with the version preview. The -preview excludes analytics, so this final comparison checks responses but not analytics insertion. - -The `worker-candidate` environment needs its own `CLOUDFLARE_WORKERS_API_TOKEN` environment secret -(create it the same way as `worker-preview`'s, in [test the Workers -candidate](../guides/test-the-workers-candidate.md)) and reads the same `CLOUDFLARE_ACCOUNT_ID` -repository variable as `worker-preview`. Giving each environment its own token means rotating one -never touches the other. A manual run is accepted only from `main`. - -Workers cutover requires a candidate built from the current `main` commit and a recorded Pages -deployment. DNS, TLS, redirects, headers, analytics, static pages, 404 handling, and every API -route are checked against the version preview before the hostname route changes. The Pages project -stays available until the Worker passes the same checks on the production hostname. +Cloudflare owns the DNS records and certificates for the two Worker custom domains. The former `/*` overlay routes and Pages custom-domain associations are absent. The Pages deployment remains reachable through its `pages.dev` address for emergency recovery. ## Rollback -Before cutover, rollback selects the preceding successful Pages deployment. During the route -overlay, removing the two website Worker routes immediately returns traffic to the Pages fallback. -For a Worker-only release regression, `wrangler rollback --message ` sends all -Worker traffic to the recorded version without changing routes or DNS. +For a Worker release regression, `wrangler rollback --message ` sends all Worker traffic to the recorded version without changing custom domains or DNS. Verify both public hostnames afterward. + +If the Worker itself cannot serve traffic, remove each Worker custom domain, restore the Pages custom-domain association and its proxied Pages CNAME, then verify TLS and the site contract on both hostnames. The Pages deployment remains accessible at `lvbt-website-5zh.pages.dev` throughout this procedure. Restoring Pages changes routing and requires a separate incident decision; it is not the response to an ordinary bad release. The rollback version must retain every binding used by that release. Deleted or incompatible storage bindings prevent Cloudflare from applying an older version. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index f4773ba..38a05b0 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -82,17 +82,16 @@ bug — please add it (see [writing-docs.md](../standards/writing-docs.md)). ## Hosting & DNS (how the site gets online) -- **Cloudflare Pages** — the service that hosts our - website and serves it to visitors. It also runs our small backend functions. -- **Pages Function** — a small backend script that runs - on Cloudflare (in `functions/api/`). It handles things a static page can't, like - receiving a form submission. Think "one API endpoint = one file." +- **Cloudflare Pages** — the former website host. Its + `pages.dev` deployment remains available for emergency rollback. +- **Pages Function** — a backend script in + `functions/api/`. Wrangler compiles these scripts into the production Worker; + each handles a request that static HTML cannot, such as a form submission. - **Wrangler** — Cloudflare's command-line tool, used to run the functions locally and to deploy. `pnpm dev` runs it for you. -- **Cloudflare Workers** — Cloudflare's service for - running small server programs close to visitors. A Worker can also serve - prebuilt files (static assets). The site is moving here from Pages; see the - [platform decision record](../explanation/decisions/organizing-platform.md). +- **Cloudflare Workers** — Cloudflare's runtime for + the production website. The `lvbt-website` Worker serves prebuilt pages and + runs the compiled API functions. - **rendering on request** — building a page's HTML on the server when someone asks for it, instead of once at build time. Astro calls it on-demand rendering; the opposite is prerendering. diff --git a/docs/reference/membership-intake.md b/docs/reference/membership-intake.md index c75fa44..c2eb2da 100644 --- a/docs/reference/membership-intake.md +++ b/docs/reference/membership-intake.md @@ -17,9 +17,9 @@ to** — not a spreadsheet, and not Notion. > [Beehiiv](./glossary.md#beehiiv) (our newsletter platform) account with an > API key, a Notion workspace where you can create a connection (for staff > follow-up, until the staff console replaces it — see -> [below](#staff-follow-up-in-notion)), and access to the Cloudflare Pages -> project to set secrets. The fastest setup path (`pnpm bootstrap --phase secrets`) -> is described under [Required Cloudflare Pages secrets](#required-cloudflare-pages-secrets). +> [below](#staff-follow-up-in-notion)), and access to the LVBT Cloudflare +> account to set Worker secrets. The fastest setup path (`pnpm bootstrap --phase secrets`) +> is described under [Required Cloudflare secrets](#required-cloudflare-secrets). ## How someone joins @@ -102,12 +102,12 @@ the request shape, every response you might get back, and a `curl` command you can use to test it before wiring up the real form. You do not need to touch any code to connect a new form tool. -## Required Cloudflare Pages secrets +## Required Cloudflare secrets The fastest path is `pnpm bootstrap --phase secrets` (see [platform secrets](./platform-secrets.md)). It checks which of these the live site -already has, asks for each missing one once, and stores it on the Pages -project, the Worker and the `worker-candidate` GitHub environment. It never +already has, asks for each missing one once, and stores it on the production +Worker, the Pages fallback and the `worker-candidate` GitHub environment. It never replaces a value that is already stored. For `LVBT_MEMBERSHIP_INTAKE_SECRET` it asks for the value the Apps Script already uses, so the form keeps working. @@ -147,7 +147,7 @@ form to the intake pipeline](../guides/connect-the-membership-form.md). The short version: the form must have **Collect email addresses** on, the script in `scripts/google-apps/membership-intake.gs` must be installed as an **On form submit** trigger, and its `LVBT_MEMBERSHIP_INTAKE_SECRET` property must -equal the Pages secret. +equal the production Worker secret. If the endpoint returns a non-2xx response, the script throws. Apps Script records the failed execution and sends the trigger owner the standard failure @@ -182,7 +182,7 @@ below and saves them in `.env.local` on your machine. `LVBT_NOTION_PARENT_PAGE_ID`. > The new Notion Developer Platform (May 2026) adds an `ntn` CLI and hosted -> Workers, but a server that writes to Notion — our Cloudflare Pages Function +> Workers, but a server that writes to Notion — the site's compiled API function > — still authenticates with a connection access token, so these steps don't > change. @@ -308,14 +308,12 @@ CLI, which cannot open its local IPC socket in restricted sandboxes. Apps Script treats any non-2xx response as a failed execution and emails the trigger owner. The status in that email says what went wrong: -- **`503 service_unavailable`**: a Pages secret is missing. Nothing reached +- **`503 service_unavailable`**: a Worker secret is missing. Nothing reached Beehiiv or Notion, and every submission fails the same way until it is - fixed. Set the secrets named in `missing` on the **Production** environment - of the Pages project that `Deploy production` targets (its account is - `CLOUDFLARE_ACCOUNT_ID` in the repo's `production` GitHub environment), - redeploy so they bind, then replay as below. + fixed. Run `pnpm bootstrap --phase secrets` to set the names in `missing` + on the production Worker, deploy the resulting Worker version, then replay as below. - **`401 unauthorized`**: the Apps Script `LVBT_MEMBERSHIP_INTAKE_SECRET` - property no longer matches the Pages secret. + property no longer matches the Worker secret. - **`502`**: Beehiiv or Notion rejected the request; the body says which. If Beehiiv succeeded and Notion failed, the person is subscribed and in the person record, but has no Notion follow-up page. Replay fixes that too. diff --git a/docs/reference/newsletter-signup.md b/docs/reference/newsletter-signup.md index 73bc1f7..9b04788 100644 --- a/docs/reference/newsletter-signup.md +++ b/docs/reference/newsletter-signup.md @@ -60,13 +60,13 @@ The email's "Not you? Remove this email" link is signed and lasts 30 days. Openi ## Troubleshooting -| Symptom | Cause | Fix | -| -------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| "We couldn't finish joining you just now" | Beehiiv refused the subscription, or its secrets are missing | Check the Cloudflare Pages function logs for "Beehiiv subscribe failed" and run `pnpm bootstrap --doctor --phase secrets` | -| Every join shows that message | The `PLATFORM_DB` binding or `LVBT_LINK_SIGNING_SECRET` is missing | The logs say which; the binding is set on the Pages project, the secret through `pnpm bootstrap --phase secrets` | -| No confirmation email | `LVBT_RESEND_API_KEY` isn't set, or the Resend domain isn't verified | Set the key; until then Beehiiv's welcome email is sent instead | -| The region step says it has expired | More than an hour passed, or cookies are blocked | The person is already a member; they can set their region later from their account | -| A join returns a server error after a deploy | A migration wasn't applied | Run the migrations in the [schema](../../platform/storage/migrations/schema.md) | +| Symptom | Cause | Fix | +| -------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| "We couldn't finish joining you just now" | Beehiiv refused the subscription, or its secrets are missing | Check the `lvbt-website` Worker logs for "Beehiiv subscribe failed" and run `pnpm bootstrap --doctor --phase secrets` | +| Every join shows that message | The `PLATFORM_DB` binding or `LVBT_LINK_SIGNING_SECRET` is missing | The logs say which; the binding is set in `wrangler.jsonc`, the secret through `pnpm bootstrap --phase secrets` | +| No confirmation email | `LVBT_RESEND_API_KEY` isn't set, or the Resend domain isn't verified | Set the key; until then Beehiiv's welcome email is sent instead | +| The region step says it has expired | More than an hour passed, or cookies are blocked | The person is already a member; they can set their region later from their account | +| A join returns a server error after a deploy | A migration wasn't applied | Run the migrations in the [schema](../../platform/storage/migrations/schema.md) | ## Related diff --git a/docs/reference/platform-secrets.md b/docs/reference/platform-secrets.md index 15977ae..a0fb16c 100644 --- a/docs/reference/platform-secrets.md +++ b/docs/reference/platform-secrets.md @@ -12,7 +12,7 @@ You don't need to set secrets by hand. Run: pnpm bootstrap --phase secrets ``` -It checks the Worker, the Pages project and the `worker-candidate` GitHub environment. Missing values are grouped by urgency: live features, the Worker switch-over, and features not yet built. Choose how far to go. Two values that no feature reads yet, for volunteer management, are listed under "Not asked for" and never asked for; see [Google service account](#google-service-account). +It checks the production Worker, the Pages fallback, and the `worker-candidate` GitHub environment. Missing values are grouped by urgency: live features and features not yet built. Choose how far to go. Two values that no feature reads yet, for volunteer management, are listed under "Not asked for" and never asked for; see [Google service account](#google-service-account). For each value, the guide shows what it is for, whether it is fine to skip it for now, where bootstrap stores it, and click-by-click steps to find or create it. You paste the value once and bootstrap stores it on every target that is missing it. Random signing keys are generated only when every target is known to be empty. If a shared secret already exists or a target cannot be checked, the guide asks for the existing value instead. Leave a prompt empty to skip that secret; re-run the command later to finish. @@ -41,11 +41,11 @@ Bootstrap first checks that it can read every place the secret is stored, so a s ## Where each secret lives -| Target | What it serves | -| ------------------------------------- | -------------------------------------------------------------------- | -| Pages project `lvbt-website` | Production today | -| Worker `lvbt-website` | Production after the move from Pages to Workers, and main candidates | -| GitHub environment `worker-candidate` | The workflow that uploads main candidates for comparison with Pages | +| Target | What it serves | +| ------------------------------------- | ----------------------------------------------------- | +| Worker `lvbt-website` | Production site and candidate versions | +| Pages project `lvbt-website` | Emergency rollback at its `pages.dev` address | +| GitHub environment `worker-candidate` | The workflow that uploads and deploys main candidates | Pull request previews run on the separate `lvbt-website-preview` Worker without these secrets, so preview API routes answer `503` by design. diff --git a/docs/reference/transit-news-pipeline.md b/docs/reference/transit-news-pipeline.md index 01dd673..93ee59b 100644 --- a/docs/reference/transit-news-pipeline.md +++ b/docs/reference/transit-news-pipeline.md @@ -125,13 +125,13 @@ in the enrichment function is a possible future addition.) ## Layer 3 — Notion form + Cloudflare enrichment The public, zero-CLI path. A Notion form view collects submissions; a Cloudflare -Pages Function enriches each one. Runs on infrastructure the site already uses — +API function enriches each one. It runs in the production Worker — no Notion Workers beta required. **Function:** `functions/api/transit-news-intake.ts` **Endpoint:** `POST /api/transit-news-intake` **Auth:** `Authorization: Bearer ` ([timing-safe](./glossary.md#timing-safe) compare) -**Secrets:** `LVBT_NOTION_API_KEY`, `LVBT_TRANSIT_NEWS_INTAKE_SECRET` (Cloudflare Pages env) +**Secrets:** `LVBT_NOTION_API_KEY`, `LVBT_TRANSIT_NEWS_INTAKE_SECRET` (production Worker) ### Endpoint contract diff --git a/docs/tutorials/first-time-setup.md b/docs/tutorials/first-time-setup.md index c29b42c..09d4e08 100644 --- a/docs/tutorials/first-time-setup.md +++ b/docs/tutorials/first-time-setup.md @@ -1,6 +1,6 @@ # First-time setup -This is the walk-through for getting the LVBT site from a fresh checkout to a live deploy. It covers what `pnpm bootstrap` will do, what it'll ask you, and what to expect at each step. +This walkthrough takes a website checkout through local setup and checks the existing LVBT production resources. `pnpm bootstrap` presents each missing action before it changes GitHub or Cloudflare. If you just want the flag list, see [reference/bootstrap.md](../reference/bootstrap.md) instead. **Just want to edit content, not deploy your own copy of the whole site?** You probably don't need this page — see [Start here](./start-here.md). @@ -8,9 +8,9 @@ If you just want the flag list, see [reference/bootstrap.md](../reference/bootst You need: -- A terminal with [`node`](../reference/glossary.md#node) (≥22), [`pnpm`](../reference/glossary.md#pnpm) (≥10), `gh` (the GitHub command-line tool), and [`wrangler`](../reference/glossary.md#wrangler) (Cloudflare's command-line tool). The `install` phase will offer to install missing tools. +- A terminal with [`node`](../reference/glossary.md#node) 24.20.0, [`pnpm`](../reference/glossary.md#pnpm) 11.25.0, `gh` (the GitHub command-line tool), and [`wrangler`](../reference/glossary.md#wrangler) (Cloudflare's command-line tool). The `install` phase offers to install missing tools. - A GitHub account (for the `repo` phase). -- A Cloudflare account with at least one **zone** (a [domain Cloudflare manages](../reference/glossary.md#zone)) if you want it to set up [DNS](../reference/glossary.md#dns) for you (the `domain` phase). Otherwise the bootstrap tells you which [CNAME](../reference/glossary.md#cname) record to add wherever you bought your domain (your "registrar"). +- Access to the LVBT Cloudflare account and the `lasvegasfortransit.org` [zone](../reference/glossary.md#zone). ## Run it @@ -33,9 +33,9 @@ pnpm bootstrap 5. **repo** — If `origin` isn't set yet, creates a GitHub repo via `gh repo create` and wires `origin` to its **SSH URL** (the `git@github.com:…` address Git pushes to, which relies on your SSH key being set up). Auto-creates an initial commit if the working tree has none. Defaults the name to `/` (so `~/Projects/LasVegansForTransit/website` becomes `LasVegansForTransit/website`). -6. **deploy** — Checks whether the Cloudflare Pages project (default name `lvbt-website`, default branch `main`) exists and already has a production deployment. If both are there, it does nothing. Otherwise it creates the project and deploys `./dist` once. After that, every push to `main` deploys through GitHub Actions. +6. **deploy** — Checks for a production deployment of the `lvbt-website` Worker. If one exists, it does nothing. Otherwise it offers to build and deploy the Worker. Routine releases from `main` run through GitHub Actions. -7. **domain** — Attaches your [apex domain](../reference/glossary.md#apex-domain) (the bare `lasvegasfortransit.org`, no `www.`) and any extra hostnames to the Pages project via the Cloudflare API, skipping any that are already attached. If your DNS [zone](../reference/glossary.md#zone) is in the same Cloudflare account, it creates the missing [CNAME](../reference/glossary.md#cname) records. If not, it tells you which CNAME to add at your registrar. +7. **domain** — Confirms that the [apex domain](../reference/glossary.md#apex-domain) and `www` belong to the production Worker. It offers to attach missing custom domains; Cloudflare handles their DNS records and certificates. A hostname already owned by another service is left untouched. 8. **secrets** — Checks every server-side secret the live site needs and asks for each missing one once, with click-by-click steps. See [platform secrets](../reference/platform-secrets.md). diff --git a/scripts/bootstrap/cold-start.ts b/scripts/bootstrap/cold-start.ts index 58180e6..fcae93d 100644 --- a/scripts/bootstrap/cold-start.ts +++ b/scripts/bootstrap/cold-start.ts @@ -20,8 +20,8 @@ * workspace — pnpm install + pnpm build smoke * env — write .env.local; prompt for Beehiiv/donate/social URLs * repo — gh repo create + push (skipped if origin already set) - * deploy — create the Pages project and first deploy, only if missing - * domain — attach lasvegasfortransit.org to the Pages project, only if missing + * deploy — check the production Worker and deploy only when missing + * domain — attach apex and www to the Worker only when missing * secrets — report and set every server-side secret the site and platform need * * The flow itself lives in `run.ts`. diff --git a/scripts/bootstrap/config/production-hosting.json b/scripts/bootstrap/config/production-hosting.json new file mode 100644 index 0000000..a410cf7 --- /dev/null +++ b/scripts/bootstrap/config/production-hosting.json @@ -0,0 +1,3 @@ +{ + "mode": "worker" +} diff --git a/scripts/bootstrap/lib/cloudflare-api.ts b/scripts/bootstrap/lib/cloudflare-api.ts index 2787c1f..ff5d3b4 100644 --- a/scripts/bootstrap/lib/cloudflare-api.ts +++ b/scripts/bootstrap/lib/cloudflare-api.ts @@ -143,6 +143,47 @@ export async function listPagesDomains( ); } +export interface WorkerDomain { + hostname: string; + service: string; + zone_id: string; + zone_name: string; +} + +export async function listWorkerDomains( + accountId: string, + hostname: string, + token: string, +): Promise> { + return cfRequest( + `/accounts/${encodeURIComponent(accountId)}/workers/domains?hostname=${encodeURIComponent(hostname)}`, + { token }, + ); +} + +export async function attachWorkerDomain( + accountId: string, + domain: WorkerDomain, + token: string, +): Promise> { + return cfRequest(`/accounts/${encodeURIComponent(accountId)}/workers/domains`, { + method: 'PUT', + body: domain, + token, + }); +} + +export async function listWorkerDeployments( + accountId: string, + workerName: string, + token: string, +): Promise }>> { + return cfRequest( + `/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}/deployments`, + { token }, + ); +} + interface CfZoneAccountRef { id: string; } diff --git a/scripts/bootstrap/lib/defaults.ts b/scripts/bootstrap/lib/defaults.ts index 9123d52..14fddb4 100644 --- a/scripts/bootstrap/lib/defaults.ts +++ b/scripts/bootstrap/lib/defaults.ts @@ -1,4 +1,5 @@ /** LVBT-specific defaults shared across phases. */ export const DEFAULT_PAGES_PROJECT = 'lvbt-website'; +export const DEFAULT_WORKER_NAME = 'lvbt-website'; export const DEFAULT_PRODUCTION_BRANCH = 'main'; export const DEFAULT_APEX_DOMAIN = 'lasvegasfortransit.org'; diff --git a/scripts/bootstrap/phases/worker-deploy.ts b/scripts/bootstrap/phases/worker-deploy.ts new file mode 100644 index 0000000..aff338f --- /dev/null +++ b/scripts/bootstrap/phases/worker-deploy.ts @@ -0,0 +1,96 @@ +import { log } from '@clack/prompts'; +import pc from 'picocolors'; +import { listWorkerDeployments } from '../lib/cloudflare-api.js'; +import { ensureCloudflareAccount } from '../lib/cloudflare.js'; +import { DEFAULT_WORKER_NAME } from '../lib/defaults.js'; +import { rt } from '../lib/runtime.js'; +import { runCommand, runStreamingCommand, summarizeOutputLine } from '../lib/shell.js'; +import { promptConfirm } from '../lib/ui.js'; +import type { FollowUp, PhaseResult } from '../lib/types.js'; +import type { DeployOptions } from './deploy.js'; + +type DeploymentState = 'deployed' | 'missing' | 'unknown'; + +function savedCloudflareAccount(): string | undefined { + // eslint-disable-next-line turbo/no-undeclared-env-vars -- local bootstrap account choice. + return process.env.CLOUDFLARE_ACCOUNT_ID?.trim(); +} + +async function deploymentState(projectRoot: string, doctorMode: boolean): Promise { + const result = runCommand(`wrangler deployments list --name ${DEFAULT_WORKER_NAME} --json`, { + cwd: projectRoot, + }); + if (!result.ok) { + const accountId = doctorMode + ? savedCloudflareAccount() + : (await ensureCloudflareAccount(projectRoot)).accountId; + // eslint-disable-next-line turbo/no-undeclared-env-vars -- local bootstrap credential. + const token = rt().wranglerOAuthToken() ?? process.env.CLOUDFLARE_API_TOKEN; + if (!accountId || !token) return 'unknown'; + const response = await listWorkerDeployments(accountId, DEFAULT_WORKER_NAME, token); + if (response.status === 404) return 'missing'; + if (!response.ok || !response.data) return 'unknown'; + return response.data.deployments.length > 0 ? 'deployed' : 'missing'; + } + try { + const deployments = JSON.parse(result.stdout) as unknown; + return Array.isArray(deployments) && deployments.length > 0 ? 'deployed' : 'missing'; + } catch { + return 'unknown'; + } +} + +export async function runWorkerDeployPhase( + projectRoot: string, + doctorMode: boolean, + options: DeployOptions = {}, +): Promise { + const followUpItems: FollowUp[] = []; + const state = await deploymentState(projectRoot, doctorMode); + if (state === 'deployed' && (doctorMode || !options.redeploy)) { + log.success(`Production Worker ${pc.cyan(DEFAULT_WORKER_NAME)} is deployed.`); + return { success: true, followUpItems }; + } + if (state === 'unknown') { + followUpItems.push({ + kind: 'auth', + message: + 'Check Wrangler access to the production Worker, then rerun `pnpm bootstrap --phase deploy`.', + }); + return { success: false, followUpItems }; + } + if (doctorMode) { + followUpItems.push({ + kind: 'remote', + message: 'Deploy the production Worker with `pnpm bootstrap --phase deploy`.', + }); + return { success: false, followUpItems }; + } + + const confirmed = await promptConfirm( + 'deploy.proceed-worker', + state === 'missing' + ? 'Build and deploy the production Worker now?' + : 'Build and redeploy this checkout to the production Worker?', + state === 'missing', + ); + if (!confirmed) { + followUpItems.push({ + kind: 'remote', + message: 'Deploy the production Worker with `pnpm bootstrap --phase deploy`.', + }); + return { success: false, followUpItems }; + } + + const result = await runStreamingCommand('pnpm worker:deploy', { cwd: projectRoot }); + if (!result.ok || (await deploymentState(projectRoot, false)) !== 'deployed') { + log.error(`Worker deployment failed: ${summarizeOutputLine(result)}`); + followUpItems.push({ + kind: 'remote', + message: 'Resolve the Worker deployment error, then rerun `pnpm bootstrap --phase deploy`.', + }); + return { success: false, followUpItems }; + } + log.success(`Production Worker ${pc.cyan(DEFAULT_WORKER_NAME)} is deployed.`); + return { success: true, followUpItems }; +} diff --git a/scripts/bootstrap/phases/worker-domain.ts b/scripts/bootstrap/phases/worker-domain.ts new file mode 100644 index 0000000..e03e437 --- /dev/null +++ b/scripts/bootstrap/phases/worker-domain.ts @@ -0,0 +1,131 @@ +import { log } from '@clack/prompts'; +import pc from 'picocolors'; +import { attachWorkerDomain, findZoneIdForName, listWorkerDomains } from '../lib/cloudflare-api.js'; +import { ensureCloudflareAccount } from '../lib/cloudflare.js'; +import { DEFAULT_APEX_DOMAIN, DEFAULT_WORKER_NAME } from '../lib/defaults.js'; +import { rt } from '../lib/runtime.js'; +import { promptConfirm } from '../lib/ui.js'; +import type { FollowUp, PhaseResult } from '../lib/types.js'; + +const HOSTS = [DEFAULT_APEX_DOMAIN, `www.${DEFAULT_APEX_DOMAIN}`] as const; + +function savedCloudflareAccount(): string | undefined { + // eslint-disable-next-line turbo/no-undeclared-env-vars -- local bootstrap account choice. + return process.env.CLOUDFLARE_ACCOUNT_ID?.trim(); +} + +async function missingWorkerDomains( + accountId: string, + token: string, + followUpItems: FollowUp[], +): Promise { + const missing: string[] = []; + for (const host of HOSTS) { + const response = await listWorkerDomains(accountId, host, token); + if (!response.ok || !response.data) { + followUpItems.push({ + kind: 'auth', + message: `Could not read the Worker domain for ${host}; check Cloudflare permissions.`, + }); + continue; + } + const attached = response.data.find((domain) => domain.hostname === host); + if (attached?.service === DEFAULT_WORKER_NAME) { + log.success(`${pc.cyan(host)} routes to ${pc.cyan(DEFAULT_WORKER_NAME)}.`); + } else if (attached) { + followUpItems.push({ + kind: 'remote', + message: `${host} belongs to Worker ${attached.service}; transfer it explicitly before rerunning bootstrap.`, + }); + } else { + missing.push(host); + } + } + return missing; +} + +async function attachMissingDomains( + accountId: string, + token: string, + missing: readonly string[], + followUpItems: FollowUp[], +): Promise { + const zone = await findZoneIdForName(DEFAULT_APEX_DOMAIN, token); + if (!zone.zoneId || !zone.zoneName) { + followUpItems.push({ + kind: 'auth', + message: `Could not find the ${DEFAULT_APEX_DOMAIN} zone in this Cloudflare account.`, + }); + return; + } + for (const host of missing) { + const attached = await attachWorkerDomain( + accountId, + { + hostname: host, + service: DEFAULT_WORKER_NAME, + zone_id: zone.zoneId, + zone_name: zone.zoneName, + }, + token, + ); + if (attached.ok) { + log.success(`${pc.cyan(host)} attached to ${pc.cyan(DEFAULT_WORKER_NAME)}.`); + } else { + followUpItems.push({ + kind: 'remote', + message: `Could not attach ${host}: ${attached.errors.map((error) => error.message).join('; ')}. Remove any conflicting Pages CNAME only during an approved cutover.`, + }); + } + } +} + +export async function runWorkerDomainPhase( + projectRoot: string, + doctorMode: boolean, +): Promise { + const followUpItems: FollowUp[] = []; + const accountId = doctorMode + ? savedCloudflareAccount() + : (await ensureCloudflareAccount(projectRoot)).accountId; + // eslint-disable-next-line turbo/no-undeclared-env-vars -- local bootstrap credential, not a build input. + const token = rt().wranglerOAuthToken() ?? process.env.CLOUDFLARE_API_TOKEN; + if (!accountId || !token) { + followUpItems.push({ + kind: 'auth', + message: + 'Sign in with Wrangler and select the LVBT Cloudflare account, then rerun `pnpm bootstrap --phase domain`.', + }); + return { success: false, followUpItems }; + } + + const missing = await missingWorkerDomains(accountId, token, followUpItems); + + if (missing.length === 0 || doctorMode || followUpItems.length > 0) { + if (doctorMode) { + for (const host of missing) { + followUpItems.push({ + kind: 'remote', + message: `Attach ${host} to ${DEFAULT_WORKER_NAME} with pnpm bootstrap --phase domain.`, + }); + } + } + return { success: followUpItems.length === 0, followUpItems }; + } + + const confirmed = await promptConfirm( + 'domain.attach-worker', + `Attach ${missing.join(' and ')} to the production Worker?`, + true, + ); + if (!confirmed) { + followUpItems.push({ + kind: 'remote', + message: 'Attach the missing Worker domains with `pnpm bootstrap --phase domain`.', + }); + return { success: false, followUpItems }; + } + + await attachMissingDomains(accountId, token, missing, followUpItems); + return { success: followUpItems.length === 0, followUpItems }; +} diff --git a/scripts/bootstrap/run.ts b/scripts/bootstrap/run.ts index c129ced..b43f5f6 100644 --- a/scripts/bootstrap/run.ts +++ b/scripts/bootstrap/run.ts @@ -10,6 +10,8 @@ */ import { intro, log, note, outro } from '@clack/prompts'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; import pc from 'picocolors'; import { detectOs } from './lib/os.js'; import { loadEnvLocal } from './lib/load-env.js'; @@ -27,6 +29,8 @@ import { runEnvPhase } from './phases/env.js'; import { runRepoPhase } from './phases/repo.js'; import { runDeployPhase } from './phases/deploy.js'; import { runDomainPhase } from './phases/domain.js'; +import { runWorkerDeployPhase } from './phases/worker-deploy.js'; +import { runWorkerDomainPhase } from './phases/worker-domain.js'; import { runSecretsPhase } from './phases/secrets.js'; interface PhaseSpec { @@ -72,14 +76,14 @@ const PHASES: readonly PhaseSpec[] = [ }, { id: 'deploy', - title: 'Cloudflare Pages', - what: 'Checking that the Pages project exists and has a production deployment. It creates the project and pushes the first build only when they are missing.', + title: 'Production deployment', + what: 'Checking the production deployment and deploying the first build only when it is missing.', local: false, }, { id: 'domain', title: 'Custom domain', - what: 'Checking whether your domain points at the Pages project, and attaching and wiring only the hosts that are missing.', + what: 'Checking the production hostnames and attaching only those that are missing.', local: false, }, { @@ -100,7 +104,7 @@ export interface CliArgs { resume: boolean; localOnly: boolean; phase: PhaseId | null; - /** Push ./dist to Pages production even when a production deployment exists. */ + /** Deploy this checkout even when a production deployment exists. */ redeploy: boolean; /** Secret names to replace with a new value, even though they are already set. */ rotate: readonly string[]; @@ -232,14 +236,28 @@ async function runPhaseById( case 'repo': return runRepoPhase(projectRoot, args.doctorMode); case 'deploy': - return runDeployPhase(projectRoot, args.doctorMode, { redeploy: args.redeploy }); + return productionHosting(projectRoot) === 'worker' + ? runWorkerDeployPhase(projectRoot, args.doctorMode, { redeploy: args.redeploy }) + : runDeployPhase(projectRoot, args.doctorMode, { redeploy: args.redeploy }); case 'domain': - return runDomainPhase(projectRoot, args.doctorMode); + return productionHosting(projectRoot) === 'worker' + ? runWorkerDomainPhase(projectRoot, args.doctorMode) + : runDomainPhase(projectRoot, args.doctorMode); case 'secrets': return runSecretsPhase(projectRoot, args.doctorMode, { rotate: args.rotate, state }); } } +function productionHosting(projectRoot: string): 'worker' | 'pages' { + const file = path.join(projectRoot, 'scripts', 'bootstrap', 'config', 'production-hosting.json'); + if (!existsSync(file)) throw new UsageError(`Missing hosting configuration: ${file}`); + const value = JSON.parse(readFileSync(file, 'utf8')) as { mode?: unknown }; + if (value.mode !== 'worker' && value.mode !== 'pages') { + throw new UsageError(`${file} must declare mode as worker or pages.`); + } + return value.mode; +} + function isLocalPhase(phaseId: PhaseId): boolean { return PHASE_BY_ID[phaseId].local; } @@ -325,6 +343,7 @@ export interface BootstrapOutcome { } export async function runBootstrap(args: CliArgs, projectRoot: string): Promise { + productionHosting(projectRoot); // Hydrate process.env from .env.local so persisted choices (e.g. // CLOUDFLARE_ACCOUNT_ID) survive across runs. loadEnvLocal(projectRoot); diff --git a/tests/bootstrap-fake-world.ts b/tests/bootstrap-fake-world.ts index 124c58b..d83faff 100644 --- a/tests/bootstrap-fake-world.ts +++ b/tests/bootstrap-fake-world.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- one composite fake runtime for the end-to-end bootstrap tests. */ // A pretend GitHub, Cloudflare and local toolchain for running the whole // bootstrap in a test. It answers the commands and API calls the bootstrap // makes, keeps what they change, and records every call that changes @@ -80,6 +81,8 @@ export class FakeWorld { readonly repos = new Set(); origin: string | null = null; readonly projects = new Map(); + workerDeployed = false; + readonly workerDomains = new Map(); readonly workerSecrets = new Set(); readonly pagesSecrets = new Set(); readonly githubSecrets = new Set(); @@ -285,6 +288,8 @@ export class FakeWorld { } private cloud(command: string): CommandResult { + const workerResult = this.workerCommand(command); + if (workerResult) return workerResult; if (command === 'wrangler whoami') { return ok( [ @@ -334,6 +339,20 @@ export class FakeWorld { throw new Error(`Unexpected command: ${command}`); } + private workerCommand(command: string): CommandResult | null { + if (command === 'wrangler deployments list --name lvbt-website --json') { + return this.workerDeployed + ? ok('[{"id":"worker-deployment-fake"}]') + : fail('Worker not found'); + } + if (command === 'pnpm worker:deploy') { + this.mutate('worker deploy lvbt-website'); + this.workerDeployed = true; + return ok('Worker deployed'); + } + return null; + } + private runWithInput(command: string, input: string): CommandResult { this.commands.push(command); if (!input) throw new Error(`Empty secret written by: ${command}`); @@ -400,6 +419,14 @@ export class FakeWorld { if (project[1] !== FAKE_ACCOUNT_ID) return cfError(403, 10000, 'Authentication error'); return this.pagesApi(method, decodeURIComponent(project[2]), Boolean(project[3]), body); } + if (route === `/accounts/${FAKE_ACCOUNT_ID}/workers/domains`) { + return this.workerDomainsApi(method, query, body); + } + if (route === `/accounts/${FAKE_ACCOUNT_ID}/workers/scripts/lvbt-website/deployments`) { + return this.workerDeployed + ? cfResult({ deployments: [{ id: 'worker-deployment-fake' }] }) + : cfError(404, 10007, 'Worker not found'); + } if (route === '/zones') { return cfResult(query.get('name') === this.zone.name ? [this.zone] : []); } @@ -408,6 +435,30 @@ export class FakeWorld { throw new Error(`Unexpected Cloudflare API call: ${method} ${route}`); } + private workerDomainsApi(method: string, query: URLSearchParams, body: unknown): Response { + if (method === 'GET') { + const hostname = query.get('hostname'); + return cfResult( + [...this.workerDomains] + .filter(([host]) => !hostname || host === hostname) + .map(([host, service]) => ({ + hostname: host, + service, + zone_id: this.zone.id, + zone_name: this.zone.name, + })), + ); + } + const domain = body as { hostname: string; service: string }; + this.mutate(`worker domain ${domain.hostname}`); + this.workerDomains.set(domain.hostname, domain.service); + return cfResult({ + ...domain, + zone_id: this.zone.id, + zone_name: this.zone.name, + }); + } + private pagesApi(method: string, name: string, domains: boolean, body: unknown): Response { const state = this.projects.get(name); if (!state) return cfError(404, 8000007, 'Project not found'); diff --git a/tests/bootstrap-idempotency.test.ts b/tests/bootstrap-idempotency.test.ts index f6862a9..95aa9f2 100644 --- a/tests/bootstrap-idempotency.test.ts +++ b/tests/bootstrap-idempotency.test.ts @@ -26,7 +26,7 @@ import { UsageError, type BootstrapOutcome, } from '../scripts/bootstrap/run.js'; -import { FakeWorld, fakeValueFor, type Answer } from './bootstrap-fake-world.js'; +import { FAKE_ACCOUNT_ID, FakeWorld, fakeValueFor, type Answer } from './bootstrap-fake-world.js'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const CONFIG_FILES = ['.env.local', '.env.example', 'wrangler.jsonc', 'package.json']; @@ -82,6 +82,11 @@ function freshSetup(name: string): Setup { for (const file of ['.env.example', 'wrangler.jsonc', 'package.json']) { copyFileSync(path.join(repoRoot, file), path.join(root, file)); } + mkdirSync(path.join(root, 'scripts', 'bootstrap', 'config'), { recursive: true }); + writeFileSync( + path.join(root, 'scripts', 'bootstrap', 'config', 'production-hosting.json'), + '{"mode":"pages"}\n', + ); return { root, home, world: new FakeWorld(root, home) }; } @@ -223,6 +228,61 @@ void test('a second run of the whole bootstrap changes nothing and reports ready assertReady(second); }); +void test('Worker hosting deploys and attaches without provisioning Pages', async () => { + const setup = freshSetup('worker-production'); + writeFileSync( + path.join(setup.root, 'scripts', 'bootstrap', 'config', 'production-hosting.json'), + '{"mode":"worker"}\n', + ); + + const deploy = await runOnce(setup, ['--phase', 'deploy']); + const deployMutations = [...setup.world.mutations]; + const domain = await runOnce(setup, ['--phase', 'domain'], firstRunAnswers()); + + assert.equal(deploy.results.deploy?.success, true); + assert.equal(domain.results.domain?.success, true); + assert.equal(setup.world.projects.size, 0, 'Pages must remain a rollback artifact'); + assert.ok(deployMutations.includes('worker deploy lvbt-website')); + assert.ok(setup.world.mutations.includes('worker domain lasvegasfortransit.org')); + assert.ok( + [...deployMutations, ...setup.world.mutations].every( + (mutation) => !mutation.startsWith('pages '), + ), + ); +}); + +void test('Worker hosting reruns and doctor checks do not change Cloudflare', async () => { + const setup = freshSetup('worker-idempotent'); + writeFileSync( + path.join(setup.root, 'scripts', 'bootstrap', 'config', 'production-hosting.json'), + '{"mode":"worker"}\n', + ); + setup.world.workerDeployed = true; + setup.world.workerDomains.set('lasvegasfortransit.org', 'lvbt-website'); + setup.world.workerDomains.set('www.lasvegasfortransit.org', 'lvbt-website'); + writeFileSync(path.join(setup.root, '.env.local'), `CLOUDFLARE_ACCOUNT_ID=${FAKE_ACCOUNT_ID}\n`); + + const deploy = await runOnce(setup, ['--phase', 'deploy']); + assert.equal(deploy.results.deploy?.success, true); + assert.deepEqual(setup.world.mutations, []); + + const deployDoctor = await runOnce(setup, ['--phase', 'deploy', '--doctor', '--redeploy']); + assert.equal(deployDoctor.results.deploy?.success, true); + assert.deepEqual(setup.world.mutations, []); + + const domain = await runOnce(setup, ['--phase', 'domain', '--doctor']); + assert.equal(domain.results.domain?.success, true); + assert.deepEqual(setup.world.mutations, []); +}); + +void test('missing hosting configuration cannot fall back to Pages', async () => { + const setup = freshSetup('missing-hosting'); + rmSync(path.join(setup.root, 'scripts', 'bootstrap', 'config', 'production-hosting.json')); + + await assert.rejects(() => runOnce(setup, ['--phase', 'deploy']), UsageError); + assert.deepEqual(setup.world.mutations, []); +}); + void test('values that are not secret are asked for in plain view and shown back; secrets never are', async () => { const setup = freshSetup('visible-values'); const asked = PLATFORM_SECRETS.filter((s) => s.listOnly !== true && s.generate !== true);