/** * Preflight checks for `celilo publish` — dirty-tree, workspace * stale-version, module stale-manifest. Builds a single * `PreflightReport` so the planner can decide which gates apply for * the current mode (alpha skips workspace + module stale; promote * skips workspace stale; normal enforces all three). * * Also owns `--release-touch`: the auto-fix path for the * release-only-drift case where source changed but manifest.yml * didn't. Touch + commit + continue is automated when the publisher * just hits Enter through it; otherwise --release-touch by itself * touches and exits. */ import { execSync, spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { checkModuleStale } from '../../../services/module-validator/git-hygiene'; import { REPO_ROOT, getPublishPackages, isAncestor, isPublished, lastCommitTouching, listModuleDirs, readPkg, } from './helpers'; import type { ModuleStaleIssue, PreflightReport, StalenessIssue } from './types'; /** * Detect "I bumped source but forgot to bump the version." If a package's * current version is already on npm AND there are commits touching the * package's source after the last commit that touched its package.json, * the publish would silently skip — which is exactly how a feature add * never makes it to npm. * * False positives: a commit that bumps a dependency in package.json * (without changing the "version" field) resets staleness even though the * version is still stale relative to the last bump. Acceptable for v1 — * the common case (forget-to-bump after a feature commit) is caught. */ export function checkStaleVersion(pkg: string): StalenessIssue | null { const { name, version } = readPkg(pkg); if (!name || !version) return null; if (!isPublished(name, version)) return null; const lastPkgJson = lastCommitTouching([`${pkg}/package.json`]); const lastSrc = lastCommitTouching([ pkg, `:(exclude)${pkg}/package.json`, `:(exclude)${pkg}/node_modules`, ]); if (!lastPkgJson || !lastSrc) return null; if (lastSrc === lastPkgJson) return null; if (!isAncestor(lastPkgJson, lastSrc)) return null; return { name, pkg, version, lastSrcCommit: lastSrc, lastPkgJsonCommit: lastPkgJson, }; } /** * Run every precondition that can fail a publish, accumulating all * issues into a single report. Used both by --dry-run (report-only) and * by the normal publish flow (where it's the gate before any side * effects run). * * Replaces the old "abort-on-first-failure per phase" pattern that made * fresh releases require N round-trips when N modules drifted. */ export function runPreflight(): PreflightReport { const dirtyOutput = execSync('git status --porcelain', { encoding: 'utf-8', cwd: REPO_ROOT, }).trim(); const workspaceStale: StalenessIssue[] = []; for (const pkg of getPublishPackages()) { const issue = checkStaleVersion(pkg); if (issue) workspaceStale.push(issue); } const moduleStale: ModuleStaleIssue[] = []; for (const dir of listModuleDirs()) { const issue = checkModuleStale(dir); if (issue) { moduleStale.push({ moduleDir: issue.moduleDir, lastSrcCommit: issue.lastSrcCommit, lastManifestCommit: issue.lastManifestCommit, }); } } return { dirty: dirtyOutput.length > 0, dirtyOutput, workspaceStale, moduleStale, }; } /** * Print a human-readable preflight report. Returns true if any issue is * blocking (i.e. publish would fail without --allow-stale). */ export function printPreflightReport(report: PreflightReport): boolean { let anyIssue = false; if (report.dirty) { anyIssue = true; console.error('\n✗ Working tree has uncommitted changes:\n'); console.error(execSync('git status --short', { encoding: 'utf-8', cwd: REPO_ROOT })); console.error(' fix: commit or stash, then re-run.'); } if (report.workspaceStale.length > 0) { anyIssue = true; console.error('\n✗ Stale-version drift on workspace packages:\n'); for (const i of report.workspaceStale) { console.error(` ${i.name}@${i.version} is on npm, but commits touching ${i.pkg} landed`); console.error(` after the last ${i.pkg}/package.json change.`); console.error(` src commit: ${i.lastSrcCommit.slice(0, 12)}`); console.error(` package.json commit: ${i.lastPkgJsonCommit.slice(0, 12)}`); console.error(` fix: bump ${i.pkg}/package.json#version, commit, re-run.`); console.error(); } } if (report.moduleStale.length > 0) { anyIssue = true; console.error('\n✗ Stale-manifest drift on celilo modules:\n'); for (const i of report.moduleStale) { const rel = relative(REPO_ROOT, i.moduleDir); console.error(` ${rel}: src committed after manifest.yml`); console.error(` src commit: ${i.lastSrcCommit.slice(0, 12)}`); console.error(` manifest.yml commit: ${i.lastManifestCommit.slice(0, 12)}`); } console.error( ' fix: bump manifest.yml#version (semver change), or run\n' + ' `bun run publish --release-touch` to auto-touch all drifted\n' + " manifests with today's date (release-only — auto-revision\n" + ' picks the next +N).', ); console.error(); } if (!anyIssue) { console.log('✓ Preflight clean: no dirty tree, no stale workspace versions, no stale modules.'); } else { console.error('Pass --allow-stale to publish anyway (the prior versions stay on npm).'); } return anyIssue; } /** * Append (or refresh) a `# released: ` trailing comment on * the given manifest.yml. Always produces a fresh diff (timestamps are * unique per second), so the resulting commit always advances past any * source commit on the same day. */ export function touchManifestRelease(manifestPath: string): void { const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const original = readFileSync(manifestPath, 'utf-8'); const lines = original.split('\n'); const cleaned: string[] = []; for (const line of lines) { if (line.match(/^#\s*released:/)) continue; cleaned.push(line); } while (cleaned.length > 0 && cleaned[cleaned.length - 1].trim() === '') { cleaned.pop(); } cleaned.push(`# released: ${now}`); cleaned.push(''); writeFileSync(manifestPath, cleaned.join('\n')); } /** * Apply --release-touch: rewrite every drifted manifest.yml with today's * release marker. Doesn't commit — operator reviews `git diff` and * commits explicitly so the touch lands as a documented release event, * not as a silent script side-effect. */ export function applyReleaseTouch(issues: ModuleStaleIssue[]): void { if (issues.length === 0) { console.log('No module drift to touch — nothing to do.'); return; } console.log(`\nTouching ${issues.length} drifted manifest(s):`); for (const i of issues) { const manifestPath = join(i.moduleDir, 'manifest.yml'); touchManifestRelease(manifestPath); console.log(` ✎ ${relative(REPO_ROOT, manifestPath)}`); } console.log('\nReview with `git diff modules/`, commit, then re-run `bun run publish`.'); } /** * Auto-cascade for the common case: only modules drifted. Touches + * commits + returns true if the operator approved continuing. Returns * false if they aborted (caller should exit). Throws on git failure. * * Confirmation is gated by `autoYes` — when -y is set, the cascade * runs without prompting. When false, the caller already prompted. */ export function autoTouchAndCommit( report: PreflightReport, spawnSyncFn: typeof spawnSync = spawnSync, ): { ok: boolean; recheck: PreflightReport | null } { applyReleaseTouch(report.moduleStale); const manifestPaths = report.moduleStale.map((i) => join(i.moduleDir, 'manifest.yml')); const stageResult = spawnSyncFn('git', ['add', '--', ...manifestPaths], { cwd: REPO_ROOT, stdio: 'inherit', }); if (stageResult.status !== 0) { console.error('git add failed; resolve manually and re-run.'); return { ok: false, recheck: null }; } const commitMessage = `modules: release-touch drifted manifests (${report.moduleStale .map((i) => relative(REPO_ROOT, i.moduleDir).replace('modules/', '')) .join(', ')})`; const commitResult = spawnSyncFn('git', ['commit', '-m', commitMessage], { cwd: REPO_ROOT, stdio: 'inherit', }); if (commitResult.status !== 0) { console.error('git commit failed; resolve manually and re-run.'); return { ok: false, recheck: null }; } const recheck = runPreflight(); return { ok: true, recheck }; }