commit e393cbccab6752b7ee6b837ae0f3d7ccd9b216fb
parent ad1dd112f7360625366e6fe3586d1583bb426a02
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 19:07:13 -0400
editor(e2e): ops-cut-release.spec.ts — the ops door over the fixture changelogs; the test server's EXPORT_CHANGELOG_FILE points at a gitignored copy
Three tests: an editor cut answers 200 with the heading (the form's page then
has nothing pending, and a second cut is the writer's 400); `all` cuts both
with one version (next-minor against the higher latest) and a partial `all`
400 still reports the editor it cut; every bad workspace / version / date /
commit / key is a 400 and a missing token a 401, with the file untouched.
dev:test/start:test now set EXPORT_CHANGELOG_FILE=test-export-changelog.md
(gitignored), so no spec can reach the tracked export/CHANGELOG.md through
the new route.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 184 insertions(+), 5 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/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/e2e/ops-cut-release.spec.ts b/editor/e2e/ops-cut-release.spec.ts
@@ -0,0 +1,175 @@
+// 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[];
+};
+
+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.
+ 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("Nothing pending to release under [Unreleased].");
+});
+
+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 the editor, refuses the export
+ // (no [Unreleased] heading at all), and the 400 still carries what was done.
+ await writeFile(EDITOR_CHANGELOG, changelog("10.1.0", ["- later"]));
+ await writeFile(EXPORT_CHANGELOG, RELEASED_EXPORT);
+ const partial = await cut(request, { workspace: "all", version: "10.1.1" });
+ expect(partial.status).toBe(400);
+ expect(partial.body.error).toBe(
+ "export: Could not find a `## [Unreleased]` heading to cut from.",
+ );
+ expect(partial.body.results?.map((r) => [r.workspace, r.ok])).toEqual([
+ ["editor", true],
+ ["export", false],
+ ]);
+ expect(await readFile(EDITOR_CHANGELOG, "utf8")).toContain(`## [10.1.1] - ${todayISO()}`);
+});
+
+test("a bad workspace, version, date or key is a 400 before anything 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", 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",