Archilyzer · Source

archilyzer

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

commit 2ea82daf8046495381e54ab4c903e48574c8b57c
parent 25f89819dd021ccc071f58f336a006d79d6fb419
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 19:43:08 -0400

Merge cli/cut-release — release 10 slice P: cut a release from the CLI — one writer (common/controller/cutRelease), the /sites form as an adapter, archilyzer release cut|show locally (no editor needed; all cuts both changelogs or neither; next/next-minor; a real calendar date; a version newer than the latest heading), pnpm ops cut-release through /api/ops/cut-release

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
M.gitignore | 6++++--
Mcommon/bin/_cli.test.ts | 195++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/bin/archilyzer.ts | 17+++++++++++++++++
Acommon/bin/release.ts | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/cutRelease.test.ts | 507+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/cutRelease.ts | 414+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/changelog.test.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/changelog.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/git.ts | 10++++++++++
Mcommon/lib/paths.ts | 4+++-
Meditor/CHANGELOG.md | 1+
Aeditor/app/api/ops/cut-release/route.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/lib/cutReleaseAction.ts | 103+++++++++++++------------------------------------------------------------------
Aeditor/app/sites/lib/revalidateAfterReleaseCut.ts | 12++++++++++++
Meditor/app/sites/page.tsx | 6++++--
Aeditor/e2e/ops-cut-release.spec.ts | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/package.json | 4++--
Mplans/release-10.md | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/archilyzer-ops.mjs | 16++++++++++++++++
Mscripts/archilyzer-ops.test.mjs | 18++++++++++++++++++
20 files changed, 2122 insertions(+), 99 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -98,9 +98,11 @@ yarn-error.log* # editor e2e fixtures and ephemeral state /editor/test-transcripts/ /editor/test-settings.json -# Disposable changelog for the cut-release e2e (EDITOR_CHANGELOG_FILE in -# dev:test/start:test). Keeps that spec off the repo's tracked CHANGELOG.md. +# Disposable changelogs for the cut-release e2e (EDITOR_CHANGELOG_FILE and +# EXPORT_CHANGELOG_FILE in dev:test/start:test). Keep those specs — and +# POST /api/ops/cut-release, which can cut both — off the tracked CHANGELOG.md files. /editor/test-changelog.md +/editor/test-export-changelog.md /editor/playwright-report/ /editor/test-results/ /editor/blob-report/ diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -9,12 +9,23 @@ import { usage, type Command, } from "./_cli"; +import { COMMANDS } from "./archilyzer"; +import { + cutMain, + formatAllLine, + formatCutLines, + formatShowLine, + parseCutArgs, + parseShowArgs, + showMain, +} from "./release"; // Run with: // pnpm --filter yt-dlp-transcript-common test // -// The archilyzer CLI's parser and lookup. The command table itself -// (archilyzer.ts) is not imported: these are the rules every row obeys. +// The archilyzer CLI's parser and lookup — the rules every row obeys. The +// command table itself (archilyzer.ts) is imported only at the end, for the +// `release` rows, which the release runbook's step 0 calls by name. function cmd(path: string[], extra: Partial<Command> = {}): Command { return { @@ -118,3 +129,183 @@ test("runCli runs the command with the rest, and refuses with 2 before running", assert.equal(await runCli(table, ["build", "site", "--help"], {}, quiet), 0); assert.equal(seen.length, 2, "no refused call reached run"); }); + +// --- archilyzer release (release 10 slice P) -------------------------------- + +test("release cut and release show are table rows; --commit never swallows a positional", () => { + const cut = resolveCommand(COMMANDS, ["release", "cut", "all", "next"]); + assert.deepEqual(cut?.command.path, ["release", "cut"]); + assert.deepEqual(cut?.rest, ["all", "next"]); + assert.deepEqual(resolveCommand(COMMANDS, ["release", "show"])?.command.path, [ + "release", + "show", + ]); + const parsed = parseArgv( + ["release", "cut", "--commit", "all", "next", "--date", "2026-09-26"], + booleanFlags(COMMANDS), + ); + assert.deepEqual(parsed, { + positionals: ["release", "cut", "all", "next"], + flags: { commit: true, date: "2026-09-26" }, + }); + const command = cut!.command; + assert.equal(argumentProblem(command, parsed.flags, ["all", "next"]), null); + assert.match(argumentProblem(command, { date: true }, [])!, /--date needs a value/); + assert.match(argumentProblem(command, { tag: true }, [])!, /unknown flag --tag \(accepts --commit, --date\)/); + assert.match(argumentProblem(command, {}, ["all", "next", "x"])!, /unexpected argument "x"/); +}); + +test("usage lists release show and release cut with their arguments", () => { + const u = usage(COMMANDS); + assert.match(u, /archilyzer release show\s+\[editor\|export\]/); + assert.match( + u, + /archilyzer release cut\s+<editor\|export\|all> <X\.Y\.Z\|next\|next-minor> \[--commit\] \[--date YYYY-MM-DD\]/, + ); +}); + +test("parseCutArgs checks the changelog, the version and the date before anything is read", () => { + assert.deepEqual(parseCutArgs(["all", "next"], { commit: true }), { + target: "all", + version: "next", + commit: true, + }); + assert.deepEqual(parseCutArgs(["editor", "0.10.0"], { date: "2026-09-25" }), { + target: "editor", + version: "0.10.0", + commit: false, + date: "2026-09-25", + }); + const error = (p: string[], f = {}) => { + const r = parseCutArgs(p, f); + return "error" in r ? r.error : ""; + }; + assert.match(error([]), /which changelog\? editor, export or all/); + assert.match(error(["site", "next"]), /"site" is not editor, export or all/); + assert.match(error(["editor"]), /which version\? X\.Y\.Z, next or next-minor/); + assert.match(error(["editor", "v1"]), /"v1" is not a valid semver/); + assert.match(error(["editor", "next"], { date: "yesterday" }), /"yesterday" is not in YYYY-MM-DD form/); + assert.match(error(["editor", "next"], { date: "2026-13-45" }), /"2026-13-45" is not a calendar date/); + assert.deepEqual(parseShowArgs([]), ["editor", "export"]); + assert.deepEqual(parseShowArgs(["all"]), ["editor", "export"]); + assert.deepEqual(parseShowArgs(["export"]), ["export"]); + assert.match((parseShowArgs(["hub"]) as { error: string }).error, /"hub" is not editor or export/); +}); + +test("a refused release command exits 2 and prints why, touching nothing", async () => { + const errors: string[] = []; + const out = { log: () => {}, error: (s: string) => errors.push(s) }; + const ctx = (positionals: string[]) => ({ positionals, flags: {}, env: {} }); + assert.equal(await cutMain(ctx(["site", "next"]), out), 2); + assert.equal(await showMain(ctx(["hub"]), out), 2); + assert.equal(errors.length, 2); +}); + +test("release cut prints one line per changelog: the heading and the commit, the refusal, what a failure stopped", () => { + assert.deepEqual( + formatCutLines({ + ok: false, + version: "0.10.0", + results: [ + { + ok: true, + workspace: "editor", + version: "0.10.0", + heading: "## [0.10.0] - 2026-09-26", + committed: true, + commitSha: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + }, + { ok: false, workspace: "export", error: "Nothing pending to release under [Unreleased]." }, + ], + notAttempted: [], + }).map((l) => l.line), + [ + "editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)", + "export: failed — Nothing pending to release under [Unreleased].", + ], + ); + assert.deepEqual( + formatCutLines({ + ok: false, + version: "0.9.1", + results: [{ ok: false, workspace: "editor", error: "Could not write x: EACCES" }], + notAttempted: ["export"], + }), + [ + { line: "editor: failed — Could not write x: EACCES", failed: true }, + { line: "export: not cut — stopped at the failure above", failed: true }, + ], + ); + // `all` refused by its preflight: it says nothing was written. + assert.deepEqual( + formatCutLines({ + ok: false, + version: "0.9.1", + results: [ + { ok: false, workspace: "export", error: "Nothing pending to release under [Unreleased]." }, + ], + notAttempted: ["editor"], + untouched: true, + }).map((l) => l.line), + [ + "export: failed — Nothing pending to release under [Unreleased].", + "editor: not cut — all cuts both or neither, and nothing was written", + ], + ); + assert.deepEqual( + formatCutLines({ + ok: true, + version: "0.9.1", + results: [ + { + ok: true, + workspace: "export", + version: "0.9.1", + heading: "## [0.9.1] - 2026-09-26", + committed: false, + }, + ], + notAttempted: [], + }), + [{ line: "export: ## [0.9.1] - 2026-09-26 (not committed)", failed: false }], + ); +}); + +test("release show says the latest release, its date and what is pending, and what all would cut", () => { + const editor = { + ok: true as const, + workspace: "editor" as const, + file: "/r/editor/CHANGELOG.md", + latest: { version: "0.9.0", date: "2026-09-26" }, + hasUnreleased: true, + pending: 8, + next: "0.9.1", + nextMinor: "0.10.0", + }; + const exp = { + ...editor, + workspace: "export" as const, + latest: { version: "0.9.2", date: null }, + pending: 1, + next: "0.9.3", + nextMinor: "0.10.0", + }; + assert.equal( + formatShowLine(editor), + "editor: latest 0.9.0 (2026-09-26); 8 bullets pending under [Unreleased]; next 0.9.1, next-minor 0.10.0", + ); + assert.equal( + formatShowLine(exp), + "export: latest 0.9.2 (undated); 1 bullet pending under [Unreleased]; next 0.9.3, next-minor 0.10.0", + ); + assert.match(formatShowLine({ ...editor, pending: 0 }), /; nothing pending under \[Unreleased\];/); + assert.match(formatShowLine({ ...editor, hasUnreleased: false, pending: 0 }), /; no \[Unreleased\] heading;/); + assert.match(formatShowLine({ ...editor, latest: null }), /^editor: no release yet;/); + assert.equal( + formatShowLine({ ok: false, workspace: "export", file: "/x", error: "Could not read /x: ENOENT" }), + "export: Could not read /x: ENOENT", + ); + // `all` resolves against the HIGHER latest, as cutReleases does. + assert.equal(formatAllLine([editor, exp]), "all: next 0.9.3, next-minor 0.10.0"); + assert.equal(formatAllLine([editor]), null); +}); diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -183,6 +183,23 @@ export const COMMANDS: Command[] = [ run: async ({ flags }) => (await import("./settings-example")).main({ check: flags.check === true }), }, + // Release notes, cut locally: the same writer as the /sites and /changelog + // form and POST /api/ops/cut-release, with no editor running (release.ts). + { + path: ["release", "show"], + usage: + "[editor|export] latest release, its date, how many bullets wait under [Unreleased]", + maxPositionals: 1, + run: async (ctx) => (await import("./release")).showMain(ctx), + }, + { + path: ["release", "cut"], + usage: + "<editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD] [Unreleased] -> a dated heading; all = both, one version, two commits", + flags: { commit: "boolean", date: "string" }, + maxPositionals: 2, + run: async (ctx) => (await import("./release")).cutMain(ctx), + }, ]; // The site a site command names: its argument, else SITE_ID (which is how diff --git a/common/bin/release.ts b/common/bin/release.ts @@ -0,0 +1,171 @@ +// `archilyzer release cut | show` — cut a release from the command line, with +// no editor running (release 10 slice P). The writer is +// common/controller/cutRelease.ts, the same one the "Cut release" form and +// POST /api/ops/cut-release call; this file is argument checking and one +// printed line per changelog. +// +// archilyzer release show [editor|export] +// archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD] +// +// It works on the checkout it is run from (the monorepo root above the cwd; +// EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE are honoured as everywhere). + +import type { CommandContext } from "./_cli"; +import type { FlagValue } from "./_parseFlags"; +import { compareVersions, suggestNextMinorVersion, suggestNextVersion } from "../lib/changelog"; +import { + cutReleases, + dateProblem, + describeRelease, + isReleaseTarget, + RELEASE_WORKSPACES, + versionSpecProblem, + type CutReleasesOutcome, + type ReleaseSummary, + type ReleaseTarget, + type ReleaseWorkspace, +} from "../controller/cutRelease"; + +type Out = { log: (s: string) => void; error: (s: string) => void }; + +export type CutArgs = { + target: ReleaseTarget; + version: string; + commit: boolean; + date?: string; +}; + +/** `release cut`'s arguments, checked before anything is read — or why not. */ +export function parseCutArgs( + positionals: readonly string[], + flags: Record<string, FlagValue>, +): CutArgs | { error: string } { + const [target, version] = positionals; + if (!target) { + return { error: "release cut: which changelog? editor, export or all" }; + } + if (!isReleaseTarget(target)) { + return { error: `release cut: "${target}" is not editor, export or all` }; + } + if (!version) { + return { error: "release cut: which version? X.Y.Z, next or next-minor" }; + } + const versionProblem = versionSpecProblem(version); + if (versionProblem) return { error: `release cut: ${versionProblem}` }; + const date = typeof flags.date === "string" ? flags.date.trim() : undefined; + if (date !== undefined) { + const problem = dateProblem(date); + if (problem) return { error: `release cut: ${problem}` }; + } + return { + target, + version: version.trim(), + commit: flags.commit === true, + ...(date !== undefined ? { date } : {}), + }; +} + +/** `release show`'s changelogs: the one named, or both (`all` is both too). */ +export function parseShowArgs( + positionals: readonly string[], +): ReleaseWorkspace[] | { error: string } { + const [which] = positionals; + if (!which || which === "all") return [...RELEASE_WORKSPACES]; + if (which === "editor" || which === "export") return [which]; + return { error: `release show: "${which}" is not editor or export` }; +} + +/** + * One line per changelog: the heading now in the file and whether it was + * committed; the refusal; and any changelog a failure stopped before — saying + * whether `all` was refused before writing anything, or stopped half-way. + */ +export function formatCutLines(outcome: CutReleasesOutcome): { line: string; failed: boolean }[] { + const lines = outcome.results.map((r) => + r.ok + ? { + line: `${r.workspace}: ${r.heading} (${ + r.committed + ? r.commitSha + ? `committed ${r.commitSha.slice(0, 8)}` + : "committed" + : "not committed" + })`, + failed: false, + } + : { line: `${r.workspace}: failed — ${r.error}`, failed: true }, + ); + for (const ws of outcome.notAttempted) { + lines.push({ + line: outcome.untouched + ? `${ws}: not cut — all cuts both or neither, and nothing was written` + : `${ws}: not cut — stopped at the failure above`, + failed: true, + }); + } + return lines; +} + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** The step-0 check, one line: latest release, its date, what is pending. */ +export function formatShowLine(s: ReleaseSummary): string { + if (!s.ok) return `${s.workspace}: ${s.error}`; + const latest = s.latest + ? `latest ${s.latest.version} (${s.latest.date ?? "undated"})` + : "no release yet"; + const pending = !s.hasUnreleased + ? "no [Unreleased] heading" + : s.pending === 0 + ? "nothing pending under [Unreleased]" + : `${plural(s.pending, "bullet")} pending under [Unreleased]`; + return `${s.workspace}: ${latest}; ${pending}; next ${s.next}, next-minor ${s.nextMinor}`; +} + +/** + * What `release cut all next` would cut: the keywords against the HIGHER of + * the two latest headings (cutReleases' rule), or null unless both were read. + */ +export function formatAllLine(summaries: ReleaseSummary[]): string | null { + if (summaries.length < 2 || summaries.some((s) => !s.ok)) return null; + let highest: string | null = null; + for (const s of summaries) { + const v = s.ok ? (s.latest?.version ?? null) : null; + if (compareVersions(v, highest) > 0) highest = v; + } + return `all: next ${suggestNextVersion(highest)}, next-minor ${suggestNextMinorVersion(highest)}`; +} + +export async function cutMain(ctx: CommandContext, out: Out = console): Promise<number> { + const args = parseCutArgs(ctx.positionals, ctx.flags); + if ("error" in args) { + out.error(args.error); + return 2; + } + const outcome = await cutReleases({ + workspace: args.target, + version: args.version, + commit: args.commit, + date: args.date, + }); + for (const { line, failed } of formatCutLines(outcome)) { + (failed ? out.error : out.log)(line); + } + return outcome.ok ? 0 : 1; +} + +export async function showMain(ctx: CommandContext, out: Out = console): Promise<number> { + const which = parseShowArgs(ctx.positionals); + if ("error" in which) { + out.error(which.error); + return 2; + } + const summaries: ReleaseSummary[] = []; + for (const ws of which) summaries.push(await describeRelease(ws)); + for (const s of summaries) (s.ok ? out.log : out.error)(formatShowLine(s)); + const all = formatAllLine(summaries); + if (all) out.log(all); + return summaries.every((s) => s.ok) ? 0 : 1; +} diff --git a/common/controller/cutRelease.test.ts b/common/controller/cutRelease.test.ts @@ -0,0 +1,507 @@ +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, + 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 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); + assert.equal(outcome.untouched, undefined); + 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.deepEqual(outcome.notAttempted, []); + 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"); +}); diff --git a/common/controller/cutRelease.ts b/common/controller/cutRelease.ts @@ -0,0 +1,414 @@ +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 `## [<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.-]+)?$/; + +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<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}` }; + } +} + +// 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<string | null> { + 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<CutReleaseResult> { + 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 fail(`Cut release ${version}, but the commit failed: ${result.error}`); + } + 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<CutReleaseResult> { + 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[]; + // `all` refused before writing anything: neither changelog was touched. + untouched?: 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 <workspace> <version>` 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<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 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<ReleaseWorkspace, string>; + 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) { + return { + ok: false, + version, + results: [...done, result], + notAttempted: workspaces.slice(done.length + 1), + }; + } + 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,99 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + compareVersions, + countUnreleasedBullets, + cutRelease, + dateISOProblem, + 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("compareVersions is semver precedence: X.Y.Z numerically, a release above its prereleases", () => { + assert.ok(compareVersions("0.10.0", "0.9.9") > 0); + assert.ok(compareVersions("0.8.7", "0.9.0") < 0); + assert.equal(compareVersions("0.9.0", "0.9.0"), 0); + assert.ok(compareVersions("1.0.0", "1.0.0-rc.1") > 0); + assert.ok(compareVersions("1.0.0-rc.1", "0.9.0") > 0); + assert.ok(compareVersions("0.9.0-rc.1", "0.9.0") < 0); + assert.ok(compareVersions("1.0.0-rc.2", "1.0.0-rc.10") < 0); + assert.ok(compareVersions("1.0.0-alpha", "1.0.0-alpha.1") < 0); + assert.ok(compareVersions("1.0.0-1", "1.0.0-alpha") < 0); + assert.ok(compareVersions("1.0.0-beta", "1.0.0-alpha") > 0); + assert.ok(compareVersions("0.1.0", null) > 0); + assert.ok(compareVersions(null, "0.1.0") < 0); + assert.equal(compareVersions(null, null), 0); +}); + +test("dateISOProblem wants YYYY-MM-DD and a day on the calendar", () => { + assert.equal(dateISOProblem("2026-09-26"), null); + assert.equal(dateISOProblem("2024-02-29"), null); + assert.equal(dateISOProblem("26/09/2026"), 'Date "26/09/2026" is not in YYYY-MM-DD form.'); + assert.equal(dateISOProblem("2026-13-45"), 'Date "2026-13-45" is not a calendar date.'); + assert.equal(dateISOProblem("2026-02-30"), 'Date "2026-02-30" is not a calendar date.'); + assert.equal(dateISOProblem("2025-02-29"), 'Date "2025-02-29" is not a calendar date.'); + assert.equal(dateISOProblem("2026-00-10"), 'Date "2026-00-10" is not a calendar date.'); +}); + +test("cutRelease refuses an impossible date and a version that does not move forward", () => { + assert.throws(() => cutRelease(SOURCE, "0.9.1", "2026-13-45"), /"2026-13-45" is not a calendar date/); + assert.throws( + () => cutRelease(SOURCE, "0.9.0", "2026-09-26"), + /^CutReleaseError: Version 0\.9\.0 is not newer than the latest release, 0\.9\.0\.$/, + ); + assert.throws(() => cutRelease(SOURCE, "0.8.9", "2026-09-26"), /not newer than the latest release, 0\.9\.0/); + assert.throws(() => cutRelease(SOURCE, "0.9.0-rc.1", "2026-09-26"), /not newer/); + // A prerelease of a later version is forward; so is the first release of all. + assert.match(cutRelease(SOURCE, "1.0.0-rc.1", "2026-09-26"), /^## \[1\.0\.0-rc\.1\] - 2026-09-26$/m); + assert.match( + cutRelease("# Changelog\n\n## [Unreleased]\n- first\n", "0.1.0", "2026-09-26"), + /^## \[0\.1\.0\] - 2026-09-26$/m, + ); +}); + +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,111 @@ 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 parsed = parseVersion(current); + if (!parsed) return "0.1.0"; + const [major, minor] = parsed.core; + return `${major}.${minor + 1}.0`; +} + +const SEMVER_PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; + +// A heading's version, split for precedence: [major, minor, patch] and the +// prerelease identifiers (empty for a release). +function parseVersion(v: string | null): { core: number[]; pre: string[] } | null { + const m = v ? SEMVER_PARTS.exec(v) : null; + if (!m) return null; + return { + core: [m[1], m[2], m[3]].map((p) => Number.parseInt(p, 10)), + pre: m[4] ? m[4].split(".") : [], + }; +} + +/** + * Semver precedence of two heading versions: X.Y.Z numerically, then a release + * above any prerelease of the same X.Y.Z, then the prerelease identifiers + * (numeric ones numerically and below alphanumeric ones). An unreadable + * version sorts first. Negative, zero or positive. + */ +export function compareVersions(a: string | null, b: string | null): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + if (!pa || !pb) return (pa ? 1 : 0) - (pb ? 1 : 0); + for (let i = 0; i < 3; i++) { + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]; + } + if (!pa.pre.length || !pb.pre.length) { + return (pa.pre.length ? 0 : 1) - (pb.pre.length ? 0 : 1); + } + for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) { + const x = pa.pre[i]; + const y = pb.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const xNum = /^\d+$/.test(x); + const yNum = /^\d+$/.test(y); + if (xNum && yNum) { + const d = Number(x) - Number(y); + if (d !== 0) return d; + } else if (xNum !== yNum) { + return xNum ? -1 : 1; + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +} + +/** + * Why `date` cannot stamp a release heading — or null. It must be YYYY-MM-DD + * AND a day on the calendar: `2026-13-45` and `2026-02-30` are refused. + */ +export function dateISOProblem(date: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return `Date "${date}" is not in YYYY-MM-DD form.`; + } + const [y, m, d] = date.split("-").map((p) => Number.parseInt(p, 10)); + const day = new Date(Date.UTC(y, m - 1, d)); + if ( + day.getUTCFullYear() !== y || + day.getUTCMonth() !== m - 1 || + day.getUTCDate() !== d + ) { + return `Date "${date}" is not a calendar date.`; + } + return null; +} + +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 ""; @@ -65,9 +170,8 @@ export function cutRelease( `Version "${version}" is not a valid semver (expected X.Y.Z or X.Y.Z-prerelease).`, ); } - if (!/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) { - throw new CutReleaseError(`Date "${dateISO}" is not in YYYY-MM-DD form.`); - } + const dateProblem = dateISOProblem(dateISO); + if (dateProblem) throw new CutReleaseError(dateProblem); const match = UNRELEASED_HEADING.exec(source); if (!match) { throw new CutReleaseError( @@ -78,6 +182,14 @@ export function cutRelease( if (!unreleased) { throw new CutReleaseError("Nothing pending to release under [Unreleased]."); } + // A release goes FORWARD: a version at or below the latest heading would + // file the notes under a release that already shipped (or before it). + const latest = getLatestReleasedVersion(source); + if (latest && compareVersions(version, latest) <= 0) { + throw new CutReleaseError( + `Version ${version} is not newer than the latest release, ${latest}.`, + ); + } const headingStart = match.index; const headingEnd = headingStart + match[0].length; const before = source.slice(0, headingStart); 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; +} diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -55,7 +55,9 @@ export type Paths = { // are TRACKED source files, and cutting a release optionally makes a real git // commit — so e2e must be able to point them somewhere disposable. Overridable // via EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE; the editor's dev:test and - // start:test scripts set the first to editor/test-changelog.md (gitignored). + // start:test scripts set them to editor/test-changelog.md and + // editor/test-export-changelog.md (both gitignored) — the second since + // POST /api/ops/cut-release made the export changelog reachable from a spec. // Without this the changelog spec rewrites the repo's own changelog and can // leave a stray "Release editor <version>" commit behind. editorChangelogFile: string; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -9,6 +9,7 @@ - **An agent working through the MCP server asks the editor for a clip instead of running yt-dlp.** The MCP server has a new tool, `fetch_clip`. Given a citation's channel, video id, start and end and a one-line reason, it asks the local editor for that window through `POST /api/media/fetch-window`: the same paced, cookie-aware job umtool uses, which records who asked and why beside the file. It answers with the file's path in the corpus (`channels/<slug>/data/<id>/clips/`). The window is the cited span with 3 seconds either side, at most 15 minutes. `full: true` asks for the whole recording instead, which lands in the saved-video store and needs a video the editor already knows. A Rumble citation's id (the embed id the archive publishes) is mapped to the id the editor names the video's folder by, through the archive record's link. The editor must already archive the channel: pointed at a public site with a fresh editor, every clip gets a 404 `Channel "<slug>" not found`. The tool waits up to 90 seconds by default (at most 300) and otherwise returns the job's id, to wait on with `job`; the fetch carries on in the editor either way. While it waits it sends a progress notification per poll to a client that asks for progress. A client whose requests time out at 60 seconds (the MCP SDK's default) must raise that or pass `wait_seconds` of 50 or less. No request to the editor waits more than 15 seconds. If the editor stops answering mid-fetch, the answer gives the job's id and says not to ask again from scratch. The `/ask` and `/sweep` plans now tell the agent to use it and never to run yt-dlp itself. The MCP needs `ARCHILYZER_EDITOR_URL` and `WORKER_TOKEN` (the editor's own) in its environment, so re-register it with the two `--env` lines in the README; without them the tool says so and fetches nothing. The MCP server itself still writes nothing. The README's `yt-dlp --download-sections` command is now only the fallback for a machine with no editor. - **"Persist source video" and a whole-recording fetch download the video even when it already has a transcript.** On a channel that takes YouTube's subtitles, the button — and a whole-recording request from umtool or the MCP server's `fetch_clip` with `full: true`, which run the same job — fetched only the subtitles again when the video already had a transcript or captions, and finished with no file. It now downloads the source and moves it into the saved-video store. YouTube's subtitles are fetched again first, as on any re-download; a Whisper transcript is not touched, and no audio is extracted beside a transcript. **Persist kept now** on a channel's Cleanup stage does the same for every kept video, so on such a channel it now downloads each kept video's source. The video page's Source video card offers the button on these channels too; it used to say persistence was for transcribe-handling channels only. - **Each video keeps a history of how its metadata changed at the source.** Every download that rewrites a video's `metadata.info.json` and changes anything in it adds one entry to `metadata.history.json` beside it: the old and new value of each field that changed (title, description, duration, availability, chapters and the rest), the view, like and comment counts that moved, and which of the fields that change on every fetch (format URLs, thumbnails, caption URLs) differed, compared by fingerprint only. A caption language appearing or disappearing counts as a change. The newest 200 entries are kept. The video page shows the history under the description: "Metadata rewritten N× · last … by …: <what changed>", with each entry's old → new values when opened. The history starts with the first rewrite after this update. +- **Release notes can be cut from the command line, with or without the editor running.** `archilyzer release show` prints each changelog's latest release, its date, how many bullets wait under `[Unreleased]`, and what `next` and `next-minor` would be. `archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD]` turns `## [Unreleased]` into the dated heading and prints one line per changelog, for example `editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)`. `next` is the patch bump of the latest heading and `next-minor` the minor bump. `all` cuts both changelogs with one version, worked out from the higher of their two latest headings, and with `--commit` makes one `Release <workspace> <version>` commit per changelog. It cuts both or neither: both changelogs are checked before either is written, so if one cannot be cut, nothing is written or committed. Only a write or commit that fails after the editor's has gone through can leave the editor cut (and committed) without the export, and the command then says so. Run it from the checkout (`pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts release …`); no editor is needed. A running editor takes the same cut over HTTP: `pnpm ops cut-release --json '{"workspace":"all","version":"next","commit":true}'` (`POST /api/ops/cut-release`). The two commands and the **Cut release** form on `/changelog` and `/sites` share one code path, so they refuse the same things in the same words: nothing pending; a malformed version; a version that is not newer than the latest release (new for the form too); a date that is not a real day; and a commit while any file is uncommitted other than the changelog being cut (for `all`, either changelog). The form's fields are unchanged, and its version box now also accepts `next` and `next-minor`. No `package.json` version is bumped and no tag is made: the changelog heading is the version. ## [0.9.0] - 2026-09-26 - **Every page now has a ground and an accent to choose, and the five theme families are gone.** The theme menu (the palette button beside the quick toggle, in the editor's sidebar and in the header of every published site, the hub and the homepage) has two groups. **Base** is System, Light, Sepia or Dark; Sepia is new, a warm paper ground for long reading. **Accent** is Signal, Brass, Vermilion, Violet, Sakura, Blue or Green, with the site's own tagged *default*; a site with a custom hex offers it first as *Site colour*. The quick toggle cycles System → Light → Sepia → Dark. A published site opens on the reader's system setting, in the accent its site form sets. The hub and the homepage open on Dark, in Signal, even with JavaScript off, and the editor follows the system, in Signal. Each accent has a value for each ground that reads at 4.5:1, and a custom hex is darkened or lightened per ground to match. A reader's accent is remembered only while it differs from the site's: picking the site's own again forgets it, so the reader follows the site if its accent changes later. Base, Archive, Selenized, Swiss and Archilyzer are gone. A choice made before this update carries over once: light stays light (Archive light becomes Sepia), dark stays dark and system stays system; the family itself is dropped. Headings are Archivo, text is IBM Plex Sans and figures are IBM Plex Mono everywhere, with one corner radius. Success, warning and other status text reads at 4.5:1 on its own tinted fill on every ground; on Light, success and warning are a shade deeper than before for it. Chart colours are fixed per ground and never follow the accent; the third is a violet, well clear of the red that marks a recording as gone. The phone's browser bar takes the page's ground, not the accent. Needs a rebuild and deploy of every site, the hub and the homepage. diff --git a/editor/app/api/ops/cut-release/route.ts b/editor/app/api/ops/cut-release/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { + cutReleases, + dateProblem, + RELEASE_TARGETS, + versionSpecProblem, +} from "yt-dlp-transcript-common/controller/cutRelease"; +import { revalidateAfterReleaseCut } from "../../../sites/lib/revalidateAfterReleaseCut"; +import { + OpsInputError, + oneOf, + ops, + opsFail, + optBool, + optString, + reqString, +} from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { workspace: "editor" | "export" | "all", +// version: "X.Y.Z" | "next" | "next-minor", +// commit?: boolean (default false), +// date?: "YYYY-MM-DD" (default today, server local time) } +// +// SYNCHRONOUS: a cut is one heading edit per changelog and at most two +// path-limited commits, not a job. The writer is the one the Cut release form +// and `archilyzer release cut` call (common/controller/cutRelease.ts), so every +// refusal is its sentence. `all` cuts editor then export with ONE version and a +// commit each — or neither: every check runs before either file is written. +// +// 200 { ok: true, version, results } when every changelog was cut. A refusal +// is a 400 { ok: false, error, version, results, notAttempted, untouched? }. +// `untouched: true` means `all` was refused before writing anything; without +// it, `results` says what WAS done before a write or a commit failed. +export async function POST(request: Request) { + return ops(request, ["workspace", "version", "commit", "date"], async (body) => { + const workspace = oneOf(body, "workspace", RELEASE_TARGETS); + const version = reqString(body, "version"); + const versionProblem = versionSpecProblem(version); + if (versionProblem) throw new OpsInputError(versionProblem); + const date = optString(body, "date"); + const badDate = date === undefined ? null : dateProblem(date); + if (badDate) throw new OpsInputError(badDate); + const commit = optBool(body, "commit") ?? false; + + const outcome = await cutReleases({ workspace, version, commit, date }); + if (outcome.results.some((r) => r.ok)) revalidateAfterReleaseCut(); + if (outcome.ok) { + return NextResponse.json({ + ok: true, + version: outcome.version, + results: outcome.results, + }); + } + const failure = outcome.results.find((r) => !r.ok); + const reason = failure && !failure.ok ? failure.error : "cut failed"; + return opsFail( + workspace === "all" && failure ? `${failure.workspace}: ${reason}` : reason, + 400, + { + version: outcome.version, + results: outcome.results, + notAttempted: outcome.notAttempted, + ...(outcome.untouched ? { untouched: true } : {}), + }, + ); + }); +} diff --git a/editor/app/sites/lib/cutReleaseAction.ts b/editor/app/sites/lib/cutReleaseAction.ts @@ -1,35 +1,18 @@ "use server"; -import fs from "node:fs"; -import path from "node:path"; -import { revalidatePath } from "next/cache"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - cutRelease, - CutReleaseError, -} from "yt-dlp-transcript-common/lib/changelog"; -import { commitPath, listDirtyPaths } from "yt-dlp-transcript-common/lib/git"; -import { writeFileAtomic } from "yt-dlp-transcript-common/lib/jsonFile-server"; +import { cutReleaseForWorkspace } from "yt-dlp-transcript-common/controller/cutRelease"; +import { revalidateAfterReleaseCut } from "./revalidateAfterReleaseCut"; + +// The "Cut release" form's server action: a FormData adapter over the one +// writer, `cutReleaseForWorkspace` (common/controller/cutRelease.ts), which the +// CLI (`archilyzer release cut`) and POST /api/ops/cut-release call too. The +// dirty-tree guard, the cut, the atomic write, the commit and every refusal's +// sentence live there. export type CutReleaseState = | { ok: true; version: string; committed: boolean } | { ok: false; error: string }; -function changelogPathFor(workspace: "editor" | "export"): string { - const paths = getPaths(); - return workspace === "export" - ? paths.exportChangelogFile - : paths.editorChangelogFile; -} - -function todayISO(): string { - const now = new Date(); - 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}`; -} - export async function cutReleaseAction( _prev: CutReleaseState | undefined, formData: FormData, @@ -50,66 +33,12 @@ export async function cutReleaseAction( if (!version) { return { ok: false, error: "Version is required." }; } - const paths = getPaths(); - const filePath = changelogPathFor(workspace); - const relPath = path.relative(paths.monorepoRoot, filePath); - // Before mutating anything, refuse to commit over an otherwise-dirty tree so - // the release commit contains only the changelog. A dirty changelog itself is - // fine (uncommitted [Unreleased] bullets get folded into the release commit). - if (shouldCommit) { - let dirty: string[]; - try { - dirty = await listDirtyPaths(paths.monorepoRoot); - } catch (err) { - return { - ok: false, - error: `Could not check git status: ${(err as Error).message}`, - }; - } - const others = dirty.filter((p) => p !== relPath); - if (others.length > 0) { - return { - ok: false, - error: `Other uncommitted changes present (${others.join( - ", ", - )}). Commit or stash them before cutting a release.`, - }; - } - } - let source: string; - try { - source = await fs.promises.readFile(filePath, "utf8"); - } catch (err) { - return { - ok: false, - error: `Could not read ${filePath}: ${(err as Error).message}`, - }; - } - let next: string; - try { - next = cutRelease(source, version, todayISO()); - } catch (err) { - if (err instanceof CutReleaseError) { - return { ok: false, error: err.message }; - } - throw err; - } - await writeFileAtomic(filePath, next); - if (shouldCommit) { - const result = await commitPath( - paths.monorepoRoot, - relPath, - `Release ${workspace} ${version}`, - ); - if (!result.ok) { - return { - ok: false, - error: `Cut release ${version}, but the commit failed: ${result.error}`, - }; - } - } - revalidatePath("/"); - revalidatePath("/changelog"); - revalidatePath("/sites"); - return { ok: true, version, committed: shouldCommit }; + const result = await cutReleaseForWorkspace({ + workspace, + version, + commit: shouldCommit, + }); + if (!result.ok) return { ok: false, error: result.error }; + revalidateAfterReleaseCut(); + return { ok: true, version: result.version, committed: result.committed }; } diff --git a/editor/app/sites/lib/revalidateAfterReleaseCut.ts b/editor/app/sites/lib/revalidateAfterReleaseCut.ts @@ -0,0 +1,12 @@ +import { revalidatePath } from "next/cache"; + +// The pages that render a changelog: the home page's "what's new", /changelog +// (the editor's notes and its Cut release form) and /sites (the export's notes +// and its form). Called after a cut by the form's server action and by +// POST /api/ops/cut-release — both run inside their request, so this is the +// plain `revalidatePath`, not the job-body `safeRevalidate`. +export function revalidateAfterReleaseCut(): void { + revalidatePath("/"); + revalidatePath("/changelog"); + revalidatePath("/sites"); +} diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -1,5 +1,4 @@ import { readFileSync } from "node:fs"; -import path from "node:path"; import type { Metadata } from "next"; import Link from "next/link"; import { Changelog } from "yt-dlp-transcript-common/components/Changelog"; @@ -44,9 +43,12 @@ const BUILD_KINDS = new Set([ "archive-combined-live-chat", ]); +// The same file the Cut release form rewrites (`exportChangelogFile`, which +// EXPORT_CHANGELOG_FILE overrides for the e2e server), or the page would show +// one changelog while the form cut another. function loadExportChangelog(): string | null { try { - return readFileSync(path.join(getPaths().exportDir, "CHANGELOG.md"), "utf8"); + return readFileSync(getPaths().exportChangelogFile, "utf8"); } catch { return null; } diff --git a/editor/e2e/ops-cut-release.spec.ts b/editor/e2e/ops-cut-release.spec.ts @@ -0,0 +1,201 @@ +// POST /api/ops/cut-release — `pnpm ops cut-release` (release 10 slice P). +// +// The route is the third caller of the one release writer +// (common/controller/cutRelease.ts), after the Cut release form +// (cut-release.spec.ts) and `archilyzer release cut` (unit-tested). What is +// pinned here is the HTTP door: the token, the body's allow-list, the 400s that +// refuse a bad workspace / version / date before anything is written, and a +// cut that answers with the heading it wrote. +// +// BOTH CHANGELOGS ARE THE FIXTURE'S. The test server's EDITOR_CHANGELOG_FILE +// and EXPORT_CHANGELOG_FILE (editor/package.json, dev:test/start:test) point at +// gitignored copies beside this directory, so `all` rewrites neither tracked +// CHANGELOG.md. Nothing here passes "commit": a commit of an ignored file is +// not a thing git does, and the commit path is covered by the controller's own +// tests, each in a git repo of its own. + +import { readFile, writeFile } from "node:fs/promises"; +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; +import { resolvePath } from "./helpers"; + +const EDITOR_CHANGELOG = resolvePath("test-changelog.md"); +const EXPORT_CHANGELOG = resolvePath("test-export-changelog.md"); +const AUTH = { authorization: "Bearer test-worker-token" }; + +type CutResponse = { + ok?: boolean; + error?: string; + version?: string | null; + results?: Record<string, unknown>[]; + notAttempted?: string[]; + untouched?: boolean; +}; + +function changelog(latest: string, pending: string[] = []): string { + return [ + "# Changelog", + "", + ...(pending.length ? ["## [Unreleased]", ...pending, ""] : []), + `## [${latest}] - 2024-01-01`, + "- old released bullet", + "", + ].join("\n"); +} + +// cut-release.spec.ts leaves the editor copy in this state, and /changelog and +// /sites read these files unconditionally. +const RELEASED_EDITOR = changelog("9.9.9"); +const RELEASED_EXPORT = changelog("1.0.0"); + +function todayISO(): string { + const now = new Date(); + 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}`; +} + +async function cut( + request: APIRequestContext, + data: Record<string, unknown>, + headers: Record<string, string> = AUTH, +): Promise<{ status: number; body: CutResponse }> { + const res = await request.post(`${baseUrl}/api/ops/cut-release`, { headers, data }); + return { status: res.status(), body: (await res.json()) as CutResponse }; +} + +test.afterEach(async () => { + await writeFile(EDITOR_CHANGELOG, RELEASED_EDITOR); + await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT); +}); + +test("cut-release cuts the editor changelog and answers with the heading it wrote", async ({ + request, +}) => { + await writeFile(EDITOR_CHANGELOG, changelog("9.9.9", ["- a bullet cut over HTTP"])); + await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT); + + const first = await cut(request, { workspace: "editor", version: "next" }); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body).toEqual({ + ok: true, + version: "9.9.10", + results: [ + { + ok: true, + workspace: "editor", + version: "9.9.10", + heading: `## [9.9.10] - ${todayISO()}`, + committed: false, + }, + ], + }); + const after = await readFile(EDITOR_CHANGELOG, "utf8"); + expect(after).toContain(`## [9.9.10] - ${todayISO()}\n- a bullet cut over HTTP`); + expect(after).not.toMatch(/^##\s+\[Unreleased\]/im); + // The export changelog was not named, so it is not touched. + expect(await readFile(EXPORT_CHANGELOG, "utf8")).toBe(RELEASED_EXPORT); + + // The form reads the same file: it now has nothing to cut. + const page = await request.get(`${baseUrl}/changelog`); + expect(await page.text()).toContain("Nothing pending under [Unreleased] yet."); + + // Cutting again is the writer's own refusal, as a 400: the cut consumed the + // [Unreleased] heading itself. + const again = await cut(request, { workspace: "editor", version: "next" }); + expect(again.status).toBe(400); + expect(again.body.ok).toBe(false); + expect(again.body.error).toBe( + "Could not find a `## [Unreleased]` heading to cut from.", + ); + expect(again.body.results).toEqual([ + { + ok: false, + workspace: "editor", + error: "Could not find a `## [Unreleased]` heading to cut from.", + }, + ]); +}); + +test("all cuts both changelogs with ONE version, resolved against the higher latest", async ({ + request, +}) => { + await writeFile(EDITOR_CHANGELOG, changelog("9.9.9", ["- editor bullet"])); + await writeFile(EXPORT_CHANGELOG, changelog("10.0.0", ["- export bullet"])); + + const res = await cut(request, { + workspace: "all", + version: "next-minor", + date: "2026-09-26", + }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.version).toBe("10.1.0"); + expect(res.body.results?.map((r) => [r.workspace, r.heading])).toEqual([ + ["editor", "## [10.1.0] - 2026-09-26"], + ["export", "## [10.1.0] - 2026-09-26"], + ]); + expect(await readFile(EDITOR_CHANGELOG, "utf8")).toContain( + "## [10.1.0] - 2026-09-26\n- editor bullet", + ); + expect(await readFile(EXPORT_CHANGELOG, "utf8")).toContain( + "## [10.1.0] - 2026-09-26\n- export bullet", + ); + + // With only the editor pending, `all` cuts NEITHER: the export (no + // [Unreleased] heading at all) is refused before anything is written. + const editorPending = changelog("10.1.0", ["- later"]); + await writeFile(EDITOR_CHANGELOG, editorPending); + await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT); + const refused = await cut(request, { workspace: "all", version: "10.1.1" }); + expect(refused.status).toBe(400); + expect(refused.body).toEqual({ + ok: false, + error: "export: Could not find a `## [Unreleased]` heading to cut from.", + version: "10.1.1", + results: [ + { + ok: false, + workspace: "export", + error: "Could not find a `## [Unreleased]` heading to cut from.", + }, + ], + notAttempted: ["editor"], + untouched: true, + }); + expect(await readFile(EDITOR_CHANGELOG, "utf8")).toBe(editorPending); + expect(await readFile(EXPORT_CHANGELOG, "utf8")).toBe(RELEASED_EXPORT); +}); + +test("a bad workspace, version, date or key — or a version that does not move forward — is a 400, and nothing is written", async ({ + request, +}) => { + const pending = changelog("9.9.9", ["- must survive every refusal"]); + await writeFile(EDITOR_CHANGELOG, pending); + + const cases: [Record<string, unknown>, RegExp][] = [ + [{ workspace: "site", version: "1.0.0" }, /"workspace" must be one of editor, export, all/], + [{ version: "next" }, /"workspace" is required/], + [{ workspace: "editor" }, /"version" is required/], + [{ workspace: "editor", version: "v1" }, /"v1" is not a valid semver/], + [{ workspace: "editor", version: "next", date: "26/09/2026" }, /not in YYYY-MM-DD form/], + [{ workspace: "editor", version: "next", date: "2026-13-45" }, /"2026-13-45" is not a calendar date/], + // The writer's own refusal: a release goes forward. + [ + { workspace: "editor", version: "9.9.9" }, + /^Version 9\.9\.9 is not newer than the latest release, 9\.9\.9\.$/, + ], + [{ workspace: "editor", version: "next", commit: "yes" }, /"commit" must be a boolean/], + [{ workspace: "editor", version: "next", push: true }, /unknown key\(s\): push/], + ]; + for (const [data, error] of cases) { + const res = await cut(request, data); + expect(res.status, JSON.stringify(data)).toBe(400); + expect(res.body.error, JSON.stringify(data)).toMatch(error); + } + // The token gate is the ops door's. + const anonymous = await cut(request, { workspace: "editor", version: "next" }, {}); + expect(anonymous.status).toBe(401); + + expect(await readFile(EDITOR_CHANGELOG, "utf8")).toBe(pending); +}); diff --git a/editor/package.json b/editor/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "next dev --port ${EDITOR_PORT:-3001}", - "dev:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", - "start:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", + "dev:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", + "start:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", "build": "next build", "start": "next start --port ${EDITOR_PORT:-3001}", "lint": "eslint", diff --git a/plans/release-10.md b/plans/release-10.md @@ -1529,6 +1529,256 @@ three hub bullets. footer's Ko-fi link on each site. An installed PWA picks up the worker change on its next update check, and no data cache moves. +### Slice P, as shipped — cut a release from the CLI (2026-09-26) + +Branch `cli/cut-release` off `main` `84c502f3`, worktree `/home/user/Projects/cli-cut-release`, +one Opus implementer. The plan is [`cut-release-cli.md`](cut-release-cli.md), Implementation +items 1–5. Reviewed SHIP AFTER FIXES; the fixes are in (the review round is under Gates). The +operator asked (2026-09-26): "Are you able to cut releases with the CLI ops? add that +ability if not." The answer was no: the only cutter was the "Cut release" form. Now three callers +share one writer: +- the form, whose fields and labels are unchanged (its version box now also takes `next` / + `next-minor`); +- `archilyzer release cut|show`, local, with no editor running; +- `pnpm ops cut-release`, through a new `POST /api/ops/cut-release`. + +**The writer** is `common/controller/cutRelease.ts`. +- `cutReleaseForWorkspace({workspace, version, commit, date?, root?})` is the old server action's + body moved to common, in the same order: the dirty-tree guard, the read, `cutRelease`, the + atomic write, then the path-limited `Release <workspace> <version>` commit. Every old refusal + keeps its wording. It returns + `{ok: true, workspace, version, heading, committed, commitSha?} | {ok: false, workspace, error}`. + Internally it is a PLAN (read, resolve, cut in memory: every refusal about the changelog) and + an APPLY (the write and the commit: only I/O and git can fail there). +- **Two new refusals, in `changelog.cutRelease` itself so every caller has them** (review L1 + + L2). They are new for the form too. + - A date must be a calendar day, not just YYYY-MM-DD-shaped (`dateISOProblem`, a `Date.UTC` + round trip): `Date "2026-13-45" is not a calendar date.` + - A version must be newer than the latest heading, by semver precedence (`compareVersions`: a + release ranks above its own prereleases, so `1.0.0-rc.1` passes over `0.9.0` and + `0.9.0-rc.1` does not): `Version 0.9.0 is not newer than the latest release, 0.9.0.` + `next` / `next-minor` pass by construction. +- `version` is a literal X.Y.Z(-pre), `next` (`suggestNextVersion`, the patch bump the form + pre-fills) or `next-minor` (the new `suggestNextMinorVersion`: `0.9.3` → `0.10.0`). +- `cutReleases({workspace: "all", …})` cuts editor, then export, with the SAME version, and + **cuts both or neither** (review M1): + - A keyword resolves against the HIGHER of the two latest headings (`compareVersions`), so + neither changelog goes backwards when they have drifted apart. + - **The preflight, before either write:** it reads both changelogs, plans both cuts in memory, + and (with `commit`) runs the dirty-tree guard once. Any refusal there returns + `{ok: false, version, results: [<the failing workspace's refusal>], notAttempted: [<the + other>], untouched: true}`, and both files and the log are left alone. A guard refusal is + reported against `editor`, the first commit it blocks. + - Both changelogs may be dirty. The guard's allowed set is both files, so export's uncommitted + bullets do not block editor's commit. (A single-workspace cut allows only its own changelog, + so there a dirty other changelog does block the commit, as it always did.) + - Only then does it write and commit each, in order: two commits in today's message form. + Past the preflight only I/O or git can stop it: a write or a commit that fails after the + editor's went through. The outcome then lists what was done (`results`) and what was not + tried (`notAttempted`), without `untouched`. + - Before the review, `all` wrote (and with `--commit` committed) the editor before it looked at + the export. "Only the editor has changes" left a lone `Release editor` commit behind a + non-zero exit. +- `describeRelease(workspace)` is the read-only side of `release show`. It uses the new + `getLatestRelease` (version + date), `hasUnreleasedHeading` and `countUnreleasedBullets` + (top-level bullets; nested items are not counted). +- **A failed write is a result, not a throw (deliberate).** A failed write (`writeFileAtomic` throwing) is now + `{ok: false, error: "Could not write <file>: …"}` instead of an exception, so `all` can still + say what it did. The form shows such a failure as its alert instead of an error boundary. +- `root` is for tests only. It means the standard layout under that directory and ignores the + environment, so every test builds its own git repo and passes it. Without `root`, the writer + uses `getPaths()`: the monorepo root, plus the `EDITOR_/EXPORT_CHANGELOG_FILE` overrides. +- `git.ts` gains `headSha`. +- **No `package.json` bump and no git tag** (decision 4). Neither has been a convention here: every + workspace is 0.1.0, and the repo has no tags. The changelog heading is the version. + +**The form** (`editor/app/sites/lib/cutReleaseAction.ts`) is now a FormData adapter over +`cutReleaseForWorkspace`. Its fields, labels, "Invalid workspace." / "Version is required." and +returned state are unchanged, and `cut-release.spec.ts` passes unmodified (3/3). What it accepts +changed in two ways, both through the shared writer: +- its version box also takes `next` and `next-minor` (resolved against the file); +- a version that is not newer than the latest heading is now refused. + +Its three `revalidatePath`s moved to `revalidateAfterReleaseCut.ts`, which the ops route shares. + +**The local verbs** are two rows in `common/bin/archilyzer.ts` over `common/bin/release.ts`: +- `archilyzer release show [editor|export]`. `all` is accepted as "both", the default. It prints + one line per changelog, plus an `all:` line when both are shown, which says what + `release cut all next` would cut: + `editor: latest 0.9.0 (2026-09-26); 8 bullets pending under [Unreleased]; next 0.9.1, next-minor 0.10.0`. +- `archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD]`. + - It prints one line per changelog, for example + `editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)`, `… (not committed)`, + `export: failed — <sentence>`, and for a workspace `all` did not reach either + `editor: not cut — all cuts both or neither, and nothing was written` (the preflight refused) + or `export: not cut — stopped at the failure above` (an I/O or git failure after the editor's + cut). + - A bad target, version or date (shape or calendar) is refused before anything is read, with + exit 2. A failed cut exits 1. + - `--date` is used verbatim (decision 5). The default is today, in local time, as the form + stamps it. +- Both verbs work with no editor running, from the checkout they are run in. + +**The remote verb.** `POST /api/ops/cut-release` takes `{workspace, version, commit?, date?}`. It +is synchronous and built on `ops()`. +- A bad workspace (`oneOf`), version, date or `commit`, or an unknown key, is an OpsInputError + 400 before any read. +- `commit` defaults to false. The CLI's `--commit` is opt-in too; only the form defaults it on. +- A full cut answers 200 `{ok: true, version, results}`. +- A refusal answers 400 `{ok: false, error, version, results, notAttempted, untouched?}`. + `error` is the writer's sentence, prefixed `<workspace>: ` for `all`. `untouched: true` means + `all` was refused before anything was written. +- The changelog pages are revalidated whenever anything was cut. +- `scripts/archilyzer-ops.mjs`: `ACTIONS` gains `cut-release`, and the header gains an example. + The usage block says what the body takes (`"commit": boolean (default false)`), that `all` cuts + both with one version and a commit each or neither, and that only an editor built from release + 10 or later has the route (an older one answers 404). With no editor running, it points to + `archilyzer release cut`. + +**Two fixes the route made necessary.** +- **The e2e server's export changelog.** `EDITOR_CHANGELOG_FILE` already pointed the test server + at a gitignored copy, but nothing redirected the export changelog. The new route would have let + a spec cut the worktree's tracked `export/CHANGELOG.md`, the trap FACTS records for the editor + one. `dev:test`/`start:test` now set + `EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md`, which is gitignored. The `paths.ts` + comment says why. +- **`/sites` read a different file from the one its form cuts.** It read + `path.join(exportDir, "CHANGELOG.md")`, while the action wrote `exportChangelogFile`. The two + are the same path unless `EXPORT_CHANGELOG_FILE` is set, which the e2e server now does. The page + now reads `exportChangelogFile`, as `/changelog` reads `editorChangelogFile`. Production is + unchanged. No spec reads the export notes' content (`deploy-page.spec.ts` checks only the + heading's position; 4/4). + +**Docs.** Neither `README.md` nor `AGENTS.md` mentions cutting a release (grepped for +"Cut release", "re-cut", "cut a release", "changelog"), so neither changes (item 5). The runbook is +the parent's. + +| sha | what | +|---|---| +| `9224059a` | `common:` the `cutRelease` controller (the one writer; next / next-minor; `all`; `describeRelease`), the changelog helpers, `headSha`; `cutRelease.test.ts` 12 + `changelog.test.ts` 4 | +| `ea390ebc` | `editor:` the form's action is a FormData adapter; `revalidateAfterReleaseCut`; `/sites` reads the export changelog the form cuts | +| `2657aa9b` | `common:` `archilyzer release show` / `release cut` (`bin/release.ts`); `_cli.test.ts` +6 | +| `867aaf15` | `editor:` `POST /api/ops/cut-release`; `pnpm ops` `ACTIONS` + usage; `archilyzer-ops.test.mjs` +1 | +| `7ef9dd6c` | `editor(e2e):` `ops-cut-release.spec.ts` (3); the test server's `EXPORT_CHANGELOG_FILE` + `.gitignore`; the `paths.ts` comment | +| `80212507` | `editor(e2e):` the second-cut expectation corrected (see e2e run 1) | +| `6a258d5d` | `plans:` this record (first cut); the `editor/CHANGELOG.md` `[Unreleased]` bullet | +| `7a1ea858` | `common:` review L1 + L2 in `cutRelease` itself: a calendar date (`dateISOProblem`), a version newer than the latest (`compareVersions` replaces `compareVersionCores`); tests changelog +2, cutRelease +1 | +| `eeb1e587` | `common:` review M1: `all` cuts both or neither (plan/apply, the preflight, `untouched`); the CLI line and the route body say so; cutRelease tests 13 → 16; `ops-cut-release.spec.ts` updated (+2 bad-input cases) | +| `7011ba62` | `scripts:` `pnpm ops` usage: "or neither"; `"commit": boolean` (review nit) | +| _this_ | `plans:` this record updated for the review round; the changelog bullet's wording (review L5 + L6) | + +**Gates**, all from the worktree root; the logs are `p-*.log` in the job's scratch dir. +- **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean before every + commit, in 72 s, 36 s, 34 s, 73 s and 57 s (`p-tsc1`–`p-tsc5`). The last run covers the tip's + code. +- **common 2,009/2,009** (1,987 + 22: cutRelease 12, changelog 4, `_cli` 6), 45 s. +- **editor unit 85/85**, 4 s. +- **`test:scripts` 174 + 1 skip** (173 + 1 before, +1 for `cut-release`), 9 s. +- **mcp 269/269**, unchanged, 18 s. +- **`pnpm --filter editor exec next build`: ok**, 38 s. `ƒ /api/ops/cut-release` is in the route + table. +- **The export build was not run.** Nothing under `export/` changed. +- **e2e** (editor suite, queued and detached, spec list in `p-specs.txt`; no queue wait on either + run). The list is `cut-release`, `ops-cut-release` (new), the three specs that grep `api/ops` + (`ops-api`, `chat-only`, `channel-rename`), and the two `/sites` specs (`deploy-page`, + `sites-crud`) for the loader change. + + | run | passed | failed | time | + |---|---|---|---| + | 1 | 52 | 1 | 2.2 min | + | 2 (after `80212507`) | **53** | **0** | **1.7 min** | + + Run 1's failure was the new spec's own mistake, not a flake. A second cut of an + already-cut file finds no `## [Unreleased]` heading at all, so the writer answers "Could not + find a `## [Unreleased]` heading to cut from.", not "Nothing pending". Run 2's per-spec + counts: `cut-release` 3 (unchanged spec), `ops-cut-release` 3, `ops-api` 22, `chat-only` 5, + `channel-rename` 2, `deploy-page` 4, `sites-crud` 14. The worktree's tracked changelogs were + untouched after both runs. +- **The manual proof** (`p-proof.log`) ran in the worktree at `80212507`, with a clean tree. Both + changelogs' md5s were identical before and after, the tree was clean after, and nothing was + committed. + ``` + $ archilyzer release show + editor: latest 0.9.0 (2026-09-26); 8 bullets pending under [Unreleased]; next 0.9.1, next-minor 0.10.0 + export: latest 0.9.0 (2026-09-26); 4 bullets pending under [Unreleased]; next 0.9.1, next-minor 0.10.0 + all: next 0.9.1, next-minor 0.10.0 + $ archilyzer release cut editor next + editor: ## [0.9.1] - 2026-09-26 (not committed) + $ archilyzer release show editor + editor: latest 0.9.1 (2026-09-26); no [Unreleased] heading; next 0.9.2, next-minor 0.10.0 + $ archilyzer release cut editor next # again + editor: failed — Could not find a `## [Unreleased]` heading to cut from. + $ archilyzer release cut site next + release cut: "site" is not editor, export or all + $ archilyzer release cut editor next --date 26/09/2026 + release cut: Date "26/09/2026" is not in YYYY-MM-DD form. + ``` + After the cut, `git diff` was the one heading line; `git checkout -- editor/CHANGELOG.md` + restored it. `--commit` was never run against a real checkout; the commit path is proven by + `cutRelease.test.ts` in temp repos. +- **The review round** (verdict SHIP AFTER FIXES, `p-review.md`; M1, L1, L2, L5, L6 and one nit + taken; L3 and L4 left, below): + - tsc clean before each commit, 37 s and 36 s (`p-tsc6`, `p-tsc7`). `7011ba62` touched only + `.mjs` and `test:scripts` covers it. + - **common 2,015/2,015** (2,009 + 6: changelog +2, cutRelease +4 net), 40 s. **editor unit + 85/85**, 3 s. **`test:scripts` 174 + 1 skip**, 9 s. **mcp 269/269**, 19 s (`p-units2.log`). + - **editor build ok**, 40 s (`p-e2e3.log`). + - **e2e, the same spec list, run 3: 53 passed, 0 failed, 1.8 min** (no queue wait; no dangling + `export/public` link). `ops-cut-release` 3/3 with the updated partial-`all` expectation + (`untouched`, the editor byte-identical) and the two new bad-input cases. + - **Manual proof, round 2** (`p-proof2.log`, `p-proof3.log`; worktree at `7011ba62`; both + changelogs copied to scratch and copied back, md5s identical before and after; no commit): + ``` + $ archilyzer release cut editor 0.9.0 + editor: failed — Version 0.9.0 is not newer than the latest release, 0.9.0. + $ archilyzer release cut editor next --date 2026-13-45 + release cut: Date "2026-13-45" is not a calendar date. + $ archilyzer release cut all next + editor: ## [0.9.1] - 2026-09-26 (not committed) + export: ## [0.9.1] - 2026-09-26 (not committed) + $ archilyzer release cut all next # again + editor: failed — Could not find a `## [Unreleased]` heading to cut from. + export: not cut — all cuts both or neither, and nothing was written + # restored; then the export alone cut, so only the editor has pending bullets: + $ archilyzer release cut export next + export: ## [0.9.1] - 2026-09-26 (not committed) + $ archilyzer release cut all next + export: failed — Could not find a `## [Unreleased]` heading to cut from. + editor: not cut — all cuts both or neither, and nothing was written + ``` + The editor changelog's md5 was the same before and after that last `all`. This is M1's case, + and before the fix it cut the editor. +- **Numbers tool: none.** + +**Found and left.** +- **Through `pnpm --filter … exec`, every non-zero exit reads as 1.** pnpm's recursive runner maps + it. `archilyzer build site --bogus` does the same, so this is not the slice's doing. Run + directly (`common/node_modules/.bin/tsx bin/archilyzer.ts …`, or `pnpm exec` from `common/`), + a refusal exits 2. +- **`--commit` refuses on any untracked file.** `git status --porcelain` lists `??` entries, and + that was already the form's behaviour: an untracked `settings.json.pre-priority-*` blocked the + 0.9.0 cut. The primary's tree was clean at this writing (0 dirty paths). +- **The `_lib.ts` header says every ops route "call[s] ONE existing server action".** This one + calls the controller that the server action also calls. The route's comment says so; the + header is left as it is. +- **Review L3, left: a commit that fails after its write revalidates nothing.** The file changed, + but the result is `{ok: false}` with no sign of the write, so neither the form nor the route + revalidates. The form always behaved this way. The pages are `force-dynamic` and re-read the + file on the next request, so the cost is small. The fix would carry `written: true` on that + failure. +- **Review L4, left: the route's top-level `error` for an `all` stopped half-way names only the + failure.** The earlier cut is in `results`, not in `error`. After M1 this happens only on an I/O + or git failure, and the 400 then lacks `untouched`. +- **Commit trailers** carry `Claude Opus 5.5 (1M context)`, as slices M–O's do. +- **Rollout** is the plan's. From the primary, after this merges and before step 1's restart: + `pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts release show`, then + `… release cut all next --commit`. With both changelogs at 0.9.0, that cuts both as 0.9.1; use + `next-minor` or an explicit version for 0.10.0. If either changelog cannot be cut, neither is. + After the restart, check `pnpm ops cut-release` + on the live editor with a request that is refused before anything is written, for example + `--json '{"workspace":"site","version":"next"}'` (expect a 400 naming the three workspaces). + The route has no read-only mode: `release show` is the CLI's. + ## Rollout Nothing is rolled out, except that **Jeralyzer is already on the brand, in Signal** (a build-deploy diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -41,6 +41,7 @@ // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' // pnpm ops tag-videos --file ids.json // pnpm ops get tags eva-collab +// pnpm ops cut-release --json '{"workspace":"all","version":"next","commit":true}' // // --file reads the BODY from a JSON file, which is how a big one gets sent: a // four-thousand-id tag-videos body is written by a script, not typed by a model @@ -117,6 +118,11 @@ const ACTIONS = [ // Set the per-video do-not-clean marker on every video of a channel whose // title/description matches a download-filter pattern ({slug, match}). "keep-videos", + // Cut a changelog's [Unreleased] into a dated release heading (release 10 + // slice P). Synchronous. The same writer as `archilyzer release cut`, which + // needs no editor at all — this route exists only on an editor built from + // release 10 or later. + "cut-release", ]; // The provenance a tag write from this CLI carries. Everything else ignores it. @@ -303,6 +309,16 @@ export function usage() { ' after, and deploy-hub ships the one already built. Both deploy to the', " Pages project set on /sites under Hub, and take \"preview\" too.", "", + 'cut-release turns a changelog\'s [Unreleased] into "## [<version>] - <date>":', + ' {"workspace": "editor" | "export" | "all",', + ' "version": "X.Y.Z" | "next" | "next-minor",', + ' "commit": boolean (default false), "date": "YYYY-MM-DD" (default today)}.', + ' "all" cuts both with ONE version and commits each ("Release <workspace>', + ' <version>") — or neither: every check runs before either file is', + " written. Only an editor built from release 10 or later has the route", + " (an older one answers 404); with no editor running, `archilyzer release", + " cut` does the same locally.", + "", "Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN,", " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\")", ].join("\n"); diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -42,6 +42,24 @@ test("an unknown action is refused by name, with the list", () => { assert.match(p.error, /metadata-scan/); }); +test("cut-release posts its body to /api/ops/cut-release and is a known action", () => { + const p = parseArgs([ + "cut-release", + "--json", + '{"workspace":"all","version":"next","commit":true}', + ]); + assert.equal(p.method, "POST"); + assert.equal(p.path, "/api/ops/cut-release"); + assert.deepEqual(p.body, { workspace: "all", version: "next", commit: true }); + // It is on the list an unknown action is refused with, and usage says what + // its body takes and where the route exists. + assert.match(parseArgs(["cut-releases"]).error, /known: .*\bcut-release\b/); + assert.match(usage(), /cut-release turns a changelog's \[Unreleased\]/); + assert.match(usage(), /"all" cuts both with ONE version/); + assert.match(usage(), /or neither: every check runs before either file is/); + assert.match(usage(), /release 10 or later/); +}); + test("malformed --json is refused before any request", () => { assert.match(parseArgs(["sync", "--json", "{"]).error, /not valid JSON/); assert.match(parseArgs(["sync", "--json", "[1]"]).error, /must be a JSON object/);