Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/preserve-generated-changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 40 additions & 1 deletion scripts/changeset-version.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
});
});
22 changes: 21 additions & 1 deletion scripts/changeset-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,6 +47,7 @@ export function versionPackages(
}

copyFileSync(canonicalChangelog, packageChangelog);
let generatedChangelog = false;
try {
const exitCode = runChangesets(paths.repoRoot);
if (exitCode !== 0) {
Expand All @@ -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 },
);
}
}

Expand Down