import { test } from "node:test"; import assert from "node:assert/strict"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { execa } from "execa"; import { cutReleaseForWorkspace, cutReleases, describeCutFailure, describeRelease, resolveVersion, todayISO, versionSpecProblem, } from "./cutRelease"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/cutRelease.test.ts // // Every test builds its OWN git repo in a temp dir and passes it as `root`, so // nothing here can read or commit the real one. The git variables a hook or a // wrapper might export would point git at another repo regardless of cwd, so // they are cleared first. for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"]) { delete process.env[key]; } const DATE = "2026-09-26"; function changelog(opts: { pending?: string[]; latest?: string; date?: string }): string { const lines = ["# Changelog", ""]; if (opts.pending) { lines.push("## [Unreleased]", ...opts.pending, ""); } if (opts.latest) { lines.push(`## [${opts.latest}] - ${opts.date ?? "2026-09-01"}`, "- an old bullet", ""); } return lines.join("\n"); } async function git(root: string, ...args: string[]): Promise { const { stdout } = await execa("git", args, { cwd: root }); return String(stdout).trim(); } async function withRepo( files: { editor?: string; export?: string; other?: string }, fn: (root: string) => Promise, ): Promise { const root = await mkdtemp(path.join(tmpdir(), "ttb-cut-release-")); try { await git(root, "init", "-q", "-b", "main"); await git(root, "config", "user.name", "cut-release test"); await git(root, "config", "user.email", "cut-release@example.invalid"); await git(root, "config", "commit.gpgsign", "false"); await mkdir(path.join(root, "editor")); await mkdir(path.join(root, "export")); if (files.editor !== undefined) { await writeFile(path.join(root, "editor", "CHANGELOG.md"), files.editor); } if (files.export !== undefined) { await writeFile(path.join(root, "export", "CHANGELOG.md"), files.export); } await writeFile(path.join(root, "README.md"), files.other ?? "readme\n"); await git(root, "add", "-A"); await git(root, "commit", "-q", "-m", "init"); await fn(root); } finally { await rm(root, { recursive: true, force: true }); } } const read = (root: string, ws: "editor" | "export") => readFile(path.join(root, ws, "CHANGELOG.md"), "utf8"); const subjects = async (root: string) => (await git(root, "log", "--format=%s")).split("\n"); test("a cut rewrites the heading and commits only that changelog, as Release ", async () => { await withRepo( { editor: changelog({ pending: ["- a new thing"], latest: "0.9.0" }) }, async (root) => { const result = await cutReleaseForWorkspace({ workspace: "editor", version: "0.10.0", commit: true, date: DATE, root, }); assert.equal(result.ok, true); if (!result.ok) return; assert.equal(result.heading, `## [0.10.0] - ${DATE}`); assert.equal(result.version, "0.10.0"); assert.equal(result.committed, true); assert.equal(result.commitSha, await git(root, "rev-parse", "HEAD")); assert.deepEqual(await subjects(root), ["Release editor 0.10.0", "init"]); assert.equal( await git(root, "show", "--name-only", "--format=", "HEAD"), "editor/CHANGELOG.md", ); const after = await read(root, "editor"); assert.match(after, /^## \[0\.10\.0\] - 2026-09-26\n- a new thing$/m); assert.doesNotMatch(after, /\[Unreleased\]/); assert.equal(await git(root, "status", "--porcelain"), ""); }, ); }); test("next is the patch bump of the latest heading, next-minor the minor bump", async () => { await withRepo( { editor: changelog({ pending: ["- x"], latest: "0.9.0" }), export: changelog({ pending: ["- y"], latest: "0.8.7" }), }, async (root) => { const patch = await cutReleaseForWorkspace({ workspace: "editor", version: "next", commit: false, date: DATE, root, }); assert.deepEqual(patch, { ok: true, workspace: "editor", version: "0.9.1", heading: `## [0.9.1] - ${DATE}`, committed: false, }); const minor = await cutReleaseForWorkspace({ workspace: "export", version: "next-minor", commit: false, date: DATE, root, }); assert.equal(minor.ok && minor.version, "0.9.0"); assert.match(await read(root, "export"), /^## \[0\.9\.0\] - 2026-09-26$/m); // Not committed: both files are left modified, and nothing new is in the log. assert.deepEqual(await subjects(root), ["init"]); }, ); }); test("nothing pending under [Unreleased] is refused and the file is untouched", async () => { const source = changelog({ pending: [], latest: "0.9.0" }); await withRepo({ editor: source }, async (root) => { const result = await cutReleaseForWorkspace({ workspace: "editor", version: "next", commit: true, date: DATE, root, }); assert.deepEqual(result, { ok: false, workspace: "editor", error: "Nothing pending to release under [Unreleased].", }); assert.equal(await read(root, "editor"), source); assert.deepEqual(await subjects(root), ["init"]); }); }); test("a commit over any other dirty file is refused, naming it, before anything is written", async () => { const source = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo({ editor: source }, async (root) => { await writeFile(path.join(root, "README.md"), "edited\n"); const result = await cutReleaseForWorkspace({ workspace: "editor", version: "0.9.1", commit: true, date: DATE, root, }); assert.equal(result.ok, false); assert.match( !result.ok ? result.error : "", /^Other uncommitted changes present \(README\.md\)\. Commit or stash them before cutting a release\.$/, ); assert.equal(await read(root, "editor"), source); // Without --commit the same tree is fine: nothing will be committed. const uncommitted = await cutReleaseForWorkspace({ workspace: "editor", version: "0.9.1", commit: false, date: DATE, root, }); assert.equal(uncommitted.ok, true); }); }); test("a dirty changelog is folded into its own release commit", async () => { await withRepo( { editor: changelog({ pending: ["- committed bullet"], latest: "0.9.0" }) }, async (root) => { await writeFile( path.join(root, "editor", "CHANGELOG.md"), changelog({ pending: ["- committed bullet", "- uncommitted bullet"], latest: "0.9.0" }), ); const result = await cutReleaseForWorkspace({ workspace: "editor", version: "next", commit: true, date: DATE, root, }); assert.equal(result.ok, true); assert.match(await git(root, "show", "HEAD:editor/CHANGELOG.md"), /- uncommitted bullet/); assert.equal(await git(root, "status", "--porcelain"), ""); }, ); }); test("all cuts both with ONE version (the higher latest, bumped) and two commits, both changelogs dirty", async () => { await withRepo( { editor: changelog({ pending: ["- e"], latest: "0.8.7" }), export: changelog({ pending: ["- x"], latest: "0.9.0" }), }, async (root) => { // Uncommitted bullets in BOTH files: neither is "another" dirty file to // the other's commit. await writeFile( path.join(root, "editor", "CHANGELOG.md"), changelog({ pending: ["- e", "- e2"], latest: "0.8.7" }), ); await writeFile( path.join(root, "export", "CHANGELOG.md"), changelog({ pending: ["- x", "- x2"], latest: "0.9.0" }), ); const outcome = await cutReleases({ workspace: "all", version: "next", commit: true, date: DATE, root, }); assert.equal(outcome.ok, true); assert.equal(outcome.version, "0.9.1"); assert.deepEqual( outcome.results.map((r) => [r.workspace, r.ok && r.heading, r.ok && r.committed]), [ ["editor", `## [0.9.1] - ${DATE}`, true], ["export", `## [0.9.1] - ${DATE}`, true], ], ); assert.deepEqual(outcome.notAttempted, []); assert.deepEqual(await subjects(root), [ "Release export 0.9.1", "Release editor 0.9.1", "init", ]); const [editorSha, exportSha] = outcome.results.map((r) => (r.ok ? r.commitSha : "")); assert.equal(exportSha, await git(root, "rev-parse", "HEAD")); assert.equal(editorSha, await git(root, "rev-parse", "HEAD~1")); assert.equal(await git(root, "status", "--porcelain"), ""); }, ); }); test("all refuses BEFORE writing when the export cannot be cut: the editor is byte-identical, no commit", async () => { const editorSource = changelog({ pending: ["- e"], latest: "0.9.0" }); const exportSource = changelog({ pending: [], latest: "0.9.0" }); await withRepo({ editor: editorSource, export: exportSource }, async (root) => { const outcome = await cutReleases({ workspace: "all", version: "next", commit: true, date: DATE, root, }); assert.deepEqual(outcome, { ok: false, version: "0.9.1", results: [ { ok: false, workspace: "export", error: "Nothing pending to release under [Unreleased].", }, ], notAttempted: ["editor"], untouched: true, }); assert.equal(await read(root, "editor"), editorSource); assert.equal(await read(root, "export"), exportSource); assert.deepEqual(await subjects(root), ["init"]); assert.equal(await git(root, "status", "--porcelain"), ""); }); }); test("all refuses BEFORE writing when the editor cannot be cut (the mirror case)", async () => { const editorSource = changelog({ latest: "0.9.0" }); const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo({ editor: editorSource, export: exportSource }, async (root) => { const outcome = await cutReleases({ workspace: "all", version: "0.9.1", commit: true, date: DATE, root, }); assert.equal(outcome.ok, false); assert.deepEqual(outcome.results, [ { ok: false, workspace: "editor", error: "Could not find a `## [Unreleased]` heading to cut from.", }, ]); assert.deepEqual(outcome.notAttempted, ["export"]); assert.equal(outcome.untouched, true); assert.equal(await read(root, "editor"), editorSource); assert.equal(await read(root, "export"), exportSource); assert.deepEqual(await subjects(root), ["init"]); }); }); test("all runs the dirty-tree guard once, before either write, and names the other file", async () => { const editorSource = changelog({ pending: ["- e"], latest: "0.9.0" }); const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo({ editor: editorSource, export: exportSource }, async (root) => { await writeFile(path.join(root, "README.md"), "edited\n"); const outcome = await cutReleases({ workspace: "all", version: "next", commit: true, date: DATE, root, }); assert.equal(outcome.ok, false); assert.equal(outcome.untouched, true); assert.match( !outcome.results[0].ok ? outcome.results[0].error : "", /^Other uncommitted changes present \(README\.md\)\./, ); assert.equal(await read(root, "editor"), editorSource); assert.equal(await read(root, "export"), exportSource); assert.deepEqual(await subjects(root), ["init"]); }); }); // Past the preflight only I/O and git can fail. An export directory that // cannot be written stands in for that: the editor is already cut and // committed, and the outcome says so. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; test( "all stopped by a failed write after the preflight reports what it already did", { skip: isRoot ? "root ignores directory permissions" : false }, async () => { const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo( { editor: changelog({ pending: ["- e"], latest: "0.9.0" }), export: exportSource }, async (root) => { const exportDir = path.join(root, "export"); await chmod(exportDir, 0o555); const outcome = await cutReleases({ workspace: "all", version: "next", commit: true, date: DATE, root, }).finally(() => chmod(exportDir, 0o755)); assert.equal(outcome.ok, false); // Something WAS written (release 11 slice O3; it used to be absent). assert.equal(outcome.untouched, false); const [first, second] = outcome.results; assert.equal(first.ok && first.committed, true); assert.equal(second.workspace, "export"); assert.match(!second.ok ? second.error : "", /^Could not write .*export\/CHANGELOG\.md: EACCES/); assert.equal(!second.ok && second.written, undefined); assert.deepEqual(outcome.notAttempted, []); // The one sentence names the failure AND the cut before it (review L4: // the route's `error` used to name only the export). const editorSha = first.ok ? (first.commitSha ?? "").slice(0, 8) : ""; assert.match( describeCutFailure(outcome, "all"), new RegExp( `^export: Could not write .*export/CHANGELOG\\.md: EACCES[^\\n]*\\. ` + `Before it, editor was already cut \\(## \\[0\\.9\\.1\\] - ${DATE}, committed ${editorSha}\\)\\.$`, ), ); assert.deepEqual(await subjects(root), ["Release editor 0.9.1", "init"]); assert.equal(await read(root, "export"), exportSource); }, ); }, ); test("a literal version must move forward from the latest heading; the refusal names both", async () => { const source = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo({ editor: source }, async (root) => { for (const version of ["0.9.0", "0.8.9", "0.9.0-rc.1"]) { const result = await cutReleaseForWorkspace({ workspace: "editor", version, commit: true, date: DATE, root, }); assert.deepEqual(result, { ok: false, workspace: "editor", error: `Version ${version} is not newer than the latest release, 0.9.0.`, }); } assert.equal(await read(root, "editor"), source); assert.deepEqual(await subjects(root), ["init"]); const pre = await cutReleaseForWorkspace({ workspace: "editor", version: "1.0.0-rc.1", commit: false, date: DATE, root, }); assert.equal(pre.ok && pre.heading, `## [1.0.0-rc.1] - ${DATE}`); }); }); test("--date is used verbatim, and a malformed or impossible one is refused", async () => { await withRepo( { editor: changelog({ pending: ["- x"], latest: "0.9.0" }) }, async (root) => { const bad = await cutReleaseForWorkspace({ workspace: "editor", version: "0.9.1", commit: false, date: "26/09/2026", root, }); assert.deepEqual(bad, { ok: false, workspace: "editor", error: 'Date "26/09/2026" is not in YYYY-MM-DD form.', }); const impossible = await cutReleaseForWorkspace({ workspace: "editor", version: "0.9.1", commit: false, date: "2026-13-45", root, }); assert.deepEqual(impossible, { ok: false, workspace: "editor", error: 'Date "2026-13-45" is not a calendar date.', }); const good = await cutReleaseForWorkspace({ workspace: "editor", version: "0.9.1", commit: false, date: "2025-01-02", root, }); assert.equal(good.ok && good.heading, "## [0.9.1] - 2025-01-02"); }, ); }); test("a single-workspace cut with no date stamps today, in local time", async () => { await withRepo( { export: changelog({ pending: ["- x"], latest: "0.9.0" }) }, async (root) => { const result = await cutReleaseForWorkspace({ workspace: "export", version: "0.9.1", commit: false, root, }); assert.equal(result.ok && result.heading, `## [0.9.1] - ${todayISO()}`); }, ); }); test("describeRelease reads the latest heading, its date and the pending bullets", async () => { await withRepo( { editor: changelog({ pending: ["- a", "- b", "- c"], latest: "0.9.0", date: "2026-09-25" }), export: changelog({ latest: "0.8.7" }), }, async (root) => { assert.deepEqual(await describeRelease("editor", root), { ok: true, workspace: "editor", file: path.join(root, "editor", "CHANGELOG.md"), latest: { version: "0.9.0", date: "2026-09-25" }, hasUnreleased: true, pending: 3, next: "0.9.1", nextMinor: "0.10.0", }); const exp = await describeRelease("export", root); assert.equal(exp.ok && exp.hasUnreleased, false); assert.equal(exp.ok && exp.pending, 0); await rm(path.join(root, "export", "CHANGELOG.md")); const missing = await describeRelease("export", root); assert.equal(missing.ok, false); assert.match(!missing.ok ? missing.error : "", /^Could not read /); }, ); }); test("versionSpecProblem passes a keyword or semver and names anything else", () => { for (const ok of ["next", "next-minor", "0.10.0", "1.0.0-rc.1", " 0.9.1 "]) { assert.equal(versionSpecProblem(ok), null, ok); } assert.equal(versionSpecProblem(" "), "Version is required."); assert.match(versionSpecProblem("v0.9.1")!, /"v0\.9\.1" is not a valid semver .* "next" or "next-minor"\./); assert.match(versionSpecProblem("next-major")!, /not a valid semver/); }); test("resolveVersion maps the keywords and keeps a literal", () => { assert.equal(resolveVersion("next", "0.9.0"), "0.9.1"); assert.equal(resolveVersion("next-minor", "0.9.3"), "0.10.0"); assert.equal(resolveVersion("next", null), "0.1.0"); assert.equal(resolveVersion("next-minor", null), "0.1.0"); assert.equal(resolveVersion(" 2.0.0 ", "0.9.0"), "2.0.0"); }); // A COMMIT THAT FAILS AFTER THE WRITE (release 11 slice O3, release 10 slice P // review L3). The changelog on disk has its new heading; the result used to be // a bare { ok: false } that nothing could tell from a refusal, so neither the // form nor the route revalidated the pages that render it. A pre-commit hook // that refuses stands in for any git failure past the guard. async function refuseCommits(root: string): Promise { const hooks = path.join(root, ".git", "hooks"); await mkdir(hooks, { recursive: true }); await writeFile( path.join(hooks, "pre-commit"), "#!/bin/sh\necho 'hook says no' >&2\nexit 1\n", ); await chmod(path.join(hooks, "pre-commit"), 0o755); // Local config beats a global core.hooksPath, which would skip .git/hooks. await git(root, "config", "core.hooksPath", hooks); } test("a commit that fails after the write says the file was written (written: true, not untouched)", async () => { const source = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo({ editor: source }, async (root) => { await refuseCommits(root); const result = await cutReleaseForWorkspace({ workspace: "editor", version: "next", commit: true, date: DATE, root, }); assert.equal(result.ok, false); assert.equal(!result.ok && result.written, true); assert.match( !result.ok ? result.error : "", /^Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/, ); assert.match(await read(root, "editor"), new RegExp(`## \\[0\\.9\\.1\\] - ${DATE}\\n- x`)); assert.deepEqual(await subjects(root), ["init"]); }); // Through cutReleases, a single workspace: not untouched, and the sentence // is the writer's. await withRepo({ editor: source }, async (root) => { await refuseCommits(root); const outcome = await cutReleases({ workspace: "editor", version: "next", commit: true, date: DATE, root, }); assert.equal(outcome.ok, false); assert.equal(outcome.untouched, false); assert.match( describeCutFailure(outcome, "editor"), /^Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/, ); }); }); test("all whose first commit fails after its write: stopped, not untouched, the export not tried", async () => { const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo( { editor: changelog({ pending: ["- e"], latest: "0.9.0" }), export: exportSource }, async (root) => { await refuseCommits(root); const outcome = await cutReleases({ workspace: "all", version: "next", commit: true, date: DATE, root, }); assert.equal(outcome.ok, false); assert.equal(outcome.untouched, false); assert.deepEqual(outcome.notAttempted, ["export"]); assert.equal(outcome.results.length, 1); const [editor] = outcome.results; assert.equal(editor.workspace, "editor"); assert.equal(!editor.ok && editor.written, true); assert.match( describeCutFailure(outcome, "all"), /^editor: Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/, ); assert.match(await read(root, "editor"), new RegExp(`## \\[0\\.9\\.1\\] - ${DATE}`)); assert.equal(await read(root, "export"), exportSource); assert.deepEqual(await subjects(root), ["init"]); }, ); }); test("untouched is true for every refusal and false for a clean cut; a refusal's sentence is the writer's", async () => { await withRepo({ editor: changelog({ pending: [], latest: "0.9.0" }) }, async (root) => { const refused = await cutReleases({ workspace: "editor", version: "next", commit: false, date: DATE, root, }); assert.equal(refused.untouched, true); assert.equal( describeCutFailure(refused, "editor"), "Nothing pending to release under [Unreleased].", ); }); await withRepo({ editor: changelog({ pending: ["- x"], latest: "0.9.0" }) }, async (root) => { const cut = await cutReleases({ workspace: "editor", version: "next", commit: false, date: DATE, root, }); assert.equal(cut.ok, true); assert.equal(cut.untouched, false); }); }); test( "all whose FIRST write fails wrote nothing: untouched, the export not tried", { skip: isRoot ? "root ignores directory permissions" : false }, async () => { const editorSource = changelog({ pending: ["- e"], latest: "0.9.0" }); const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); await withRepo({ editor: editorSource, export: exportSource }, async (root) => { const editorDir = path.join(root, "editor"); await chmod(editorDir, 0o555); const outcome = await cutReleases({ workspace: "all", version: "next", commit: false, date: DATE, root, }).finally(() => chmod(editorDir, 0o755)); assert.equal(outcome.ok, false); assert.equal(outcome.untouched, true); assert.deepEqual(outcome.notAttempted, ["export"]); assert.match(describeCutFailure(outcome, "all"), /^editor: Could not write .*: EACCES/); assert.doesNotMatch(describeCutFailure(outcome, "all"), /Before it/); assert.equal(await read(root, "editor"), editorSource); assert.equal(await read(root, "export"), exportSource); }); }, );