/** * writeAndCommitVersion — the versioned-doc regeneration folded into the * version commit. * * Why it exists: generated doc pages commonly EMBED the version (SmartStack.cli * stamps a `v{version}` badge into all 23 of them) while the publish pipeline * gates on `npm run docs:check`. A version commit that does not carry the * regenerated pages turns that gate red on EVERY release, in the Build stage, * before anything is published — how v5.19.1 got tagged but never shipped. * * The two properties under test are what make this safe in a GENERIC tool that * ships to client repos: * 1. opt-in by capability — no `build:docs` script, no run, no output; * 2. only what the generator actually changed is staged — an unrelated dirty * file is never swept into the version commit. */ import { execFileSync } from 'node:child_process' import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { writeAndCommitVersion } from '../version.js' const roots: string[] = [] const git = (cwd: string, ...args: string[]): string => execFileSync('git', args, { cwd, encoding: 'utf-8' }).trim() /** A committed repo at 1.0.0, optionally with a `build:docs` script. */ function repo(opts: { buildDocs?: string } = {}): string { const root = mkdtempSync(join(tmpdir(), 'gf-version-docs-')) roots.push(root) git(root, 'init', '-q', '-b', 'main') git(root, 'config', 'user.email', 'test@example.com') git(root, 'config', 'user.name', 'Test') const pkg: Record = { name: 'fixture', version: '1.0.0' } if (opts.buildDocs) pkg.scripts = { 'build:docs': opts.buildDocs } writeFileSync(join(root, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf8') mkdirSync(join(root, 'docs'), { recursive: true }) writeFileSync(join(root, 'docs', 'index.txt'), 'v1.0.0\n', 'utf8') writeFileSync(join(root, 'untouched.txt'), 'original\n', 'utf8') git(root, 'add', '-A') git(root, 'commit', '-q', '-m', 'init') return root } /** Files carried by HEAD. */ const filesInHead = (root: string): string[] => git(root, 'show', '--name-only', '--pretty=format:', 'HEAD').split('\n').filter(Boolean) afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }) }) describe('writeAndCommitVersion — versioned docs', () => { it('is INERT when the project has no build:docs script (the client-repo case)', async () => { const root = repo() const res = await writeAndCommitVersion(root, '1.1.0', ['package.json'], 'chore: 1.1.0') expect(res.committed).toBe(true) expect(res.warning).toBeUndefined() expect(filesInHead(root)).toEqual(['package.json']) // The generator never ran, so the stale doc is left exactly as it was. expect(readFileSync(join(root, 'docs', 'index.txt'), 'utf8')).toBe('v1.0.0\n') }) it('regenerates and COMMITS the doc alongside the version, reading the NEW version', async () => { // Stamps whatever package.json currently holds — so it can only produce // 1.1.0 if it ran AFTER the version sources were rewritten. const root = repo({ buildDocs: 'node -e "const v=require(\'./package.json\').version;require(\'fs\').writeFileSync(\'docs/index.txt\',\'v\'+v+String.fromCharCode(10))"', }) const res = await writeAndCommitVersion(root, '1.1.0', ['package.json'], 'chore: 1.1.0') expect(res.committed).toBe(true) expect(res.warning).toBeUndefined() expect(readFileSync(join(root, 'docs', 'index.txt'), 'utf8')).toBe('v1.1.0\n') expect(filesInHead(root).sort()).toEqual(['docs/index.txt', 'package.json']) expect(git(root, 'status', '--porcelain')).toBe('') }) it('never sweeps an UNRELATED dirty file into the version commit', async () => { const root = repo({ buildDocs: 'node -e "const v=require(\'./package.json\').version;require(\'fs\').writeFileSync(\'docs/index.txt\',\'v\'+v+String.fromCharCode(10))"', }) writeFileSync(join(root, 'untouched.txt'), 'work in progress\n', 'utf8') const res = await writeAndCommitVersion(root, '1.1.0', ['package.json'], 'chore: 1.1.0') expect(res.committed).toBe(true) expect(filesInHead(root)).not.toContain('untouched.txt') // Still dirty, still the author's own edit. expect(git(root, 'status', '--porcelain')).toContain('untouched.txt') expect(readFileSync(join(root, 'untouched.txt'), 'utf8')).toBe('work in progress\n') }) it('reports a generator failure as a WARNING and still commits the version', async () => { // A red docs gate is better news than a half-written release branch. const root = repo({ buildDocs: 'node -e "process.exit(1)"' }) const res = await writeAndCommitVersion(root, '1.1.0', ['package.json'], 'chore: 1.1.0') expect(res.committed).toBe(true) expect(res.warning).toContain('build:docs failed') expect(filesInHead(root)).toEqual(['package.json']) }) })