fix(release): preserve attribution placement; default update notifs to o

- Move What's Changed/New Contributors before Installation in release notes
- Preserve committed attribution when regenerating via writeReleaseNotesForVersion
- Change notificationType default from 'both' to 'overlay' for new installs
This commit is contained in:
2026-06-10 23:53:31 -07:00
parent 131b23efa9
commit b3b45521b6
12 changed files with 170 additions and 25 deletions
+94 -2
View File
@@ -1122,13 +1122,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Whats Changed\n\n/);
assert.match(releaseNotes, /## What's Changed\n\n/);
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
assert.match(
releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
);
assert.ok(
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
"What's Changed should follow Highlights",
);
assert.ok(
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
'contributor attribution should appear before Installation',
);
assert.doesNotMatch(releaseNotes, /## Whats Changed/);
assert.doesNotMatch(
releaseNotes,
/ksyasuda made their first contribution/,
@@ -1137,13 +1146,96 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.doesNotMatch(changelog, /Whats Changed/);
assert.doesNotMatch(changelog, /What's Changed|Whats Changed/);
assert.doesNotMatch(changelog, /New Contributors/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
const { writeReleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('release-notes-preserve-attribution');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
'## v0.8.0 (2026-04-17)',
'### Added',
'- Polished: released feature.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'### Internal',
'- Polished: internal release note.',
'',
'</details>',
'',
].join('\n');
const committedReleaseNotes = [
'## Highlights',
'### Added',
'- Old generated body.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'',
'## Whats Changed',
'',
'- feat(release): add contributor attribution by @ksyasuda in #114',
'',
'## New Contributors',
'',
'- @bee-san made their first contribution in #112',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
committedReleaseNotes,
'utf8',
);
try {
const outputPath = writeReleaseNotesForVersion({
cwd: projectRoot,
version: '0.8.0',
});
const releaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
assert.match(
releaseNotes,
/## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
);
assert.match(
releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
);
assert.ok(
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
"What's Changed should follow Highlights",
);
assert.ok(
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
'New Contributors should appear before Installation',
);
assert.doesNotMatch(releaseNotes, /## Whats Changed/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section');
+48 -3
View File
@@ -433,12 +433,45 @@ function resolveContributionsForFragments(
);
}
function isWhatsChangedHeading(line: string): boolean {
return line === "## What's Changed" || line === '## Whats Changed';
}
function extractContributorSections(releaseNotes: string): string[] {
const lines = releaseNotes.split(/\r?\n/);
const start = lines.findIndex(isWhatsChangedHeading);
if (start === -1) {
return [];
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (line.startsWith('## ') && !isWhatsChangedHeading(line) && line !== '## New Contributors') {
end = index;
break;
}
}
const block = lines.slice(start, end);
while (block.length > 0 && block[block.length - 1] === '') {
block.pop();
}
if (block.length === 0) {
return [];
}
block[0] = "## What's Changed";
block.push('');
return block;
}
function renderContributorsSections(contributions: Contribution[]): string[] {
if (contributions.length === 0) {
return [];
}
const lines: string[] = ['## Whats Changed', ''];
const lines: string[] = ["## What's Changed", ''];
for (const contribution of contributions) {
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
}
@@ -635,14 +668,18 @@ function renderReleaseNotes(
options?: {
disclaimer?: string;
contributions?: Contribution[];
contributorSections?: string[];
},
): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
const contributorSections =
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
return [
...prefix,
'## Highlights',
changes,
'',
...contributorSections,
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
@@ -656,7 +693,6 @@ function renderReleaseNotes(
'',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'',
...renderContributorsSections(options?.contributions ?? []),
].join('\n');
}
@@ -668,6 +704,7 @@ function writeReleaseNotesFile(
disclaimer?: string;
outputPath?: string;
contributions?: Contribution[];
contributorSections?: string[];
},
): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -960,6 +997,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
const changelogPath = path.join(cwd, 'CHANGELOG.md');
@@ -970,7 +1008,14 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
throw new Error(`Missing CHANGELOG section for v${version}.`);
}
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
const contributorSections = existsSync(releaseNotesPath)
? extractContributorSections(readFileSync(releaseNotesPath, 'utf8'))
: [];
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps, {
contributorSections,
});
}
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {