commit 6b8f38373580e248895f9d305eb4d5f07465494d
parent e0cea7ec237d7fd9ccc6cce7860291bb2b3bcf41
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:44:44 -0400
common: a cut whose commit fails after the write says so; a half-way all names the earlier cut
Release 10 slice P review L3 + L4.
L3: applyCut's commit failure now returns { ok: false, error, written:
true }: the changelog on disk carries the new heading, and a bare
{ ok: false } could not be told from a refusal, so nothing revalidated.
L4: CutReleasesOutcome.untouched is set on every outcome (true: no
changelog was written, every refusal included; false once anything was
written, a written-then-failed commit included), and describeCutFailure
gives the one sentence for a cut that did not fully happen: the failure,
prefixed with its workspace for `all`, then "Before it, editor was already
cut (## [x] - date, committed <sha8>)." The ops route answers every 400
with that `error` and `untouched`, and revalidates whenever a file changed.
cutRelease.test.ts +4 (a pre-commit hook that refuses stands in for any git
failure past the guard; a first write that fails) and the half-way test
now pins untouched: false and the sentence; ops-cut-release.spec.ts pins
untouched: true on a refusal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 242 insertions(+), 23 deletions(-)
diff --git a/common/controller/cutRelease.test.ts b/common/controller/cutRelease.test.ts
@@ -7,6 +7,7 @@ import { execa } from "execa";
import {
cutReleaseForWorkspace,
cutReleases,
+ describeCutFailure,
describeRelease,
resolveVersion,
todayISO,
@@ -363,12 +364,24 @@ test(
root,
}).finally(() => chmod(exportDir, 0o755));
assert.equal(outcome.ok, false);
- assert.equal(outcome.untouched, undefined);
+ // Something WAS written (release 11 slice O3; it used to be absent).
+ assert.equal(outcome.untouched, false);
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.equal(!second.ok && second.written, undefined);
assert.deepEqual(outcome.notAttempted, []);
+ // The one sentence names the failure AND the cut before it (review L4:
+ // the route's `error` used to name only the export).
+ const editorSha = first.ok ? (first.commitSha ?? "").slice(0, 8) : "";
+ assert.match(
+ describeCutFailure(outcome, "all"),
+ new RegExp(
+ `^export: Could not write .*export/CHANGELOG\\.md: EACCES[^\\n]*\\. ` +
+ `Before it, editor was already cut \\(## \\[0\\.9\\.1\\] - ${DATE}, committed ${editorSha}\\)\\.$`,
+ ),
+ );
assert.deepEqual(await subjects(root), ["Release editor 0.9.1", "init"]);
assert.equal(await read(root, "export"), exportSource);
},
@@ -505,3 +518,146 @@ test("resolveVersion maps the keywords and keeps a literal", () => {
assert.equal(resolveVersion("next-minor", null), "0.1.0");
assert.equal(resolveVersion(" 2.0.0 ", "0.9.0"), "2.0.0");
});
+
+// A COMMIT THAT FAILS AFTER THE WRITE (release 11 slice O3, release 10 slice P
+// review L3). The changelog on disk has its new heading; the result used to be
+// a bare { ok: false } that nothing could tell from a refusal, so neither the
+// form nor the route revalidated the pages that render it. A pre-commit hook
+// that refuses stands in for any git failure past the guard.
+async function refuseCommits(root: string): Promise<void> {
+ const hooks = path.join(root, ".git", "hooks");
+ await mkdir(hooks, { recursive: true });
+ await writeFile(
+ path.join(hooks, "pre-commit"),
+ "#!/bin/sh\necho 'hook says no' >&2\nexit 1\n",
+ );
+ await chmod(path.join(hooks, "pre-commit"), 0o755);
+ // Local config beats a global core.hooksPath, which would skip .git/hooks.
+ await git(root, "config", "core.hooksPath", hooks);
+}
+
+test("a commit that fails after the write says the file was written (written: true, not untouched)", async () => {
+ const source = changelog({ pending: ["- x"], latest: "0.9.0" });
+ await withRepo({ editor: source }, async (root) => {
+ await refuseCommits(root);
+ const result = await cutReleaseForWorkspace({
+ workspace: "editor",
+ version: "next",
+ commit: true,
+ date: DATE,
+ root,
+ });
+ assert.equal(result.ok, false);
+ assert.equal(!result.ok && result.written, true);
+ assert.match(
+ !result.ok ? result.error : "",
+ /^Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/,
+ );
+ assert.match(await read(root, "editor"), new RegExp(`## \\[0\\.9\\.1\\] - ${DATE}\\n- x`));
+ assert.deepEqual(await subjects(root), ["init"]);
+ });
+ // Through cutReleases, a single workspace: not untouched, and the sentence
+ // is the writer's.
+ await withRepo({ editor: source }, async (root) => {
+ await refuseCommits(root);
+ const outcome = await cutReleases({
+ workspace: "editor",
+ version: "next",
+ commit: true,
+ date: DATE,
+ root,
+ });
+ assert.equal(outcome.ok, false);
+ assert.equal(outcome.untouched, false);
+ assert.match(
+ describeCutFailure(outcome, "editor"),
+ /^Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/,
+ );
+ });
+});
+
+test("all whose first commit fails after its write: stopped, not untouched, the export not tried", async () => {
+ const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" });
+ await withRepo(
+ { editor: changelog({ pending: ["- e"], latest: "0.9.0" }), export: exportSource },
+ async (root) => {
+ await refuseCommits(root);
+ const outcome = await cutReleases({
+ workspace: "all",
+ version: "next",
+ commit: true,
+ date: DATE,
+ root,
+ });
+ assert.equal(outcome.ok, false);
+ assert.equal(outcome.untouched, false);
+ assert.deepEqual(outcome.notAttempted, ["export"]);
+ assert.equal(outcome.results.length, 1);
+ const [editor] = outcome.results;
+ assert.equal(editor.workspace, "editor");
+ assert.equal(!editor.ok && editor.written, true);
+ assert.match(
+ describeCutFailure(outcome, "all"),
+ /^editor: Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/,
+ );
+ assert.match(await read(root, "editor"), new RegExp(`## \\[0\\.9\\.1\\] - ${DATE}`));
+ assert.equal(await read(root, "export"), exportSource);
+ assert.deepEqual(await subjects(root), ["init"]);
+ },
+ );
+});
+
+test("untouched is true for every refusal and false for a clean cut; a refusal's sentence is the writer's", async () => {
+ await withRepo({ editor: changelog({ pending: [], latest: "0.9.0" }) }, async (root) => {
+ const refused = await cutReleases({
+ workspace: "editor",
+ version: "next",
+ commit: false,
+ date: DATE,
+ root,
+ });
+ assert.equal(refused.untouched, true);
+ assert.equal(
+ describeCutFailure(refused, "editor"),
+ "Nothing pending to release under [Unreleased].",
+ );
+ });
+ await withRepo({ editor: changelog({ pending: ["- x"], latest: "0.9.0" }) }, async (root) => {
+ const cut = await cutReleases({
+ workspace: "editor",
+ version: "next",
+ commit: false,
+ date: DATE,
+ root,
+ });
+ assert.equal(cut.ok, true);
+ assert.equal(cut.untouched, false);
+ });
+});
+
+test(
+ "all whose FIRST write fails wrote nothing: untouched, the export not tried",
+ { skip: isRoot ? "root ignores directory permissions" : false },
+ 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) => {
+ const editorDir = path.join(root, "editor");
+ await chmod(editorDir, 0o555);
+ const outcome = await cutReleases({
+ workspace: "all",
+ version: "next",
+ commit: false,
+ date: DATE,
+ root,
+ }).finally(() => chmod(editorDir, 0o755));
+ assert.equal(outcome.ok, false);
+ assert.equal(outcome.untouched, true);
+ assert.deepEqual(outcome.notAttempted, ["export"]);
+ assert.match(describeCutFailure(outcome, "all"), /^editor: Could not write .*: EACCES/);
+ assert.doesNotMatch(describeCutFailure(outcome, "all"), /Before it/);
+ assert.equal(await read(root, "editor"), editorSource);
+ assert.equal(await read(root, "export"), exportSource);
+ });
+ },
+);
diff --git a/common/controller/cutRelease.ts b/common/controller/cutRelease.ts
@@ -128,7 +128,16 @@ export type CutReleaseResult =
// HEAD after the release commit; only when `committed`.
commitSha?: string;
}
- | { ok: false; workspace: ReleaseWorkspace; error: string };
+ | {
+ ok: false;
+ workspace: ReleaseWorkspace;
+ error: string;
+ // THE FILE WAS WRITTEN; only the commit after it failed (release 11
+ // slice O3, release 10 review L3). The changelog on disk now carries the
+ // new heading, so a caller that revalidates pages on a cut must do so
+ // here too. Absent on every other failure: nothing was written.
+ written?: true;
+ };
export type CutReleaseOptions = {
workspace: ReleaseWorkspace;
@@ -237,7 +246,12 @@ async function applyCut(
`Release ${workspace} ${version}`,
);
if (!result.ok) {
- return fail(`Cut release ${version}, but the commit failed: ${result.error}`);
+ return {
+ ok: false,
+ workspace,
+ error: `Cut release ${version}, but the commit failed: ${result.error}`,
+ written: true,
+ };
}
const sha = await headSha(repo.root);
return {
@@ -284,10 +298,20 @@ 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;
+ // No changelog was written. True for every refusal, `all`'s preflight
+ // included; false once any file was written, a cut whose commit then failed
+ // (`written`) included. cutReleases ALWAYS sets it (release 11 slice O3, so
+ // a caller can tell a refusal from a cut stopped half-way — review L4); it
+ // is optional only so a hand-built outcome (the CLI's display tests) need
+ // not carry it, and there absent reads as false.
+ untouched?: boolean;
};
+// Whether any result of a run wrote its changelog.
+function nothingWritten(results: readonly CutReleaseResult[]): boolean {
+ return !results.some((r) => r.ok || r.written === 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
@@ -312,6 +336,7 @@ export async function cutReleases(
version: result.ok ? result.version : null,
results: [result],
notAttempted: [],
+ untouched: nothingWritten([result]),
};
}
const workspaces: ReleaseWorkspace[] = [...RELEASE_WORKSPACES];
@@ -365,16 +390,48 @@ export async function cutReleases(
for (const cut of plans) {
const result = await applyCut(repo, cut, opts.commit);
if (!result.ok) {
+ const results = [...done, result];
return {
ok: false,
version,
- results: [...done, result],
+ results,
notAttempted: workspaces.slice(done.length + 1),
+ untouched: nothingWritten(results),
};
}
done.push(result);
}
- return { ok: true, version, results: done, notAttempted: [] };
+ return { ok: true, version, results: done, notAttempted: [], untouched: false };
+}
+
+/**
+ * The one sentence for a cut that did not fully happen — the ops route's
+ * top-level `error` (release 11 slice O3, release 10 review L4). The failing
+ * workspace's refusal (prefixed with its name for `all`), then whatever was
+ * already cut before it: an `all` stopped half-way by I/O or git has cut and
+ * possibly committed the editor, and a caller that reads only `error` must not
+ * be told only about the export.
+ */
+export function describeCutFailure(
+ outcome: CutReleasesOutcome,
+ target: ReleaseTarget,
+): string {
+ const failure = outcome.results.find((r) => !r.ok);
+ if (!failure || failure.ok) return "cut failed";
+ const head = target === "all" ? `${failure.workspace}: ${failure.error}` : failure.error;
+ const done = outcome.results.flatMap((r) =>
+ r.ok
+ ? [
+ `${r.workspace} was already cut (${r.heading}, ${
+ r.committed
+ ? `committed${r.commitSha ? ` ${r.commitSha.slice(0, 8)}` : ""}`
+ : "not committed"
+ })`,
+ ]
+ : [],
+ );
+ if (done.length === 0) return head;
+ return `${head.replace(/\.$/, "")}. Before it, ${done.join("; ")}.`;
}
export type ReleaseSummary =
diff --git a/editor/app/api/ops/cut-release/route.ts b/editor/app/api/ops/cut-release/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import {
cutReleases,
dateProblem,
+ describeCutFailure,
RELEASE_TARGETS,
versionSpecProblem,
} from "yt-dlp-transcript-common/controller/cutRelease";
@@ -30,9 +31,12 @@ export const dynamic = "force-dynamic";
// 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, untouched? }.
-// `untouched: true` means `all` was refused before writing anything; without
-// it, `results` says what WAS done before a write or a commit failed.
+// is a 400 { ok: false, error, version, results, notAttempted, untouched }.
+// `untouched` is always there (release 11 slice O3): true means no changelog
+// was written — every refusal, `all`'s preflight included; false means a cut
+// stopped half-way (a write or a commit failed after something was written),
+// and then `error` names what was already cut as well as what failed, and a
+// result with `written: true` is a changelog cut whose commit failed.
export async function POST(request: Request) {
return ops(request, ["workspace", "version", "commit", "date"], async (body) => {
const workspace = oneOf(body, "workspace", RELEASE_TARGETS);
@@ -45,7 +49,9 @@ export async function POST(request: Request) {
const commit = optBool(body, "commit") ?? false;
const outcome = await cutReleases({ workspace, version, commit, date });
- if (outcome.results.some((r) => r.ok)) revalidateAfterReleaseCut();
+ // Whenever a changelog changed on disk — a cut whose commit then failed
+ // included (review L3: it used to revalidate nothing).
+ if (outcome.untouched !== true) revalidateAfterReleaseCut();
if (outcome.ok) {
return NextResponse.json({
ok: true,
@@ -53,17 +59,11 @@ export async function POST(request: Request) {
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,
- ...(outcome.untouched ? { untouched: true } : {}),
- },
- );
+ return opsFail(describeCutFailure(outcome, workspace), 400, {
+ version: outcome.version,
+ results: outcome.results,
+ notAttempted: outcome.notAttempted,
+ untouched: outcome.untouched === true,
+ });
});
}
diff --git a/editor/e2e/ops-cut-release.spec.ts b/editor/e2e/ops-cut-release.spec.ts
@@ -116,6 +116,12 @@ test("cut-release cuts the editor changelog and answers with the heading it wrot
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 ({