diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 02ddbfa86a..0000000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Docs site - -# The documentation site (website/) mirrors docs/*.md via scripts/sync-docs.mjs, -# which runs as the first step of `pnpm run build`. This workflow rebuilds that -# mirror: -# - on every push to main that touches docs/ or website/, -# - manually via the Actions tab, -# - and as a build-only check on pull requests. -# -# The Cloudflare Pages deploy step is temporarily disabled until setup is ready. -# When re-enabled, deploys require two repository secrets: CLOUDFLARE_API_TOKEN -# and CLOUDFLARE_ACCOUNT_ID. Set the site's public URL via the DOCS_SITE_URL -# repository variable (used for OG/sitemap absolute URLs). - -on: - push: - branches: [main] - paths: - - 'docs/**' - - 'website/**' - - '.github/workflows/deploy-docs.yml' - pull_request: - paths: - - 'docs/**' - - 'website/**' - - '.github/workflows/deploy-docs.yml' - workflow_dispatch: - -# Never run two docs site jobs at once; let an in-flight job finish. -concurrency: - group: deploy-docs - cancel-in-progress: false - -jobs: - build-and-deploy: - # Keep enabled forks from spending CI on their own copy of this workflow. - # PRs from forks into Fission-AI/OpenSpec still run in the base repository. - if: ${{ github.repository == 'Fission-AI/OpenSpec' }} - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: pnpm - cache-dependency-path: website/pnpm-lock.yaml - - - name: Install dependencies - working-directory: website - run: pnpm install --frozen-lockfile - - - name: Build site (mirrors docs/*.md, then next build) - working-directory: website - env: - NEXT_PUBLIC_SITE_URL: ${{ vars.DOCS_SITE_URL }} - run: pnpm run build - - # Temporarily disabled until Cloudflare setup is ready. - # - name: Deploy to Cloudflare Pages - # # Only deploy from main on the canonical repo. This keeps PRs build-only, - # # keeps forks (no secrets) build-only, and because the wrangler command - # # below hardcodes `--branch=main` (a *production* deploy) prevents a - # # `workflow_dispatch` on a feature branch from overwriting the live site. - # if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'Fission-AI/OpenSpec' }} - # uses: cloudflare/wrangler-action@v3 - # with: - # apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - # accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - # workingDirectory: website - # command: pages deploy out --project-name=openspec-docs --branch=main diff --git a/website/README.md b/website/README.md index 5f2b55a2ca..52150249fd 100644 --- a/website/README.md +++ b/website/README.md @@ -33,19 +33,28 @@ directory and use these settings: | Root directory | `website` | | Build command | `pnpm run build` | | Build output directory | `out` | -| Node version | `20.19.0` or higher (set `NODE_VERSION` if needed) | +| Node version | `22` | Set one environment variable so social/Open Graph image URLs resolve to your real domain: | Variable | Example | |----------|---------| -| `NEXT_PUBLIC_SITE_URL` | `https://your-docs-domain.com` | +| `NEXT_PUBLIC_SITE_URL` | `https://openspec.dev` | -That's it. No Workers, adapters, or server runtime are required. (If you later -want server-side rendering on Cloudflare Workers instead, swap `output: 'export'` -in `next.config.mjs` for the `@opennextjs/cloudflare` adapter — but the static -path above is the simplest and is what this site is tuned for.) +The site itself needs no server runtime. A small routing Worker exposes the +separate Pages project at `openspec.dev/docs` while the Astro landing project +continues to own the rest of `openspec.dev`. It also routes the supporting +`/_next`, search, Open Graph, icon, and `llms` paths. Its source and Wrangler +configuration live in `cloudflare/router/`. + +Cloudflare's Free plan cannot override the Host header or DNS origin in an +Origin Rule, so the routing Worker proxies these paths to +`openspec-docs.pages.dev` instead. Deploy routing changes from `website/` with: + +```bash +npx wrangler deploy --config cloudflare/router/wrangler.jsonc +``` ### Deploy with Wrangler (optional) @@ -78,61 +87,28 @@ then: - regenerates `content/docs/meta.json` and `content/docs/reference/meta.json`. Because the docs are the source, the site cannot drift from them: every build -re-mirrors, and CI redeploys on a schedule (see below). +re-mirrors them before producing the static export. ## Automated deploys -`.github/workflows/deploy-docs.yml` rebuilds the mirror and deploys the static -export to Cloudflare Pages via Wrangler: - -- on every push to `main` that touches `docs/**` or `website/**`, -- daily on a schedule (so docs merged elsewhere still go live), -- manually via the Actions tab, -- and as a build-only check on pull requests (never deploys). +The `openspec-docs` Cloudflare Pages project is connected directly to +`Fission-AI/OpenSpec`. Cloudflare rebuilds and deploys `main` when `docs/**` or +`website/**` changes, and creates preview deployments for pull requests. Once the site changes, that's it — a `docs/*.md` edit merged to `main` re-mirrors and redeploys with no manual step. -### One-time deploy setup (maintainer) - -The workflow is ready, but auto-deploy stays dormant until these three are done. -Until then, docs still mirror correctly on build — they just don't reach -Cloudflare on their own. - -1. **Create the Cloudflare Pages project** named `openspec-docs`, with its - production branch set to `main`. Once, via the dashboard or: - - ```bash - npx wrangler pages project create openspec-docs --production-branch main - ``` - - (Non-interactive CI can't create it on the fly, so this must exist first.) - -2. **Add two repository secrets** (Settings → Secrets and variables → Actions): - - | Secret | Where to get it | - |--------|-----------------| - | `CLOUDFLARE_API_TOKEN` | Cloudflare dashboard → My Profile → API Tokens → "Edit Cloudflare Pages" template | - | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare dashboard → Workers & Pages → Account ID | - - Optional: set a repository **variable** `DOCS_SITE_URL` to the site's public URL - (used for Open Graph / sitemap absolute links). Without it, the build falls - back to `https://openspec.dev`, so this is not required. - -3. **Merge this to `main`.** GitHub Actions only runs the `push`-to-`main` and - scheduled triggers from workflows on the default branch, so the automation - activates when the PR merges. - -To smoke-test before merging: run the workflow by hand from the **Actions** tab -(**workflow_dispatch**) once the project and secrets exist. +No GitHub Actions workflow, deployment secrets, or repository variables are +required for the Git-connected Pages project. Cloudflare reports production and +preview build statuses directly to GitHub. -### Landing page — a maintainer decision +### Landing page -The current [openspec.dev](https://openspec.dev) landing page is a separate Astro -site. This site ships its own Fumadocs landing page at `app/(home)/page.tsx` -(the only hand-authored page here; everything under `/docs` is mirrored). Whether -to keep this landing page, port the Astro one into it, or point Pages only at -`/docs` is a maintainer call — nothing else in this pipeline depends on it. +The current [openspec.dev](https://openspec.dev) landing page remains in the +separate Astro project. The routing Worker sends only documentation-owned paths +to this Pages project, so its Fumadocs landing page at `app/(home)/page.tsx` is +built but is not served at the public root. The projects can be consolidated +later without changing the mirrored documentation workflow. ## Project structure @@ -152,6 +128,7 @@ website/ │ ├── source.ts # Fumadocs content source + sidebar icons │ └── layout.shared.tsx # shared nav/header options ├── components/ # MDX components, search dialog, root provider +├── cloudflare/router/ # Worker that mounts this site on openspec.dev/docs ├── next.config.mjs # static export config └── source.config.ts # Fumadocs MDX collection config ``` diff --git a/website/cloudflare/router/worker.js b/website/cloudflare/router/worker.js new file mode 100644 index 0000000000..b6f014444c --- /dev/null +++ b/website/cloudflare/router/worker.js @@ -0,0 +1,87 @@ +addEventListener('fetch', (event) => { + event.respondWith(proxyDocs(event.request)); +}); + +const ALLOWED_METHODS = new Set(['GET', 'HEAD']); +const FORWARDED_REQUEST_HEADERS = [ + 'accept', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-unmodified-since', + 'range', + 'user-agent', +]; + +async function proxyDocs(request) { + const incoming = new URL(request.url); + + if (!isDocsRoute(incoming.pathname)) { + return fetch(request); + } + + if (!ALLOWED_METHODS.has(request.method)) { + return new Response(null, { + status: 405, + headers: { allow: 'GET, HEAD' }, + }); + } + + const upstream = new URL( + incoming.pathname + incoming.search, + 'https://openspec-docs.pages.dev', + ); + + const headers = new Headers(); + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = request.headers.get(name); + if (value !== null) { + headers.set(name, value); + } + } + + const init = { + method: request.method, + headers, + redirect: 'manual', + }; + + const response = await fetch(upstream.toString(), init); + const responseHeaders = new Headers(response.headers); + const location = responseHeaders.get('location'); + + if (location) { + const redirected = new URL(location, upstream); + if (redirected.hostname === 'openspec-docs.pages.dev') { + redirected.protocol = incoming.protocol; + redirected.host = incoming.host; + responseHeaders.set('location', redirected.toString()); + } + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} + +function isDocsRoute(pathname) { + return ( + pathname === '/docs' || + pathname.startsWith('/docs/') || + pathname.startsWith('/_next/') || + pathname === '/api/search' || + pathname === '/api/search/' || + pathname === '/og/docs' || + pathname.startsWith('/og/docs/') || + pathname === '/llms.txt' || + pathname === '/llms-full.txt' || + pathname === '/llms.mdx/docs' || + pathname.startsWith('/llms.mdx/docs/') || + pathname === '/icon.svg' + ); +} diff --git a/website/cloudflare/router/wrangler.jsonc b/website/cloudflare/router/wrangler.jsonc new file mode 100644 index 0000000000..3cb0997d89 --- /dev/null +++ b/website/cloudflare/router/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "openspec-docs-router", + "main": "worker.js", + "compatibility_date": "2026-07-10", + "routes": [ + { "pattern": "openspec.dev/docs*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/docs/*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/_next/*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/api/search*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/og/docs/*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/llms*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/llms.mdx/docs/*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/icon.svg*", "zone_name": "openspec.dev" } + ] +}