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.