diff --git a/.claude/commands/prepare-release.md b/.claude/commands/prepare-release.md index 5d599eaa47..2d9ba9d3e5 100644 --- a/.claude/commands/prepare-release.md +++ b/.claude/commands/prepare-release.md @@ -89,6 +89,11 @@ requires `--from`/`--to` overrides before doing anything else. exists; - the computed `x.y.0` already exists as a git tag or as a published npm version of `@mittwald/flow-react-components`. + - **Report** (not a stop): the migration entries still on the `UNRELEASED` + placeholder — `pnpm release:check-unreleased` lists every one and exits 1. + Failing here is the **normal** state: those entries are exactly what Step + 10 resolves to `x.y.0`. Name them in the preview so the maintainer knows + which migrations this release carries. 6. **Collect raw material.** List the Conventional Commits in `origin/..origin/`. Group by type, capture scope and PR number @@ -177,6 +182,16 @@ requires `--from`/`--to` overrides before doing anything else. --yes --no-push --tag-version-prefix "" git tag -d x.y.0 + # Resolve the migration entries that could not name their version at PR + # time (#2890) and fold the result into the graduation commit. + pnpm release:resolve-unreleased --current --target x.y.0 + pnpm nx build codemods # regenerates the guide from the catalogue + pnpm release:check-unreleased # HARD-STOP on a surviving placeholder + git add 'packages/*/MIGRATION.md' \ + packages/codemods/src/migrations \ + packages/codemods/src/migrations.generated.ts + git commit --amend --no-edit + git push origin release/x.y.0 ``` @@ -211,6 +226,44 @@ requires `--from`/`--to` overrides before doing anything else. comparing `x.y.0-next.N...x.y.0` — that is expected, and exactly why the GitHub release body comes from the curated marker block, not this file.) + **The migration guides get their version here, and only here (#2890).** A + migration entry authored in a `feat:` PR cannot name the version it + applies to: the PR lands on `next` and is promoted later, in a bundle + whose `x.y.0` depends on what else is promoted with it. So the author + writes the literal `UNRELEASED`, and this step fills it in — the first + moment both numbers are known. + + `release:resolve-unreleased` globs the guides rather than hardcoding them, + and works one layer down in each case: + + - `packages/codemods/src/migrations//entry.md` — the `since:` + frontmatter field. `packages/components/MIGRATION.md` is **generated** + from that catalogue, so the rewrite goes into the entry and + `pnpm nx build codemods` regenerates the guide. Writing into the + generated file would be reverted by the next build; the script detects + the `AUTO-GENERATED` marker and refuses. + - A hand-written `packages/*/MIGRATION.md` (today only `ext-bridge`'s) — + the `## From version …` heading, rewritten to name `` and + `>=x.y.0`. All placeholder sections of one release **collapse** into a + single heading, bodies concatenated in document order at the position of + the first: three promoted PRs each carrying an entry must not produce + three identical headings. + + `release:check-unreleased` is the guard, and it is a hard stop. A + placeholder that survives into a published release is a silent defect — + the entry sits in the guide with no version a reader can match against + their own. Run it **after** the regeneration, or it cannot see the guide + the catalogue produced. + + The placeholder is **`next`-only**. Nothing resolves one on the hotfix + path: a `fix:` PR straight onto `main` is released by `publish.yml`, which + never runs this command, and a `fix:` author knows their version anyway + (last published, `>=` the next patch). A placeholder that lands on `main` + is forward-merged into `next` and would be resolved by the next promotion + — under a version far later than the one it actually shipped in. That is + why the convention is documented as `next`-only in + [`packages/codemods/AGENTS.md`](../../packages/codemods/AGENTS.md). + - Open a **Draft** PR into ``. The title must be a **Conventional Commit** — `commit-guard.yml` lints every PR title and rejected a plain `Release x.y.0` in the #2769 rehearsal. It must also **not** begin with diff --git a/.github/scripts/migration-unreleased-lib.mjs b/.github/scripts/migration-unreleased-lib.mjs new file mode 100644 index 0000000000..a9bd44233d --- /dev/null +++ b/.github/scripts/migration-unreleased-lib.mjs @@ -0,0 +1,217 @@ +// @ts-check +/** + * Resolution of the `UNRELEASED` migration placeholder — pure text, no IO. + * + * A migration entry authored in a `feat:` PR cannot name the version it applies + * to: the PR lands on `next` and is promoted later, in a bundle whose stable + * `x.y.0` depends on what else is promoted with it (#2890). The author writes + * the literal `UNRELEASED` instead, and `/prepare-release` calls this while it + * builds the release branch, once both versions are known. + * + * Two shapes carry a version, one per layer: + * + * - **`packages/codemods/src/migrations//entry.md`** — frontmatter field + * `since:`. The catalogue is the source of + * `packages/components/MIGRATION.md`, which is generated; the placeholder is + * resolved here and the guide is then regenerated (`pnpm nx build codemods`). + * Writing into the generated file would be reverted by the next build. + * - **A hand-written `MIGRATION.md`** (today only `ext-bridge`'s) — a level-2 + * heading of the form "## From version to >=", each version in + * backticks. + * + * Placeholder headings **collapse**: three promoted PRs each carrying one must + * not produce three identical headings, so their bodies concatenate under a + * single resolved heading, in document order, at the position of the first. + * + * The IO shell is `migration-unreleased.mjs`; the tests are + * `migration-unreleased-lib.test.mjs`. + */ + +/** + * The literal an author writes in place of a version. + * + * Kept in sync with `unreleasedSince` in + * `packages/codemods/src/catalog/unreleased.ts` — the catalogue's runtime has + * to recognise the same string, and a test here asserts both spell it alike. + */ +export const unreleasedPlaceholder = "UNRELEASED"; + +const placeholderHeadingPattern = new RegExp( + `^##\\s+From version\\s+\`?${unreleasedPlaceholder}\`?\\s+to\\s+\`?${unreleasedPlaceholder}\`?\\s*$`, +); + +/** Frontmatter block of an `entry.md`, delimiters included. */ +const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---\r?\n/; + +const sincePattern = new RegExp( + `^(since:[ \\t]*)(['"]?)${unreleasedPlaceholder}\\2[ \\t]*$`, + "m", +); + +/** + * Whether a `MIGRATION.md` is generated and must not be written to. + * + * `packages/components/MIGRATION.md` carries the marker; its placeholder lives + * in the catalogue entry instead. + * + * @param {string} markdown + */ +export function isGenerated(markdown) { + return markdown.includes("AUTO-GENERATED"); +} + +/** + * The heading a resolved placeholder gets. + * + * `>=` on the upper bound is the existing convention and it earns its keep: if + * another change ships first the reader is still routed correctly. + * + * @param {string} current The version the release starts from + * @param {string} target The graduated version + */ +export function resolvedHeading(current, target) { + return `## From version \`${current}\` to \`>=${target}\``; +} + +/** + * Splits a guide into its preamble and its level-2 sections. + * + * Fenced code is tracked, so a `## …` line inside a diff or shell block is not + * mistaken for a heading. + * + * @param {string} markdown + */ +function splitSections(markdown) { + /** @type {string[]} */ + const preamble = []; + /** @type {{ heading: string; body: string[] }[]} */ + const sections = []; + let fenced = false; + + for (const line of markdown.split("\n")) { + if (/^\s*(```|~~~)/.test(line)) { + fenced = !fenced; + } else if (!fenced && line.startsWith("## ")) { + sections.push({ heading: line, body: [] }); + continue; + } + (sections.at(-1)?.body ?? preamble).push(line); + } + + return { preamble, sections }; +} + +/** + * A section body without its trailing blank lines and `---` separator. + * + * The separator is inter-section glue, not content: keeping it would leave a + * horizontal rule in the middle of a collapsed section, and `render` puts it + * back between the sections that survive. + * + * @param {string[]} lines + */ +function sectionBody(lines) { + const kept = [...lines]; + const dropBlanks = () => { + while (kept.at(-1)?.trim() === "") kept.pop(); + }; + + dropBlanks(); + if (kept.at(-1)?.trim() === "---") { + kept.pop(); + dropBlanks(); + } + return kept.join("\n").trim(); +} + +/** + * Rewrites every placeholder heading to the resolved one and collapses them + * into a single section. + * + * A document with no placeholder heading is returned byte-identical — the pass + * runs over every guide, and most of them have nothing to do. + * + * @param {string} markdown + * @param {{ current: string; target: string }} versions + * @returns {{ markdown: string; collapsed: number }} `collapsed` counts the + * placeholder sections found; 1 means a rewrite with nothing to merge. + */ +export function resolveVersionHeadings(markdown, { current, target }) { + const { preamble, sections } = splitSections(markdown); + const placeholders = sections + .map((section, index) => + placeholderHeadingPattern.test(section.heading) ? index : -1, + ) + .filter((index) => index >= 0); + + const [first] = placeholders; + if (first === undefined) { + return { markdown, collapsed: 0 }; + } + + const collapsedBody = placeholders + .map((index) => sectionBody(sections[index]?.body ?? [])) + .filter((body) => body !== "") + .join("\n\n"); + + const rendered = sections.flatMap((section, index) => { + if (index === first) { + return [`${resolvedHeading(current, target)}\n\n${collapsedBody}`]; + } + if (placeholders.includes(index)) { + return []; + } + return [`${section.heading}\n\n${sectionBody(section.body)}`]; + }); + + const head = sectionBody(preamble); + const parts = head === "" ? rendered : [head, ...rendered]; + + return { + markdown: `${parts.join("\n\n---\n\n")}\n`, + collapsed: placeholders.length, + }; +} + +/** + * Rewrites a catalogue entry's `since: UNRELEASED` frontmatter field. + * + * Only `target` matters here: `since` names the version a change shipped in, + * and the guide renders the range from it. + * + * @param {string} source The whole `entry.md` + * @param {string} target The graduated version + * @returns {{ source: string; changed: boolean }} + */ +export function resolveEntrySince(source, target) { + const frontmatter = frontmatterPattern.exec(source)?.[0]; + if (frontmatter === undefined || !sincePattern.test(frontmatter)) { + return { source, changed: false }; + } + + const resolved = frontmatter.replace(sincePattern, `$1${target}`); + return { + source: resolved + source.slice(frontmatter.length), + changed: true, + }; +} + +/** + * Every line still carrying the placeholder — the guard's finding. + * + * Deliberately a plain substring scan rather than the heading and frontmatter + * patterns: a placeholder that survives in a shape those do not match is + * exactly the case the guard exists for. + * + * @param {string} text + * @returns {{ line: number; text: string }[]} + */ +export function findPlaceholders(text) { + return text + .split("\n") + .flatMap((line, index) => + line.includes(unreleasedPlaceholder) + ? [{ line: index + 1, text: line.trim() }] + : [], + ); +} diff --git a/.github/scripts/migration-unreleased-lib.test.mjs b/.github/scripts/migration-unreleased-lib.test.mjs new file mode 100644 index 0000000000..6d9415cfce --- /dev/null +++ b/.github/scripts/migration-unreleased-lib.test.mjs @@ -0,0 +1,272 @@ +// @ts-check +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + findPlaceholders, + isGenerated, + resolveEntrySince, + resolveVersionHeadings, + resolvedHeading, + unreleasedPlaceholder, +} from "./migration-unreleased-lib.mjs"; + +const versions = { current: "1.1.10", target: "1.2.0" }; +const resolved = "## From version `1.1.10` to `>=1.2.0`"; +const placeholder = `## From version \`${unreleasedPlaceholder}\` to \`${unreleasedPlaceholder}\``; + +const guide = (...sections) => + ["# Migrations", ...sections].join("\n\n---\n\n") + "\n"; + +test("resolvedHeading writes the `>=` upper bound", () => { + assert.equal(resolvedHeading("1.1.10", "1.2.0"), resolved); +}); + +test("a single placeholder heading is rewritten, the body untouched", () => { + const source = guide(`${placeholder}\n\n### Foo\n\nbody A`); + + const result = resolveVersionHeadings(source, versions); + + assert.equal(result.collapsed, 1); + assert.equal(result.markdown, guide(`${resolved}\n\n### Foo\n\nbody A`)); +}); + +test("three placeholder sections collapse under one heading, in document order", () => { + const source = guide( + `${placeholder}\n\n### Foo\n\nbody A`, + `${placeholder}\n\n### Bar\n\nbody B`, + `${placeholder}\n\n### Baz\n\nbody C`, + ); + + const result = resolveVersionHeadings(source, versions); + + assert.equal(result.collapsed, 3); + assert.equal( + result.markdown, + guide( + `${resolved}\n\n### Foo\n\nbody A\n\n### Bar\n\nbody B\n\n### Baz\n\nbody C`, + ), + ); + // The separators between the collapsed sections are gone — they were + // inter-section glue, and a `---` inside one section reads as a rule. + assert.equal(result.markdown.match(/^---$/gm)?.length, 1); +}); + +test("a file with no placeholder is returned byte-identical", () => { + const source = guide( + "## From version `1.0.0` to `>=1.0.1`\n\nbody A", + "## From version `0.9.0` to `>=0.9.1`\n\nbody B", + ); + + const result = resolveVersionHeadings(source, versions); + + assert.equal(result.collapsed, 0); + assert.equal(result.markdown, source); +}); + +test("placeholder and real headings mix: only the placeholders collapse, order kept", () => { + const source = guide( + `${placeholder}\n\n### New\n\nbody A`, + "## From version `1.0.0` to `>=1.0.1`\n\nbody released", + `${placeholder}\n\n### Also new\n\nbody B`, + "## From version `0.9.0` to `>=0.9.1`\n\nbody older", + ); + + const result = resolveVersionHeadings(source, versions); + + assert.equal(result.collapsed, 2); + assert.equal( + result.markdown, + guide( + `${resolved}\n\n### New\n\nbody A\n\n### Also new\n\nbody B`, + "## From version `1.0.0` to `>=1.0.1`\n\nbody released", + "## From version `0.9.0` to `>=0.9.1`\n\nbody older", + ), + ); +}); + +test("the collapsed section keeps the position of the first placeholder", () => { + const source = guide( + "## From version `1.0.0` to `>=1.0.1`\n\nbody released", + `${placeholder}\n\n### New\n\nbody A`, + ); + + const result = resolveVersionHeadings(source, versions); + + assert.equal( + result.markdown, + guide( + "## From version `1.0.0` to `>=1.0.1`\n\nbody released", + `${resolved}\n\n### New\n\nbody A`, + ), + ); +}); + +test("resolving is idempotent — a second pass finds nothing", () => { + const once = resolveVersionHeadings( + guide(`${placeholder}\n\nbody A`, `${placeholder}\n\nbody B`), + versions, + ); + const twice = resolveVersionHeadings(once.markdown, versions); + + assert.equal(twice.collapsed, 0); + assert.equal(twice.markdown, once.markdown); +}); + +test("a `## ` line inside fenced code is not a heading", () => { + const fenced = [ + placeholder, + "", + "```md", + "## From version `9.9.9` to `>=9.9.9`", + "```", + ].join("\n"); + + const result = resolveVersionHeadings(guide(fenced), versions); + + assert.equal(result.collapsed, 1); + assert.match(result.markdown, /```md\n## From version `9\.9\.9`/); +}); + +test("the heading matches without backticks too", () => { + const source = guide( + `## From version ${unreleasedPlaceholder} to ${unreleasedPlaceholder}\n\nbody A`, + ); + + assert.equal(resolveVersionHeadings(source, versions).collapsed, 1); +}); + +test("resolveEntrySince rewrites the frontmatter field", () => { + const source = [ + "---", + `since: ${unreleasedPlaceholder}`, + 'title: "Something changed"', + "kind: migration", + "---", + "", + "Body prose.", + ].join("\n"); + + const result = resolveEntrySince(source, "1.2.0"); + + assert.equal(result.changed, true); + assert.match(result.source, /^since: 1\.2\.0$/m); + assert.match(result.source, /Body prose\./); +}); + +test("resolveEntrySince accepts a quoted placeholder", () => { + const result = resolveEntrySince( + `---\nsince: "${unreleasedPlaceholder}"\n---\n\nBody.\n`, + "1.2.0", + ); + + assert.equal(result.changed, true); + assert.match(result.source, /^since: 1\.2\.0$/m); +}); + +test("resolveEntrySince leaves a released entry alone", () => { + const source = "---\nsince: 1.0.16\nkind: migration\n---\n\nBody.\n"; + + assert.deepEqual(resolveEntrySince(source, "1.2.0"), { + source, + changed: false, + }); +}); + +test("resolveEntrySince ignores the placeholder outside the frontmatter", () => { + // Prose mentioning the convention must not be mistaken for the field. + const source = [ + "---", + "since: 1.0.16", + "---", + "", + `Write \`since: ${unreleasedPlaceholder}\` in a feat PR.`, + ].join("\n"); + + assert.equal(resolveEntrySince(source, "1.2.0").changed, false); +}); + +test("findPlaceholders reports every line, with 1-based numbers", () => { + const findings = findPlaceholders( + `a\n${placeholder}\nb\nsince: ${unreleasedPlaceholder}\n`, + ); + + assert.deepEqual( + findings.map(({ line }) => line), + [2, 4], + ); + assert.equal(findings[1]?.text, `since: ${unreleasedPlaceholder}`); +}); + +test("findPlaceholders is empty for a resolved document", () => { + assert.deepEqual(findPlaceholders(guide(`${resolved}\n\nbody A`)), []); +}); + +test("isGenerated recognises the generated guide, not a hand-written one", () => { + assert.equal( + isGenerated( + "# Migrations\n\n