commit 2a14a6625f7b7e3bc6f545e809c399f849f23b79
parent 2ffebd1bf45817d76be456fd7a5365746f33d155
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 19:33:15 -0400
common: `all` cuts both or neither — a preflight reads both changelogs, cuts both in memory and runs the dirty-tree guard once before either write (review M1)
It used to write (and with --commit, commit) the editor before it had looked
at the export, so "only the editor has changes" left a lone `Release editor`
commit behind a non-zero exit. The writer is now plan (read + resolve + cut
in memory, every changelog refusal) and apply (atomic write + commit, only
I/O and git can fail). `all` plans both, guards once, then applies each; a
preflight refusal names the failing workspace, lists the other as not
attempted and sets `untouched: true` — `release cut` then says "all cuts
both or neither, and nothing was written", and the route's 400 carries it.
A write or commit failing after the editor's went through is still reported
with what was done. Single-workspace order is the form's, unchanged.
Tests: cutRelease 13 → 16 (export refused → editor byte-identical and no
commit; the mirror; the guard once; a failed export write after the
preflight, skipped as root); _cli +1 case; ops-cut-release.spec's partial
`all` now expects nothing written, and the bad-input list gains an impossible
date and a version that does not move forward.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 321 insertions(+), 150 deletions(-)
diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts
@@ -228,14 +228,30 @@ test("release cut prints one line per changelog: the heading and the commit, the
formatCutLines({
ok: false,
version: "0.9.1",
- results: [{ ok: false, workspace: "editor", error: "boom" }],
+ results: [{ ok: false, workspace: "editor", error: "Could not write x: EACCES" }],
notAttempted: ["export"],
}),
[
- { line: "editor: failed — boom", failed: true },
+ { 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,
diff --git a/common/bin/release.ts b/common/bin/release.ts
@@ -77,7 +77,8 @@ export function parseShowArgs(
/**
* One line per changelog: the heading now in the file and whether it was
- * committed; the refusal; and any changelog a failure stopped before.
+ * 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) =>
@@ -95,7 +96,12 @@ export function formatCutLines(outcome: CutReleasesOutcome): { line: string; fai
: { 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 });
+ 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;
}
diff --git a/common/controller/cutRelease.test.ts b/common/controller/cutRelease.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+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";
@@ -259,56 +259,123 @@ test("all cuts both with ONE version (the higher latest, bumped) and two commits
);
});
-test("all stops at the first failure and reports what was already done", async () => {
+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: changelog({ pending: ["- e"], latest: "0.9.0" }), 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.equal(outcome.results.length, 2);
- const [first, second] = outcome.results;
- assert.equal(first.ok && first.committed, true);
- assert.equal(first.workspace, "editor");
- assert.deepEqual(second, {
+ 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: "export",
- error: "Nothing pending to release under [Unreleased].",
- });
- assert.deepEqual(outcome.notAttempted, []);
- assert.deepEqual(await subjects(root), ["Release editor 0.9.1", "init"]);
- assert.equal(await read(root, "export"), exportSource);
- },
- );
- // A failure on the FIRST workspace never reaches the second.
- const exportPending = changelog({ pending: ["- x"], latest: "0.9.0" });
- await withRepo(
- { editor: changelog({ pending: [], latest: "0.9.0" }), export: exportPending },
- async (root) => {
- const outcome = await cutReleases({
- workspace: "all",
- version: "next",
- commit: false,
- date: DATE,
- root,
- });
- assert.equal(outcome.ok, false);
- assert.deepEqual(
- outcome.results.map((r) => [r.workspace, r.ok]),
- [["editor", false]],
- );
- assert.deepEqual(outcome.notAttempted, ["export"]);
- assert.equal(await read(root, "export"), exportPending);
- },
- );
+ 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) => {
diff --git a/common/controller/cutRelease.ts b/common/controller/cutRelease.ts
@@ -151,87 +151,129 @@ async function readChangelog(
}
}
-// One workspace's cut: the guard, the read, the cut, the atomic write, the
-// commit — the order the server action always had. `mayBeDirty` is every
-// changelog this run will commit: a dirty changelog is fine (its uncommitted
-// [Unreleased] bullets are folded into the release commit), anything else
-// dirty refuses a commit BEFORE anything is written.
-async function cutOne(
+// 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,
- commit: boolean,
date: string,
- mayBeDirty: string[],
-): Promise<CutReleaseResult> {
- const fail = (error: string): CutReleaseResult => ({ ok: false, workspace, error });
- const filePath = repo.changelogs[workspace];
- const relPath = path.relative(repo.root, filePath);
- if (commit) {
- let dirty: string[];
- try {
- dirty = await listDirtyPaths(repo.root);
- } catch (err) {
- return fail(`Could not check git status: ${(err as Error).message}`);
- }
- const others = dirty.filter((p) => p !== relPath && !mayBeDirty.includes(p));
- if (others.length > 0) {
- return fail(
- `Other uncommitted changes present (${others.join(
- ", ",
- )}). Commit or stash them before cutting a release.`,
- );
- }
- }
- const read = await readChangelog(filePath);
- if (!read.ok) return fail(read.error);
- const resolved = resolveVersion(version, getLatestReleasedVersion(read.source));
+): { ok: true; cut: PlannedCut } | { ok: false; error: string } {
+ const resolved = resolveVersion(version, getLatestReleasedVersion(source));
let next: string;
try {
- next = cutRelease(read.source, resolved, date);
+ next = cutRelease(source, resolved, date);
} catch (err) {
- if (err instanceof CutReleaseError) return fail(err.message);
+ 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(filePath, next);
+ await writeFileAtomic(cut.filePath, cut.next);
} catch (err) {
- return fail(`Could not write ${filePath}: ${(err as Error).message}`);
+ return fail(`Could not write ${cut.filePath}: ${(err as Error).message}`);
}
- const heading = `## [${resolved}] - ${date}`;
if (!commit) {
- return { ok: true, workspace, version: resolved, heading, committed: false };
+ return { ok: true, workspace, version, heading, committed: false };
}
const result = await commitPath(
repo.root,
- relPath,
- `Release ${workspace} ${resolved}`,
+ cut.relPath,
+ `Release ${workspace} ${version}`,
);
if (!result.ok) {
- return fail(`Cut release ${resolved}, but the commit failed: ${result.error}`);
+ return fail(`Cut release ${version}, but the commit failed: ${result.error}`);
}
const sha = await headSha(repo.root);
return {
ok: true,
workspace,
- version: resolved,
+ version,
heading,
committed: true,
...(sha ? { commitSha: sha } : {}),
};
}
-/** Cut one workspace's changelog. What the form does. */
+/**
+ * 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 { ok: false, workspace, error: "Version is required." };
+ if (!version) return fail("Version is required.");
const date = opts.date ?? todayISO();
const repo = releaseRepo(opts.root);
- const relPath = path.relative(repo.root, repo.changelogs[workspace]);
- return cutOne(repo, workspace, version, opts.commit, date, [relPath]);
+ 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 = {
@@ -242,14 +284,23 @@ export type CutReleasesOutcome = {
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. Stops at the first failure and
- * reports what was already done.
+ * `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 },
@@ -266,48 +317,61 @@ export async function cutReleases(
const workspaces: ReleaseWorkspace[] = [...RELEASE_WORKSPACES];
const repo = releaseRepo(opts.root);
const date = opts.date ?? todayISO();
- const stopped = (
- result: CutReleaseResult,
- done: CutReleaseResult[],
+ const refused = (
+ workspace: ReleaseWorkspace,
+ error: string,
version: string | null,
- ): CutReleasesOutcome => {
- const results = [...done, result];
- return {
- ok: false,
- version,
- results,
- notAttempted: workspaces.slice(results.length),
- };
- };
- let version = opts.version.trim();
- if (!version) {
- return stopped({ ok: false, workspace: "editor", error: "Version is required." }, [], 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;
}
- if (isVersionKeyword(version)) {
+ let version = requested;
+ if (isVersionKeyword(requested)) {
let highest: string | null = null;
for (const ws of workspaces) {
- const read = await readChangelog(repo.changelogs[ws]);
- if (!read.ok) {
- // Nothing was cut: the read failed before either write.
- return {
- ok: false,
- version: null,
- results: [{ ok: false, workspace: ws, error: read.error }],
- notAttempted: workspaces.filter((w) => w !== ws),
- };
- }
- const latest = getLatestReleasedVersion(read.source);
+ const latest = getLatestReleasedVersion(sources[ws]);
if (compareVersions(latest, highest) > 0) highest = latest;
}
- version = resolveVersion(version, highest);
+ version = resolveVersion(requested, highest);
}
- const mayBeDirty = workspaces.map((ws) =>
- path.relative(repo.root, repo.changelogs[ws]),
- );
- const done: CutReleaseResult[] = [];
+ const plans: PlannedCut[] = [];
for (const ws of workspaces) {
- const result = await cutOne(repo, ws, version, opts.commit, date, mayBeDirty);
- if (!result.ok) return stopped(result, done, version);
+ 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: [] };
diff --git a/editor/app/api/ops/cut-release/route.ts b/editor/app/api/ops/cut-release/route.ts
@@ -26,12 +26,13 @@ export const dynamic = "force-dynamic";
// 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.
+// 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 }: `results`
-// still says what WAS cut before it (`all` may have cut and committed editor).
+// 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);
@@ -61,6 +62,7 @@ export async function POST(request: Request) {
version: outcome.version,
results: outcome.results,
notAttempted: outcome.notAttempted,
+ ...(outcome.untouched ? { untouched: true } : {}),
},
);
});
diff --git a/editor/e2e/ops-cut-release.spec.ts b/editor/e2e/ops-cut-release.spec.ts
@@ -29,6 +29,7 @@ type CutResponse = {
version?: string | null;
results?: Record<string, unknown>[];
notAttempted?: string[];
+ untouched?: boolean;
};
function changelog(latest: string, pending: string[] = []): string {
@@ -141,23 +142,32 @@ test("all cuts both changelogs with ONE version, resolved against the higher lat
"## [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"]));
+ // 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 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()}`);
+ 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 is a 400 before anything is written", async ({
+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"]);
@@ -169,6 +179,12 @@ test("a bad workspace, version, date or key is a 400 before anything is written"
[{ 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/],
];