Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Acommon/controller/cutRelease.test.ts | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/cutRelease.ts | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/changelog.test.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/changelog.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/git.ts | 10++++++++++
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; +}