Archilyzer · Source

archilyzer

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

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:
Mcommon/bin/_cli.test.ts | 20++++++++++++++++++--
Mcommon/bin/release.ts | 10++++++++--
Mcommon/controller/cutRelease.test.ts | 161++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/controller/cutRelease.ts | 226+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Meditor/app/api/ops/cut-release/route.ts | 10++++++----
Meditor/e2e/ops-cut-release.spec.ts | 44++++++++++++++++++++++++++++++--------------
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/], ];