commit bee55a498b270eef10993bbd138af6558d2f22e9
parent 59ae09eea6ee07aeaccfafbb57bddcf6c87e36a1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 19:03:51 -0400
common: archilyzer release show [editor|export] and release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date] — the local release cutter, no editor needed
`show` prints the latest release, its date, the pending bullet count and what
next / next-minor would be (plus the `all` line, against the higher latest);
`cut` prints one line per changelog (`editor: ## [0.10.0] - 2026-09-26
(committed 1a2b3c4d)`), refuses bad arguments with 2 before reading anything,
and exits 1 on any failure. bin/release.ts over controller/cutRelease;
_cli.test.ts +6 (the rows, the flags, usage, parsing, the printed lines).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 358 insertions(+), 2 deletions(-)
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,166 @@ 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.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: "boom" }],
+ notAttempted: ["export"],
+ }),
+ [
+ { line: "editor: failed — boom", failed: true },
+ { line: "export: not cut — stopped at the failure above", failed: true },
+ ],
+ );
+ 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,165 @@
+// `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 { compareVersionCores, 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.
+ */
+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: `${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 (compareVersionCores(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;
+}