From a74e9bb08f2888c748d2fc968b5340622c2208e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:57:16 +0000 Subject: [PATCH] Deploy to GitHub Pages: Fumadocs latest + Sphinx-built legacy versions deploy.yml publishes the whole site to GitHub Pages on pushes to main, a daily cron, and a docs-updated repository_dispatch: - /en/latest is the Fumadocs build of sqlc-dev/sqlc@main, as before. - Every tag the old Read the Docs site served (v1.7.0..v1.31.1, frozen in legacy-versions.json) is rebuilt with Sphinx from that tag's own docs/ and fully pinned requirements.txt, so /en/vX.Y.Z/ URLs keep resolving. The immutable HTML is cached per tag; a miss rebuilds in ~2 minutes. - scripts/assemble-site.mjs stitches the artifacts into one tree and injects, into every legacy page, an "older release" banner plus a search-engine hint in the docs.djangoproject.com style: rel=canonical to the same path under /en/latest when it still exists there, noindex when it does not. It also generates /en/stable redirect stubs (RTD's newest-release alias), versions.json for the version switcher, and a 404 page. Any missing snapshot fails the deploy loudly. - components/version-banner.tsx bakes the same banner into future Fumadocs-built versioned snapshots (any NEXT_PUBLIC_BASE_PATH other than /en/latest). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014SnbcV34MHXfFMKSiAjkLY --- .github/workflows/deploy.yml | 168 +++++++++++++++++++++++++ .gitignore | 4 + README.md | 65 +++++++--- app/(docs)/layout.tsx | 18 +-- components/version-banner.tsx | 23 ++++ legacy-versions.json | 34 ++++++ scripts/assemble-site.mjs | 222 ++++++++++++++++++++++++++++++++++ 7 files changed, 511 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 components/version-banner.tsx create mode 100644 legacy-versions.json create mode 100644 scripts/assemble-site.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e72b87a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,168 @@ +# Build and deploy docs.sqlc.dev to GitHub Pages. +# +# The published tree (see README "URL scheme"): +# +# /en/latest/ Fumadocs build of sqlc-dev/sqlc@main docs/ +# /en/vX.Y.Z/ legacy snapshots (legacy-versions.json) rebuilt with +# Sphinx from each tag's own docs/ + pinned +# requirements.txt, exactly as Read the Docs built them, +# with an "old version" banner injected per page +# /en/stable/ redirect stubs into the newest legacy snapshot +# /, /en/ redirects to /en/latest/ +# +# Legacy snapshots are immutable, so their HTML is cached per tag; a cache +# miss (first run, or eviction) rebuilds from source in ~2 minutes. Bump +# CACHE_EPOCH to force a rebuild of every snapshot (e.g. after changing +# the Sphinx build steps below). +name: Deploy + +on: + push: + branches: [main] + # Content pushes to sqlc-dev/sqlc docs/ (a workflow there sends this). + repository_dispatch: + types: [docs-updated] + # Safety net: pick up content changes even if no dispatch arrived. + schedule: + - cron: '23 5 * * *' + workflow_dispatch: + +permissions: + contents: read + +# One deploy at a time; a queued run supersedes anything else waiting. +concurrency: + group: deploy-pages + cancel-in-progress: false + +env: + CACHE_EPOCH: 1 + +jobs: + # Fumadocs build of the current docs (/en/latest) + domain-root redirects. + latest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check out sqlc docs + uses: actions/checkout@v4 + with: + repository: sqlc-dev/sqlc + path: .cache/sqlc + sparse-checkout: docs + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Ingest + run: node scripts/ingest.mjs --src .cache/sqlc/docs + + - name: Build + run: npm run build + + - uses: actions/upload-artifact@v4 + with: + name: site-latest + path: | + out/ + out-root/ + retention-days: 1 + + # The legacy snapshot tags, read from legacy-versions.json. + versions: + runs-on: ubuntu-latest + outputs: + sphinx: ${{ steps.read.outputs.sphinx }} + steps: + - uses: actions/checkout@v4 + - id: read + run: echo "sphinx=$(jq -c .sphinx legacy-versions.json)" >> "$GITHUB_OUTPUT" + + # One Sphinx build per legacy tag, exactly as Read the Docs built it: + # the tag's own docs/ directory, conf.py, and fully pinned + # requirements.txt. Python 3.11 is what RTD's config specified for the + # newest tags and builds every older toolchain back to Sphinx 3.4 too. + sphinx: + needs: versions + runs-on: ubuntu-latest + strategy: + matrix: + version: ${{ fromJSON(needs.versions.outputs.sphinx) }} + steps: + - name: Restore snapshot cache + id: cache + uses: actions/cache@v4 + with: + path: html + key: sphinx-html-${{ matrix.version }}-${{ env.CACHE_EPOCH }} + + - name: Check out sqlc ${{ matrix.version }} + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/checkout@v4 + with: + repository: sqlc-dev/sqlc + ref: ${{ matrix.version }} + path: sqlc + sparse-checkout: docs + + - uses: actions/setup-python@v5 + if: steps.cache.outputs.cache-hit != 'true' + with: + python-version: '3.11' + + - name: Build with Sphinx + if: steps.cache.outputs.cache-hit != 'true' + run: | + python -m venv .venv + .venv/bin/pip install -r sqlc/docs/requirements.txt + .venv/bin/sphinx-build -b html -d .doctrees sqlc/docs html + + - uses: actions/upload-artifact@v4 + with: + name: sphinx-${{ matrix.version }} + path: html + retention-days: 1 + + # Assemble the full tree and publish it. + deploy: + needs: [latest, sphinx] + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Download latest build + uses: actions/download-artifact@v4 + with: + name: site-latest + path: _artifacts/latest + + - name: Download Sphinx snapshots + uses: actions/download-artifact@v4 + with: + pattern: sphinx-* + path: _artifacts/sphinx + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Assemble site + run: node scripts/assemble-site.mjs --latest _artifacts/latest --sphinx _artifacts/sphinx --out _site + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 776009d..7cefb21 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ out/ out-root/ out-segments/ +# Assembled GitHub Pages tree (scripts/assemble-site.mjs) +_site/ +_artifacts/ + # fumadocs-mdx generated files .source/ next-env.d.ts diff --git a/README.md b/README.md index 428e475..0a1cf05 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ never change or even redirect: /en/latest/howto/select.html the current docs (canonical) /en/latest/ section index /en/v1.32.0/howto/select.html versioned snapshots (same scheme RTD used for tags) +/en/stable/howto/select.html RTD's newest-release alias; redirects into + the newest versioned snapshot / redirects to /en/latest/ /en/latest/howto/upload.html redirects to /en/latest/howto/push.html (carried over from the old rediraffe config) @@ -103,25 +105,56 @@ Versions are immutable build artifacts, not branches: (static export bakes absolute asset/link/search paths, so the prefix is a build-time setting) and is uploaded to the `en/v1.32.0/` prefix, once, forever. -- `versions.json` at the domain root, appended by each release, drives the - version-switcher dropdown; every snapshot fetches it at runtime so old +- `versions.json` at the domain root, regenerated on every deploy, drives + the version-switcher dropdown; every snapshot fetches it at runtime so old snapshots list new versions. -- Versioned builds set `noindex` so stale versions never outrank current - docs in search engines. -- Versioning starts at the first tag that contains `docs/toc.yaml`; older - tags are not backfilled. Old RTD tag URLs for those (`/en/v1.29.0/...`) - can redirect to `/en/latest/` at the edge. - -## CI +- Fumadocs versioning starts at the first tag that contains + `docs/toc.yaml`. Every older tag the RTD site served (`v1.7.0` through + `v1.31.1`, listed in `legacy-versions.json`) is rebuilt with **Sphinx** + from that tag's own `docs/` and fully pinned `requirements.txt` — exactly + as Read the Docs built it — so its `/en/vX.Y.Z/...` URLs keep resolving + byte-for-byte. That list is frozen; new releases never go in it. + +Non-latest builds, both kinds, carry an "older release" treatment: + +- A banner at the top of every page linking to `/en/latest/`. Fumadocs + snapshots bake it in at build time (`components/version-banner.tsx`, + keyed off `NEXT_PUBLIC_BASE_PATH`); Sphinx snapshots get it injected + post-build by `scripts/assemble-site.mjs`. +- Old releases stay out of search results the way docs.djangoproject.com + does it: each Sphinx-snapshot page gets `rel=canonical` pointing at the + same path under `/en/latest/` when that page still exists there (old + inbound links keep passing ranking signal to the current docs), and + `noindex` when it doesn't. Fumadocs snapshots set `noindex` at build time. + +## CI and deployment `ci.yml` runs ingest + build + typecheck on every PR and push to main, and uploads the built site as a `site-preview` artifact (serve it locally with `python3 -m http.server` and open `/en/latest/`). -Deployment is not wired up yet — hosting is still to be decided. When it -is, the deploy needs to upload `out/` and `out-segments/` into the version -prefix (`en/latest/` or `en//`) and the `out-root/` redirect objects to -the domain root, and a release deploy appends its tag to `versions.json`. -The trigger side is a `repository_dispatch` from a small workflow in -sqlc-dev/sqlc on pushes to `main` that touch `docs/**` (plus a tag-push -equivalent), with a daily cron here as a safety net. +`deploy.yml` builds the whole site and publishes it to **GitHub Pages**: + +1. **latest** — ingest `sqlc-dev/sqlc@main` docs and build with Fumadocs + (`out/` + the `out-root/` redirect objects). `out-segments/` is not + deployed: its keys collide with page paths on a filesystem host, and the + client falls back to full-page payloads (see "URL scheme"). +2. **sphinx** — a matrix job per tag in `legacy-versions.json`, each + building that tag's docs with its own pinned toolchain on Python 3.11. + Snapshots are immutable, so the built HTML is cached per tag + (`actions/cache`); a cache miss just rebuilds from source. Bump + `CACHE_EPOCH` in the workflow to force a full rebuild. +3. **deploy** — `scripts/assemble-site.mjs` stitches the artifacts into one + tree: root redirects, `en/latest/`, each `en/vX.Y.Z/` with the banner and + canonical/noindex injected, `en/stable/` redirect stubs mirroring the + newest snapshot's pages, `versions.json`, and a `404.html`. A missing + snapshot fails the deploy — it would silently break published URLs. + `actions/deploy-pages` publishes the tree. + +It runs on pushes to main here, on a daily cron, and on a +`repository_dispatch` (`docs-updated`) — still to be wired up as a small +workflow in sqlc-dev/sqlc that fires on pushes to `main` touching `docs/**`. + +One-time repo setup: Settings → Pages → source "GitHub Actions", custom +domain `docs.sqlc.dev`; cutover is pointing that DNS record at GitHub Pages +instead of Read the Docs. diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index 44afa22..37fd142 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -2,15 +2,19 @@ import { source } from '@/lib/source'; import { DocsLayout } from 'fumadocs-ui/layouts/docs'; import { baseOptions } from '@/lib/layout.shared'; import { VersionSwitcher } from '@/components/version-switcher'; +import { VersionBanner } from '@/components/version-banner'; export default function Layout({ children }: LayoutProps<'/'>) { return ( - }} - {...baseOptions()} - > - {children} - + <> + + }} + {...baseOptions()} + > + {children} + + ); } diff --git a/components/version-banner.tsx b/components/version-banner.tsx new file mode 100644 index 0000000..c8e6908 --- /dev/null +++ b/components/version-banner.tsx @@ -0,0 +1,23 @@ +// The URL prefix this build was mounted under; resolved (with default) via +// next.config.mjs `env`. +const CURRENT = process.env.NEXT_PUBLIC_BASE_PATH ?? '/en/latest'; + +/** + * Old-version notice baked into versioned snapshots (/en/vX.Y.Z builds) at + * build time. The /en/latest build renders nothing. Legacy Sphinx snapshots + * get the equivalent banner injected by scripts/assemble-site.mjs. + */ +export function VersionBanner() { + if (CURRENT === '/en/latest') return null; + const version = CURRENT.replace(/^\/en\//, ''); + return ( +
+ You are viewing the documentation for {version}, an older release of sqlc.{' '} + {/* Plain : the link leaves this build's basePath, so it must be a + full page load, not a Next.js client navigation. */} + + View the latest documentation. + +
+ ); +} diff --git a/legacy-versions.json b/legacy-versions.json new file mode 100644 index 0000000..1675f07 --- /dev/null +++ b/legacy-versions.json @@ -0,0 +1,34 @@ +{ + "//": "Versioned snapshots built with Sphinx from each sqlc tag's own docs/ and pinned requirements.txt — exactly the tag versions the old Read the Docs site served, so /en/vX.Y.Z/ URLs keep working. Frozen: future versions are built with Fumadocs and never belong here. Consumed by deploy.yml (build matrix) and scripts/assemble-site.mjs.", + "sphinx": [ + "v1.7.0", + "v1.8.0", + "v1.9.0", + "v1.10.0", + "v1.11.0", + "v1.12.0", + "v1.13.0", + "v1.14.0", + "v1.15.0", + "v1.16.0", + "v1.17.0", + "v1.17.1", + "v1.17.2", + "v1.18.0", + "v1.19.0", + "v1.19.1", + "v1.20.0", + "v1.21.0", + "v1.22.0", + "v1.23.0", + "v1.24.0", + "v1.25.0", + "v1.26.0", + "v1.27.0", + "v1.28.0", + "v1.29.0", + "v1.30.0", + "v1.31.0", + "v1.31.1" + ] +} diff --git a/scripts/assemble-site.mjs b/scripts/assemble-site.mjs new file mode 100644 index 0000000..a5be1ae --- /dev/null +++ b/scripts/assemble-site.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +// Assemble the complete docs.sqlc.dev tree for GitHub Pages. +// +// Inputs (see deploy.yml): +// +// --latest the Fumadocs build: /out (the /en/latest pages) +// and /out-root (domain-root redirect objects) +// --sphinx one subdirectory per legacy version — /v1.7.0 or +// /sphinx-v1.7.0 (the artifact name downloads under) +// --out the assembled site, ready for upload-pages-artifact +// +// Every version in legacy-versions.json must be present — a missing build +// would silently break published /en/vX.Y.Z/ URLs, so it fails the deploy. +// +// Legacy pages are served byte-for-byte as Sphinx built them, except for +// two injections into each page: +// +// - an "old version" banner at the top of the content area, since these +// snapshots no longer get the Read the Docs version flyout; +// - a search-engine hint keeping old releases out of results, the way +// docs.djangoproject.com does it: rel=canonical to the same page under +// /en/latest/ when it still exists there (old inbound links keep +// passing signal to the current docs), noindex when it doesn't. +// +// /en/stable/ (the Read the Docs alias for the newest release) becomes a +// tree of redirect stubs mirroring the newest legacy snapshot's pages, and +// versions.json at the domain root drives the /en/latest version switcher. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function fail(msg) { + console.error(`assemble-site: ${msg}`); + process.exit(1); +} + +function parseArgs(argv) { + const args = { latest: null, sphinx: null, out: null }; + for (let i = 0; i < argv.length; i++) { + const key = argv[i].replace(/^--/, ''); + if (!(key in args) || argv[i + 1] === undefined) { + fail(`usage: assemble-site.mjs --latest --sphinx --out `); + } + args[key] = path.resolve(argv[++i]); + } + for (const [key, value] of Object.entries(args)) { + if (value === null) fail(`missing --${key}`); + } + return args; +} + +// Newest-first semver order for versions.json and the stable alias. +function byVersionDesc(a, b) { + const parse = (v) => v.slice(1).split('.').map(Number); + const [pa, pb] = [parse(a), parse(b)]; + for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]; + return 0; +} + +function htmlFiles(dir, base = dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...htmlFiles(p, base)); + else if (entry.name.endsWith('.html')) out.push(path.relative(base, p).split(path.sep).join('/')); + } + return out; +} + +// Sphinx support directories whose .html files (raw sources, static assets) +// are not pages and get no banner. +function isSupportFile(rel) { + return rel.startsWith('_sources/') || rel.startsWith('_static/'); +} + +function banner(version) { + return ( + `
` + + `You are viewing the documentation for ${version}, an older release of sqlc. ` + + `View the latest documentation.` + + `
` + ); +} + +// Injects the banner and the search-engine hint into one Sphinx-built page. +// Every sphinx_rtd_theme page (0.5.1 through 3.1.0) has exactly one +//
wrapping the content — anything else is drift in +// what we're rebuilding, and must fail loudly rather than ship half-marked +// snapshots. +function injectIntoPage(file, version, headTag) { + const src = fs.readFileSync(file, 'utf8'); + // Redirect stubs (sphinxext-rediraffe, e.g. howto/upload.html) have no + // theme markup and need no banner — the target page carries it. + if (/http-equiv="refresh"/i.test(src)) return false; + const withMeta = src.replace(/]*)>/, `${headTag}`); + if (withMeta === src) fail(`${file}: no tag found`); + const anchor = /
]*>/g; + const matches = withMeta.match(anchor); + if (!matches || matches.length !== 1) { + fail(`${file}: expected exactly one
anchor, found ${matches ? matches.length : 0}`); + } + fs.writeFileSync(file, withMeta.replace(anchor, `$&${banner(version)}`)); + return true; +} + +function redirectStub(target) { + return ` + + + +Redirecting… + + + + + +

This page has moved to ${target}.

+ + +`; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const versions = JSON.parse(fs.readFileSync(path.join(ROOT, 'legacy-versions.json'), 'utf8')) + .sphinx.sort(byVersionDesc); + + fs.rmSync(args.out, { recursive: true, force: true }); + + // Domain root: redirect objects (/ and /en/ → /en/latest/). + const outRoot = path.join(args.latest, 'out-root'); + if (!fs.existsSync(path.join(outRoot, 'index.html'))) fail(`${outRoot}/index.html not found`); + fs.cpSync(outRoot, args.out, { recursive: true }); + + // The current docs, straight from the Fumadocs export. + const latestOut = path.join(args.latest, 'out'); + if (!fs.existsSync(path.join(latestOut, 'index.html'))) fail(`${latestOut}/index.html not found`); + fs.cpSync(latestOut, path.join(args.out, 'en', 'latest'), { recursive: true }); + + // Which legacy page paths still exist under /en/latest/ decides each + // page's search-engine hint: canonical to its successor, else noindex. + const latestPages = new Set(htmlFiles(latestOut)); + const headTagFor = (rel) => + latestPages.has(rel) + ? `` + : ``; + + // Legacy Sphinx snapshots, with banner + search hint injected per page. + for (const version of versions) { + const src = [version, `sphinx-${version}`] + .map((name) => path.join(args.sphinx, name)) + .find((dir) => fs.existsSync(path.join(dir, 'index.html'))); + if (!src) fail(`no Sphinx build found for ${version} under ${args.sphinx}`); + + const dest = path.join(args.out, 'en', version); + fs.cpSync(src, dest, { recursive: true }); + let pages = 0; + let canonical = 0; + let redirects = 0; + for (const rel of htmlFiles(dest)) { + if (isSupportFile(rel)) continue; + const headTag = headTagFor(rel); + if (!injectIntoPage(path.join(dest, ...rel.split('/')), version, headTag)) { + redirects++; + continue; + } + pages++; + if (headTag.startsWith(' + + + + +Page not found — sqlc + + + +

Page not found

+

This page doesn't exist. It may have moved in a newer release.

+

Go to the latest sqlc documentation

+ + +`, + ); + + console.log(`assemble-site: done — ${versions.length} legacy versions + en/latest in ${args.out}`); +} + +main();