commit ad302e7b45bc7915afa7f469da5b87731c2094f4
parent 25f89819dd021ccc071f58f336a006d79d6fb419
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 19:00:24 -0400
common: cutRelease controller — the one writer of a release cut (guard, read, cut, atomic write, commit); next / next-minor; all = editor then export with one version, two commits, stops at the first failure; describeRelease for `release show`
`cutReleaseForWorkspace` is the server action's body moved to common with its
sentences unchanged; `cutReleases` adds `all` (a keyword resolves against the
higher of the two latest headings, and both changelogs may be dirty). A failed
write is now a result, not a throw, so `all` can say what it already did.
changelog.ts gains suggestNextMinorVersion, compareVersionCores,
getLatestRelease, hasUnreleasedHeading, countUnreleasedBullets; git.ts headSha.
cutRelease.test.ts (12) builds its own git repo per test and passes it as root;
changelog.test.ts (4).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 878 insertions(+), 0 deletions(-)
diff --git a/common/controller/cutRelease.test.ts b/common/controller/cutRelease.test.ts
@@ -0,0 +1,398 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { 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,
+ 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<string> {
+ 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<void>,
+): Promise<void> {
+ 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 <workspace> <version>", 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 stops at the first failure and reports what was already done", async () => {
+ const exportSource = changelog({ pending: [], latest: "0.9.0" });
+ await withRepo(
+ { editor: changelog({ pending: ["- e"], latest: "0.9.0" }), 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.equal(outcome.results.length, 2);
+ const [first, second] = outcome.results;
+ assert.equal(first.ok && first.committed, true);
+ assert.equal(first.workspace, "editor");
+ assert.deepEqual(second, {
+ ok: false,
+ workspace: "export",
+ error: "Nothing pending to release under [Unreleased].",
+ });
+ assert.deepEqual(outcome.notAttempted, []);
+ assert.deepEqual(await subjects(root), ["Release editor 0.9.1", "init"]);
+ assert.equal(await read(root, "export"), exportSource);
+ },
+ );
+ // A failure on the FIRST workspace never reaches the second.
+ const exportPending = changelog({ pending: ["- x"], latest: "0.9.0" });
+ await withRepo(
+ { editor: changelog({ pending: [], latest: "0.9.0" }), export: exportPending },
+ async (root) => {
+ const outcome = await cutReleases({
+ workspace: "all",
+ version: "next",
+ commit: false,
+ date: DATE,
+ root,
+ });
+ assert.equal(outcome.ok, false);
+ assert.deepEqual(
+ outcome.results.map((r) => [r.workspace, r.ok]),
+ [["editor", false]],
+ );
+ assert.deepEqual(outcome.notAttempted, ["export"]);
+ assert.equal(await read(root, "export"), exportPending);
+ },
+ );
+});
+
+test("--date is used verbatim, and a malformed 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 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");
+});
diff --git a/common/controller/cutRelease.ts b/common/controller/cutRelease.ts
@@ -0,0 +1,347 @@
+import fs from "node:fs";
+import path from "node:path";
+import { getPaths } from "../lib/paths";
+import {
+ compareVersionCores,
+ countUnreleasedBullets,
+ cutRelease,
+ CutReleaseError,
+ 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 `## [<version>] - <date>` in editor/CHANGELOG.md or
+// export/CHANGELOG.md, optionally followed by a path-limited commit of that one
+// file, `Release <workspace> <version>`. 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.-]+)?$/;
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
+
+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. */
+export function dateProblem(date: string): string | null {
+ return ISO_DATE.test(date) ? null : `Date "${date}" is not in YYYY-MM-DD form.`;
+}
+
+/** 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<ReleaseWorkspace, string>;
+};
+
+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 };
+
+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}` };
+ }
+}
+
+// One workspace's cut: the guard, the read, the cut, the atomic write, the
+// commit — the order the server action always had. `mayBeDirty` is every
+// changelog this run will commit: a dirty changelog is fine (its uncommitted
+// [Unreleased] bullets are folded into the release commit), anything else
+// dirty refuses a commit BEFORE anything is written.
+async function cutOne(
+ repo: ReleaseRepo,
+ workspace: ReleaseWorkspace,
+ version: string,
+ commit: boolean,
+ date: string,
+ mayBeDirty: string[],
+): Promise<CutReleaseResult> {
+ const fail = (error: string): CutReleaseResult => ({ ok: false, workspace, error });
+ const filePath = repo.changelogs[workspace];
+ const relPath = path.relative(repo.root, filePath);
+ if (commit) {
+ let dirty: string[];
+ try {
+ dirty = await listDirtyPaths(repo.root);
+ } catch (err) {
+ return fail(`Could not check git status: ${(err as Error).message}`);
+ }
+ const others = dirty.filter((p) => p !== relPath && !mayBeDirty.includes(p));
+ if (others.length > 0) {
+ return fail(
+ `Other uncommitted changes present (${others.join(
+ ", ",
+ )}). Commit or stash them before cutting a release.`,
+ );
+ }
+ }
+ const read = await readChangelog(filePath);
+ if (!read.ok) return fail(read.error);
+ const resolved = resolveVersion(version, getLatestReleasedVersion(read.source));
+ let next: string;
+ try {
+ next = cutRelease(read.source, resolved, date);
+ } catch (err) {
+ if (err instanceof CutReleaseError) return fail(err.message);
+ throw err;
+ }
+ try {
+ await writeFileAtomic(filePath, next);
+ } catch (err) {
+ return fail(`Could not write ${filePath}: ${(err as Error).message}`);
+ }
+ const heading = `## [${resolved}] - ${date}`;
+ if (!commit) {
+ return { ok: true, workspace, version: resolved, heading, committed: false };
+ }
+ const result = await commitPath(
+ repo.root,
+ relPath,
+ `Release ${workspace} ${resolved}`,
+ );
+ if (!result.ok) {
+ return fail(`Cut release ${resolved}, but the commit failed: ${result.error}`);
+ }
+ const sha = await headSha(repo.root);
+ return {
+ ok: true,
+ workspace,
+ version: resolved,
+ heading,
+ committed: true,
+ ...(sha ? { commitSha: sha } : {}),
+ };
+}
+
+/** Cut one workspace's changelog. What the form does. */
+export async function cutReleaseForWorkspace(
+ opts: CutReleaseOptions,
+): Promise<CutReleaseResult> {
+ const { workspace } = opts;
+ const version = opts.version.trim();
+ if (!version) return { ok: false, workspace, error: "Version is required." };
+ const date = opts.date ?? todayISO();
+ const repo = releaseRepo(opts.root);
+ const relPath = path.relative(repo.root, repo.changelogs[workspace]);
+ return cutOne(repo, workspace, version, opts.commit, date, [relPath]);
+}
+
+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[];
+};
+
+/**
+ * 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 <workspace> <version>` commit. Stops at the first failure and
+ * reports what was already done.
+ */
+export async function cutReleases(
+ opts: Omit<CutReleaseOptions, "workspace"> & { workspace: ReleaseTarget },
+): Promise<CutReleasesOutcome> {
+ 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: [],
+ };
+ }
+ const workspaces: ReleaseWorkspace[] = [...RELEASE_WORKSPACES];
+ const repo = releaseRepo(opts.root);
+ const date = opts.date ?? todayISO();
+ const stopped = (
+ result: CutReleaseResult,
+ done: CutReleaseResult[],
+ version: string | null,
+ ): CutReleasesOutcome => {
+ const results = [...done, result];
+ return {
+ ok: false,
+ version,
+ results,
+ notAttempted: workspaces.slice(results.length),
+ };
+ };
+ let version = opts.version.trim();
+ if (!version) {
+ return stopped({ ok: false, workspace: "editor", error: "Version is required." }, [], null);
+ }
+ if (isVersionKeyword(version)) {
+ let highest: string | null = null;
+ for (const ws of workspaces) {
+ const read = await readChangelog(repo.changelogs[ws]);
+ if (!read.ok) {
+ // Nothing was cut: the read failed before either write.
+ return {
+ ok: false,
+ version: null,
+ results: [{ ok: false, workspace: ws, error: read.error }],
+ notAttempted: workspaces.filter((w) => w !== ws),
+ };
+ }
+ const latest = getLatestReleasedVersion(read.source);
+ if (compareVersionCores(latest, highest) > 0) highest = latest;
+ }
+ version = resolveVersion(version, highest);
+ }
+ const mayBeDirty = workspaces.map((ws) =>
+ path.relative(repo.root, repo.changelogs[ws]),
+ );
+ const done: CutReleaseResult[] = [];
+ for (const ws of workspaces) {
+ const result = await cutOne(repo, ws, version, opts.commit, date, mayBeDirty);
+ if (!result.ok) return stopped(result, done, version);
+ done.push(result);
+ }
+ return { ok: true, version, results: done, notAttempted: [] };
+}
+
+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<ReleaseSummary> {
+ 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),
+ };
+}
diff --git a/common/lib/changelog.test.ts b/common/lib/changelog.test.ts
@@ -0,0 +1,64 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ compareVersionCores,
+ countUnreleasedBullets,
+ getLatestRelease,
+ hasUnreleasedHeading,
+ suggestNextMinorVersion,
+ suggestNextVersion,
+} from "./changelog";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test lib/changelog.test.ts
+
+const SOURCE = [
+ "# Changelog",
+ "",
+ "## [Unreleased]",
+ "- **One.** A bullet that runs long, on one line as this repo writes them.",
+ "- **Two.**",
+ " - a nested item is not a change of its own",
+ "* **Three.**",
+ "",
+ "## [0.9.0] - 2026-09-26",
+ "- released",
+ "",
+ "## [0.8.7] - 2026-08-12",
+ "- older",
+].join("\n");
+
+test("suggestNextMinorVersion bumps the minor and zeroes the patch", () => {
+ assert.equal(suggestNextMinorVersion("0.9.3"), "0.10.0");
+ assert.equal(suggestNextMinorVersion("1.2.0-rc.1"), "1.3.0");
+ assert.equal(suggestNextMinorVersion(null), "0.1.0");
+ assert.equal(suggestNextMinorVersion("garbage"), "0.1.0");
+ // The patch bump is unchanged.
+ assert.equal(suggestNextVersion("0.9.0"), "0.9.1");
+});
+
+test("compareVersionCores orders by X.Y.Z, numerically", () => {
+ assert.ok(compareVersionCores("0.10.0", "0.9.9") > 0);
+ assert.ok(compareVersionCores("0.8.7", "0.9.0") < 0);
+ assert.equal(compareVersionCores("1.0.0-rc.1", "1.0.0"), 0);
+ assert.ok(compareVersionCores("0.1.0", null) > 0);
+ assert.ok(compareVersionCores(null, "0.1.0") < 0);
+ assert.equal(compareVersionCores(null, null), 0);
+});
+
+test("getLatestRelease reads the newest released heading and its date", () => {
+ assert.deepEqual(getLatestRelease(SOURCE), { version: "0.9.0", date: "2026-09-26" });
+ assert.deepEqual(getLatestRelease("## [1.0.0]\n- undated\n"), {
+ version: "1.0.0",
+ date: null,
+ });
+ assert.equal(getLatestRelease("# Changelog\n\n## [Unreleased]\n- x\n"), null);
+});
+
+test("countUnreleasedBullets counts the top-level bullets under [Unreleased] only", () => {
+ assert.equal(countUnreleasedBullets(SOURCE), 3);
+ assert.equal(countUnreleasedBullets("## [Unreleased]\n\n## [0.1.0] - 2026-01-01\n- x\n"), 0);
+ assert.equal(countUnreleasedBullets("## [0.1.0] - 2026-01-01\n- x\n"), 0);
+ assert.equal(hasUnreleasedHeading(SOURCE), true);
+ assert.equal(hasUnreleasedHeading("## [0.1.0] - 2026-01-01\n- x\n"), false);
+});
diff --git a/common/lib/changelog.ts b/common/lib/changelog.ts
@@ -37,6 +37,65 @@ export function suggestNextVersion(current: string | null): string {
return `${major}.${minor}.${patch + 1}`;
}
+/**
+ * The minor bump: `0.9.3` → `0.10.0`. No release yet (or an unreadable
+ * version) is `0.1.0`, as `suggestNextVersion` has it.
+ */
+export function suggestNextMinorVersion(current: string | null): string {
+ const core = versionCore(current);
+ if (!core) return "0.1.0";
+ const [major, minor] = core;
+ return `${major}.${minor + 1}.0`;
+}
+
+// [major, minor, patch] of a released version, prerelease dropped — or null.
+function versionCore(v: string | null): [number, number, number] | null {
+ if (!v) return null;
+ const parts = v.split("-")[0].split(".").map((p) => Number.parseInt(p, 10));
+ if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
+ return [parts[0], parts[1], parts[2]];
+}
+
+/**
+ * Orders two released versions by their X.Y.Z cores (a prerelease suffix is
+ * ignored; an unreadable version sorts first). Negative, zero or positive.
+ */
+export function compareVersionCores(a: string | null, b: string | null): number {
+ const ca = versionCore(a);
+ const cb = versionCore(b);
+ if (!ca || !cb) return (ca ? 1 : 0) - (cb ? 1 : 0);
+ for (let i = 0; i < 3; i++) {
+ if (ca[i] !== cb[i]) return ca[i] - cb[i];
+ }
+ return 0;
+}
+
+const RELEASED_HEADING_DATED =
+ /^##\s+\[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\](?:\s+-\s+(\d{4}-\d{2}-\d{2}))?/m;
+
+/** The newest released heading's version and its date (null when undated). */
+export function getLatestRelease(
+ source: string,
+): { version: string; date: string | null } | null {
+ const match = source.match(RELEASED_HEADING_DATED);
+ return match ? { version: match[1], date: match[2] ?? null } : null;
+}
+
+export function hasUnreleasedHeading(source: string): boolean {
+ return UNRELEASED_HEADING.test(source);
+}
+
+/**
+ * How many top-level bullets wait under `## [Unreleased]` — one per change, in
+ * this repo's changelogs (a bullet is one long line; nested items are not
+ * counted).
+ */
+export function countUnreleasedBullets(source: string): number {
+ return extractUnreleasedSection(source)
+ .split("\n")
+ .filter((line) => /^[-*+]\s/.test(line)).length;
+}
+
export function extractUnreleasedSection(source: string): string {
const start = source.search(UNRELEASED_HEADING);
if (start === -1) return "";
diff --git a/common/lib/git.ts b/common/lib/git.ts
@@ -49,3 +49,13 @@ export async function commitPath(
`git commit exited with code ${result.exitCode}`;
return { ok: false, error };
}
+
+/** The full sha HEAD points at, or null when it cannot be read. */
+export async function headSha(repoRoot: string): Promise<string | null> {
+ const result = await execa("git", ["rev-parse", "HEAD"], {
+ cwd: repoRoot,
+ reject: false,
+ });
+ const sha = result.exitCode === 0 ? String(result.stdout).trim() : "";
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
+}