Compare commits

..

1 Commits

Author SHA1 Message Date
sudacode b3b45521b6 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
2026-06-10 23:53:31 -07:00
12 changed files with 170 additions and 25 deletions
@@ -0,0 +1,4 @@
type: fixed
area: release
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
@@ -0,0 +1,4 @@
type: changed
area: updates
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
+1 -1
View File
@@ -172,7 +172,7 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
+1 -1
View File
@@ -24,7 +24,7 @@
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes. - **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads. - **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering. - **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications. - **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
**Fixed** **Fixed**
+2 -2
View File
@@ -197,7 +197,7 @@ Configure automatic update checks and update notifications:
"updates": { "updates": {
"enabled": true, "enabled": true,
"checkIntervalHours": 24, "checkIntervalHours": 24,
"notificationType": "both", "notificationType": "overlay",
"channel": "stable" "channel": "stable"
} }
} }
@@ -207,7 +207,7 @@ Configure automatic update checks and update notifications:
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. | | `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. | | `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"both"`, which means overlay + system. | | `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow. When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
+1 -1
View File
@@ -172,7 +172,7 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
+1 -1
View File
@@ -77,7 +77,7 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## Whats Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free. - `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
+11 -11
View File
@@ -55,6 +55,17 @@
</details> </details>
## What's Changed
- feat(notifications): add overlay notifications with position config by @ksyasuda in #110
- feat(stats): speed up session maintenance and improve stats UI by @ksyasuda in #111
- [codex] Restart Jellyfin remote session after setup login by @bee-san in #112
- docs(changelog): require reconciled fragments, not just new ones by @ksyasuda in #113
- feat(release): add contributor attribution to release notes by @ksyasuda in #114
- fix(anilist): mark entry completed when final episode is reached by @ksyasuda in #115
- feat(aniskip): move intro detection from mpv plugin to app runtime by @ksyasuda in #117
- fix(anki): write sentence card audio only to sentence audio field by @ksyasuda in #118
## Installation ## Installation
See the README and docs/installation guide for full setup steps. See the README and docs/installation guide for full setup steps.
@@ -67,14 +78,3 @@ See the README and docs/installation guide for full setup steps.
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
## Whats Changed
- feat(notifications): add overlay notifications with position config by @ksyasuda in #110
- feat(stats): speed up session maintenance and improve stats UI by @ksyasuda in #111
- [codex] Restart Jellyfin remote session after setup login by @bee-san in #112
- docs(changelog): require reconciled fragments, not just new ones by @ksyasuda in #113
- feat(release): add contributor attribution to release notes by @ksyasuda in #114
- fix(anilist): mark entry completed when final episode is reached by @ksyasuda in #115
- feat(aniskip): move intro detection from mpv plugin to app runtime by @ksyasuda in #117
- fix(anki): write sentence card audio only to sentence audio field by @ksyasuda in #118
+94 -2
View File
@@ -1122,13 +1122,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
path.join(projectRoot, 'release', 'release-notes.md'), path.join(projectRoot, 'release', 'release-notes.md'),
'utf8', '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, /- 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, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
assert.match( assert.match(
releaseNotes, releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/, /## 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( assert.doesNotMatch(
releaseNotes, releaseNotes,
/ksyasuda made their first contribution/, /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. // Attribution is a release-notes concern only; the CHANGELOG stays clean.
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); 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/); assert.doesNotMatch(changelog, /New Contributors/);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); 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 () => { test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule(); const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section'); 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[] { function renderContributorsSections(contributions: Contribution[]): string[] {
if (contributions.length === 0) { if (contributions.length === 0) {
return []; return [];
} }
const lines: string[] = ['## Whats Changed', '']; const lines: string[] = ["## What's Changed", ''];
for (const contribution of contributions) { for (const contribution of contributions) {
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`); lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
} }
@@ -635,14 +668,18 @@ function renderReleaseNotes(
options?: { options?: {
disclaimer?: string; disclaimer?: string;
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[];
}, },
): string { ): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
const contributorSections =
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
return [ return [
...prefix, ...prefix,
'## Highlights', '## Highlights',
changes, changes,
'', '',
...contributorSections,
'## Installation', '## Installation',
'', '',
'See the README and docs/installation guide for full setup steps.', '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`.', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'', '',
...renderContributorsSections(options?.contributions ?? []),
].join('\n'); ].join('\n');
} }
@@ -668,6 +704,7 @@ function writeReleaseNotesFile(
disclaimer?: string; disclaimer?: string;
outputPath?: string; outputPath?: string;
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[];
}, },
): string { ): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -960,6 +997,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string { export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {}); const version = resolveVersion(options ?? {});
const changelogPath = path.join(cwd, 'CHANGELOG.md'); 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}.`); 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 { export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
+2 -2
View File
@@ -153,7 +153,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.stats.autoOpenBrowser, false); assert.equal(config.stats.autoOpenBrowser, false);
assert.equal(config.updates.enabled, true); assert.equal(config.updates.enabled, true);
assert.equal(config.updates.checkIntervalHours, 24); assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'both'); assert.equal(config.updates.notificationType, 'overlay');
assert.equal(config.updates.channel, 'stable'); assert.equal(config.updates.channel, 'stable');
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath); assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
assert.equal(config.mpv.backend, 'auto'); assert.equal(config.mpv.backend, 'auto');
@@ -2814,7 +2814,7 @@ test('template generator includes known keys', () => {
); );
assert.match( assert.match(
output, output,
/"notificationType": "both",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/, /"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
); );
assert.match( assert.match(
output, output,
+1 -1
View File
@@ -128,7 +128,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
updates: { updates: {
enabled: true, enabled: true,
checkIntervalHours: 24, checkIntervalHours: 24,
notificationType: 'both', notificationType: 'overlay',
channel: 'stable', channel: 'stable',
}, },
notifications: { notifications: {