diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-01 14:48:45 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-01 14:48:45 +0300 |
| commit | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (patch) | |
| tree | ed7dc7e8c6f50d66bde721fae78576aede929f66 /scripts/docs | |
| parent | b54a78aa510b87d1e3b56c619b72eaeb59e4a2a1 (diff) | |
refactor: separate website presentation from runtime
Diffstat (limited to 'scripts/docs')
| -rw-r--r-- | scripts/docs/preview.integration.test.ts | 90 | ||||
| -rw-r--r-- | scripts/docs/preview.mjs | 75 | ||||
| -rw-r--r-- | scripts/docs/snapshot.mjs | 116 | ||||
| -rw-r--r-- | scripts/docs/versions.integration.test.ts | 129 | ||||
| -rw-r--r-- | scripts/docs/versions.mjs | 283 | ||||
| -rw-r--r-- | scripts/docs/versions.test.ts | 33 |
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', + ]); + }); +}); |
