Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .claude/commands/prepare-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<to>..origin/<from>`. Group by type, capture scope and PR number
Expand Down Expand Up @@ -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 <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
```

Expand Down Expand Up @@ -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/<id>/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 `<current>` 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 `<to>`. 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
Expand Down
217 changes: 217 additions & 0 deletions .github/scripts/migration-unreleased-lib.mjs
Original file line number Diff line number Diff line change
@@ -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/<id>/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 <x> to >=<y>", 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() }]
: [],
);
}
Loading
Loading