Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/controller/cutRelease.test.ts | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/cutRelease.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/app/api/ops/cut-release/route.ts | 32++++++++++++++++----------------
Meditor/e2e/ops-cut-release.spec.ts | 6++++++
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 ({