diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml
index 52908cd8b..881a66dc8 100644
--- a/.github/workflows/website-pages.yml
+++ b/.github/workflows/website-pages.yml
@@ -19,12 +19,33 @@ jobs:
contents: read
pages: read
steps:
+ - name: Require Cloudflare publishing credentials
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ if [[ -z "$CLOUDFLARE_API_TOKEN" || -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then
+ echo "::error::Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in repository Actions secrets before publishing."
+ exit 1
+ fi
- name: Check out the repository
uses: actions/checkout@v4
- name: Prepare the example download
run: python3 scripts/prepare-website.py
- name: Check website links and publishing configuration
run: python3 scripts/verify-website.py
+ - name: Record the published commit
+ run: |
+ python3 - <<'PY'
+ import json
+ import os
+ from pathlib import Path
+
+ Path("website/public/deployment.json").write_text(
+ json.dumps({"commit": os.environ["GITHUB_SHA"]}) + "\n",
+ encoding="utf-8",
+ )
+ PY
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload the website
@@ -35,14 +56,81 @@ jobs:
deploy:
needs: prepare
runs-on: ubuntu-latest
- timeout-minutes: 5
+ timeout-minutes: 15
permissions:
+ contents: read
+ actions: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
+ - name: Check out publishing configuration
+ uses: actions/checkout@v4
+ - name: Set up Node.js for Wrangler
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ - name: Download the verified website
+ uses: actions/download-artifact@v4
+ with:
+ name: github-pages
+ path: ${{ runner.temp }}/website-archive
+ - name: Extract the shared publishing artifact
+ run: |
+ mkdir -p "$RUNNER_TEMP/rea-website-public"
+ tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
+ - name: Publish the website to Cloudflare
+ uses: cloudflare/wrangler-action@v4
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ wranglerVersion: "4.149.0"
+ packageManager: npm
+ command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
- name: Publish to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+ - name: Verify both published revisions
+ env:
+ PAGES_URL: ${{ steps.deployment.outputs.page_url }}
+ run: |
+ python3 - <<'PY'
+ import json
+ import os
+ import sys
+ import time
+ from urllib.error import URLError
+ from urllib.parse import urljoin
+ from urllib.request import Request, urlopen
+
+ expected = os.environ["GITHUB_SHA"]
+ pages_url = os.environ["PAGES_URL"]
+ if not pages_url:
+ sys.exit("GitHub Pages returned no published URL.")
+ pending = {"Cloudflare": "https://rea.tools/", "GitHub Pages": pages_url}
+ for attempt in range(24):
+ for name, base in list(pending.items()):
+ url = urljoin(base, "deployment.json") + "?commit=" + expected
+ try:
+ request = Request(url, headers={"Cache-Control": "no-cache"})
+ with urlopen(request, timeout=10) as response:
+ actual = json.load(response).get("commit")
+ if actual == expected:
+ print(f"{name} serves {expected}: {base}")
+ del pending[name]
+ else:
+ print(f"Waiting for {name}: observed revision {actual}")
+ except (URLError, TimeoutError, ValueError) as error:
+ print(f"Waiting for {name}: {error}")
+ if not pending:
+ break
+ if attempt < 23:
+ time.sleep(5)
+ if pending:
+ sys.exit("Published revision did not match on: " + ", ".join(pending))
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
+ summary.write(f"Published commit `{expected}` to [rea.tools](https://rea.tools/) "
+ f"and [GitHub Pages]({pages_url}).\n")
+ PY
diff --git a/.gitignore b/.gitignore
index f8266c11d..daca8832e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,7 @@ Thumbs.db
# Generated website download
/website/public/examples/notes-example.zip
+/website/public/deployment.json
# Local Cloudflare development state
/website/.wrangler/
diff --git a/website/AGENTS.md b/website/AGENTS.md
index a4bf9b8dc..47bee247a 100644
--- a/website/AGENTS.md
+++ b/website/AGENTS.md
@@ -19,5 +19,6 @@ for asset notes and the source of case-study claims.
- Check both local root and `/rea/` paths, mobile layouts, expanded details,
copying and downloads. Run `scripts/prepare-website.py` followed by
`scripts/verify-website.py`.
-- Keep `website-pages.yml` as the sole, manual Pages publisher. Follow the
- publication procedure in the website README.
+- Keep `website-pages.yml` as the sole, manual production publisher for
+ Cloudflare and Pages. Both hosts must receive the same verified artifact and
+ pass the published-commit check. Follow the website README.
diff --git a/website/README.md b/website/README.md
index 0f29bc32b..92b0be51d 100644
--- a/website/README.md
+++ b/website/README.md
@@ -185,7 +185,7 @@ Wrangler version without adding it to REA's package dependencies.
Cloudflare documents static-asset requests as free and unlimited, with no
additional storage cost. See [billing and limitations](https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/).
-### Preview and deploy
+### Local and hosted previews
Run these commands from the repository root. `--cwd website` makes the build
paths consistent with the website configuration. Preview locally first:
@@ -230,13 +230,7 @@ See Cloudflare's [nameserver setup procedure](https://developers.cloudflare.com/
for the complete migration steps. A CNAME to the preview's `workers.dev` hostname
at the current DNS provider does not configure a Workers Custom Domain.
-Publish to the production Worker:
-
-```sh
-npx wrangler@4.149.0 deploy --cwd website --env production
-```
-
-This creates or updates `rea-website`, attaches `rea.tools`, and lets Cloudflare
+The production environment creates or updates `rea-website`, attaches `rea.tools`, and lets Cloudflare
manage its DNS record and HTTPS certificate. Deployment can succeed while the
zone is pending; verify public HTTPS after activation and certificate issuance.
An existing CNAME at `rea.tools`
@@ -250,15 +244,34 @@ Verify , ,
resolve correctly. The root README and browser guide use `https://rea.tools/`
as the public website URL.
-Deployments are manual. The existing GitHub Pages publisher remains available
-as a separate host; it does not publish to Cloudflare.
+Production publication uses the synchronized GitHub Actions workflow below.
+Local Wrangler commands above publish only the separate preview Worker.
-## GitHub Pages
+## Synchronized production publishing
-`.github/workflows/website-pages.yml` prepares and deploys only `website/public`.
-It is the sole Pages publisher, manually triggered and restricted to `main`;
+`.github/workflows/website-pages.yml` prepares `website/public` once and deploys
+the same artifact to Cloudflare (`rea.tools`) and GitHub Pages. It is the sole
+production publisher, manually triggered and restricted to `main`;
ordinary pushes and pull requests do not publish the site.
+Configure these repository **Actions secrets** before publishing:
+
+- `CLOUDFLARE_API_TOKEN`: a Cloudflare token using the **Edit Cloudflare Workers**
+ template, scoped to the website's account and zone.
+- `CLOUDFLARE_ACCOUNT_ID`: that Cloudflare account's ID.
+
+See Cloudflare's [GitHub Actions authentication instructions](https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/).
+The workflow checks that both secrets are present before preparing a release.
+Cloudflare deploys first; GitHub Pages deploys after that step succeeds.
+
+Preparation adds `deployment.json` with the workflow's commit SHA. Cloudflare
+receives the extracted Pages artifact through Wrangler's explicit assets path;
+the local custom-build output is not the uploaded directory. The workflow then
+checks both public version markers and reports success only when they match
+that commit. A failed publication remains failed; fix its cause and rerun the
+workflow to complete both deployments. The two hosts can update at different
+times while a run is in progress.
+
Website checks run only for pull requests that change `website/`, the verification
script or workflow definitions. They check local links, HTML fragments, SVG XML
and the single Pages publisher without installing npm dependencies. The same
@@ -277,8 +290,9 @@ the old site.
After the site is approved and merged, select **GitHub Actions** under the
repository's **Settings → Pages → Build and deployment**. Then run **Publish REA
-website** from the Actions tab on `main`. The workflow uses the `github-pages`
-environment and the official Pages actions.
+website** from the Actions tab on `main`. This publishes both production hosts.
+The workflow uses the `github-pages` environment, the official Pages actions and
+Cloudflare's Wrangler action.
The equivalent CLI command is:
diff --git a/website/style-guide.md b/website/style-guide.md
index e17475d57..5a05299c6 100644
--- a/website/style-guide.md
+++ b/website/style-guide.md
@@ -233,6 +233,8 @@ New analysis or behavior claims need the real evidence that supports them; a moc
or a page-loading check cannot establish those claims.
Follow the PR review and CI process, then publish using the existing manual
-`website-pages.yml` workflow on `main`. Ordinary commits should not publish the
-site. After deployment, check the live page and confirm it matches the reviewed
-version. The legacy VitePress build remains separate from the Pages publisher.
+`website-pages.yml` workflow on `main`. It publishes the same verified artifact
+to Cloudflare and GitHub Pages and checks both published commit markers.
+Ordinary commits should not publish the site. After deployment, check both live
+pages and confirm they match the reviewed version. The legacy VitePress build
+remains separate from the Pages publisher.