-
Notifications
You must be signed in to change notification settings - Fork 94
ci/docs: deploy documentation site on Cloudflare with PR previews #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ifireball
merged 2 commits into
fullsend-ai:main
from
ifireball:ifireball/site-cloudflare-workers
Apr 15, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| name: Build Site | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: site-build-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6.0.2 | ||
| with: | ||
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | ||
|
|
||
| - name: Prepare site | ||
| run: | | ||
| mkdir -p _site | ||
| cp docs/mindmap.html _site/index.html | ||
|
|
||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: site | ||
| path: _site/ | ||
| retention-days: 5 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| name: Deploy Site | ||
|
|
||
| on: | ||
| workflow_run: | ||
| workflows: [Build Site] | ||
| types: [completed] | ||
|
|
||
| permissions: | ||
| contents: read | ||
| actions: read | ||
| deployments: write | ||
| pull-requests: write | ||
|
|
||
| concurrency: | ||
| group: site-deploy-${{ github.event.workflow_run.event }}-${{ github.event.workflow_run.head_repository.owner.login }}-${{ github.event.workflow_run.head_branch }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| deploy: | ||
| runs-on: ubuntu-latest | ||
| if: >- | ||
| github.event.workflow_run.conclusion == 'success' && | ||
| github.event.workflow_run.repository.full_name == github.repository && | ||
| contains(fromJSON('["pull_request","push"]'), github.event.workflow_run.event) | ||
| env: | ||
| HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} | ||
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | ||
| steps: | ||
| - uses: actions/checkout@v6.0.2 | ||
|
|
||
| - name: Download build artifact | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| name: site | ||
| path: site/public | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| run-id: ${{ github.event.workflow_run.id }} | ||
|
|
||
| - name: Resolve preview context (PR number + preview alias) | ||
| id: preview-context | ||
| if: success() | ||
| uses: actions/github-script@v8 | ||
| with: | ||
| script: | | ||
| const run = context.payload.workflow_run; | ||
| if (run.event !== 'pull_request') { | ||
| core.setOutput('preview_alias', ''); | ||
| core.setOutput('pr_number', ''); | ||
| return; | ||
| } | ||
|
|
||
| const owner = context.repo.owner; | ||
| const repo = context.repo.repo; | ||
| let prNumber = run.pull_requests?.[0]?.number; | ||
|
|
||
| if (!prNumber) { | ||
| const head = `${run.head_repository.owner.login}:${run.head_branch}`; | ||
| const { data: prs } = await github.rest.pulls.list({ | ||
| owner, | ||
| repo, | ||
| state: 'open', | ||
| head, | ||
| per_page: 100, | ||
| }); | ||
| if (prs.length === 1) { | ||
| prNumber = prs[0].number; | ||
| } else if (prs.length === 0) { | ||
| core.setFailed(`Cannot resolve PR for preview: no open PR for head=${head}`); | ||
| return; | ||
| } else { | ||
| core.warning( | ||
| `Multiple open PRs (${prs.length}) for head=${head}; preview alias falls back to workflow_run.id; PR comment upsert skipped until head is unique`, | ||
| ); | ||
| prNumber = null; | ||
| } | ||
| } | ||
|
|
||
| const workflowRunId = run.id; | ||
| const previewAlias = prNumber != null ? `pr-${prNumber}` : `pr-${workflowRunId}`; | ||
| core.setOutput('preview_alias', previewAlias); | ||
| core.setOutput('pr_number', prNumber != null ? String(prNumber) : ''); | ||
|
|
||
| - name: Deploy to production (Workers + static assets) | ||
| id: cf-prod | ||
| if: github.event.workflow_run.event == 'push' | ||
| uses: cloudflare/wrangler-action@v3.14.1 | ||
| with: | ||
| wranglerVersion: "4.30.0" | ||
| workingDirectory: site | ||
| apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| command: deploy --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}" | ||
|
|
||
| - name: Upload preview version (Workers + static assets) | ||
| id: cf-preview | ||
| if: github.event.workflow_run.event == 'pull_request' | ||
| uses: cloudflare/wrangler-action@v3.14.1 | ||
| with: | ||
| wranglerVersion: "4.30.0" | ||
| workingDirectory: site | ||
| apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| command: >- | ||
| versions upload | ||
| --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}" | ||
| --preview-alias ${{ steps.preview-context.outputs.preview_alias }} | ||
|
|
||
| - name: Resolve deployment URL | ||
| id: meta | ||
| if: >- | ||
| (steps.cf-prod.outcome == 'success' || steps.cf-preview.outcome == 'success') | ||
| env: | ||
| URL_PROD: ${{ steps.cf-prod.outputs.deployment-url }} | ||
| URL_PREVIEW: ${{ steps.cf-preview.outputs.deployment-url }} | ||
| OUT_PROD: ${{ steps.cf-prod.outputs.command-output }} | ||
| ERR_PROD: ${{ steps.cf-prod.outputs.command-stderr }} | ||
| OUT_PR: ${{ steps.cf-preview.outputs.command-output }} | ||
| ERR_PR: ${{ steps.cf-preview.outputs.command-stderr }} | ||
| run: | | ||
| set -euo pipefail | ||
| url="${URL_PROD:-}" | ||
| if [ -z "$url" ]; then | ||
| url="${URL_PREVIEW:-}" | ||
| fi | ||
| if [ -z "$url" ]; then | ||
| comb="${OUT_PROD:-}${ERR_PROD:-}${OUT_PR:-}${ERR_PR:-}" | ||
| url=$(printf '%s' "$comb" | grep -oE 'https://[a-zA-Z0-9._/?#&=%_-]+' | grep -E '\.workers\.dev(/|$)' | head -1 || true) | ||
| fi | ||
| if [ -z "$url" ]; then | ||
| echo "::error::Could not determine Workers deployment URL from Wrangler output" | ||
| exit 1 | ||
| fi | ||
| echo "deployment_url=$url" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Validate preview deployment URL (alias vs versioned URL) | ||
| if: >- | ||
| github.event.workflow_run.event == 'pull_request' && | ||
| steps.cf-preview.outcome == 'success' && | ||
| steps.meta.outcome == 'success' | ||
| env: | ||
| URL: ${{ steps.meta.outputs.deployment_url }} | ||
| ALIAS_TOKEN: ${{ steps.preview-context.outputs.preview_alias }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [[ -z "${ALIAS_TOKEN:-}" ]]; then | ||
| exit 0 | ||
| fi | ||
| if [[ "$URL" != *"$ALIAS_TOKEN"* ]]; then | ||
| echo "::warning::Preview deployment URL does not include alias token '${ALIAS_TOKEN}' (got: ${URL}). wrangler-action may be returning a versioned URL instead of the preview-alias hostname; confirm in Cloudflare or Wrangler structured output." | ||
| fi | ||
|
|
||
| - name: GitHub Deployment + preview comment | ||
| if: steps.meta.outcome == 'success' | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| DEPLOYMENT_URL: ${{ steps.meta.outputs.deployment_url }} | ||
| PREVIEW_PR_NUMBER: ${{ steps.preview-context.outputs.pr_number }} | ||
| with: | ||
| script: | | ||
| const run = context.payload.workflow_run; | ||
| const owner = context.repo.owner; | ||
| const repo = context.repo.repo; | ||
| const sha = run.head_sha; | ||
| const isPR = run.event === 'pull_request'; | ||
| const environment = isPR ? 'site-preview' : 'site-production'; | ||
| const url = process.env.DEPLOYMENT_URL; | ||
| if (!url) { | ||
| core.setFailed('Missing deployment URL after Workers deploy/upload'); | ||
| return; | ||
| } | ||
|
|
||
| const deployment = await github.rest.repos.createDeployment({ | ||
| owner, | ||
| repo, | ||
| ref: sha, | ||
| environment, | ||
| auto_merge: false, | ||
| required_contexts: [], | ||
| transient_environment: isPR, | ||
| production_environment: !isPR, | ||
| }); | ||
| const deploymentId = deployment.data.id; | ||
|
|
||
| await github.rest.repos.createDeploymentStatus({ | ||
| owner, | ||
| repo, | ||
| deployment_id: deploymentId, | ||
| state: 'success', | ||
| environment_url: url, | ||
| description: 'Cloudflare Workers (static assets)', | ||
| auto_inactive: isPR, | ||
| }); | ||
|
|
||
| if (!isPR) return; | ||
|
|
||
| const raw = process.env.PREVIEW_PR_NUMBER || ''; | ||
| const prNumber = raw ? Number.parseInt(raw, 10) : NaN; | ||
| if (!Number.isFinite(prNumber)) { | ||
| core.warning( | ||
| 'Skipping PR preview comment upsert: no unique PR number (preview deployment and GitHub Deployment still recorded)', | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const marker = '<!-- site-preview -->'; | ||
| const body = [ | ||
| marker, | ||
| '### Site preview', | ||
| '', | ||
| `**Preview:** ${url}`, | ||
| '', | ||
| `Commit: \`${sha}\``, | ||
| ].join('\n'); | ||
|
|
||
| let existing = null; | ||
| for (let page = 1; page <= 20; page++) { | ||
| const { data: comments } = await github.rest.issues.listComments({ | ||
| owner, | ||
| repo, | ||
| issue_number: prNumber, | ||
| per_page: 100, | ||
| page, | ||
| }); | ||
| existing = comments.find((c) => c.body?.includes(marker)); | ||
| if (existing || comments.length < 100) break; | ||
| } | ||
| if (existing) { | ||
| await github.rest.issues.updateComment({ | ||
| owner, | ||
| repo, | ||
| comment_id: existing.id, | ||
| body, | ||
| }); | ||
| } else { | ||
| await github.rest.issues.createComment({ | ||
| owner, | ||
| repo, | ||
| issue_number: prNumber, | ||
| body, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| # Documentation site deployment (Cloudflare Workers) | ||
|
|
||
| ## Overview | ||
|
|
||
| This repository publishes a static documentation site built from `docs/mindmap.html` (copied to `_site/index.html` in CI, then deployed from `site/public/`). Deployment uses **Cloudflare Workers with [static assets](https://developers.cloudflare.com/workers/static-assets/)** (not the legacy **Pages direct-upload** / `wrangler pages deploy` flow). | ||
|
|
||
| Two GitHub Actions workflows: | ||
|
|
||
| - **Build Site** — on `pull_request` and `push` to `main`, checks out the PR head when relevant, builds `_site/`, uploads artifact **`site`**. | ||
| - **Deploy Site** — on successful **Build Site** via `workflow_run`, checks out the repo (for [`site/wrangler.toml`](../site/wrangler.toml)), downloads the artifact into `site/public/`, then: | ||
| - **push to `main`:** `wrangler deploy` → production Worker traffic. | ||
| - **pull_request:** `wrangler versions upload --preview-alias pr-<pr-number>` → preview URL on `*.workers.dev` without changing production (alias falls back to `pr-<workflow_run.id>` only when the same fork branch matches more than one open PR). | ||
|
|
||
| GitHub **Deployments** use environments **`site-preview`** and **`site-production`**; PRs also get a single upserted comment with the preview link. | ||
|
|
||
| For architecture and naming, see [2026-04-09-site-cloudflare-pages-design.md](superpowers/specs/2026-04-09-site-cloudflare-pages-design.md) (document filename still says “pages” for history; content describes Workers). | ||
|
|
||
| ## Cloudflare setup | ||
|
|
||
| ### Worker (not a Pages “project”) | ||
|
|
||
| 1. In the Cloudflare dashboard, use **Workers & Pages** → **Create** → **Create Worker** (or let the first `wrangler deploy` create it). The Worker name must match the GitHub variable below. | ||
| 2. Configure **[preview URLs](https://developers.cloudflare.com/workers/configuration/previews/)** (default on when `workers_dev` is enabled). PR builds rely on **`wrangler versions upload`** with `--preview-alias`. | ||
| 3. Optional: set a **[workers.dev](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/)** subdomain for your account. | ||
|
|
||
| ### API token | ||
|
|
||
| Create an API token that can deploy Workers for your account, for example: | ||
|
|
||
| - **Account** → **Cloudflare Workers** → **Edit** (or the “Edit Cloudflare Workers” template), and | ||
| - **Account** → **Account Settings** → **Read** if Wrangler requires it. | ||
|
|
||
| Store it as GitHub secret **`CLOUDFLARE_API_TOKEN`**. A token scoped **only** to “Cloudflare Pages — Edit” is **not** enough for `wrangler deploy` / `versions upload` on a Worker. | ||
|
|
||
| ### Account ID and Worker name | ||
|
|
||
| - Copy **Account ID** → secret **`CLOUDFLARE_ACCOUNT_ID`**. | ||
| - Set **`CLOUDFLARE_PROJECT_NAME`** as a GitHub **Actions variable** (same name as before for compatibility): value = **Worker name** in the dashboard. The deploy workflow passes it as `wrangler deploy --name=…` / `versions upload --name=…`. | ||
|
|
||
| ### Custom domains (e.g. fork demo or `konflux.sh`) | ||
|
|
||
| Attach routes or custom domains to the **Worker** (Workers → your Worker → **Domains & Routes**), not to a Pages project. Production URLs in GitHub Deployments will follow the hostname Wrangler reports (often `*.workers.dev` until a custom domain is primary). | ||
|
|
||
| ### Migrating from an old Pages project | ||
|
|
||
| If you previously used **Cloudflare Pages** with `wrangler pages deploy`, create the Worker as above, point DNS/custom hostnames to the Worker, then disable or delete the old Pages project to avoid confusion. | ||
|
|
||
| ## GitHub fork phase 1 | ||
|
|
||
| On a **fork**, open **Settings → Secrets and variables → Actions**. Add secrets **`CLOUDFLARE_API_TOKEN`**, **`CLOUDFLARE_ACCOUNT_ID`**, and variable **`CLOUDFLARE_PROJECT_NAME`** (Worker name). | ||
|
|
||
| Under **Settings → Actions → General**, allow **Fork pull request workflows** from contributors so fork PRs can run **Build Site** without Cloudflare credentials in the fork. | ||
|
|
||
| **Deploy Site** runs in the base repository with secrets; fork workflow logs should not show those values. | ||
|
|
||
| ## GitHub upstream phase 2 | ||
|
|
||
| Configure the same secrets/variables at org or repo scope. Confirm **`pull-requests: write`** on the deploy workflow matches org policy for fork PR comments. | ||
|
|
||
| Disable **GitHub Pages** under **Settings → Pages** if it was only used for this site. | ||
|
|
||
| ## Local preview (optional) | ||
|
|
||
| From the repository root: | ||
|
|
||
| ```bash | ||
| mkdir -p site/public && cp docs/mindmap.html site/public/index.html | ||
| cd site && npx wrangler@4 dev | ||
| ``` | ||
|
|
||
| Requires a Cloudflare login or API token in the environment per [Wrangler docs](https://developers.cloudflare.com/workers/wrangler/). | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| **Deploy job skipped.** The triggering workflow display name must be **Build Site** exactly, and `workflow_run.repository` must match the current repo. | ||
|
|
||
| **`Could not determine Workers deployment URL`.** The workflow reads `deployment-url` from `cloudflare/wrangler-action`, then falls back to parsing Wrangler stdout/stderr for a `workers.dev` URL. Upgrade **`wranglerVersion`** in the workflow if Wrangler output format changed. | ||
|
|
||
| **Preview upload fails (PR builds).** Requires Wrangler **≥ 4.21.0** for `--preview-alias`. The workflow pins **4.30.0**. | ||
|
|
||
| **Artifact download 404.** **Build Site** must upload artifact **`site`**; **Deploy Site** needs `actions: read`. | ||
|
|
||
| **No PR comment.** Same as before: ambiguous `head` when resolving the PR number; see the design spec. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verify
deployment-urloutput forversions uploadwrangler-actionextractsdeployment-urlfrom wrangler's structured output. Forwrangler deploythis reliably returns the production URL, but forversions upload --preview-aliasit's unclear whether the output contains the alias URL (e.g.,pr-XXX.<worker>.workers.dev) or the internal versioned URL. If it returns the versioned URL, the preview comment will show a less stable link. The grep fallback at line 84 helps but depends on wrangler output format. Worth validating with a test run.