/** * GitFlow Version — Detection, SemVer bumping, source file updates. * Self-contained: imports only from local lib + node built-ins. */ import { readFile, writeFile } from 'fs/promises'; import { existsSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import { execFile } from 'child_process'; import { execGit, addFiles, commit as gitCommit, isTracked } from './git.js'; import { updateCurrentVersion } from './config.js'; import type { VersionInfo } from './types.js'; export async function detectVersion(projectDir: string): Promise { const csprojVersion = await findCsprojVersion(projectDir); if (csprojVersion) return csprojVersion; const buildPropsPath = join(projectDir, 'Directory.Build.props'); if (existsSync(buildPropsPath)) { const content = await readFile(buildPropsPath, 'utf-8'); const match = content.match(/([^<]+)<\/Version>/); if (match) return { current: match[1], source: 'Directory.Build.props', file: buildPropsPath }; } const pkgPath = join(projectDir, 'package.json'); if (existsSync(pkgPath)) { try { const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')); if (pkg.version) return { current: pkg.version, source: 'package.json', file: pkgPath }; } catch { /* skip */ } } const versionFile = join(projectDir, 'VERSION'); if (existsSync(versionFile)) { const content = (await readFile(versionFile, 'utf-8')).trim(); if (/^\d+\.\d+\.\d+/.test(content)) return { current: content, source: 'VERSION', file: versionFile }; } const tagResult = await execGit(['describe', '--tags', '--abbrev=0'], projectDir); if (tagResult.exitCode === 0) { const version = tagResult.stdout.replace(/^v/, ''); if (/^\d+\.\d+\.\d+/.test(version)) return { current: version, source: 'tag' }; } return { current: '0.0.0', source: 'default' }; } export function bumpVersion(current: string, type: 'major' | 'minor' | 'patch'): string { const parts = current.replace(/^v/, '').split('.').map(Number); const [major = 0, minor = 0, patch = 0] = parts; switch (type) { case 'major': return `${major + 1}.0.0`; case 'minor': return `${major}.${minor + 1}.0`; case 'patch': return `${major}.${minor}.${patch + 1}`; } } export function getBumpType(branchType: string): 'major' | 'minor' | 'patch' { switch (branchType) { case 'hotfix': return 'patch'; case 'release': return 'minor'; default: return 'minor'; } } /** * Next DEVELOPMENT version after finishing a release/hotfix, computed from the * RELEASED version (the branch / tag version) — restores the 4.x * finish-version-bumping rule EXACTLY: * release → major.(minor+1).0 * hotfix → major.minor.(patch+2) (patch+1 was already consumed by the tag, * leaving patch+1 free for the next hotfix) * NB: this bumps from the released version, NOT from config.current (which may * be a stale placeholder like 0.0.0). */ /** * Is `a` strictly newer than `b`? Numeric, tolerant of a leading `v` and of * short forms (`5.2` → 5.2.0). */ export function isNewerVersion(a: string, b: string): boolean { const parse = (v: string): number[] => { const [major = 0, minor = 0, patch = 0] = v.replace(/^v/, '').split('.').map(Number) return [major, minor, patch].map((n) => (Number.isFinite(n) ? n : 0)) } const x = parse(a) const y = parse(b) for (let i = 0; i < 3; i++) { if (x[i]! !== y[i]!) return x[i]! > y[i]! } return false } export function nextDevVersion(released: string, branchType: string): string { const [major = 0, minor = 0, patch = 0] = released.replace(/^v/, '').split('.').map(Number); if (branchType === 'hotfix') return `${major}.${minor}.${patch + 2}`; return `${major}.${minor + 1}.0`; } export async function updateVersionSources( projectDir: string, newVersion: string, sources?: string[], ): Promise { const modified: string[] = []; const toCheck = sources || ['package.json', 'Directory.Build.props', 'VERSION']; for (const source of toCheck) { switch (source) { case 'package.json': { const pkgPath = join(projectDir, 'package.json'); if (existsSync(pkgPath)) { const content = await readFile(pkgPath, 'utf-8'); const pkg = JSON.parse(content); if (pkg.version !== newVersion) { pkg.version = newVersion; await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); modified.push(pkgPath); } } break; } case 'Directory.Build.props': { const propsPath = join(projectDir, 'Directory.Build.props'); if (existsSync(propsPath)) { const content = await readFile(propsPath, 'utf-8'); const updated = content.replace(/[^<]+<\/Version>/, `${newVersion}`); if (updated !== content) { await writeFile(propsPath, updated); modified.push(propsPath); } } break; } case 'VERSION': { const vFile = join(projectDir, 'VERSION'); if (existsSync(vFile)) { await writeFile(vFile, newVersion + '\n'); modified.push(vFile); } break; } case 'csproj': { const csprojFiles = await findCsprojFiles(projectDir); for (const f of csprojFiles) { const content = await readFile(f, 'utf-8'); const updated = content.replace(/[^<]+<\/Version>/, `${newVersion}`); if (updated !== content) { await writeFile(f, updated); modified.push(f); } } break; } } } return modified; } /** Paths reported dirty by `git status --porcelain` (renames → the NEW path). */ async function dirtyPaths(workdir: string): Promise> { const res = await execGit(['status', '--porcelain'], workdir); if (res.exitCode !== 0) throw new Error(`git status failed in '${workdir}': ${res.stderr || 'cannot read the working tree'}`); const out = new Set(); for (const line of res.stdout.split('\n')) { if (line.length < 4) continue; const raw = line.slice(3); const arrow = raw.indexOf(' -> '); out.add(arrow >= 0 ? raw.slice(arrow + 4) : raw); } return out; } /** * Regenerate version-stamped documentation, when the project HAS a `build:docs` * script — and fold the result into the version commit. * * Why this belongs to the version commit and nowhere else: 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 therefore turns the * pipeline red on EVERY release, in the Build stage, BEFORE it publishes * anything — which is exactly how v5.19.1 came to be tagged but never shipped. * * Two properties make this safe in a generic tool deployed to client repos: * - opt-in BY CAPABILITY — no `build:docs` script, no run, no output; * - it stages only the paths the generator ACTUALLY changed (before/after * status diff), so an unrelated dirty file is never swept into the commit. * * A generator failure is reported, never fatal: the version commit still goes * through and the caller surfaces the warning (a red docs gate is better news * than a half-written release branch). */ async function regenerateVersionedDocs( workdir: string, ): Promise<{ changed: string[]; error?: string }> { const pkgPath = join(workdir, 'package.json'); if (!existsSync(pkgPath)) return { changed: [] }; try { const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')); if (!pkg?.scripts?.['build:docs']) return { changed: [] }; } catch { return { changed: [] }; } const before = await dirtyPaths(workdir); const ran = await new Promise<{ ok: boolean; err: string }>((resolve) => { // npm is a `.cmd` shim on Windows — execFile cannot launch it without a // shell (same reason provider.ts shells out for az/gh). execFile( 'npm', ['run', 'build:docs'], { cwd: workdir, encoding: 'utf-8', timeout: 300000, shell: process.platform === 'win32' }, (error, _stdout, stderr) => resolve({ ok: !error, err: error ? (stderr || String(error)) : '' }), ); }); if (!ran.ok) return { changed: [], error: `build:docs failed: ${ran.err.trim().split('\n').slice(-3).join(' ')}` }; const after = await dirtyPaths(workdir); return { changed: [...after].filter((p) => !before.has(p)) }; } /** * Write `newVersion` into the source files AND the gitflow config, then stage + * commit them in `workdir`. The config is only staged when it is a TRACKED file * (so we never force a local/gitignored config into the repo). Does NOT push. * * Shared by `start` (set the release version on the release branch) and `finish` * (bump develop to the next dev version) so both follow identical mechanics — * including the doc regeneration, so NEITHER path can leave version-stamped * pages behind (finish's develop bump used to break the next release's gate). */ export async function writeAndCommitVersion( workdir: string, newVersion: string, sources: string[] | undefined, commitMessage: string, ): Promise<{ committed: boolean; modified: string[]; error?: string; warning?: string }> { const modified = await updateVersionSources(workdir, newVersion, sources); const cfgPath = await updateCurrentVersion(newVersion, workdir); const toAdd = [...modified]; if (cfgPath && (await isTracked(cfgPath, workdir))) toAdd.push(cfgPath); // AFTER the sources carry the new version — the generator reads it. const docs = await regenerateVersionedDocs(workdir); toAdd.push(...docs.changed); if (toAdd.length === 0) return { committed: false, modified, ...(docs.error ? { warning: docs.error } : {}) }; await addFiles(toAdd, workdir); const res = await gitCommit(commitMessage, workdir); const warn = docs.error ? { warning: docs.error } : {}; if (res.exitCode !== 0) return { committed: false, modified, error: res.stderr || res.stdout, ...warn }; return { committed: true, modified: [...modified, ...docs.changed], ...warn }; } async function findCsprojVersion(projectDir: string): Promise { const files = await findCsprojFiles(projectDir); for (const f of files) { const content = await readFile(f, 'utf-8'); const match = content.match(/([^<]+)<\/Version>/); if (match) return { current: match[1], source: 'csproj', file: f }; } return null; } async function findCsprojFiles(projectDir: string): Promise { const results: string[] = []; const srcDir = join(projectDir, 'src'); if (!existsSync(srcDir)) return results; try { for (const folder of readdirSync(srcDir)) { const folderPath = join(srcDir, folder); if (!statSync(folderPath).isDirectory()) continue; for (const file of readdirSync(folderPath)) { if (file.endsWith('.csproj')) results.push(join(folderPath, file)); } } } catch { /* skip */ } return results; }