Skip to content

Commit f509d18

Browse files
fix(release): absorb mislabeled unreleased version sections when stamping MIGRATION.md (#1667)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c745ab9 commit f509d18

2 files changed

Lines changed: 54 additions & 18 deletions

File tree

scripts/sync-release-version.test.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { execFile } from "node:child_process";
3-
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3+
import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import test from "node:test";
@@ -21,9 +21,11 @@ void test("synchronizes every release-owned version reference", async () => {
2121
join(cwd, "README.md"),
2222
"```bash\n VERSION=0.74.0 # the release you are installing\n```\n",
2323
);
24+
// v0.76.0 and v0.75.0 are mislabeled hand-written sections for unreleased work (the released
25+
// history starts at v0.74.0) — the exact shape that broke the Version PR on main.
2426
await writeFile(
2527
join(cwd, "MIGRATION.md"),
26-
"### Next release\n\n#### 🔴 Existing action\n\nDo it.\n\n### v0.74.0\n\nOld.\n",
28+
"### Next release\n\n#### 🔴 Existing action\n\nDo it.\n\n### v0.76.0\n\n#### 🔴 Mislabeled newer action\n\nFold me.\n\n### v0.75.0\n\n#### 🔴 Mislabeled same-version action\n\nFold me too.\n\n### v0.74.0\n\nOld.\n",
2729
);
2830
await writeFile(
2931
join(cwd, ".migration/z-last.md"),
@@ -42,29 +44,35 @@ void test("synchronizes every release-owned version reference", async () => {
4244
await readFile(join(cwd, "README.md"), "utf8"),
4345
"```bash\n VERSION=0.75.0 # the release you are installing\n```\n",
4446
);
45-
assert.equal(
46-
await readFile(join(cwd, "MIGRATION.md"), "utf8"),
47-
"### Next release\n\n### v0.75.0\n\n#### 🔴 Existing action\n\nDo it.\n\n#### 🔴 A action\n\nDo A.\n\n#### 🔴 Z action\n\nDo Z: keep `$$VAR`, `$&`, and `$'` literal.\n\n### v0.74.0\n\nOld.\n",
48-
);
47+
const stamped =
48+
"### Next release\n\n### v0.75.0\n\n#### 🔴 Existing action\n\nDo it.\n\n#### 🔴 Mislabeled newer action\n\nFold me.\n\n#### 🔴 Mislabeled same-version action\n\nFold me too.\n\n#### 🔴 A action\n\nDo A.\n\n#### 🔴 Z action\n\nDo Z: keep `$$VAR`, `$&`, and `$'` literal.\n\n### v0.74.0\n\nOld.\n";
49+
assert.equal(await readFile(join(cwd, "MIGRATION.md"), "utf8"), stamped);
4950
assert.deepEqual(await readdir(join(cwd, ".migration")), ["README.md"]);
5051

52+
// Regeneration re-reads the script's own prior output and must be byte-idempotent.
53+
await run(process.execPath, [join(import.meta.dirname, "sync-release-version.ts")], { cwd });
54+
assert.equal(await readFile(join(cwd, "MIGRATION.md"), "utf8"), stamped);
55+
assert.deepEqual(await readdir(join(cwd, ".migration")), ["README.md"]);
56+
57+
// A fragment landing between regenerations merges into the same unreleased section.
58+
await writeFile(join(cwd, ".migration/late.md"), "#### 🔴 Late action\n");
5159
await run(process.execPath, [join(import.meta.dirname, "sync-release-version.ts")], { cwd });
5260
assert.equal(
5361
await readFile(join(cwd, "MIGRATION.md"), "utf8"),
54-
"### Next release\n\n### v0.75.0\n\n#### 🔴 Existing action\n\nDo it.\n\n#### 🔴 A action\n\nDo A.\n\n#### 🔴 Z action\n\nDo Z: keep `$$VAR`, `$&`, and `$'` literal.\n\n### v0.74.0\n\nOld.\n",
62+
stamped.replace("\n### v0.74.0", "\n#### 🔴 Late action\n\n### v0.74.0"),
5563
);
5664
assert.deepEqual(await readdir(join(cwd, ".migration")), ["README.md"]);
5765

58-
await writeFile(join(cwd, ".migration/late.md"), "#### 🔴 Late action\n");
66+
// A same-version heading below released history is a real conflict, not prior output.
67+
await writeFile(
68+
join(cwd, "MIGRATION.md"),
69+
`${await readFile(join(cwd, "MIGRATION.md"), "utf8")}\n### v0.75.0\n\nGhost.\n`,
70+
);
5971
await assert.rejects(
6072
run(process.execPath, [join(import.meta.dirname, "sync-release-version.ts")], { cwd }),
6173
/already contains ### v0\.75\.0/,
6274
);
63-
await rm(join(cwd, ".migration/late.md"));
64-
await writeFile(
65-
join(cwd, "MIGRATION.md"),
66-
`### Next release\n\n${await readFile(join(cwd, "MIGRATION.md"), "utf8")}`,
67-
);
75+
await writeFile(join(cwd, "MIGRATION.md"), `### Next release\n\n${stamped}`);
6876
await assert.rejects(
6977
run(process.execPath, [join(import.meta.dirname, "sync-release-version.ts")], { cwd }),
7078
/exactly one ### Next release/,

scripts/sync-release-version.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,46 @@ const fragmentFiles = existsSync(fragmentDirectory)
4444
.filter((file) => file.endsWith(".md") && file !== "README.md")
4545
.toSorted()
4646
: [];
47-
if (pending !== "" || fragmentFiles.length > 0) {
48-
if (migration.split("\n").includes(`### v${version}`)) {
47+
48+
// A version can only gain a history section by being released, and `changeset version` computes
49+
// the lowest unreleased version — so any section for this version or a later one is unreleased
50+
// content that ships now. Sections directly below the pending anchor whose headings say otherwise
51+
// (hand-written before fragments existed, or this script's own output when a regeneration re-reads
52+
// it) are absorbed into the section being stamped instead of failing the release.
53+
const semverAtLeast = (candidate: string, reference: string): boolean => {
54+
const left = candidate.split(".").map(Number);
55+
const right = reference.split(".").map(Number);
56+
for (let index = 0; index < 3; index += 1) {
57+
if ((left[index] ?? 0) !== (right[index] ?? 0)) return (left[index] ?? 0) > (right[index] ?? 0);
58+
}
59+
return true;
60+
};
61+
const regionStart = pendingSections[0]?.index ?? 0;
62+
let regionLength = pendingSections[0]?.[0]?.length ?? 0;
63+
const unreleased: string[] = [];
64+
for (;;) {
65+
const tail = migration.slice(regionStart + regionLength);
66+
const next = /^### v(\d+\.\d+\.\d+)\n([\s\S]*?)(?=^### |(?![\s\S]))/m.exec(tail);
67+
if (!next || next.index !== 0 || !semverAtLeast(next[1] ?? "", version)) break;
68+
unreleased.push(next[2]?.trim() ?? "");
69+
regionLength += next[0].length;
70+
}
71+
72+
if (pending !== "" || unreleased.length > 0 || fragmentFiles.length > 0) {
73+
const remainder = migration.slice(0, regionStart) + migration.slice(regionStart + regionLength);
74+
if (remainder.split("\n").includes(`### v${version}`)) {
4975
throw new Error(`sync-release-version: MIGRATION.md already contains ### v${version}`);
5076
}
5177
const fragments = fragmentFiles.map((file) =>
5278
readFileSync(join(fragmentDirectory, file), "utf8").trim(),
5379
);
54-
const section = ["### Next release", `### v${version}`, pending, ...fragments].filter(Boolean);
80+
const section = ["### Next release", `### v${version}`, pending, ...unreleased, ...fragments]
81+
.filter(Boolean)
82+
.join("\n\n");
83+
// Splicing by offset keeps `$&`/`$$` in migration notes literal.
5584
writeFileSync(
5685
migrationFile,
57-
// The replacer is a function so `$&`/`$$` in migration notes stay literal.
58-
migration.replace(pendingSections[0]?.[0] ?? "", () => `${section.join("\n\n")}\n\n`),
86+
`${migration.slice(0, regionStart)}${section}\n\n${migration.slice(regionStart + regionLength)}`,
5987
);
6088
for (const file of fragmentFiles) rmSync(join(fragmentDirectory, file));
6189
}

0 commit comments

Comments
 (0)