diff --git a/.changeset/preserve-generated-changelog.md b/.changeset/preserve-generated-changelog.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/preserve-generated-changelog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/scripts/changeset-version.test.ts b/scripts/changeset-version.test.ts index 51571ea57..83896fcca 100644 --- a/scripts/changeset-version.test.ts +++ b/scripts/changeset-version.test.ts @@ -1,5 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { versionPackages } from "./changeset-version"; @@ -74,4 +82,35 @@ describe("canonical changelog versioning", () => { ); expect(existsSync(path.join(paths.packageRoot, "CHANGELOG.md"))).toBe(false); }); + + test("keeps the generated changelog when it cannot be returned to the root", () => { + if (process.platform === "win32" || process.getuid?.() === 0) { + // Both make the read-only repository root writable anyway, so the write-back would + // succeed and the test could not observe the failure it exists to cover. + return; + } + + const paths = createTestWorkspace(); + const staged = path.join(paths.packageRoot, "CHANGELOG.md"); + const canonical = path.join(paths.repoRoot, "CHANGELOG.md"); + + try { + // Deny the write-back specifically, after Changesets has already consumed the + // `.changeset/*.md` entries, so the staged file is the only copy of the new notes. + // The target file itself must be read-only: `copyFileSync` overwrites through the + // existing inode, which a read-only parent directory does not prevent. + expect(() => + versionPackages(paths, () => { + writeFileSync(staged, "# Changelog\n\n## 1.1.0\n\n- Generated once.\n"); + chmodSync(canonical, 0o400); + return 0; + }), + ).toThrow("The generated history is preserved at"); + } finally { + chmodSync(canonical, 0o600); + } + + // The release notes survive the failure and are still the generated ones. + expect(readFileSync(staged, "utf8")).toContain("## 1.1.0"); + }); }); diff --git a/scripts/changeset-version.ts b/scripts/changeset-version.ts index 874a253c8..1acf1795b 100644 --- a/scripts/changeset-version.ts +++ b/scripts/changeset-version.ts @@ -6,6 +6,11 @@ * Changesets writes a changelog beside each publishable manifest. Hunk intentionally keeps its * release history at the repository root, so the root file is staged beside `packages/hunk` only * for the duration of the Changesets command and copied back after a successful version update. + * + * The staged copy is removed once it is reproducible again, never before. After Changesets runs it + * holds the only copy of the new release notes — the `.changeset/*.md` entries behind them are + * already consumed — so a failed write-back leaves it in place and says where to find it rather + * than cleaning up the one artifact the release cannot regenerate. */ import { copyFileSync, existsSync, rmSync } from "node:fs"; @@ -42,6 +47,7 @@ export function versionPackages( } copyFileSync(canonicalChangelog, packageChangelog); + let generatedChangelog = false; try { const exitCode = runChangesets(paths.repoRoot); if (exitCode !== 0) { @@ -50,9 +56,23 @@ export function versionPackages( if (!existsSync(packageChangelog)) { throw new Error("Changesets did not produce the hunkdiff package changelog."); } + // Past this point the staged file is the only copy of the new release notes: Changesets + // has already consumed the `.changeset/*.md` entries that produced them. + generatedChangelog = true; copyFileSync(packageChangelog, canonicalChangelog); - } finally { rmSync(packageChangelog, { force: true }); + } catch (error) { + // Clean up the staging copy only while it is still reproducible. If the write-back + // failed, deleting it would destroy the generated history for good and leave the root + // holding pre-version content, so it stays behind for the operator to recover from. + if (!generatedChangelog) { + rmSync(packageChangelog, { force: true }); + throw error; + } + throw new Error( + `Failed to return the generated changelog to ${canonicalChangelog}. The generated history is preserved at ${packageChangelog}; move it to the repository root before retrying.`, + { cause: error }, + ); } }