/** * @changesets/cli integration — openspec/changes/publilo-cli/proposal.md Phase 4. * * Changesets is the "what versions does this publish produce" piece * of the workflow. During dev, the operator runs `bun changeset` to * record a pending bump as a markdown file under `.changeset/`. At * publish time, `celilo publish` finds those pending changesets and * runs `bunx changeset version` to apply the bumps (writes new * versions to each affected package.json, generates / updates * CHANGELOG.md, deletes the consumed changesets), then commits the * resulting diff. The downstream workspace publisher reads from * package.json as before — it doesn't need to know changesets exist. * * Falls back to "operator manually bumped package.json" when there * are no pending changesets — preserving the muscle memory operators * built during the pre-changesets era. The `--skip-changesets` flag * is the escape hatch for cases where pending changesets exist but * the operator wants to publish what's in source right now. * * Skipped entirely in alpha mode (alphas are throwaway pre-releases; * applying changesets would burn them on alpha publishes) and in * promote mode (promote is graduating an existing alpha, not bumping * to a new version). */ import { spawnSync } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; import { join, relative } from 'node:path'; import { REPO_ROOT } from './helpers'; /** * Pure: given a directory listing of `.changeset/`, return the * filenames that represent actual pending changesets. Filters out * `README.md` and `config.json` (the changesets default scaffolding) * and anything that isn't a markdown file. * * Exposed for unit testing — `listPendingChangesets` is the disk * wrapper. */ export function filterChangesetEntries(filenames: string[]): string[] { return filenames.filter((f) => f.endsWith('.md') && f !== 'README.md'); } /** * List pending changesets. Returns absolute paths to the markdown * files under `.changeset/` that represent actual unapplied bumps. */ export function listPendingChangesets(): string[] { const dir = join(REPO_ROOT, '.changeset'); if (!existsSync(dir)) return []; return filterChangesetEntries(readdirSync(dir)).map((f) => join(dir, f)); } /** * Parse `git status --porcelain` output into a list of file paths. * Used to figure out exactly which files `bunx changeset version` * touched so we can git-add them specifically (per CLAUDE.md's * "prefer adding specific files by name" guidance). * * Pure — exposed so the test suite can exercise the parser without * a real git invocation. */ export function parseGitStatusFiles(porcelainOutput: string): string[] { const files: string[] = []; for (const line of porcelainOutput.split('\n')) { if (!line.trim()) continue; // Porcelain format: "XY " where XY is two-char status, // followed by a space, followed by the path. Renamed entries // use " -> " separator; for our purposes we only care about // the destination side, which is what changeset version emits // in the new-file case anyway. const match = line.match(/^.{2}\s+(.+?)(?:\s+->\s+(.+))?$/); if (!match) continue; files.push(match[2] ?? match[1]); } return files; } /** * Apply pending changesets: * 1. Run `bunx changeset version` to bump every affected * package.json + write CHANGELOG.md + delete consumed * .changeset/*.md files. * 2. Stage every file the previous step changed (parsed from * `git status --porcelain`). * 3. Commit with a message naming the count. * * Throws on any failure; the caller decides what to do (typically: * exit non-zero). */ export function applyPendingChangesets(pendingCount: number): void { const versionResult = spawnSync('bunx', ['changeset', 'version'], { cwd: REPO_ROOT, stdio: 'inherit', }); if (versionResult.status !== 0) { throw new Error(`bunx changeset version failed (exit ${versionResult.status})`); } // Find every file changeset touched and stage it specifically. const statusResult = spawnSync('git', ['status', '--porcelain'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', }); if (statusResult.status !== 0) { throw new Error(`git status failed after changeset version (exit ${statusResult.status})`); } const changedFiles = parseGitStatusFiles(statusResult.stdout ?? ''); if (changedFiles.length === 0) { // `bunx changeset version` ran but didn't change anything — possible // if the changesets resolved to no-ops. Nothing to commit. console.log('changeset version produced no changes — nothing to commit.'); return; } const addResult = spawnSync('git', ['add', '--', ...changedFiles], { cwd: REPO_ROOT, stdio: 'inherit', }); if (addResult.status !== 0) { throw new Error(`git add failed after changeset version (exit ${addResult.status})`); } const message = `publish: apply ${pendingCount} pending changeset${pendingCount === 1 ? '' : 's'}`; const commitResult = spawnSync('git', ['commit', '-m', message], { cwd: REPO_ROOT, stdio: 'inherit', }); if (commitResult.status !== 0) { throw new Error(`git commit failed after changeset version (exit ${commitResult.status})`); } } /** * Display the pending changesets to the operator before prompting * for approval. Shows relative paths (less noise than absolute). */ export function printPendingChangesets(paths: string[]): void { console.log(`\n${paths.length} pending changeset${paths.length === 1 ? '' : 's'} found:`); for (const p of paths) { console.log(` ${relative(REPO_ROOT, p)}`); } }