From 7ffa1ac7a6280c49bb049a8681a46d37f6f60615 Mon Sep 17 00:00:00 2001 From: shashank-100 Date: Sun, 6 Sep 2026 12:11:44 +0530 Subject: [PATCH] fix(release): keep generated release notes when the write-back fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `versionPackages` staged the root changelog beside the package, let Changesets write the new release notes into it, copied the result back to the root, and removed the staged file in a `finally`. That cleanup ran unconditionally. If the write-back on line 53 failed — disk full, permissions, an interrupted write — the staged file was deleted anyway, and it was at that moment the only copy of the new release notes: Changesets has already consumed the `.changeset/*.md` entries that produced them, and the root still holds pre-version content. Recovery was manual. Remove the staged copy only after the write-back succeeds. A failed write-back now leaves the generated history in place and reports where to find it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019jAgP7j1gaT9fSk5syLHDH --- .changeset/preserve-generated-changelog.md | 2 ++ scripts/changeset-version.test.ts | 41 +++++++++++++++++++++- scripts/changeset-version.ts | 22 +++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/preserve-generated-changelog.md 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 }, + ); } }