diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 730bd1f..e236218 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,33 +1,42 @@ name: Pages # Publishes the landing page (site/, as it is) at / and the docs (docs-site/, built with -# Starlight from uptide-dev/uptide's docs/ folder) at /docs. The docs live in the other -# repository, so the site is rebuilt on every push to main here, once a day to pick up docs -# changes there, and by hand. See README.md. +# Starlight) at /docs, both from the latest uptide release: the `latest` dist-tag of `uptide` +# on npm, checked out as tag v of uptide-dev/uptide (docs-site/scripts/release.mjs). +# A missing tag fails the build; it never falls back to main. The site is rebuilt on every push +# to main here, once a day so a new release reaches it within a day, and by hand. See README.md. +# +# A manual run with `ref` set builds a preview from that ref and uploads it as an artifact. It +# is never deployed: the deploy job runs only when the build reports it was built from the +# release tag, and only when no `ref` was given. on: push: branches: [main] schedule: - cron: '23 5 * * *' workflow_dispatch: + inputs: + ref: + description: 'Preview only: build from this uptide-dev/uptide ref (branch, tag or commit) instead of the latest release. Never deployed.' + required: false + default: '' + type: string concurrency: - group: pages + group: pages-${{ inputs.ref || 'release' }} cancel-in-progress: false permissions: {} jobs: - deploy: + build: runs-on: ubuntu-latest permissions: - # checkout reads the repository; deploy-pages publishes with the OIDC identity. contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + outputs: + release: ${{ steps.assemble.outputs.release }} + version: ${{ steps.assemble.outputs.version }} + ref: ${{ steps.assemble.outputs.ref }} env: # The page's own address, for its canonical and social-preview URLs. The one value to # change when the page moves to a domain. @@ -41,14 +50,17 @@ jobs: node-version-file: docs-site/.node-version cache: npm cache-dependency-path: docs-site/package-lock.json - - name: Build the docs from uptide-dev/uptide@main + - name: Build the docs from the latest uptide release (or the preview ref) working-directory: docs-site env: ASTRO_TELEMETRY_DISABLED: '1' + # Empty on push and schedule; set only by a manual run's `ref` input. + UPTIDE_REF: ${{ inputs.ref }} run: | npm ci npm run build - name: Put the landing page and the docs together + id: assemble run: node scripts/assemble-site.mjs _site - name: Write the site URL into the page run: | @@ -59,8 +71,34 @@ jobs: fi - name: No broken internal links run: node scripts/check-links.mjs _site + - name: Say which uptide release the site reflects + env: + VERSION: ${{ steps.assemble.outputs.version }} + REF: ${{ steps.assemble.outputs.ref }} + RELEASE: ${{ steps.assemble.outputs.release }} + run: | + echo "### uptide ${VERSION} (${REF})" >> "$GITHUB_STEP_SUMMARY" + if [ "$RELEASE" = true ]; then + echo "Built from the latest release on npm; deployed by the next job." >> "$GITHUB_STEP_SUMMARY" + else + echo "Preview built from ${REF}: uploaded as an artifact, not deployed." >> "$GITHUB_STEP_SUMMARY" + fi - uses: actions/upload-pages-artifact@v3 with: path: _site + + deploy: + needs: build + # Only a build from the npm release's tag, and never a manual run with a `ref`. + if: needs.build.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || inputs.ref == '') + runs-on: ubuntu-latest + permissions: + # deploy-pages publishes the uploaded artifact with the OIDC identity. + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: - id: deployment uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index b4faea5..7bf5b4c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Two parts, published together: uptide-dev/uptide's `docs/` folder, checked out at build time and never copied here: [docs-site/README.md](docs-site/README.md). +Both reflect the latest published release of uptide: the `latest` dist-tag on npm, checked out +as tag `v`. Every built page names it in `` and +``. + ## See it locally ```sh @@ -32,8 +36,14 @@ python3 -m http.server 8000 --directory _site # http://localhost:8000 and /doc ## Publishing `.github/workflows/pages.yml` builds the docs, puts them under `/docs` next to `site/`, checks -every internal link and deploys to GitHub Pages: on every push to `main`, daily (to pick up -docs changes in uptide-dev/uptide), and by hand. Its `SITE_URL` is the one value to change if +every internal link and deploys to GitHub Pages: on every push to `main`, daily (so a new +uptide release reaches the site within a day), and by hand. A missing release tag fails the +build rather than falling back to `main`. + +**Previews:** Actions → Pages → Run workflow, with `ref` set to an uptide-dev/uptide branch, +tag or commit, builds the site from that ref and uploads it as the run's `github-pages` +artifact. It is never deployed: the deploy job runs only when the build reports it was built +from the release tag and no `ref` was given. Its `SITE_URL` is the one value to change if the page moves to a domain; the docs take their canonical and `og:image` URLs from it too. One-time setup: @@ -52,7 +62,7 @@ One-time setup: the URLs from `SITE_URL`, nothing loaded from another origin, the deploy's triggers and permissions, exact docs dependency versions, no docs committed, and the docs transforms (titles, sidebar, links). -- **The site build**: the docs build against uptide-dev/uptide@main, the landing page's +- **The site build**: the docs build against the latest uptide release, the landing page's version and packs are rendered from that checkout and its terminal blocks checked against their public sources (`scripts/landing-data.mjs`), and `scripts/check-links.mjs` finds no broken internal link or anchor in landing + docs. diff --git a/docs-site/README.md b/docs-site/README.md index eb62fed..0aaeb15 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -2,9 +2,24 @@ The Uptide documentation at `/docs`, built with [Starlight](https://starlight.astro.build) from [uptide-dev/uptide](https://github.com/uptide-dev/uptide)'s `docs/` folder. The docs -are never copied into this repository: every build checks out `main` of uptide-dev/uptide +are never copied into this repository: every build checks out the latest uptide release (anonymous HTTPS) and generates the content collection from it. +## Which release + +`scripts/release.mjs` asks npm for the `latest` dist-tag of `uptide` and checks out tag +`v` of uptide-dev/uptide. If that tag does not exist the build fails, naming the +version and the tag; it never falls back to `main`. The build log prints what it resolved: + +``` +uptide source: uptide 0.6.1: npm latest, tag v0.6.1 (7df8235) +``` + +Every built page, landing and 404 included, carries it in its head: +``, `` and ``. +Links into the repository point at the tag; "Edit page" opens the file on `main`, since a tag +cannot be edited. + ```sh npm ci npm run build # fetch the docs, then astro build into dist/ @@ -19,8 +34,10 @@ node scripts/check-links.mjs # no broken internal link or # python3 -m http.server 8000 --directory _site # http://localhost:8000/docs/ ``` -`UPTIDE_CHECKOUT=/path/to/uptide` builds from a local checkout instead of cloning (offline -work, or previewing docs changes before they land); `UPTIDE_REF` picks another branch. +For previews, `UPTIDE_REF=` builds from another ref, and +`UPTIDE_CHECKOUT=/path/to/uptide` from a local checkout (offline work, or docs changes before +they land). A site built either way is marked not deployable, and the Pages workflow never +deploys it. `UPTIDE_NPM_REGISTRY` points the release lookup at another registry. ## How the content is made diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index fcfe073..ec6c3ea 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -1,17 +1,22 @@ // The docs site, served at /docs next to the landing page (site/). Content comes from // uptide-dev/uptide's docs/ folder at build time: see scripts/fetch-docs.mjs. +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; import { unified } from '@astrojs/markdown-remark'; import remarkUptideLinks from './src/remark-uptide-links.mjs'; import { sidebar } from './src/sidebar.mjs'; +import { metaFields } from './scripts/release.mjs'; // The site's address, as in .github/workflows/pages.yml; canonical and og:image URLs use it. const SITE_URL = process.env.SITE_URL || 'https://uptide-dev.github.io/'; const base = '/docs'; const siteDir = fileURLToPath(new URL('.', import.meta.url)); const ogImage = new URL('og.png', SITE_URL).href; +// The uptide release (or preview ref) the docs were built from, written by scripts/fetch-docs.mjs. +const source = JSON.parse(readFileSync(new URL('./src/generated/source.json', import.meta.url), 'utf8')); +const releaseMeta = metaFields(source).map((attrs) => ({ tag: 'meta', attrs })); export default defineConfig({ site: SITE_URL, @@ -38,6 +43,7 @@ export default defineConfig({ { tag: 'meta', attrs: { property: 'og:image:height', content: '640' } }, { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, { tag: 'meta', attrs: { name: 'twitter:image', content: ogImage } }, + ...releaseMeta, ], // Code blocks are a dark terminal in both themes, like the landing page's output blocks. expressiveCode: { diff --git a/docs-site/scripts/fetch-docs.mjs b/docs-site/scripts/fetch-docs.mjs index 9640c5b..419fa9f 100644 --- a/docs-site/scripts/fetch-docs.mjs +++ b/docs-site/scripts/fetch-docs.mjs @@ -3,28 +3,27 @@ // node scripts/fetch-docs.mjs // // The docs are never committed here: the checkout (.cache/uptide) and the generated collection -// (src/content/docs) are both gitignored and rebuilt on every build. Environment: -// -// UPTIDE_REPO git URL to clone (default: https://github.com/uptide-dev/uptide.git) -// UPTIDE_REF branch to check out (default: main) -// UPTIDE_CHECKOUT an existing local checkout to read instead of cloning (offline work) +// (src/content/docs) are both gitignored and rebuilt on every build. What is checked out is +// decided by scripts/release.mjs: the tag of the latest uptide release on npm, or, for previews +// that are never deployed, UPTIDE_REF or UPTIDE_CHECKOUT. The resolved version, ref and commit +// are printed and recorded in src/generated/source.json, which the site's meta tags and the +// landing page read. // // For each Markdown page under docs/ (docs/screenshots/ is image evidence, not pages): // - README.md becomes the index page; // - the page's front matter title and description are kept; a page without them takes its // title from its first `# ` heading and its description from its first paragraph; // - the first `# ` heading is dropped, since Starlight renders the title as the page's h1; -// - editUrl points at the file on GitHub. +// - editUrl points at the file on main on GitHub (a release tag cannot be edited). // Links are rewritten when the page is rendered, by src/remark-uptide-links.mjs, from the map // this script writes to src/generated/source.json. import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { deployable, describe, resolveSource } from './release.mjs'; const site = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const repoUrl = process.env.UPTIDE_REPO || 'https://github.com/uptide-dev/uptide.git'; -const ref = process.env.UPTIDE_REF || 'main'; const github = 'https://github.com/uptide-dev/uptide'; const git = (args, cwd) => @@ -36,12 +35,15 @@ const git = (args, cwd) => stdio: ['ignore', 'pipe', 'inherit'], }).trim(); -function checkout() { - if (process.env.UPTIDE_CHECKOUT) return resolve(process.env.UPTIDE_CHECKOUT); +/** A shallow checkout of exactly `source.fetchRef` (a tag, branch or commit), nothing else. */ +function checkout(source) { + if (source.kind === 'local') return resolve(source.path); const dir = join(site, '.cache', 'uptide'); rmSync(dir, { recursive: true, force: true }); - mkdirSync(dirname(dir), { recursive: true }); - git(['-c', 'credential.helper=', 'clone', '--quiet', '--depth', '1', '--branch', ref, repoUrl, dir]); + mkdirSync(dir, { recursive: true }); + git(['init', '--quiet'], dir); + git(['-c', 'credential.helper=', 'fetch', '--quiet', '--depth', '1', source.repoUrl, source.fetchRef], dir); + git(['checkout', '--quiet', '--detach', 'FETCH_HEAD'], dir); return dir; } @@ -125,9 +127,17 @@ const hasKey = (data, key) => new RegExp(`^${key}\\s*:`, 'm').test(data); export const slugOf = (docPath) => docPath.replace(/(^|\/)README\.md$/i, '$1').replace(/\.md$/i, '').replace(/\/$/, ''); -function main() { - const repo = checkout(); - const sha = git(['rev-parse', 'HEAD'], repo); +async function main() { + const source = await resolveSource(); + const repo = checkout(source); + source.sha = git(['rev-parse', 'HEAD'], repo); + const version = JSON.parse(readFileSync(join(repo, 'packages/cli/package.json'), 'utf8')).version; + if (source.kind === 'release' && version !== source.version) + throw new Error(`tag ${source.ref} has packages/cli/package.json version ${version}, but npm's latest is ${source.version}`); + source.version = version; + // Links into the repository point at what was built; a local checkout has no ref to link to. + const linkRef = source.kind === 'local' ? 'main' : source.ref; + console.log(`uptide source: ${describe(source)}`); const docsDir = join(repo, 'docs'); if (!existsSync(join(docsDir, 'README.md'))) throw new Error(`${docsDir}/README.md not found`); @@ -150,7 +160,9 @@ function main() { } if (!hasKey(data, 'description') && read.description) extra.push(`description: ${JSON.stringify(read.description)}`); - if (!hasKey(data, 'editUrl')) extra.push(`editUrl: ${JSON.stringify(`${github}/edit/${ref}/docs/${file}`)}`); + // A tag cannot be edited: "Edit page" opens the file on main. + if (!hasKey(data, 'editUrl')) + extra.push(`editUrl: ${JSON.stringify(`${github}/edit/main/docs/${file}`)}`); const front = [data, ...extra].filter(Boolean).join('\n'); const target = join(out, slug === '' ? 'index.md' : `${slug}.md`); mkdirSync(dirname(target), { recursive: true }); @@ -162,9 +174,27 @@ function main() { mkdirSync(generated, { recursive: true }); writeFileSync( join(generated, 'source.json'), - `${JSON.stringify({ repo: github, ref, sha, checkout: relative(site, repo) || '.', pages }, null, 2)}\n`, + `${JSON.stringify( + { + kind: source.kind, + deployable: deployable(source), + version: source.version, + ref: source.ref, + sha: source.sha, + repo: github, + linkRef, + checkout: relative(site, repo) || '.', + pages, + }, + null, + 2, + )}\n`, ); - console.log(`docs: ${files.length} pages from ${github}@${sha.slice(0, 7)} (${ref})`); + console.log(`docs: ${files.length} pages from ${github}@${source.sha.slice(0, 7)} (${source.ref})`); } -if (process.argv[1] === fileURLToPath(import.meta.url)) main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) + main().catch((error) => { + console.error(`fetch-docs: ${error.message}`); + process.exit(1); + }); diff --git a/docs-site/scripts/release.mjs b/docs-site/scripts/release.mjs new file mode 100644 index 0000000..d98c13c --- /dev/null +++ b/docs-site/scripts/release.mjs @@ -0,0 +1,109 @@ +// Which uptide-dev/uptide ref the site is built from. +// +// By default, the latest published release: the `latest` dist-tag of `uptide` on npm, checked +// out as tag v. When that tag does not exist the build fails; it never falls back to +// main. Two explicit overrides exist for previews, and a site built with either is never +// deployed (see deployable() and .github/workflows/pages.yml): +// +// UPTIDE_REF another ref to build from (a branch, tag or commit), for previews +// UPTIDE_CHECKOUT an existing local checkout (offline work) +// +// UPTIDE_NPM_REGISTRY registry to ask (default: https://registry.npmjs.org) +// UPTIDE_REPO git URL of uptide (default: https://github.com/uptide-dev/uptide.git) +import { execFileSync } from 'node:child_process'; + +export const PACKAGE = 'uptide'; +export const REPO_URL = 'https://github.com/uptide-dev/uptide.git'; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +/** The version npm's `latest` dist-tag points at. */ +export async function latestVersion({ fetch = globalThis.fetch, registry = 'https://registry.npmjs.org' } = {}) { + const url = `${registry.replace(/\/+$/, '')}/-/package/${PACKAGE}/dist-tags`; + let response; + try { + response = await fetch(url, { headers: { accept: 'application/json' } }); + } catch (error) { + throw new Error(`npm: could not reach ${url} (${error.message}); cannot tell which uptide release to build`); + } + if (!response.ok) throw new Error(`npm: ${url} answered ${response.status}; cannot tell which uptide release to build`); + let tags; + try { + tags = await response.json(); + } catch { + throw new Error(`npm: ${url} did not answer JSON; cannot tell which uptide release to build`); + } + const version = tags?.latest; + if (typeof version !== 'string' || !VERSION.test(version)) + throw new Error(`npm: ${url} has no usable "latest" dist-tag (got ${JSON.stringify(version)})`); + return version; +} + +export const tagFor = (version) => `v${version}`; + +/** `git ls-remote` against the anonymous HTTPS remote: never prompts, never uses credentials. */ +export function lsRemote(repoUrl, ref) { + return execFileSync('git', ['-c', 'credential.helper=', 'ls-remote', '--tags', repoUrl, ref], { + encoding: 'utf8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }, + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +/** The object id of tag `tag` in the repository, or a clear failure when it is not there. */ +export function findTag({ version, repoUrl = REPO_URL, lsRemote: run = lsRemote }) { + const tag = tagFor(version); + const ref = `refs/tags/${tag}`; + const line = run(repoUrl, ref) + .split('\n') + .find((l) => l.split('\t')[1] === ref); + if (!line) + throw new Error( + `uptide ${version} is the latest release on npm, but ${repoUrl} has no tag ${tag}. ` + + 'The site is built from release tags only and does not fall back to main: push the tag, ' + + 'or run the Pages workflow by hand with a `ref` to preview another ref.', + ); + return { tag, ref, object: line.split('\t')[0] }; +} + +/** + * Where to build from: { kind: 'release' | 'override' | 'local', ref, fetchRef?, version?, path? }. + * `version` is set for a release (from npm); for the others it comes from the checkout. + */ +export async function resolveSource({ env = process.env, fetch, lsRemote: run } = {}) { + if (env.UPTIDE_CHECKOUT) return { kind: 'local', ref: 'local checkout', path: env.UPTIDE_CHECKOUT }; + const repoUrl = env.UPTIDE_REPO || REPO_URL; + const override = env.UPTIDE_REF?.trim(); + if (override) { + // A branch, tag or commit; nothing that could smuggle text into a log or step output. + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(override) || override.includes('..')) + throw new Error(`UPTIDE_REF ${JSON.stringify(override)} is not a branch, tag or commit name`); + return { kind: 'override', ref: override, fetchRef: override, repoUrl }; + } + const version = await latestVersion({ fetch, registry: env.UPTIDE_NPM_REGISTRY || undefined }); + const { tag, ref } = findTag({ version, repoUrl, lsRemote: run }); + return { kind: 'release', ref: tag, fetchRef: ref, version, repoUrl }; +} + +/** Only a site built from the npm release's tag may be deployed. */ +export const deployable = (source) => source?.kind === 'release'; + +/** The line the build log shows, and what the built pages carry in their meta tags. */ +export function describe(source) { + const sha = source.sha ? ` (${source.sha.slice(0, 7)})` : ''; + if (source.kind === 'release') return `uptide ${source.version}: npm latest, tag ${source.ref}${sha}`; + if (source.kind === 'override') return `uptide ${source.version ?? '?'}: ref ${source.ref}${sha}, preview, not deployable`; + return `uptide ${source.version ?? '?'}: ${source.ref}${sha}, preview, not deployable`; +} + +/** The meta tags naming the release the site reflects, as { name, content } pairs. */ +export const metaFields = (source) => [ + { name: 'uptide-version', content: String(source.version) }, + { name: 'uptide-ref', content: String(source.ref) }, + { name: 'uptide-commit', content: String(source.sha) }, +]; + +/** The same, as HTML. */ +export function metaTags(source) { + const esc = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/ ``); +} diff --git a/docs-site/src/remark-uptide-links.mjs b/docs-site/src/remark-uptide-links.mjs index 9de20b6..dc3e6d8 100644 --- a/docs-site/src/remark-uptide-links.mjs +++ b/docs-site/src/remark-uptide-links.mjs @@ -31,9 +31,9 @@ export default function remarkUptideLinks({ base, source }) { const slug = source.pages[target]; if (slug !== undefined) return `${root}/${slug ? `${slug}/` : ''}${suffix}`; const onDisk = resolve(checkout, target); - if (kind === 'image') return `https://raw.githubusercontent.com/uptide-dev/uptide/${source.ref}/${target}`; + if (kind === 'image') return `https://raw.githubusercontent.com/uptide-dev/uptide/${source.linkRef ?? source.ref}/${target}`; const isDir = existsSync(onDisk) && statSync(onDisk).isDirectory(); - return `${source.repo}/${isDir ? 'tree' : 'blob'}/${source.ref}/${target.replace(/\/$/, '')}${suffix}`; + return `${source.repo}/${isDir ? 'tree' : 'blob'}/${source.linkRef ?? source.ref}/${target.replace(/\/$/, '')}${suffix}`; }; return (tree, file) => { diff --git a/docs-site/src/sidebar.mjs b/docs-site/src/sidebar.mjs index e04bbe7..7af5adf 100644 --- a/docs-site/src/sidebar.mjs +++ b/docs-site/src/sidebar.mjs @@ -34,7 +34,7 @@ export function sidebar(siteDir) { const slugs = Object.values(source.pages).filter((s) => s.startsWith(`${folder.slice(5)}/`)); if (folder.startsWith('docs/') && slugs.length) return { label, collapsed: true, items: [{ autogenerate: { directory: folder.slice(5) } }] }; - return { label, link: `${source.repo}/blob/${source.ref}/${path}`, attrs: { rel: 'external' } }; + return { label, link: `${source.repo}/blob/${source.linkRef ?? source.ref}/${path}`, attrs: { rel: 'external' } }; }; return [ { label: 'Overview', slug: '' }, diff --git a/scripts/assemble-site.mjs b/scripts/assemble-site.mjs index 3839fbc..217624e 100644 --- a/scripts/assemble-site.mjs +++ b/scripts/assemble-site.mjs @@ -7,12 +7,18 @@ // of the landing page, never on site/ itself: // - the data-uptide slots (version, packs) are filled from the uptide-dev/uptide checkout the // docs build made, cross-checked against its README (scripts/landing-data.mjs); -// - every
 block must match its public source in that checkout.
+// - every 
 block must match its public source in that checkout;
+// - the landing page and the 404 page get the uptide-version, uptide-ref and uptide-commit
+//   meta tags the docs pages already carry.
 // The %SITE_URL% placeholders are written in by the Pages workflow afterwards.
-import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+//
+// In GitHub Actions it also writes step outputs: `release` is true only when the site was built
+// from the tag of the latest uptide release on npm, and the Pages workflow deploys nothing else.
+import { appendFileSync, cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
 import { join, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { checkSources, fill, readPacks, readVersion, slots } from './landing-data.mjs';
+import { deployable, describe, metaTags } from '../docs-site/scripts/release.mjs';
 
 const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
 const out = resolve(process.argv[2] ?? join(root, '_site'));
@@ -38,9 +44,29 @@ if (problems.length) {
 }
 const packs = readPacks(checkout);
 const version = readVersion(checkout);
-writeFileSync(page, fill(html, slots({ version, packs })));
+if (version !== source.version)
+  throw new Error(`the checkout says uptide ${version}, but the docs were built for ${source.version}`);
+writeFileSync(page, withMeta(fill(html, slots({ version, packs }))));
+const notFound = join(out, '404.html');
+writeFileSync(notFound, withMeta(readFileSync(notFound, 'utf8')));
+
+/** The page with the release's meta tags after its charset. */
+function withMeta(text) {
+  const charset = '\n';
+  if (!text.includes(charset)) throw new Error('a page has no  to put the release tags after');
+  return text.replace(charset, `${charset}${metaTags(source).join('\n')}\n`);
+}
+
+if (process.env.GITHUB_OUTPUT)
+  appendFileSync(
+    process.env.GITHUB_OUTPUT,
+    // `release` last, so nothing before it can override it.
+    `version=${source.version}\nref=${source.ref}\nsha=${source.sha}\nrelease=${deployable(source)}\n`,
+  );
 
+console.log(`site: ${out} (landing at /, docs at /docs/)`);
+console.log(`site: built from ${describe(source)}`);
 console.log(
-  `site: ${out} (landing at /, docs at /docs/), uptide ${version} @ ${source.sha.slice(0, 7)}: ` +
-    `${packs.filter((p) => p.status === 'verified').length} verified packs (${packs.map((p) => p.package).join(', ')})`,
+  `site: ${packs.filter((p) => p.status === 'verified').length} verified packs (${packs.map((p) => p.package).join(', ')})`,
 );
+console.log(`site: deployable: ${deployable(source) ? 'yes' : 'no, not built from the npm release tag'}`);
diff --git a/site/README.md b/site/README.md
index 9692b48..c8d1b4e 100644
--- a/site/README.md
+++ b/site/README.md
@@ -32,7 +32,8 @@ that one value.
 
 Every number on the page traces to a public run or a public file in
 [uptide-dev/uptide](https://github.com/uptide-dev/uptide), and the deploy checks it against the
-checkout the docs build makes (`scripts/landing-data.mjs`, run by `scripts/assemble-site.mjs`):
+checkout the docs build makes, which is the tag of the latest uptide release on npm
+(`scripts/landing-data.mjs`, run by `scripts/assemble-site.mjs`):
 
 - **Version and packs** are not written in `index.html`. Elements marked `data-uptide="…"`
   hold a fallback, and the deploy fills them: the version from `packages/cli/package.json`,
diff --git a/test/release.test.mjs b/test/release.test.mjs
new file mode 100644
index 0000000..f4dbcba
--- /dev/null
+++ b/test/release.test.mjs
@@ -0,0 +1,154 @@
+// The site is built from the latest uptide release on npm, checked out as its tag, and only that
+// build is ever deployed (docs-site/scripts/release.mjs, .github/workflows/pages.yml).
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+import {
+  deployable,
+  findTag,
+  latestVersion,
+  metaTags,
+  resolveSource,
+  tagFor,
+} from '../docs-site/scripts/release.mjs';
+
+const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
+const pages = read('.github/workflows/pages.yml');
+
+/** A fetch that answers one URL with `body` and records what it was asked. */
+function npm(body, { status = 200, json = true } = {}) {
+  const calls = [];
+  const fetch = async (url) => {
+    calls.push(url);
+    return {
+      ok: status >= 200 && status < 300,
+      status,
+      json: async () => (json ? body : JSON.parse('not json')),
+    };
+  };
+  return { fetch, calls };
+}
+
+/** `git ls-remote --tags` output for a repository with these tags. */
+const remote = (tags) => (repoUrl, ref) =>
+  tags
+    .flatMap((t) => [`${'a'.repeat(40)}\trefs/tags/${t}`, `${'b'.repeat(40)}\trefs/tags/${t}^{}`])
+    .filter((line) => line.split('\t')[1].startsWith(ref))
+    .join('\n');
+
+test('the version is npm\'s latest dist-tag of uptide', async () => {
+  const { fetch, calls } = npm({ latest: '0.6.1', next: '0.6.2-next.1' });
+  assert.equal(await latestVersion({ fetch }), '0.6.1');
+  assert.deepEqual(calls, ['https://registry.npmjs.org/-/package/uptide/dist-tags']);
+  assert.equal(await latestVersion({ fetch, registry: 'https://npm.example/' }), '0.6.1');
+  assert.equal(calls[1], 'https://npm.example/-/package/uptide/dist-tags');
+});
+
+test('an npm answer the build cannot trust fails it', async () => {
+  await assert.rejects(latestVersion(npm({}, { status: 503 })), /answered 503; cannot tell which uptide release to build/);
+  await assert.rejects(latestVersion(npm({}, { json: false })), /did not answer JSON/);
+  await assert.rejects(latestVersion(npm({ next: '1.0.0' })), /no usable "latest" dist-tag \(got undefined\)/);
+  await assert.rejects(latestVersion(npm({ latest: 'main' })), /no usable "latest" dist-tag \(got "main"\)/);
+  const offline = { fetch: async () => { throw new Error('ENOTFOUND'); } };
+  await assert.rejects(latestVersion(offline), /could not reach .*ENOTFOUND/);
+});
+
+test('the release is checked out as tag v', () => {
+  assert.equal(tagFor('0.6.1'), 'v0.6.1');
+  const found = findTag({ version: '0.6.1', lsRemote: remote(['v0.6.0', 'v0.6.1']) });
+  assert.deepEqual(found, { tag: 'v0.6.1', ref: 'refs/tags/v0.6.1', object: 'a'.repeat(40) });
+  // v0.6.10 must not pass for v0.6.1, nor the reverse.
+  assert.throws(() => findTag({ version: '0.6.1', lsRemote: remote(['v0.6.10']) }), /no tag v0\.6\.1/);
+});
+
+test('a release without its tag fails the build, naming the version and the tag, and never falls back to main', async () => {
+  const lsRemote = remote(['v0.6.0']);
+  assert.throws(
+    () => findTag({ version: '0.6.1', lsRemote }),
+    (error) =>
+      /uptide 0\.6\.1 is the latest release on npm/.test(error.message) &&
+      /has no tag v0\.6\.1/.test(error.message) &&
+      /does not fall back to main/.test(error.message),
+  );
+  await assert.rejects(
+    resolveSource({ env: {}, fetch: npm({ latest: '0.6.1' }).fetch, lsRemote }),
+    /has no tag v0\.6\.1/,
+  );
+});
+
+test('by default the source is the release tag, and only that is deployable', async () => {
+  const source = await resolveSource({ env: {}, fetch: npm({ latest: '0.6.1' }).fetch, lsRemote: remote(['v0.6.1']) });
+  assert.deepEqual(source, {
+    kind: 'release',
+    ref: 'v0.6.1',
+    fetchRef: 'refs/tags/v0.6.1',
+    version: '0.6.1',
+    repoUrl: 'https://github.com/uptide-dev/uptide.git',
+  });
+  assert.equal(deployable(source), true);
+});
+
+test('a ref override builds a preview without asking npm, and is never deployable', async () => {
+  const neverCalled = async () => assert.fail('a preview must not depend on npm');
+  const source = await resolveSource({ env: { UPTIDE_REF: 'main' }, fetch: neverCalled, lsRemote: () => assert.fail() });
+  assert.equal(source.kind, 'override');
+  assert.equal(source.fetchRef, 'main');
+  assert.equal(deployable(source), false);
+  assert.equal(deployable(await resolveSource({ env: { UPTIDE_CHECKOUT: '/tmp/uptide' } })), false);
+  assert.equal(deployable(undefined), false);
+  // Whitespace-only is no override; anything that is not a ref name is refused.
+  const blank = await resolveSource({ env: { UPTIDE_REF: '  ' }, fetch: npm({ latest: '0.6.1' }).fetch, lsRemote: remote(['v0.6.1']) });
+  assert.equal(blank.kind, 'release');
+  for (const bad of ['main\nrelease=true', '../x', '-o', 'a b', '$(id)'])
+    await assert.rejects(resolveSource({ env: { UPTIDE_REF: bad } }), /is not a branch, tag or commit name/, bad);
+});
+
+test('the built pages name the release in meta tags', () => {
+  assert.deepEqual(metaTags({ version: '0.6.1', ref: 'v0.6.1', sha: 'abc' }), [
+    '',
+    '',
+    '',
+  ]);
+  assert.match(metaTags({ version: '1', ref: 'a"b', sha: 'c' })[1], /content="a"b"/);
+  assert.match(read('docs-site/astro.config.mjs'), /\.\.\.releaseMeta,/);
+  assert.match(read('scripts/assemble-site.mjs'), /withMeta\(fill\(html/);
+  assert.match(read('scripts/assemble-site.mjs'), /writeFileSync\(notFound, withMeta\(/);
+});
+
+/** One job's block of pages.yml. */
+const job = (name) => {
+  const start = pages.indexOf(`\n  ${name}:\n`);
+  assert.ok(start > 0, `pages.yml has no ${name} job`);
+  const next = pages.slice(start + 1).search(/\n {2}[a-z-]+:\n/);
+  return next < 0 ? pages.slice(start) : pages.slice(start, start + 1 + next);
+};
+
+test('push, the daily schedule and manual runs build the site; a manual run may name a preview ref', () => {
+  assert.match(pages, /\non:\n {2}push:\n {4}branches: \[main\]\n {2}schedule:\n {4}- cron: '[^']+'\n {2}workflow_dispatch:\n {4}inputs:\n {6}ref:\n/);
+  assert.match(pages, /\n {6}ref:\n(?: {8}.*\n)*? {8}default: ''\n/);
+  assert.doesNotMatch(pages, /UPTIDE_REF:(?! \$\{\{ inputs\.ref \}\}\n)/, 'UPTIDE_REF comes only from the input');
+});
+
+test('a ref override never reaches the deploy step', () => {
+  const build = job('build');
+  const deploy = job('deploy');
+  // The input is read in the build job only, through the environment, never pasted into a script.
+  assert.equal(pages.match(/inputs\.ref/g).length, 3, 'concurrency, UPTIDE_REF and the deploy condition, nowhere else');
+  assert.ok(build.includes('UPTIDE_REF: ${{ inputs.ref }}'));
+  assert.doesNotMatch(pages, /run: [^\n]*\$\{\{ inputs\./);
+  // The deploy job needs the build's own verdict, and refuses any manual run with a ref.
+  assert.match(deploy, /\n {4}needs: build\n/);
+  assert.match(
+    deploy,
+    /\n {4}if: needs\.build\.outputs\.release == 'true' && \(github\.event_name != 'workflow_dispatch' \|\| inputs\.ref == ''\)\n/,
+  );
+  assert.match(build, /\n {6}release: \$\{\{ steps\.assemble\.outputs\.release \}\}\n/);
+  assert.match(build, /\n {8}id: assemble\n {8}run: node scripts\/assemble-site\.mjs _site\n/);
+  // Deploying is all the deploy job does, and only it may.
+  assert.deepEqual([...deploy.matchAll(/uses: (\S+)/g)].map((m) => m[1]), ['actions/deploy-pages@v4']);
+  assert.doesNotMatch(build, /deploy-pages|pages: write|id-token/);
+  assert.match(deploy, /permissions:\n(?: {6}#.*\n)* {6}pages: write\n {6}id-token: write\n/);
+  // The build's verdict is written last, so nothing written before it can override it.
+  const assemble = read('scripts/assemble-site.mjs');
+  assert.match(assemble, /`version=\$\{source\.version\}\\nref=\$\{source\.ref\}\\nsha=\$\{source\.sha\}\\nrelease=\$\{deployable\(source\)\}\\n`/);
+});
diff --git a/test/site.test.mjs b/test/site.test.mjs
index 80e47db..4d5db90 100644
--- a/test/site.test.mjs
+++ b/test/site.test.mjs
@@ -40,16 +40,12 @@ test('loads nothing from another origin: fonts, styles, scripts and images are l
   assert.doesNotMatch(page, /url\((["']?)(https?:)?\/\//);
 });
 
-test('deploys landing and docs together: on a push to main, daily and by hand, with Pages permissions', () => {
-  assert.match(pages, /\non:\n {2}push:\n {4}branches: \[main\]\n {2}schedule:\n {4}- cron: '[^']+'\n {2}workflow_dispatch:\n/);
-  assert.match(
-    pages,
-    /permissions:\n(?: {6}#.*\n)? {6}contents: read\n {6}pages: write\n {6}id-token: write/,
-  );
+test('builds landing and docs together and deploys them to Pages', () => {
   assert.ok(pages.includes('run: node scripts/assemble-site.mjs _site'));
   assert.ok(pages.includes('run: node scripts/check-links.mjs _site'));
   assert.match(pages, /uses: actions\/upload-pages-artifact@v3\n {8}with:\n {10}path: _site\n/);
   assert.ok(pages.includes('uses: actions/deploy-pages@v4'));
+  // Triggers, permissions and the deploy guard: test/release.test.mjs.
 });
 
 test('the landing page links to the docs, in the top bar and the footer', () => {