import fs from "node:fs"; import path from "node:path"; import { getPaths } from "../lib/paths"; import { compareVersions, countUnreleasedBullets, cutRelease, CutReleaseError, dateISOProblem, getLatestRelease, getLatestReleasedVersion, hasUnreleasedHeading, suggestNextMinorVersion, suggestNextVersion, } from "../lib/changelog"; import { commitPath, headSha, listDirtyPaths } from "../lib/git"; import { writeFileAtomic } from "../lib/jsonFile-server"; // CUT A RELEASE — THE ONE WRITER (release 10 slice P). // // A release here is a changelog heading and nothing else: `## [Unreleased]` // becomes `## [] - ` in editor/CHANGELOG.md or // export/CHANGELOG.md, optionally followed by a path-limited commit of that one // file, `Release `. No package.json is bumped (every // workspace is 0.1.0 and stays so) and no tag is made; neither has ever been a // convention in this repo. // // Three callers, one body: the /sites and /changelog "Cut release" form (the // server action, a FormData adapter), `archilyzer release cut` (local, needs no // editor) and `POST /api/ops/cut-release` (`pnpm ops cut-release`). The // refusals are this module's sentences, so all three say the same thing. export const RELEASE_WORKSPACES = ["editor", "export"] as const; export type ReleaseWorkspace = (typeof RELEASE_WORKSPACES)[number]; // `all` is the CLI's and the API's: both changelogs, ONE version, two commits. export const RELEASE_TARGETS = ["editor", "export", "all"] as const; export type ReleaseTarget = (typeof RELEASE_TARGETS)[number]; // `next` is the patch bump of the latest released heading (what the form // pre-fills); `next-minor` bumps the minor and zeroes the patch. export const VERSION_KEYWORDS = ["next", "next-minor"] as const; export type VersionKeyword = (typeof VERSION_KEYWORDS)[number]; const SEMVER_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; export function isReleaseTarget(v: unknown): v is ReleaseTarget { return (RELEASE_TARGETS as readonly unknown[]).includes(v); } function isVersionKeyword(v: string): v is VersionKeyword { return (VERSION_KEYWORDS as readonly string[]).includes(v); } /** * Why `version` can never be cut, as one sentence — or null. A keyword or an * X.Y.Z(-pre) passes; the CLI and the route ask this before touching anything. */ export function versionSpecProblem(version: string): string | null { const v = version.trim(); if (!v) return "Version is required."; if (isVersionKeyword(v) || SEMVER_VERSION.test(v)) return null; return `Version "${v}" is not a valid semver (expected X.Y.Z or X.Y.Z-prerelease), "next" or "next-minor".`; } /** * Why `date` cannot stamp a heading — or null: YYYY-MM-DD and a real day. * The same check `cutRelease` makes, asked before anything is read. */ export function dateProblem(date: string): string | null { return dateISOProblem(date); } /** Today in LOCAL time, as the form has always stamped it. */ export function todayISO(now: Date = new Date()): string { const y = now.getFullYear().toString().padStart(4, "0"); const m = (now.getMonth() + 1).toString().padStart(2, "0"); const d = now.getDate().toString().padStart(2, "0"); return `${y}-${m}-${d}`; } /** The literal version a keyword means against `latest` (a literal is itself). */ export function resolveVersion(version: string, latest: string | null): string { const v = version.trim(); if (v === "next") return suggestNextVersion(latest); if (v === "next-minor") return suggestNextMinorVersion(latest); return v; } // The work tree and the two files. The default is the running process's // (getPaths(): the monorepo root, and the EDITOR_/EXPORT_CHANGELOG_FILE // overrides the e2e server sets). An explicit root means the standard layout // under it and NOTHING from the environment — which is what every test passes, // so no test can reach the real repo. export type ReleaseRepo = { root: string; changelogs: Record; }; export function releaseRepo(root?: string): ReleaseRepo { if (root) { return { root, changelogs: { editor: path.join(root, "editor", "CHANGELOG.md"), export: path.join(root, "export", "CHANGELOG.md"), }, }; } const paths = getPaths(); return { root: paths.monorepoRoot, changelogs: { editor: paths.editorChangelogFile, export: paths.exportChangelogFile, }, }; } export type CutReleaseResult = | { ok: true; workspace: ReleaseWorkspace; version: string; // The line now in the file: `## [0.10.0] - 2026-09-26`. heading: string; committed: boolean; // HEAD after the release commit; only when `committed`. commitSha?: string; } | { ok: false; workspace: ReleaseWorkspace; error: string; // THE FILE WAS WRITTEN; only the commit after it failed (release 11 // slice O3, release 10 review L3). The changelog on disk now carries the // new heading, so a caller that revalidates pages on a cut must do so // here too. Absent on every other failure: nothing was written. written?: true; }; export type CutReleaseOptions = { workspace: ReleaseWorkspace; // A literal X.Y.Z(-pre), or "next" / "next-minor". version: string; commit: boolean; // YYYY-MM-DD; default today. For re-cutting an already-dated release. date?: string; // An explicit work tree (tests). Default: the running process's. root?: string; }; async function readChangelog( file: string, ): Promise<{ ok: true; source: string } | { ok: false; error: string }> { try { return { ok: true, source: await fs.promises.readFile(file, "utf8") }; } catch (err) { return { ok: false, error: `Could not read ${file}: ${(err as Error).message}` }; } } // The dirty-tree guard, asked before a commit and before anything is written: // nothing but the changelogs this run commits may be dirty. A dirty changelog // is fine — its uncommitted [Unreleased] bullets are folded into its release // commit. Null when the tree is clear. async function dirtyTreeProblem( repo: ReleaseRepo, changelogRelPaths: string[], ): Promise { let dirty: string[]; try { dirty = await listDirtyPaths(repo.root); } catch (err) { return `Could not check git status: ${(err as Error).message}`; } const others = dirty.filter((p) => !changelogRelPaths.includes(p)); if (others.length === 0) return null; return `Other uncommitted changes present (${others.join( ", ", )}). Commit or stash them before cutting a release.`; } // A cut worked out in memory. Nothing is on disk yet. type PlannedCut = { workspace: ReleaseWorkspace; filePath: string; relPath: string; version: string; heading: string; next: string; }; // Resolve the version against this changelog and cut it in memory. Every // refusal that is about the changelog itself (no [Unreleased], nothing // pending, a version that does not move forward, a bad date) happens here. function planCut( repo: ReleaseRepo, workspace: ReleaseWorkspace, source: string, version: string, date: string, ): { ok: true; cut: PlannedCut } | { ok: false; error: string } { const resolved = resolveVersion(version, getLatestReleasedVersion(source)); let next: string; try { next = cutRelease(source, resolved, date); } catch (err) { if (err instanceof CutReleaseError) return { ok: false, error: err.message }; throw err; } const filePath = repo.changelogs[workspace]; return { ok: true, cut: { workspace, filePath, relPath: path.relative(repo.root, filePath), version: resolved, heading: `## [${resolved}] - ${date}`, next, }, }; } // The atomic write, then the path-limited commit. Only I/O and git can fail // here — every other refusal was asked before. async function applyCut( repo: ReleaseRepo, cut: PlannedCut, commit: boolean, ): Promise { const { workspace, version, heading } = cut; const fail = (error: string): CutReleaseResult => ({ ok: false, workspace, error }); try { await writeFileAtomic(cut.filePath, cut.next); } catch (err) { return fail(`Could not write ${cut.filePath}: ${(err as Error).message}`); } if (!commit) { return { ok: true, workspace, version, heading, committed: false }; } const result = await commitPath( repo.root, cut.relPath, `Release ${workspace} ${version}`, ); if (!result.ok) { return { ok: false, workspace, error: `Cut release ${version}, but the commit failed: ${result.error}`, written: true, }; } const sha = await headSha(repo.root); return { ok: true, workspace, version, heading, committed: true, ...(sha ? { commitSha: sha } : {}), }; } /** * Cut one workspace's changelog. What the form does, in the order it always * did: the dirty-tree guard (when committing), the read, the cut, the atomic * write, the commit. */ export async function cutReleaseForWorkspace( opts: CutReleaseOptions, ): Promise { const { workspace } = opts; const fail = (error: string): CutReleaseResult => ({ ok: false, workspace, error }); const version = opts.version.trim(); if (!version) return fail("Version is required."); const date = opts.date ?? todayISO(); const repo = releaseRepo(opts.root); const filePath = repo.changelogs[workspace]; if (opts.commit) { const problem = await dirtyTreeProblem(repo, [path.relative(repo.root, filePath)]); if (problem) return fail(problem); } const read = await readChangelog(filePath); if (!read.ok) return fail(read.error); const plan = planCut(repo, workspace, read.source, version, date); if (!plan.ok) return fail(plan.error); return applyCut(repo, plan.cut, opts.commit); } export type CutReleasesOutcome = { ok: boolean; // The literal version cut (a keyword resolved); null when it never resolved. version: string | null; // One per workspace ATTEMPTED, in order; a run stops at its first failure. results: CutReleaseResult[]; // The workspaces a failure stopped before they were tried. notAttempted: ReleaseWorkspace[]; // No changelog was written. True for every refusal, `all`'s preflight // included; false once any file was written, a cut whose commit then failed // (`written`) included. cutReleases ALWAYS sets it (release 11 slice O3, so // a caller can tell a refusal from a cut stopped half-way — review L4); it // is optional only so a hand-built outcome (the CLI's display tests) need // not carry it, and there absent reads as false. untouched?: boolean; }; // Whether any result of a run wrote its changelog. function nothingWritten(results: readonly CutReleaseResult[]): boolean { return !results.some((r) => r.ok || r.written === true); } /** * Cut `editor`, `export`, or `all` (editor then export, with the SAME version: * a keyword resolves against the HIGHER of the two latest headings, so neither * changelog goes backwards). With `commit`, each workspace is its own * `Release ` commit. * * `all` CUTS BOTH OR NEITHER, as far as that can be known in advance. Before * anything is written it reads both changelogs, cuts both in memory and (when * committing) runs the dirty-tree guard once; any refusal there answers with * the failing workspace's sentence and leaves both files and the log alone. * Only then does it write and commit each in turn. What can still stop it * half-way is I/O or git — a write or a commit that fails after the editor's * went through — and the outcome then says what was already done. */ export async function cutReleases( opts: Omit & { workspace: ReleaseTarget }, ): Promise { if (opts.workspace !== "all") { const result = await cutReleaseForWorkspace({ ...opts, workspace: opts.workspace }); return { ok: result.ok, version: result.ok ? result.version : null, results: [result], notAttempted: [], untouched: nothingWritten([result]), }; } const workspaces: ReleaseWorkspace[] = [...RELEASE_WORKSPACES]; const repo = releaseRepo(opts.root); const date = opts.date ?? todayISO(); const refused = ( workspace: ReleaseWorkspace, error: string, version: string | null, ): CutReleasesOutcome => ({ ok: false, version, results: [{ ok: false, workspace, error }], notAttempted: workspaces.filter((w) => w !== workspace), untouched: true, }); // --- the preflight: nothing below writes until every check has passed --- const requested = opts.version.trim(); if (!requested) return refused("editor", "Version is required.", null); const sources = {} as Record; for (const ws of workspaces) { const read = await readChangelog(repo.changelogs[ws]); if (!read.ok) return refused(ws, read.error, null); sources[ws] = read.source; } let version = requested; if (isVersionKeyword(requested)) { let highest: string | null = null; for (const ws of workspaces) { const latest = getLatestReleasedVersion(sources[ws]); if (compareVersions(latest, highest) > 0) highest = latest; } version = resolveVersion(requested, highest); } const plans: PlannedCut[] = []; for (const ws of workspaces) { const plan = planCut(repo, ws, sources[ws], version, date); if (!plan.ok) return refused(ws, plan.error, version); plans.push(plan.cut); } if (opts.commit) { // Not about either changelog, so it is reported against the first commit // it blocks. const problem = await dirtyTreeProblem(repo, plans.map((c) => c.relPath)); if (problem) return refused(workspaces[0], problem, version); } // --- the writes, in order --- const done: CutReleaseResult[] = []; for (const cut of plans) { const result = await applyCut(repo, cut, opts.commit); if (!result.ok) { const results = [...done, result]; return { ok: false, version, results, notAttempted: workspaces.slice(done.length + 1), untouched: nothingWritten(results), }; } done.push(result); } return { ok: true, version, results: done, notAttempted: [], untouched: false }; } /** * The one sentence for a cut that did not fully happen — the ops route's * top-level `error` (release 11 slice O3, release 10 review L4). The failing * workspace's refusal (prefixed with its name for `all`), then whatever was * already cut before it: an `all` stopped half-way by I/O or git has cut and * possibly committed the editor, and a caller that reads only `error` must not * be told only about the export. */ export function describeCutFailure( outcome: CutReleasesOutcome, target: ReleaseTarget, ): string { const failure = outcome.results.find((r) => !r.ok); if (!failure || failure.ok) return "cut failed"; const head = target === "all" ? `${failure.workspace}: ${failure.error}` : failure.error; const done = outcome.results.flatMap((r) => r.ok ? [ `${r.workspace} was already cut (${r.heading}, ${ r.committed ? `committed${r.commitSha ? ` ${r.commitSha.slice(0, 8)}` : ""}` : "not committed" })`, ] : [], ); if (done.length === 0) return head; return `${head.replace(/\.$/, "")}. Before it, ${done.join("; ")}.`; } export type ReleaseSummary = | { ok: true; workspace: ReleaseWorkspace; file: string; latest: { version: string; date: string | null } | null; hasUnreleased: boolean; // Top-level bullets under [Unreleased]. pending: number; next: string; nextMinor: string; } | { ok: false; workspace: ReleaseWorkspace; file: string; error: string }; /** What `archilyzer release show` prints: read-only. */ export async function describeRelease( workspace: ReleaseWorkspace, root?: string, ): Promise { const file = releaseRepo(root).changelogs[workspace]; const read = await readChangelog(file); if (!read.ok) return { ok: false, workspace, file, error: read.error }; const latest = getLatestRelease(read.source); const latestVersion = latest?.version ?? null; return { ok: true, workspace, file, latest, hasUnreleased: hasUnreleasedHeading(read.source), pending: countUnreleasedBullets(read.source), next: suggestNextVersion(latestVersion), nextMinor: suggestNextMinorVersion(latestVersion), }; }