From 969c8f087a2b49e82219b21e3a7461d410acc16c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:09:42 +0000 Subject: [PATCH 1/3] Initial plan From 59c2e39893d342a41b968fcc48ab4a0bfa42ea69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:15:25 +0000 Subject: [PATCH 2/3] fix(changelog): preserve "Older entries" footer in README when rotating to CHANGELOG_OLD.md Agent-Logs-Url: https://github.com/AlCalzone/release-script/sessions/134fe249-fffc-4001-b82e-feb41c0a2020 --- packages/plugin-changelog/src/index.test.ts | 93 +++++++++++++++++++++ packages/plugin-changelog/src/index.ts | 16 ++++ 2 files changed, 109 insertions(+) diff --git a/packages/plugin-changelog/src/index.test.ts b/packages/plugin-changelog/src/index.test.ts index 3c19693..0d2326f 100644 --- a/packages/plugin-changelog/src/index.test.ts +++ b/packages/plugin-changelog/src/index.test.ts @@ -61,6 +61,8 @@ stuff ### Subsection 2 * New entry 4 * New entry 5`, + + readme_olderEntriesFooter: `Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).`, }; describe("Changelog plugin", () => { @@ -232,6 +234,42 @@ ${fixtures.changelog_old_testParseFooter}`, * New entry 2`); }); + it('strips the "Older entries" footer from the last entry and stores it in changelog_after', async () => { + const changelogPlugin = new ChangelogPlugin(); + const context = createMockContext({ + plugins: [changelogPlugin], + cwd: testFSRoot, + }); + + await testFS.create({ + "README.md": `${fixtures.readme_testParseHeader} +${fixtures.readme_testParse1} + +${fixtures.readme_testParse2} + +${fixtures.readme_testParse3} + +${fixtures.readme_olderEntriesFooter}`, + "CHANGELOG_OLD.md": `${fixtures.changelog_old_testParseHeader} +${fixtures.changelog_old_testParse1} + +${fixtures.changelog_old_testParse2}`, + }); + + await changelogPlugin.executeStage(context, DefaultStages.check); + expect(context.errors).toHaveLength(0); + + // The last README entry should not contain the footer + const entries = context.getData("changelog_entries"); + expect(entries[2]).toBe(fixtures.readme_testParse3); + + // The footer should have been moved to changelog_after + const changelogAfter = context.getData("changelog_after"); + expect(changelogAfter).toBe( + `\n\n${fixtures.readme_olderEntriesFooter}`, + ); + }); + it("correctly handles changelogs with sub-sections", async () => { const changelogPlugin = new ChangelogPlugin(); const context = createMockContext({ @@ -419,6 +457,61 @@ ${fixtures.readme_testParse3.slice(1)} ${fixtures.changelog_old_testParse1} +${fixtures.changelog_old_testParse2}`); + }); + + it('keeps the "Older entries" footer in README.md and excludes it from CHANGELOG_OLD.md when rotating', async () => { + const changelogPlugin = new ChangelogPlugin(); + const context = createMockContext({ + plugins: [changelogPlugin], + cwd: testFSRoot, + argv: { + numChangelogEntries: 2, + }, + }); + + context.setData("changelog_filename", "README.md"); + context.setData("changelog_location", "readme"); + context.setData("changelog_before", fixtures.readme_testParseHeader); + // The footer has been moved to changelog_after by the check stage fix + context.setData("changelog_entries", [ + fixtures.readme_testParse1, + fixtures.readme_testParse2, + fixtures.readme_testParse3, + fixtures.changelog_old_testParse1, + fixtures.changelog_old_testParse2, + ]); + context.setData( + "changelog_after", + `\n\n${fixtures.readme_olderEntriesFooter}`, + ); + context.setData("changelog_final_newline", false); + context.setData("changelog_old_before", fixtures.changelog_old_testParseHeader); + context.setData("changelog_old_after", ""); + context.setData("changelog_entry_prefix", "###"); + context.setData("version_new", "2.3.4"); + + await changelogPlugin.executeStage(context, DefaultStages.edit); + + const readmeContent = await fs.readFile(path.join(testFSRoot, "README.md"), "utf8"); + const oldContent = await fs.readFile(path.join(testFSRoot, "CHANGELOG_OLD.md"), "utf8"); + + // Footer must appear in README + expect(readmeContent).toContain(fixtures.readme_olderEntriesFooter); + expect(readmeContent).toBe(`${fixtures.readme_testParseHeader} +${fixtures.readme_testReplaced} + +${fixtures.readme_testParse2} + +${fixtures.readme_olderEntriesFooter}`); + + // Footer must NOT appear in CHANGELOG_OLD + expect(oldContent).not.toContain(fixtures.readme_olderEntriesFooter); + expect(oldContent).toBe(`${fixtures.changelog_old_testParseHeader} +${fixtures.readme_testParse3.slice(1)} + +${fixtures.changelog_old_testParse1} + ${fixtures.changelog_old_testParse2}`); }); diff --git a/packages/plugin-changelog/src/index.ts b/packages/plugin-changelog/src/index.ts index 6919096..2174133 100644 --- a/packages/plugin-changelog/src/index.ts +++ b/packages/plugin-changelog/src/index.ts @@ -159,6 +159,22 @@ class ChangelogPlugin implements Plugin { parsedOld = parseChangelogFile(changelogOld, changelogPlaceholderPrefix.substr(1)); } + // When CHANGELOG_OLD.md is present, the "Older entries" footer link at the end of + // the last README entry must stay in the README and not be rotated to CHANGELOG_OLD.md. + // Detect this footer and move it from the last entry into the "after" section. + if (changelogOld && parsed.entries.length > 0) { + const lastIndex = parsed.entries.length - 1; + const olderEntriesFooterRegex = + /\n+Older entries are in \[CHANGELOG_OLD\.md\]\(CHANGELOG_OLD\.md\)\.\s*$/; + const footerMatch = olderEntriesFooterRegex.exec(parsed.entries[lastIndex]); + if (footerMatch) { + parsed.entries[lastIndex] = parsed.entries[lastIndex].slice(0, footerMatch.index); + parsed.after = + "\n\nOlder entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md)." + + parsed.after; + } + } + const entries = [...parsed.entries, ...(parsedOld?.entries ?? [])]; context.setData("changelog_filename", changelogFilename); From f30c25cd43d62711615e69e21c8184d89bcec5c6 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Tue, 5 May 2026 09:43:04 +0200 Subject: [PATCH 3/3] cleanup, more tests --- packages/plugin-changelog/src/index.test.ts | 107 ++++++++++++++++++-- packages/plugin-changelog/src/index.ts | 13 +-- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/packages/plugin-changelog/src/index.test.ts b/packages/plugin-changelog/src/index.test.ts index 0d2326f..11a6a4f 100644 --- a/packages/plugin-changelog/src/index.test.ts +++ b/packages/plugin-changelog/src/index.test.ts @@ -63,6 +63,8 @@ stuff * New entry 5`, readme_olderEntriesFooter: `Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).`, + readme_olderEntriesFooterVariant: `Older entries have been moved to [CHANGELOG_OLD.md](CHANGELOG_OLD.md).`, + readme_olderEntriesFooterCustomLabel: `See [the older changelog](CHANGELOG_OLD.md) for previous releases.`, }; describe("Changelog plugin", () => { @@ -265,9 +267,103 @@ ${fixtures.changelog_old_testParse2}`, // The footer should have been moved to changelog_after const changelogAfter = context.getData("changelog_after"); - expect(changelogAfter).toBe( - `\n\n${fixtures.readme_olderEntriesFooter}`, - ); + expect(changelogAfter).toBe(`\n\n${fixtures.readme_olderEntriesFooter}`); + }); + + it('preserves a custom-phrased "Older entries" footer verbatim', async () => { + const changelogPlugin = new ChangelogPlugin(); + const context = createMockContext({ + plugins: [changelogPlugin], + cwd: testFSRoot, + }); + + await testFS.create({ + "README.md": `${fixtures.readme_testParseHeader} +${fixtures.readme_testParse1} + +${fixtures.readme_testParse2} + +${fixtures.readme_testParse3} + +${fixtures.readme_olderEntriesFooterVariant}`, + "CHANGELOG_OLD.md": `${fixtures.changelog_old_testParseHeader} +${fixtures.changelog_old_testParse1} + +${fixtures.changelog_old_testParse2}`, + }); + + await changelogPlugin.executeStage(context, DefaultStages.check); + expect(context.errors).toHaveLength(0); + + const entries = context.getData("changelog_entries"); + expect(entries[2]).toBe(fixtures.readme_testParse3); + + // Custom phrasing must be preserved exactly as the user wrote it + const changelogAfter = context.getData("changelog_after"); + expect(changelogAfter).toBe(`\n\n${fixtures.readme_olderEntriesFooterVariant}`); + }); + + it("detects a footer even without a blank line separator", async () => { + const changelogPlugin = new ChangelogPlugin(); + const context = createMockContext({ + plugins: [changelogPlugin], + cwd: testFSRoot, + }); + + await testFS.create({ + "README.md": `${fixtures.readme_testParseHeader} +${fixtures.readme_testParse1} + +${fixtures.readme_testParse2} + +${fixtures.readme_testParse3} +${fixtures.readme_olderEntriesFooter}`, + "CHANGELOG_OLD.md": `${fixtures.changelog_old_testParseHeader} +${fixtures.changelog_old_testParse1} + +${fixtures.changelog_old_testParse2}`, + }); + + await changelogPlugin.executeStage(context, DefaultStages.check); + expect(context.errors).toHaveLength(0); + + const entries = context.getData("changelog_entries"); + expect(entries[2]).toBe(fixtures.readme_testParse3); + + const changelogAfter = context.getData("changelog_after"); + expect(changelogAfter).toBe(`\n\n${fixtures.readme_olderEntriesFooter}`); + }); + + it("preserves a footer with custom link label verbatim", async () => { + const changelogPlugin = new ChangelogPlugin(); + const context = createMockContext({ + plugins: [changelogPlugin], + cwd: testFSRoot, + }); + + await testFS.create({ + "README.md": `${fixtures.readme_testParseHeader} +${fixtures.readme_testParse1} + +${fixtures.readme_testParse2} + +${fixtures.readme_testParse3} + +${fixtures.readme_olderEntriesFooterCustomLabel}`, + "CHANGELOG_OLD.md": `${fixtures.changelog_old_testParseHeader} +${fixtures.changelog_old_testParse1} + +${fixtures.changelog_old_testParse2}`, + }); + + await changelogPlugin.executeStage(context, DefaultStages.check); + expect(context.errors).toHaveLength(0); + + const entries = context.getData("changelog_entries"); + expect(entries[2]).toBe(fixtures.readme_testParse3); + + const changelogAfter = context.getData("changelog_after"); + expect(changelogAfter).toBe(`\n\n${fixtures.readme_olderEntriesFooterCustomLabel}`); }); it("correctly handles changelogs with sub-sections", async () => { @@ -481,10 +577,7 @@ ${fixtures.changelog_old_testParse2}`); fixtures.changelog_old_testParse1, fixtures.changelog_old_testParse2, ]); - context.setData( - "changelog_after", - `\n\n${fixtures.readme_olderEntriesFooter}`, - ); + context.setData("changelog_after", `\n\n${fixtures.readme_olderEntriesFooter}`); context.setData("changelog_final_newline", false); context.setData("changelog_old_before", fixtures.changelog_old_testParseHeader); context.setData("changelog_old_after", ""); diff --git a/packages/plugin-changelog/src/index.ts b/packages/plugin-changelog/src/index.ts index 2174133..4ddc275 100644 --- a/packages/plugin-changelog/src/index.ts +++ b/packages/plugin-changelog/src/index.ts @@ -159,19 +159,16 @@ class ChangelogPlugin implements Plugin { parsedOld = parseChangelogFile(changelogOld, changelogPlaceholderPrefix.substr(1)); } - // When CHANGELOG_OLD.md is present, the "Older entries" footer link at the end of - // the last README entry must stay in the README and not be rotated to CHANGELOG_OLD.md. - // Detect this footer and move it from the last entry into the "after" section. + // When CHANGELOG_OLD.md is present, a trailing footer paragraph linking to + // CHANGELOG_OLD.md (any phrasing/link text) must stay in the README and not + // be rotated. Move it from the last entry into the "after" section verbatim. if (changelogOld && parsed.entries.length > 0) { const lastIndex = parsed.entries.length - 1; - const olderEntriesFooterRegex = - /\n+Older entries are in \[CHANGELOG_OLD\.md\]\(CHANGELOG_OLD\.md\)\.\s*$/; + const olderEntriesFooterRegex = /\n+([^\n]*\]\(CHANGELOG_OLD\.md\)[^\n]*)$/; const footerMatch = olderEntriesFooterRegex.exec(parsed.entries[lastIndex]); if (footerMatch) { parsed.entries[lastIndex] = parsed.entries[lastIndex].slice(0, footerMatch.index); - parsed.after = - "\n\nOlder entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md)." + - parsed.after; + parsed.after = "\n\n" + footerMatch[1] + parsed.after; } }