// 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[]; 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, headers: Record = 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.", }, ]); // Every 400 says whether anything was written (release 11 slice O3): a // refusal wrote nothing. A cut stopped half-way — a write or a commit that // failed after a file changed — answers `untouched: false` with the earlier // cut named in `error`; the controller's tests drive those (a git failure is // not something this server's fixture changelogs can produce). expect(again.body.untouched).toBe(true); }); 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, 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); });