aboutsummaryrefslogtreecommitdiff
path: root/scripts/docs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-09-01 14:48:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-09-01 14:48:45 +0300
commit8a6d697308162874a236c84aac0b0f8c7ac01f83 (patch)
treeed7dc7e8c6f50d66bde721fae78576aede929f66 /scripts/docs
parentb54a78aa510b87d1e3b56c619b72eaeb59e4a2a1 (diff)
refactor: separate website presentation from runtime
Diffstat (limited to 'scripts/docs')
-rw-r--r--scripts/docs/preview.integration.test.ts90
-rw-r--r--scripts/docs/preview.mjs75
-rw-r--r--scripts/docs/snapshot.mjs116
-rw-r--r--scripts/docs/versions.integration.test.ts129
-rw-r--r--scripts/docs/versions.mjs283
-rw-r--r--scripts/docs/versions.test.ts33
6 files changed, 726 insertions, 0 deletions
diff --git a/scripts/docs/preview.integration.test.ts b/scripts/docs/preview.integration.test.ts
new file mode 100644
index 00000000..74e54957
--- /dev/null
+++ b/scripts/docs/preview.integration.test.ts
@@ -0,0 +1,90 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const script = resolve(import.meta.dir, 'preview.mjs');
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+describe('preview documentation snapshots', () => {
+ test('snapshots and validates the selected commit exactly', async () => {
+ const root = await fixture();
+ const commit = git(root, ['rev-parse', 'HEAD']).trim();
+ await write(root, 'docs/preview/website/stale.mdx', 'stale\n');
+ await writeManifest(root, commit);
+
+ runScript(root, ['snapshot', commit]);
+ runScript(root, ['check']);
+
+ expect(await read(root, 'docs/preview/website/src/content/docs/index.mdx')).toBe(
+ 'selected preview\n',
+ );
+ await expect(read(root, 'docs/preview/website/stale.mdx')).rejects.toThrow();
+ });
+
+ test('rejects snapshot drift', async () => {
+ const root = await fixture();
+ const commit = git(root, ['rev-parse', 'HEAD']).trim();
+ await writeManifest(root, commit);
+ runScript(root, ['snapshot', commit]);
+ await write(root, 'docs/preview/website/src/content/docs/index.mdx', 'changed\n');
+
+ expect(() => runScript(root, ['check'])).toThrow();
+ });
+});
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'herdr-preview-docs-'));
+ temporaryDirectories.push(root);
+ await write(root, 'docs/next/website/src/content/docs/index.mdx', 'selected preview\n');
+ await write(root, 'docs/next/website/src/data/config-reference.json', '{"preview":true}\n');
+ git(root, ['init', '-q']);
+ git(root, ['config', 'user.email', 'test@example.com']);
+ git(root, ['config', 'user.name', 'Test']);
+ git(root, ['add', '.']);
+ git(root, ['commit', '-qm', 'preview fixture']);
+ return root;
+}
+
+async function writeManifest(root: string, commit: string) {
+ await write(
+ root,
+ 'distribution/preview.json',
+ `${JSON.stringify({
+ schema_version: 1,
+ channel: 'preview',
+ build_id: 'test-build',
+ commit,
+ builds: { 'test-build': { commit, tag: 'preview-test-build' } },
+ })}\n`,
+ );
+}
+
+async function write(root: string, path: string, content: string) {
+ const destination = resolve(root, path);
+ await mkdir(resolve(destination, '..'), { recursive: true });
+ await writeFile(destination, content, 'utf8');
+}
+
+async function read(root: string, path: string) {
+ return readFile(resolve(root, path), 'utf8');
+}
+
+function git(root: string, args: string[]) {
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function runScript(root: string, args: string[]) {
+ execFileSync('node', [script, ...args], {
+ cwd: root,
+ env: { ...process.env, HERDR_DOCS_REPO_ROOT: root },
+ stdio: 'pipe',
+ });
+}
diff --git a/scripts/docs/preview.mjs b/scripts/docs/preview.mjs
new file mode 100644
index 00000000..9ef85728
--- /dev/null
+++ b/scripts/docs/preview.mjs
@@ -0,0 +1,75 @@
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ compareGitTree,
+ createGit,
+ extractGitTree,
+ gitPathExists,
+ resolveCommit,
+} from './snapshot.mjs';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = process.env.HERDR_DOCS_REPO_ROOT
+ ? resolve(process.env.HERDR_DOCS_REPO_ROOT)
+ : resolve(scriptDir, '../..');
+const git = createGit(repoRoot);
+const sourceRoot = 'docs/next/website';
+const snapshotRoot = resolve(repoRoot, 'docs/preview/website');
+const manifestPath = resolve(repoRoot, 'distribution/preview.json');
+const fullShaPattern = /^[0-9a-f]{40}$/;
+
+export async function snapshotPreview(ref) {
+ const commit = resolveCommit(git, ref);
+ if (!gitPathExists(git, commit, `${sourceRoot}/src/content/docs`)) {
+ throw new Error(`${commit} does not contain staged website documentation`);
+ }
+ if (!gitPathExists(git, commit, `${sourceRoot}/src/data/config-reference.json`)) {
+ throw new Error(`${commit} does not contain the staged config reference`);
+ }
+ await extractGitTree(git, commit, sourceRoot, snapshotRoot);
+ process.stdout.write(`snapshotted preview documentation from ${commit}\n`);
+}
+
+export async function checkPreview() {
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
+ if (manifest.schema_version !== 1 || manifest.channel !== 'preview') {
+ throw new Error(`${manifestPath} has an unsupported schema`);
+ }
+ if (typeof manifest.commit !== 'string' || !fullShaPattern.test(manifest.commit)) {
+ throw new Error(`${manifestPath} must contain a full preview commit SHA`);
+ }
+ const build = manifest.builds?.[manifest.build_id];
+ if (!build || build.commit !== manifest.commit) {
+ throw new Error(`${manifestPath} active build does not match its preview commit`);
+ }
+ const expectedTag = `preview-${manifest.build_id}`;
+ if (build.tag !== expectedTag) {
+ throw new Error(`${manifestPath} active build tag does not match ${expectedTag}`);
+ }
+ if (!gitPathExists(git, manifest.commit, `${sourceRoot}/src/content/docs`)) {
+ throw new Error(`${manifest.commit} does not contain staged website documentation`);
+ }
+ if (!gitPathExists(git, manifest.commit, `${sourceRoot}/src/data/config-reference.json`)) {
+ throw new Error(`${manifest.commit} does not contain the staged config reference`);
+ }
+ await compareGitTree(git, manifest.commit, sourceRoot, snapshotRoot);
+ process.stdout.write(`validated preview documentation snapshot ${manifest.commit}\n`);
+}
+
+async function main() {
+ const [command, value] = process.argv.slice(2);
+ if (command === 'snapshot' && value) {
+ await snapshotPreview(value);
+ return;
+ }
+ if (command === 'check' && !value) {
+ await checkPreview();
+ return;
+ }
+ throw new Error('usage: node scripts/docs/preview.mjs check | snapshot <commit>');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/scripts/docs/snapshot.mjs b/scripts/docs/snapshot.mjs
new file mode 100644
index 00000000..e9eaf92c
--- /dev/null
+++ b/scripts/docs/snapshot.mjs
@@ -0,0 +1,116 @@
+import { execFileSync } from 'node:child_process';
+import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, relative, resolve } from 'node:path';
+
+export function createGit(repoRoot) {
+ return function git(args, options = {}) {
+ return execFileSync('git', args, {
+ cwd: repoRoot,
+ encoding: options.binary ? undefined : 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ };
+}
+
+export function resolveCommit(git, ref) {
+ return git(['rev-parse', `${ref}^{commit}`]).trim();
+}
+
+export function gitPathExists(git, ref, path) {
+ try {
+ git(['cat-file', '-e', `${ref}:${path}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function gitTreesEqual(git, ref, left, right) {
+ try {
+ git(['diff', '--quiet', `${ref}:${left}`, `${ref}:${right}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function listGitEntries(git, ref, root) {
+ const output = git(['ls-tree', '-r', '-z', ref, '--', root]);
+ return output
+ .split('\0')
+ .filter(Boolean)
+ .map((line) => {
+ const separator = line.indexOf('\t');
+ const [mode, type, object] = line.slice(0, separator).split(' ');
+ return { mode, type, object, path: line.slice(separator + 1) };
+ });
+}
+
+export async function extractGitTree(git, ref, sourceRoot, destinationRoot) {
+ const entries = listGitEntries(git, ref, sourceRoot);
+ if (entries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
+
+ await rm(destinationRoot, { recursive: true, force: true });
+ for (const entry of entries) {
+ assertOrdinaryFile(entry, ref);
+ const relativePath = relative(sourceRoot, entry.path);
+ assertRelativePath(relativePath, entry.path);
+ const destination = resolve(destinationRoot, relativePath);
+ await mkdir(dirname(destination), { recursive: true });
+ await writeFile(destination, git(['cat-file', 'blob', entry.object], { binary: true }));
+ }
+}
+
+export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) {
+ const expectedEntries = listGitEntries(git, ref, sourceRoot);
+ if (expectedEntries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
+
+ const expected = new Map();
+ for (const entry of expectedEntries) {
+ assertOrdinaryFile(entry, ref);
+ const relativePath = relative(sourceRoot, entry.path);
+ assertRelativePath(relativePath, entry.path);
+ expected.set(relativePath, entry);
+ }
+ const actualPaths = await listDocumentationPaths(snapshotRoot);
+ const expectedPaths = [...expected.keys()].sort();
+ if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
+ throw new Error(`${snapshotRoot} file list differs from ${ref}:${sourceRoot}`);
+ }
+
+ for (const relativePath of expectedPaths) {
+ const entry = expected.get(relativePath);
+ const snapshotPath = resolve(snapshotRoot, relativePath);
+ const expectedContent = git(['cat-file', 'blob', entry.object], { binary: true });
+ if (!(await readFile(snapshotPath)).equals(expectedContent)) {
+ throw new Error(`${relativePath} differs from ${ref}:${entry.path}`);
+ }
+ }
+}
+
+export async function listDocumentationPaths(root) {
+ const paths = [];
+ async function walk(directory) {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = resolve(directory, entry.name);
+ if (entry.isDirectory()) await walk(path);
+ else if (entry.isFile()) paths.push(relative(root, path));
+ else throw new Error(`${path} is not an ordinary documentation file`);
+ }
+ }
+ await walk(root);
+ return paths.sort();
+}
+
+function assertOrdinaryFile(entry, ref) {
+ if (entry.type !== 'blob' || entry.mode !== '100644') {
+ throw new Error(`${ref}:${entry.path} is not an ordinary documentation file`);
+ }
+}
+
+function assertRelativePath(relativePath, originalPath) {
+ if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
+ throw new Error(`unsafe snapshot path ${originalPath}`);
+ }
+}
diff --git a/scripts/docs/versions.integration.test.ts b/scripts/docs/versions.integration.test.ts
new file mode 100644
index 00000000..a52be1be
--- /dev/null
+++ b/scripts/docs/versions.integration.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
+import { join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const script = resolve(import.meta.dir, 'versions.mjs');
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+describe('documentation release publishing', () => {
+ test('publishes immutable tagged snapshots and validates maintained corrections', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'herdr-docs-'));
+ temporaryDirectories.push(root);
+ await write(root, 'distribution/latest.json', '{"version":"0.9.0"}\n');
+ await write(root, 'README.md', 'stable readme\n');
+ await write(root, 'README.zh-CN.md', 'stable readme zh-cn\n');
+ const nextDocs = '---\ntitle: Documentation\n---\n\nnext docs\n';
+ await write(root, 'docs/next/website/src/content/docs/index.mdx', nextDocs);
+ await write(root, 'docs/next/website/src/data/config-reference.json', '{"next":true}\n');
+ await write(root, 'docs/next/README.md', 'next readme\n');
+ await write(root, 'docs/next/README.zh-CN.md', 'next readme zh-cn\n');
+
+ git(root, ['init', '-q']);
+ git(root, ['config', 'user.email', 'test@example.com']);
+ git(root, ['config', 'user.name', 'Test']);
+ git(root, ['add', '.']);
+ git(root, ['commit', '-qm', 'release fixture']);
+ git(root, ['tag', 'v0.9.0']);
+ git(root, ['tag', 'v1.0.0']);
+
+ runScript(root, ['publish', 'v1.0.0']);
+
+ expect(await read(root, 'README.md')).toBe('next readme\n');
+ expect(await read(root, 'README.zh-CN.md')).toBe('next readme zh-cn\n');
+ expect(await read(root, 'docs/versions/1.0.0/website/src/content/docs/index.mdx')).toBe(
+ nextDocs,
+ );
+
+ const manifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
+ expect(manifest.current).toBe('1.0.0');
+ expect(manifest.stable_source).toBe('snapshot');
+ expect(manifest.versions[0]).toMatchObject({
+ version: '1.0.0',
+ tag: 'v1.0.0',
+ commit: expect.stringMatching(/^[0-9a-f]{40}$/),
+ source: 'docs/next/website/src/content/docs',
+ });
+
+ await write(root, 'distribution/latest.json', '{"version":"1.0.0"}\n');
+ runScript(root, ['check']);
+
+ const correctedDocs = '---\ntitle: Documentation\n---\n\ncorrected docs\n';
+ await write(root, 'docs/versions/1.0.0/website/src/content/docs/index.mdx', correctedDocs);
+ await write(
+ root,
+ 'docs/versions/1.0.0/website/src/data/config-reference.json',
+ '{"corrected":true}\n',
+ );
+ runScript(root, ['check']);
+
+ await write(root, 'README.md', 'post-release correction\n');
+ runScript(root, ['publish', 'v1.0.0']);
+ expect(await read(root, 'README.md')).toBe('post-release correction\n');
+
+ runScript(root, ['publish', 'v0.9.0']);
+ const archivedManifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
+ expect(archivedManifest.current).toBe('1.0.0');
+ expect(archivedManifest.versions.map(({ version }) => version)).toEqual(['1.0.0', '0.9.0']);
+ expect(await read(root, 'README.md')).toBe('post-release correction\n');
+ runScript(root, ['check']);
+
+ const correctedArchivedDocs = '---\ntitle: Documentation\n---\n\ncorrected archived docs\n';
+ await write(
+ root,
+ 'docs/versions/0.9.0/website/src/content/docs/index.mdx',
+ correctedArchivedDocs,
+ );
+ runScript(root, ['publish', 'v0.9.0']);
+ expect(await read(root, 'docs/versions/0.9.0/website/src/content/docs/index.mdx')).toBe(
+ correctedArchivedDocs,
+ );
+
+ const archivedDocsRoot = resolve(root, 'docs/versions/0.9.0/website/src/content/docs');
+ await rm(archivedDocsRoot, { recursive: true, force: true });
+ expect(() => runScript(root, ['check'])).toThrow();
+ await write(
+ root,
+ 'docs/versions/0.9.0/website/src/content/docs/index.mdx',
+ correctedArchivedDocs,
+ );
+ const linkedPage = resolve(archivedDocsRoot, 'linked.mdx');
+ await symlink('index.mdx', linkedPage);
+ expect(() => runScript(root, ['check'])).toThrow();
+ await rm(linkedPage);
+ runScript(root, ['check']);
+
+ delete archivedManifest.versions[0].commit;
+ await write(root, 'docs/versions/manifest.json', `${JSON.stringify(archivedManifest)}\n`);
+ expect(() => runScript(root, ['check'])).toThrow();
+ }, 30_000);
+});
+
+async function write(root: string, path: string, content: string) {
+ const destination = resolve(root, path);
+ await mkdir(resolve(destination, '..'), { recursive: true });
+ await writeFile(destination, content, 'utf8');
+}
+
+async function read(root: string, path: string) {
+ return readFile(resolve(root, path), 'utf8');
+}
+
+function git(root: string, args: string[]) {
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function runScript(root: string, args: string[]) {
+ execFileSync('node', [script, ...args], {
+ cwd: root,
+ env: { ...process.env, HERDR_DOCS_REPO_ROOT: root },
+ stdio: 'pipe',
+ });
+}
diff --git a/scripts/docs/versions.mjs b/scripts/docs/versions.mjs
new file mode 100644
index 00000000..163d3ae6
--- /dev/null
+++ b/scripts/docs/versions.mjs
@@ -0,0 +1,283 @@
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ createGit,
+ extractGitTree,
+ gitPathExists,
+ gitTreesEqual,
+ listDocumentationPaths,
+ resolveCommit,
+} from './snapshot.mjs';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = process.env.HERDR_DOCS_REPO_ROOT
+ ? resolve(process.env.HERDR_DOCS_REPO_ROOT)
+ : resolve(scriptDir, '../..');
+const versionsDir = resolve(repoRoot, 'docs/versions');
+const manifestPath = resolve(versionsDir, 'manifest.json');
+const latestManifestPath = resolve(repoRoot, 'distribution/latest.json');
+const git = createGit(repoRoot);
+
+const VERSION_PATTERN = /^v?(\d+\.\d+\.\d+)$/;
+
+export function normalizeVersion(value) {
+ const match = VERSION_PATTERN.exec(value);
+ if (!match) throw new Error(`version must look like 0.7.5 or v0.7.5, got ${value}`);
+ return match[1];
+}
+
+export function sortVersionsNewestFirst(versions) {
+ return [...versions].sort((left, right) => compareVersions(right.version, left.version));
+}
+
+function compareVersions(left, right) {
+ const a = left.split('.').map(Number);
+ const b = right.split('.').map(Number);
+ for (let index = 0; index < 3; index += 1) {
+ if (a[index] !== b[index]) return a[index] - b[index];
+ }
+ return 0;
+}
+
+async function readManifest() {
+ try {
+ return JSON.parse(await readFile(manifestPath, 'utf8'));
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return { schema_version: 1, stable_source: 'legacy', current: null, versions: [] };
+ }
+ throw error;
+ }
+}
+
+async function writeManifest(manifest) {
+ manifest.versions = sortVersionsNewestFirst(manifest.versions).map(
+ ({ version, tag, commit, source }) => ({
+ version,
+ tag,
+ ...(commit ? { commit } : {}),
+ source,
+ }),
+ );
+ await mkdir(dirname(manifestPath), { recursive: true });
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
+}
+
+function releaseMetadata(tag) {
+ const version = normalizeVersion(tag);
+ const normalizedTag = `v${version}`;
+ return { version, tag: normalizedTag, commit: resolveCommit(git, normalizedTag) };
+}
+
+async function snapshotTag(tag, sourceRoot) {
+ const metadata = { ...releaseMetadata(tag), source: sourceRoot };
+ const destination = resolve(versionsDir, metadata.version);
+ await rm(destination, { recursive: true, force: true });
+ await extractGitTree(git, tag, sourceRoot, resolve(destination, 'website/src/content/docs'));
+
+ const referenceSource = sourceRoot.includes('docs/next/')
+ ? 'docs/next/website/src/data/config-reference.json'
+ : 'website/src/data/config-reference.json';
+ if (gitPathExists(git, tag, referenceSource)) {
+ const referenceDestination = resolve(destination, 'website/src/data/config-reference.json');
+ await mkdir(dirname(referenceDestination), { recursive: true });
+ await writeFile(referenceDestination, git(['show', `${tag}:${referenceSource}`], { binary: true }));
+ }
+
+ return metadata;
+}
+
+export async function backfillVersions() {
+ const tags = git(['tag', '--list', 'v*', '--sort=v:refname']).split('\n').filter(Boolean);
+ const manifest = await readManifest();
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+
+ for (const tag of tags) {
+ const version = normalizeVersion(tag);
+ if (entries.has(version)) continue;
+ const nextRoot = 'docs/next/website/src/content/docs';
+ const legacyRoot = 'website/src/content/docs';
+ const hasNext = gitPathExists(git, tag, nextRoot);
+ const hasLegacy = gitPathExists(git, tag, legacyRoot);
+ if (!hasNext && !hasLegacy) continue;
+ const sourceRoot =
+ hasNext && (!hasLegacy || !gitTreesEqual(git, tag, legacyRoot, nextRoot))
+ ? nextRoot
+ : legacyRoot;
+ const metadata = await snapshotTag(tag, sourceRoot);
+ entries.set(metadata.version, metadata);
+ process.stdout.write(`snapshotted ${tag}\n`);
+ }
+
+ const current = JSON.parse(await readFile(latestManifestPath, 'utf8')).version;
+ await writeManifest({
+ schema_version: 1,
+ stable_source: manifest.stable_source ?? 'legacy',
+ current: normalizeVersion(current),
+ versions: [...entries.values()],
+ });
+}
+
+export async function checkVersions() {
+ const manifest = await readManifest();
+ if (
+ manifest.schema_version !== 1 ||
+ typeof manifest.current !== 'string' ||
+ !['legacy', 'snapshot'].includes(manifest.stable_source ?? 'legacy')
+ ) {
+ throw new Error(`${manifestPath} has an unsupported schema`);
+ }
+
+ const latest = JSON.parse(await readFile(latestManifestPath, 'utf8')).version;
+ if (manifest.current !== normalizeVersion(latest)) {
+ throw new Error(`docs current version ${manifest.current} does not match distribution latest ${latest}`);
+ }
+
+ const expectedOrder = sortVersionsNewestFirst(manifest.versions).map(({ version }) => version);
+ if (JSON.stringify(manifest.versions.map(({ version }) => version)) !== JSON.stringify(expectedOrder)) {
+ throw new Error('documentation versions are not sorted newest first');
+ }
+
+ const seen = new Set();
+ for (const entry of manifest.versions) {
+ if (seen.has(entry.version)) throw new Error(`duplicate docs version ${entry.version}`);
+ seen.add(entry.version);
+ if (normalizeVersion(entry.tag) !== entry.version) {
+ throw new Error(`docs version ${entry.version} has mismatched tag ${entry.tag}`);
+ }
+ const requiresCommit =
+ entry.source === 'docs/next/website/src/content/docs' ||
+ ((manifest.stable_source ?? 'legacy') === 'snapshot' && entry.version === manifest.current);
+ if (requiresCommit && !entry.commit) {
+ throw new Error(`docs version ${entry.version} is missing commit provenance`);
+ }
+ if (entry.commit) {
+ if (!/^[0-9a-f]{40}$/.test(entry.commit)) {
+ throw new Error(`docs version ${entry.version} has invalid commit ${entry.commit}`);
+ }
+ const taggedCommit = resolveCommit(git, entry.tag);
+ if (taggedCommit !== entry.commit) {
+ throw new Error(
+ `docs version ${entry.version} tag ${entry.tag} moved from ${entry.commit} to ${taggedCommit}`,
+ );
+ }
+ }
+ if (!['website/src/content/docs', 'docs/next/website/src/content/docs'].includes(entry.source)) {
+ throw new Error(`docs version ${entry.version} has unsupported source ${entry.source}`);
+ }
+
+ const versionRoot = resolve(versionsDir, entry.version, 'website');
+ const documentationPaths = await listDocumentationPaths(
+ resolve(versionRoot, 'src/content/docs'),
+ );
+ if (!documentationPaths.includes('index.md') && !documentationPaths.includes('index.mdx')) {
+ throw new Error(`docs version ${entry.version} has no root index`);
+ }
+
+ try {
+ JSON.parse(await readFile(resolve(versionRoot, 'src/data/config-reference.json'), 'utf8'));
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ }
+
+ if (!seen.has(manifest.current)) {
+ throw new Error(`current docs version ${manifest.current} has no snapshot`);
+ }
+ process.stdout.write(`validated ${manifest.versions.length} documentation versions\n`);
+}
+
+export async function publishVersion(tag) {
+ const release = releaseMetadata(tag);
+ if (!gitPathExists(git, tag, 'docs/next/website/src/content/docs')) {
+ throw new Error(`${tag} does not contain staged website documentation`);
+ }
+
+ const manifest = await readManifest();
+ const existing = manifest.versions.find((entry) => entry.version === release.version);
+ if (manifest.current === release.version) {
+ if ((manifest.stable_source ?? 'legacy') === 'legacy') {
+ process.stdout.write(`documentation ${release.tag} is already published with the legacy stable source\n`);
+ return;
+ }
+ if (!existing?.commit || existing.commit !== release.commit) {
+ throw new Error(`published documentation ${release.version} has mismatched commit provenance`);
+ }
+ process.stdout.write(`documentation ${release.tag} is already published\n`);
+ return;
+ }
+ if (manifest.current && compareVersions(release.version, manifest.current) < 0) {
+ if (existing) {
+ if (existing.tag !== release.tag) {
+ throw new Error(`version ${release.version} is already associated with ${existing.tag}`);
+ }
+ if (existing.commit && existing.commit !== release.commit) {
+ throw new Error(`version ${release.version} has mismatched commit provenance`);
+ }
+ process.stdout.write(`documentation ${release.tag} is already archived\n`);
+ return;
+ }
+ const archived = await snapshotTag(tag, 'docs/next/website/src/content/docs');
+ const archivedEntries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+ archivedEntries.set(archived.version, archived);
+ await writeManifest({
+ ...manifest,
+ versions: [...archivedEntries.values()],
+ });
+ process.stdout.write(
+ `archived documentation snapshot ${release.tag} without changing current ${manifest.current}\n`,
+ );
+ return;
+ }
+ if (existing) {
+ throw new Error(`version ${release.version} already has published documentation`);
+ }
+
+ const metadata = await snapshotTag(tag, 'docs/next/website/src/content/docs');
+
+ for (const readme of ['README.md', 'README.zh-CN.md']) {
+ const nextReadme = `docs/next/${readme}`;
+ if (gitPathExists(git, tag, nextReadme)) {
+ await writeFile(resolve(repoRoot, readme), git(['show', `${tag}:${nextReadme}`], { binary: true }));
+ }
+ }
+
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+ entries.set(metadata.version, metadata);
+ await writeManifest({
+ schema_version: 1,
+ stable_source: 'snapshot',
+ current: metadata.version,
+ versions: [...entries.values()],
+ });
+ process.stdout.write(`published documentation snapshot ${metadata.tag}\n`);
+}
+
+async function main() {
+ const [command, value] = process.argv.slice(2);
+ if (command === 'backfill' && !value) {
+ await backfillVersions();
+ return;
+ }
+ if (command === 'publish' && value) {
+ await publishVersion(value);
+ return;
+ }
+ if (command === 'check' && !value) {
+ await checkVersions();
+ return;
+ }
+ if (command === 'current' && !value) {
+ const manifest = await readManifest();
+ process.stdout.write(`${manifest.current ?? ''}\n`);
+ return;
+ }
+ throw new Error(
+ 'usage: node scripts/docs/versions.mjs backfill | check | current | publish <tag>',
+ );
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/scripts/docs/versions.test.ts b/scripts/docs/versions.test.ts
new file mode 100644
index 00000000..c40ee854
--- /dev/null
+++ b/scripts/docs/versions.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from 'bun:test';
+import { normalizeVersion, sortVersionsNewestFirst } from './versions.mjs';
+
+describe('normalizeVersion', () => {
+ test.each([
+ ['0.7.5', '0.7.5'],
+ ['v0.7.5', '0.7.5'],
+ ])('normalizes %s', (input, expected) => {
+ expect(normalizeVersion(input)).toBe(expected);
+ });
+
+ test('rejects non-release refs', () => {
+ expect(() => normalizeVersion('preview-123')).toThrow();
+ });
+});
+
+describe('sortVersionsNewestFirst', () => {
+ test('sorts semantic versions numerically', () => {
+ const versions = [
+ { version: '0.6.10' },
+ { version: '0.7.2' },
+ { version: '0.7.10' },
+ { version: '0.5.12' },
+ ];
+
+ expect(sortVersionsNewestFirst(versions).map(({ version }) => version)).toEqual([
+ '0.7.10',
+ '0.7.2',
+ '0.6.10',
+ '0.5.12',
+ ]);
+ });
+});