commit ad1dd112f7360625366e6fe3586d1583bb426a02
parent bee55a498b270eef10993bbd138af6558d2f22e9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 19:07:13 -0400
editor: POST /api/ops/cut-release and `pnpm ops cut-release` — the remote path to the same writer (workspace editor|export|all, version X.Y.Z|next|next-minor, commit, date)
Synchronous, on ops(): a bad workspace / version / date is an OpsInputError
400 before anything is read; a cut answers 200 { ok, version, results } with
each heading; a refusal is a 400 carrying the writer's sentence plus what was
already cut (`all` stops at the first failure). Revalidates the changelog
pages on any cut. ACTIONS + usage gain cut-release (the body, that `all` cuts
both with one version, and that only a release-10+ editor has the route);
archilyzer-ops.test.mjs +1.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 99 insertions(+), 0 deletions(-)
diff --git a/editor/app/api/ops/cut-release/route.ts b/editor/app/api/ops/cut-release/route.ts
@@ -0,0 +1,67 @@
+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, a
+// commit each, and stops at the first failure.
+//
+// 200 { ok: true, version, results } when every changelog was cut. A refusal
+// is a 400 { ok: false, error, version, results, notAttempted }: `results`
+// still says what WAS cut before it (`all` may have cut and committed editor).
+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,
+ },
+ );
+ });
+}
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,15 @@ 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": true (default false), "date": "YYYY-MM-DD" (default today)}.',
+ ' "all" cuts both with ONE version and commits each ("Release <workspace>',
+ ' <version>"); it stops at the first failure. 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,23 @@ 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(), /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/);