Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions .github/workflows/mindmap.yml

This file was deleted.

32 changes: 32 additions & 0 deletions .github/workflows/site-build.yml
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
241 changes: 241 additions & 0 deletions .github/workflows/site-deploy.yml
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 }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify deployment-url output for versions upload

wrangler-action extracts deployment-url from wrangler's structured output. For wrangler deploy this reliably returns the production URL, but for versions upload --preview-alias it'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.

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,
});
}
83 changes: 83 additions & 0 deletions docs/site-deployment.md
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.
Loading
Loading