From 19cd94aeac72a0fbe39d20967e7362b0f0ac0b9e Mon Sep 17 00:00:00 2001 From: Ruth-2424 Date: Wed, 14 Oct 2026 11:02:29 -0400 Subject: [PATCH] build(release): while a breaking entry is pending, a new version must open a line no release has reached A dependent's caret range takes the later versions of its line, so a breaking change released as a patch reaches each dependent whose range takes it at its next fresh install. membrane 0.5.87 went out that way, with a long `### Breaking` section, and installs of agent-framework 0.21.1 (^0.5.78) and context-manager 0.14.0 (^0.5.69) took it. scripts/release-bump.mjs runs first in `preversion`, beside the release-hold check. npm gives that hook npm_old_version and npm_new_version before it writes anything (npm 8, 9, 10 and 11.17, checked with `npm version patch` in a throwaway package), so a refused bump leaves nothing bumped, written or folded. - A breaking entry is pending when changelog.d/ holds a *.breaking.md fragment, or when `## Unreleased` has a subsection whose title starts with "Breaking", in any case, with anything in it. Those are what release-changelog.mjs folds into the release. - A line is a version's minor before 1.0 (0.22.x), its major from 1.0 (1.x), and the version itself for 0.0.z, which ^0.0.z alone takes. While a breaking entry is pending, the new version must open a line no release has reached: one above the current version's, or the current one when that is a line-opening prerelease (a preminor before 1.0, a premajor from 1.0), whose line has no release a dependent's range could hold. The refusal says why, lists where each entry is filed, and gives the bump to run instead, what npm's `minor` or `major` gives, e.g. "npm version minor (0.22.1 to 0.23.0)". - A prerelease counts as the release it leads to, so one in the current line is refused too: no caret range takes a prerelease, but the version hook would fold the entries into its own section, and the next bump would find nothing pending. - A version below the current line is refused as well: dependents hold ranges on the last release, which can sit below an unfinished prerelease (from 0.23.0-0, an explicit 0.22.2 would be the newest 0.22.x). With nothing pending, a run outside `npm version` goes ahead. CONTRIBUTING gains a "Breaking changes" bullet: the rule, filing a breaking change as breaking, and that --ignore-scripts skips this check but also the fold, so the publish workflow refuses the tag for want of a changelog section. The release-holds bullet adds that a hold stops releases, not merges, so a hazard in an open pull request is said on that pull request, whose comment thread is this repository's gate. Tests (test/release-bump.test.ts, 16): what counts as pending, lines before and from 1.0 and for 0.0.z, prereleases, lower versions, runs outside npm version, and npm itself through this repository's preversion and version hook: `npm version patch`, `prepatch` and `prerelease` stop with package.json and CHANGELOG.md unchanged and the fragment still pending; `minor` and `preminor` fold it under the new section's `### Breaking`. release-holds.test.ts's wired project copies release-bump.mjs, since preversion now runs both. Each piece of the check, removed on its own, fails the tests aimed at it. Read before opening by Greta-2425, who ran every bump kind through npm 11.19.0 from 0.22.1, 1.4.2 and four prereleases. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 29 +++- package.json | 2 +- scripts/release-bump.mjs | 140 +++++++++++++++++ test/release-bump.test.ts | 301 +++++++++++++++++++++++++++++++++++++ test/release-holds.test.ts | 3 + 5 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 scripts/release-bump.mjs create mode 100644 test/release-bump.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb6012a45..cb4183a61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,9 +117,11 @@ never do. - **Breaking entries are audience-scoped.** Open the bullet by naming who needs to act (`- **Module authors:** …`) and cover: **who needs to act**, **migration**, and **unchanged** (what readers might fear broke but - didn't). Because connectome-host and other hosts pin this package by - range, a breaking change here surfaces in their next install — spell out - the minimum sibling versions it requires. + didn't). A breaking change goes out in a new minor version before 1.0 + (see **Breaking changes** below), so a host's caret range doesn't take + it until the host raises that range in a change of its own, which is + where these entries are read — spell out the minimum sibling versions it + requires. - **Editing `## Unreleased` in `CHANGELOG.md` directly still works** and is merged with the fragments at release time — it remains the right place to restructure pending entries, and the escape hatch for anything the @@ -142,6 +144,22 @@ never do. both; past it, so do a tag with no changelog section, which neither can release, and an open release hold (below), which each checks. Version bumps are a maintainer release-time action, not part of feature PRs. +- **Breaking changes.** A dependent's caret range takes the later + versions of its line (0.22.x before 1.0, 1.x from 1.0, and 0.0.z alone), + so a breaking change released as a patch reaches each dependent whose + range takes it at its next fresh install. Before 1.0, a breaking change + goes out in a new minor version (0.22.x to 0.23.0), and from 1.0 in a + new major. File it as breaking, as a `breaking` fragment or under a + `### Breaking` subsection of `Unreleased`: that is what `npm version` + reads. While such an entry is pending, `npm version` refuses a version + that doesn't open a line no release has reached, before it bumps or + writes anything, and says where each entry is filed and the bump to run + instead. A prerelease counts as the release it leads to, so one in the + current line is refused too. One that opens the next line (a preminor + before 1.0, a premajor from 1.0) isn't, and nor is any later version of + that line. A version below the current line is refused as well. npm's + `--ignore-scripts` skips this check, but it also skips the fold, and the + publish workflow refuses a tag whose version has no changelog section. - **Release holds.** When releasing `main` as it stands would harm someone running it, open an issue that says what has to happen first and label it `release-hold`, or ask a maintainer to label it. Close it @@ -156,7 +174,10 @@ never do. `npm version` it skips the changelog fold too, so the tag lands without its changelog section and the publish workflow's changelog check fails it. Publishing a tarball made earlier with `npm pack` runs - no lifecycle script, so no check sees it. + no lifecycle script, so no check sees it. A hold stops releases, not + merges, so when the hazard is in an open pull request, say so on that + pull request, naming what has to happen first: its comment thread is + the gate (see Review process). ## Building and testing diff --git a/package.json b/package.json index 5bf6c0fa6..792024938 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "typecheck": "tsc --noEmit", "pretest": "mkdir -p dist/test/fixtures && cp test/fixtures/*.mjs dist/test/fixtures/", "test": "node --test --test-force-exit dist/test/*.test.js", - "preversion": "node scripts/release-holds.mjs", + "preversion": "node scripts/release-bump.mjs && node scripts/release-holds.mjs", "version": "node scripts/release-changelog.mjs && git add CHANGELOG.md changelog.d", "prepublishOnly": "node scripts/release-holds.mjs && npm run build" }, diff --git a/scripts/release-bump.mjs b/scripts/release-bump.mjs new file mode 100644 index 000000000..b092d43bb --- /dev/null +++ b/scripts/release-bump.mjs @@ -0,0 +1,140 @@ +// Runs in npm's `preversion` lifecycle hook (see package.json), where npm +// gives both versions (npm_old_version, npm_new_version) before it writes +// anything: npm 8, 9, 10 and 11.17 all do, checked with `npm version patch` +// in a throwaway package. While a breaking entry is pending, the new version +// must open a release line that no release has reached, so that no +// dependent's caret range takes it: before 1.0 a breaking change needs a new +// minor (0.22.x to 0.23.0), and from 1.0 a new major. A host that depends on +// `^0.22.1` then keeps 0.22.x until it raises its range in a change of its +// own, where the breaking entries are read. +// +// A breaking entry is pending when changelog.d/ holds a `.breaking.md` +// fragment, or when CHANGELOG.md's `## Unreleased` section has a subsection +// whose title starts with "Breaking" (in any case) with anything in it. +// release-changelog.mjs releases those under that subsection's own title, or +// `### Breaking` for a fragment with no such subsection to join. +// +// A line is a version's minor before 1.0 (0.22.x), its major from 1.0 (1.x), +// and the version itself for 0.0.z, which `^0.0.z` alone takes. A final +// release's line has been reached. So has a prerelease's, unless it opens a +// line (x.y.0-n before 1.0, x.0.0-n from 1.0, any 0.0.z-n): from one of those, +// any version in its line goes ahead, finishing it included, since no release +// of that line exists for a dependent's range to hold. A prerelease target +// counts as the release it leads to, so a prepatch can't carry a breaking +// entry into a patch: the version hook would fold the pending entries into +// the prerelease's own section, where the next bump wouldn't see them. A +// version below the current one's line is refused too: a dependent's range +// may still hold that line. +import { existsSync, readdirSync, readFileSync } from "node:fs"; + +const CHANGELOG = "CHANGELOG.md"; +const FRAGMENT_DIR = "changelog.d"; + +/** Where each pending breaking entry is filed. */ +function pendingBreaking() { + const found = []; + if (existsSync(FRAGMENT_DIR)) { + for (const name of readdirSync(FRAGMENT_DIR).sort()) { + if (name.endsWith(".breaking.md")) found.push(`${FRAGMENT_DIR}/${name}`); + } + } + if (existsSync(CHANGELOG)) { + const text = readFileSync(CHANGELOG, "utf8"); + const header = text.match(/^## Unreleased[ \t]*$/m); + if (header) { + const after = text.slice(header.index + header[0].length); + const end = after.search(/^## /m); + const body = end === -1 ? after : after.slice(0, end); + // Subsections in order; release-changelog.mjs releases every line of a + // Breaking subsection, bullet or not. + let title = null; + for (const line of body.split("\n")) { + const h = line.match(/^###\s+(.*)$/); + if (h) { + title = h[1].trim(); + } else if (title && title.toLowerCase().startsWith("breaking") && line.trim() !== "") { + found.push(`${CHANGELOG}: '### ${title}' under '## Unreleased'`); + title = null; + } + } + } + } + return found; +} + +/** major.minor.patch with an optional prerelease, or null. */ +function parse(version) { + const m = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version ?? ""); + return m ? { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] !== undefined } : null; +} + +/** A version's line: [major] from 1.0, [0, minor] before it, [0, 0, patch] for 0.0.z. */ +function lineOf(v) { + if (v.major > 0) return [v.major]; + return v.minor > 0 ? [0, v.minor] : [0, 0, v.patch]; +} + +/** Orders two lines. */ +function compareLines(a, b) { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +const label = (line) => (line.length === 1 ? `${line[0]}.x` : line.length === 2 ? `0.${line[1]}.x` : `0.0.${line[2]}`); + +/** Whether a prerelease opens a line no release has reached. */ +function opensLine(v) { + if (!v.pre) return false; + if (v.major > 0) return v.minor === 0 && v.patch === 0; + return v.minor > 0 ? v.patch === 0 : true; +} + +/** The version npm's `minor` (before 1.0) or `major` bump gives, which opens the next line. */ +function nextLine(v) { + if (v.major > 0) return opensLine(v) ? `${v.major}.0.0` : `${v.major + 1}.0.0`; + if (v.minor === 0) return "0.1.0"; + return opensLine(v) ? `0.${v.minor}.0` : `0.${v.minor + 1}.0`; +} + +function main() { + const pending = pendingBreaking(); + if (pending.length === 0) return 0; + const oldVersion = process.env.npm_old_version; + const newVersion = process.env.npm_new_version; + const old = parse(oldVersion); + const next = parse(newVersion); + if (!old || !next) { + console.error( + "release-bump: a breaking change is pending, and this check needs npm version's old and new versions " + + `to decide (npm_old_version=${oldVersion ?? "unset"}, npm_new_version=${newVersion ?? "unset"}): ` + + "run it through npm version.", + ); + return 1; + } + const order = compareLines(lineOf(next), lineOf(old)); + if (order > 0 || (order === 0 && opensLine(old))) return 0; + const line = label(lineOf(old)); + const why = + order < 0 + ? `goes back to ${label(lineOf(next))}, which a dependent's caret range may still hold` + : next.pre + ? `stays in ${line}: no caret range takes a prerelease, but the version hook would fold the pending ` + + "entries into its own section, where the next version's check wouldn't see them" + : `stays in ${line}, which a dependent's caret range on ${line} takes`; + const [level, rule] = + old.major === 0 + ? ["minor", "Before 1.0, a breaking change goes out in a new minor version"] + : ["major", "A breaking change goes out in a new major version"]; + console.error( + `Release refused: a breaking change is pending, and ${oldVersion} to ${newVersion} ${why}.\n` + + pending.map((place) => ` ${place}\n`).join("") + + `${rule}, which a dependent's range doesn't take until the dependent raises it. ` + + `Run npm version ${level} (${oldVersion} to ${nextLine(old)}). Nothing has been bumped or written.`, + ); + return 1; +} + +process.exitCode = main(); diff --git a/test/release-bump.test.ts b/test/release-bump.test.ts new file mode 100644 index 000000000..2f59a19f8 --- /dev/null +++ b/test/release-bump.test.ts @@ -0,0 +1,301 @@ +// Black-box coverage of scripts/release-bump.mjs. Most cases run the script +// as npm's preversion hook runs it, with npm_old_version and npm_new_version +// set, in a throwaway directory holding its own changelog. The last cases +// run npm itself with this repository's preversion entry, and with the +// version hook (release-changelog.mjs) beside it, so the wiring that makes +// `npm version` refuse before it writes anything is tested too. +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = fileURLToPath(new URL("../../scripts/release-bump.mjs", import.meta.url)); +const HOLDS = fileURLToPath(new URL("../../scripts/release-holds.mjs", import.meta.url)); +const FOLD = fileURLToPath(new URL("../../scripts/release-changelog.mjs", import.meta.url)); +const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url)); + +const made: string[] = []; +after(() => { + for (const dir of made) rmSync(dir, { recursive: true, force: true }); +}); + +const UNRELEASED = "# Changelog\n\n## Unreleased\n\n## 0.22.1 — 2026-10-14\n\n### Breaking\n\n- An old breaking entry, already released.\n"; + +/** A throwaway project: package.json at `version`, a CHANGELOG.md, and + * changelog.d/ holding `fragments` (name to body). */ +function project(version: string, fragments: Record = {}, changelog = UNRELEASED): string { + const dir = mkdtempSync(join(tmpdir(), "release-bump-")); + made.push(dir); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "fixture", version, repository: "github:owner/repo" }), + ); + writeFileSync(join(dir, "CHANGELOG.md"), changelog); + mkdirSync(join(dir, "changelog.d")); + writeFileSync(join(dir, "changelog.d", "README.md"), "Fragments go here.\n"); + for (const [name, body] of Object.entries(fragments)) writeFileSync(join(dir, "changelog.d", name), body); + return dir; +} + +/** A changelog whose Unreleased section holds `title` with `body` under it. */ +const filed = (title: string, body: string) => + `# Changelog\n\n## Unreleased\n\n### ${title}\n\n${body}\n## 0.22.1 — 2026-10-14\n\n### Fixed\n\n- A fix.\n`; + +function exec( + file: string, + args: string[], + cwd: string, + env: Record, +): Promise<{ code: number; stderr: string }> { + return new Promise((resolve) => { + execFile(file, args, { cwd, env: { PATH: process.env.PATH ?? "", ...env } }, (error, _stdout, stderr) => { + resolve({ code: error ? Number(error.code ?? 1) : 0, stderr }); + }); + }); +} + +/** The script as preversion runs it, going from `from` to `to`. */ +const bump = (dir: string, from: string, to: string) => + exec(process.execPath, [SCRIPT], dir, { npm_old_version: from, npm_new_version: to }); + +const BREAKING = { "300-retry-hint.breaking.md": "- **Hosts:** a breaking change.\n" }; +const goesAhead = { code: 0, stderr: "" }; + +test("a pending breaking fragment refuses a patch, naming it and the bump that would do", async () => { + const result = await bump(project("0.22.1", BREAKING), "0.22.1", "0.22.2"); + assert.equal(result.code, 1); + assert.equal( + result.stderr, + "Release refused: a breaking change is pending, and 0.22.1 to 0.22.2 stays in 0.22.x, which a dependent's caret range on 0.22.x takes.\n" + + " changelog.d/300-retry-hint.breaking.md\n" + + "Before 1.0, a breaking change goes out in a new minor version, which a dependent's range doesn't take until the dependent raises it. " + + "Run npm version minor (0.22.1 to 0.23.0). Nothing has been bumped or written.\n", + ); +}); + +test("a new minor, or 1.0, takes a pending breaking fragment", async () => { + const dir = project("0.22.1", BREAKING); + assert.deepEqual(await bump(dir, "0.22.1", "0.23.0"), goesAhead); + assert.deepEqual(await bump(dir, "0.22.1", "1.0.0"), goesAhead); +}); + +test("with no breaking entry pending, a patch goes ahead", async () => { + const dir = project("0.22.1", { "301-fix.fixed.md": "- A fix.\n", "302-new.added.md": "- A feature.\n" }); + assert.deepEqual(await bump(dir, "0.22.1", "0.22.2"), goesAhead); +}); + +test("anything filed under a Breaking subsection of Unreleased counts, whatever its form or its title's case", async () => { + const bodies = [ + "- **Module authors:** a bullet.\n", + "* a star bullet.\n", + " - an indented bullet.\n", + "+ a plus bullet.\n", + "1. A numbered item.\n", + "Prose, with no list at all.\n", + "| a | table |\n|---|---|\n", + ]; + for (const body of bodies) { + const result = await bump(project("0.22.1", {}, filed("Breaking", body)), "0.22.1", "0.22.2"); + assert.equal(result.code, 1, `refused for ${JSON.stringify(body)}`); + assert.match(result.stderr, /\n {2}CHANGELOG\.md: '### Breaking' under '## Unreleased'\n/); + } + for (const title of ["Breaking (module authors only)", "BREAKING", "breaking changes"]) { + const result = await bump(project("0.22.1", {}, filed(title, "- A change.\n")), "0.22.1", "0.22.2"); + assert.equal(result.code, 1, `refused under '### ${title}'`); + assert.ok(result.stderr.includes(` CHANGELOG.md: '### ${title}' under '## Unreleased'\n`), result.stderr); + } +}); + +test("an empty Breaking heading, or a Breaking section already released, isn't pending", async () => { + assert.deepEqual(await bump(project("0.22.1", {}, filed("Breaking", "")), "0.22.1", "0.22.2"), goesAhead); + assert.deepEqual(await bump(project("0.22.1"), "0.22.1", "0.22.2"), goesAhead); +}); + +test("each pending breaking entry is named once", async () => { + const changelog = "## Unreleased\n\n### Breaking\n\n- **Hosts:** filed directly.\n- **Module authors:** and another.\n"; + const result = await bump( + project("0.22.1", { ...BREAKING, "303-another.breaking.md": "- **Module authors:** another.\n" }, changelog), + "0.22.1", + "0.22.2", + ); + assert.equal(result.code, 1); + assert.match( + result.stderr, + /\n {2}changelog\.d\/300-retry-hint\.breaking\.md\n {2}changelog\.d\/303-another\.breaking\.md\n {2}CHANGELOG\.md: '### Breaking' under '## Unreleased'\nBefore 1\.0/, + ); +}); + +test("from 1.0, a breaking change needs a new major", async () => { + const dir = project("1.4.2", BREAKING); + const minor = await bump(dir, "1.4.2", "1.5.0"); + assert.equal(minor.code, 1); + assert.match(minor.stderr, /1\.4\.2 to 1\.5\.0 stays in 1\.x, which a dependent's caret range on 1\.x takes\./); + assert.match(minor.stderr, /A breaking change goes out in a new major version, /); + assert.match(minor.stderr, /Run npm version major \(1\.4\.2 to 2\.0\.0\)\./); + assert.deepEqual(await bump(dir, "1.4.2", "2.0.0"), goesAhead); +}); + +test("a prerelease counts as the release it leads to: a prepatch can't carry a breaking change, a preminor can", async () => { + const dir = project("0.22.1", BREAKING); + const prepatch = await bump(dir, "0.22.1", "0.22.2-0"); + assert.equal(prepatch.code, 1); + assert.match( + prepatch.stderr, + /0\.22\.1 to 0\.22\.2-0 stays in 0\.22\.x: no caret range takes a prerelease, but the version hook would fold the pending entries into its own section, where the next version's check wouldn't see them\./, + ); + assert.deepEqual(await bump(dir, "0.22.1", "0.23.0-0"), goesAhead); + assert.deepEqual(await bump(dir, "1.4.2", "2.0.0-0"), goesAhead); +}); + +test("finishing a prerelease: a new line's goes ahead, a patch's is refused as a patch", async () => { + const dir = project("0.23.0-1", BREAKING); + // 0.23 and 2.x have no release yet, so no dependent's range holds them. + assert.deepEqual(await bump(dir, "0.23.0-1", "0.23.0"), goesAhead); + assert.deepEqual(await bump(dir, "0.23.0-1", "0.23.1"), goesAhead); + assert.deepEqual(await bump(dir, "2.0.0-0", "2.0.0"), goesAhead); + // 0.22.2-0 leads to a patch of 0.22, and 1.4.0-1 to a minor of 1.x. + const patch = await bump(dir, "0.22.2-0", "0.22.2"); + assert.equal(patch.code, 1); + assert.match(patch.stderr, /0\.22\.2-0 to 0\.22\.2 stays in 0\.22\.x, which a dependent's caret range on 0\.22\.x takes\./); + assert.match(patch.stderr, /Run npm version minor \(0\.22\.2-0 to 0\.23\.0\)\./); + const minor = await bump(dir, "1.4.0-1", "1.4.0"); + assert.equal(minor.code, 1); + assert.match(minor.stderr, /Run npm version major \(1\.4\.0-1 to 2\.0\.0\)\./); +}); + +test("^0.0.z takes nothing but itself, so any bump goes ahead", async () => { + assert.deepEqual(await bump(project("0.0.3", BREAKING), "0.0.3", "0.0.4"), goesAhead); +}); + +test("a version below the current line is refused too, since a dependent's range may still hold it", async () => { + const dir = project("0.22.1", BREAKING); + const older = await bump(dir, "0.22.1", "0.21.9"); + assert.equal(older.code, 1); + assert.match(older.stderr, /0\.22\.1 to 0\.21\.9 goes back to 0\.21\.x, which a dependent's caret range may still hold\./); + // From a prerelease that opens 0.23: 0.22.2 is above every 0.22 release so far. + const back = await bump(dir, "0.23.0-0", "0.22.2"); + assert.equal(back.code, 1); + assert.match(back.stderr, /0\.23\.0-0 to 0\.22\.2 goes back to 0\.22\.x/); + assert.match(back.stderr, /Run npm version minor \(0\.23\.0-0 to 0\.23\.0\)\./); + // Lower but in the current line: still the line a dependent's range takes. + const lower = await bump(dir, "0.22.5", "0.22.3"); + assert.equal(lower.code, 1); + assert.match(lower.stderr, /0\.22\.5 to 0\.22\.3 stays in 0\.22\.x, which a dependent's caret range on 0\.22\.x takes\./); + assert.equal((await bump(dir, "1.4.2", "1.3.0")).code, 1); +}); + +test("a final release that opened its line has reached it: a patch or minor of it is refused", async () => { + const dir = project("0.23.0", BREAKING); + const patch = await bump(dir, "0.23.0", "0.23.1"); + assert.equal(patch.code, 1); + assert.match(patch.stderr, /0\.23\.0 to 0\.23\.1 stays in 0\.23\.x/); + const minor = await bump(dir, "2.0.0", "2.1.0"); + assert.equal(minor.code, 1); + assert.match(minor.stderr, /2\.0\.0 to 2\.1\.0 stays in 2\.x/); +}); + +test("entries under other subsections of Unreleased aren't pending", async () => { + const changelog = "## Unreleased\n\n### Fixed\n\n- A fix.\n\n### Changed (module authors)\n\nProse about a change.\n"; + assert.deepEqual(await bump(project("0.22.1", {}, changelog), "0.22.1", "0.22.2"), goesAhead); +}); + +test("run outside npm version, it goes ahead with nothing pending, and refuses, saying why, with something pending", async () => { + assert.deepEqual(await exec(process.execPath, [SCRIPT], project("0.22.1"), {}), goesAhead); + const result = await exec(process.execPath, [SCRIPT], project("0.22.1", BREAKING), {}); + assert.equal(result.code, 1); + assert.equal( + result.stderr, + "release-bump: a breaking change is pending, and this check needs npm version's old and new versions to decide " + + "(npm_old_version=unset, npm_new_version=unset): run it through npm version.\n", + ); + const half = await exec(process.execPath, [SCRIPT], project("0.22.1", BREAKING), { npm_old_version: "0.22.1" }); + assert.equal(half.code, 1); + assert.match(half.stderr, /\(npm_old_version=0\.22\.1, npm_new_version=unset\)/); +}); + +/** A local stand-in for GitHub's issues endpoint with no hold open, for the + * hold check that preversion also runs. */ +async function noHolds(): Promise<{ url: string; close: () => Promise }> { + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("[]"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { url: `http://127.0.0.1:${port}`, close: () => new Promise((resolve) => server.close(() => resolve())) }; +} + +/** A throwaway project whose preversion is this repository's own, and whose + * version hook is its changelog fold, with the scripts beside them. (The + * repository's version hook also stages the fold with git, which this + * project, with no repository, leaves out.) */ +function wiredProject(fragments: Record): string { + const scripts = (JSON.parse(readFileSync(PACKAGE_JSON, "utf8")) as { scripts: Record }).scripts; + const dir = project("0.22.1", fragments); + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as Record; + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ ...pkg, scripts: { preversion: scripts.preversion, version: "node scripts/release-changelog.mjs" } }), + ); + mkdirSync(join(dir, "scripts")); + copyFileSync(SCRIPT, join(dir, "scripts", "release-bump.mjs")); + copyFileSync(HOLDS, join(dir, "scripts", "release-holds.mjs")); + copyFileSync(FOLD, join(dir, "scripts", "release-changelog.mjs")); + writeFileSync(join(dir, ".npmrc"), ""); + return dir; +} + +/** npm, kept to the throwaway directory and the stand-in for GitHub. */ +function npm(dir: string, args: string[], api: string) { + return exec("npm", args, dir, { + HOME: dir, + npm_config_cache: join(dir, ".npm-cache"), + npm_config_userconfig: join(dir, ".npmrc"), + npm_config_update_notifier: "false", + npm_config_fund: "false", + npm_config_audit: "false", + GITHUB_API_URL: api, + }); +} + +const versionOf = (dir: string) => (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version: string }).version; +const fragmentPending = (dir: string) => existsSync(join(dir, "changelog.d", "300-retry-hint.breaking.md")); + +test("this repository's preversion stops npm version patch, prepatch and prerelease before anything is written", async () => { + const gh = await noHolds(); + try { + for (const level of ["patch", "prepatch", "prerelease"]) { + const dir = wiredProject(BREAKING); + const changelog = readFileSync(join(dir, "CHANGELOG.md"), "utf8"); + const result = await npm(dir, ["version", level, "--no-git-tag-version"], gh.url); + assert.notEqual(result.code, 0, level); + assert.match(result.stderr, /Release refused: a breaking change is pending, and 0\.22\.1 to 0\.22\.2(-0)? stays in 0\.22\.x/); + assert.equal(versionOf(dir), "0.22.1", `${level}: package.json unchanged`); + assert.ok(fragmentPending(dir), `${level}: the fragment is still pending, not folded`); + assert.equal(readFileSync(join(dir, "CHANGELOG.md"), "utf8"), changelog, `${level}: CHANGELOG.md unchanged`); + } + } finally { + await gh.close(); + } +}); + +test("this repository's preversion lets npm version minor, and preminor, take a breaking change", async () => { + const gh = await noHolds(); + try { + for (const [level, version] of [["minor", "0.23.0"], ["preminor", "0.23.0-0"]]) { + const dir = wiredProject(BREAKING); + const result = await npm(dir, ["version", level!, "--no-git-tag-version"], gh.url); + assert.equal(result.code, 0, result.stderr); + assert.equal(versionOf(dir), version); + assert.ok(!fragmentPending(dir), `${level}: the version hook folded the fragment`); + assert.match(readFileSync(join(dir, "CHANGELOG.md"), "utf8"), new RegExp(`## ${version.replace(/[.]/g, "\\.")} — [^\\n]*\\n\\n### Breaking\\n\\n- \\*\\*Hosts:\\*\\* a breaking change\\.`)); + } + } finally { + await gh.close(); + } +}); diff --git a/test/release-holds.test.ts b/test/release-holds.test.ts index 9ecbb5356..e47e33576 100644 --- a/test/release-holds.test.ts +++ b/test/release-holds.test.ts @@ -17,6 +17,8 @@ const SCRIPT = fileURLToPath( new URL("../../scripts/release-holds.mjs", import.meta.url), ); const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url)); +// preversion runs the bump check before the hold check. +const BUMP = fileURLToPath(new URL("../../scripts/release-bump.mjs", import.meta.url)); interface Seen { url: string; @@ -405,6 +407,7 @@ function wiredProject(): string { }); mkdirSync(join(dir, "scripts")); copyFileSync(SCRIPT, join(dir, "scripts", "release-holds.mjs")); + copyFileSync(BUMP, join(dir, "scripts", "release-bump.mjs")); writeFileSync(join(dir, ".npmrc"), ""); return dir; }