chore: prep v0.5.5 release

This commit is contained in:
2026-03-09 18:07:01 -07:00
parent e59192bbe1
commit a34a7489db
16 changed files with 240 additions and 94 deletions

View File

@@ -1,22 +1,44 @@
# Changelog # Changelog
## v0.5.5 (2026-03-09)
### Changed
- Overlay: Added `f` as the default overlay fullscreen toggle and changed the default AniSkip intro-jump key to `Tab`.
- Dictionary: Aligned AniList character dictionary generation more closely with the upstream reference by preserving duplicate shared names across characters, skipping characters without native Japanese names, restoring richer character info fields, and using upstream-style role mapping plus hint-aware kanji readings.
- Startup: Ordered startup OSD messages so tokenization loads first, annotation loading appears next if still pending, and character dictionary sync progress waits until annotation loading finishes.
- Dictionary: Added a visible startup OSD step for merged character-dictionary building so long rebuilds show progress before the later import/upload phase.
### Fixed
- Dictionary: Fixed AniList media guessing for character dictionary auto-sync by using filename-only `guessit` input and preserving multi-part guessit titles instead of truncating them to the first segment.
- Dictionary: Refresh the current subtitle after character dictionary auto-sync completes so newly imported character names highlight on the active line instead of waiting for the next subtitle change.
- Dictionary: Show character dictionary auto-sync progress on the mpv OSD without sending desktop notifications.
- Dictionary: Keep character dictionary auto-sync non-blocking during startup by letting snapshot/build work run in parallel and delaying only the Yomitan import/settings phase until current-media tokenization is already ready.
- Overlay: Fixed visible overlay keyboard handling so pressing `Tab` still reaches mpv and triggers the default AniSkip skip-intro binding while the overlay has focus.
- Plugin: Fix Windows mpv plugin binary override lookup so `SUBMINER_BINARY_PATH` still resolves to `SubMiner.exe` when no AppImage override is set.
## v0.5.3 (2026-03-09) ## v0.5.3 (2026-03-09)
### Changed ### Changed
- Release: Publish unsigned Windows `.exe` and `.zip` artifacts directly from release CI instead of routing them through SignPath. - Release: Publish unsigned Windows `.exe` and `.zip` artifacts directly from release CI instead of routing them through SignPath.
- Release: Added `bun run build:win:unsigned` for explicit local unsigned Windows packaging. - Release: Added `bun run build:win:unsigned` for explicit local unsigned Windows packaging.
## v0.5.2 (2026-03-09) ## v0.5.2 (2026-03-09)
### Internal ### Internal
- Release: Pinned the Windows SignPath submission workflow to an explicit artifact-configuration slug instead of relying on the SignPath project's default configuration. - Release: Pinned the Windows SignPath submission workflow to an explicit artifact-configuration slug instead of relying on the SignPath project's default configuration.
## v0.5.1 (2026-03-09) ## v0.5.1 (2026-03-09)
### Changed ### Changed
- Launcher: Removed the YouTube subtitle generation mode switch so YouTube playback always preloads subtitles before mpv starts. - Launcher: Removed the YouTube subtitle generation mode switch so YouTube playback always preloads subtitles before mpv starts.
### Fixed ### Fixed
- Launcher: Hardened YouTube AI subtitle fixing so fenced SRT output and text-only one-cue-per-block responses can still be applied without losing original cue timing. - Launcher: Hardened YouTube AI subtitle fixing so fenced SRT output and text-only one-cue-per-block responses can still be applied without losing original cue timing.
- Launcher: Skipped AniSkip lookup during URL playback and YouTube subtitle-preload playback, limiting AniSkip to local file targets where it can actually resolve anime metadata. - Launcher: Skipped AniSkip lookup during URL playback and YouTube subtitle-preload playback, limiting AniSkip to local file targets where it can actually resolve anime metadata.
- Launcher: Keep the background SubMiner process running after a launcher-managed mpv session exits so the next mpv instance can reconnect without restarting the app. - Launcher: Keep the background SubMiner process running after a launcher-managed mpv session exits so the next mpv instance can reconnect without restarting the app.
@@ -24,6 +46,7 @@
- Windows: Acquire the app single-instance lock earlier so Windows overlay/video launches reuse the running background SubMiner process instead of booting a second full app and repeating startup warmups. - Windows: Acquire the app single-instance lock earlier so Windows overlay/video launches reuse the running background SubMiner process instead of booting a second full app and repeating startup warmups.
## v0.3.0 (2026-03-05) ## v0.3.0 (2026-03-05)
- Added keyboard-driven Yomitan navigation and popup controls, including optional auto-pause. - Added keyboard-driven Yomitan navigation and popup controls, including optional auto-pause.
- Added subtitle/jump keyboard handling fixes for smoother subtitle playback control. - Added subtitle/jump keyboard handling fixes for smoother subtitle playback control.
- Improved Anki/Yomitan reliability with stronger Yomitan proxy syncing and safer extension refresh logic. - Improved Anki/Yomitan reliability with stronger Yomitan proxy syncing and safer extension refresh logic.
@@ -34,6 +57,7 @@
- Removed docs Plausible integration and cleaned associated tracker settings. - Removed docs Plausible integration and cleaned associated tracker settings.
## v0.2.3 (2026-03-02) ## v0.2.3 (2026-03-02)
- Added performance and tokenization optimizations (faster warmup, persistent MeCab usage, reduced enrichment lookups). - Added performance and tokenization optimizations (faster warmup, persistent MeCab usage, reduced enrichment lookups).
- Added subtitle controls for no-jump delay shifts. - Added subtitle controls for no-jump delay shifts.
- Improved subtitle highlight logic with priority and reliability fixes. - Improved subtitle highlight logic with priority and reliability fixes.
@@ -42,30 +66,36 @@
- Updated startup flow to load dictionaries asynchronously and unblock first tokenization sooner. - Updated startup flow to load dictionaries asynchronously and unblock first tokenization sooner.
## v0.2.2 (2026-03-01) ## v0.2.2 (2026-03-01)
- Improved subtitle highlighting reliability for frequency modes. - Improved subtitle highlighting reliability for frequency modes.
- Fixed Jellyfin misc info formatting cleanup. - Fixed Jellyfin misc info formatting cleanup.
- Version bump maintenance for 0.2.2. - Version bump maintenance for 0.2.2.
## v0.2.1 (2026-03-01) ## v0.2.1 (2026-03-01)
- Delivered Jellyfin and Subsync fixes from release patch cycle. - Delivered Jellyfin and Subsync fixes from release patch cycle.
- Version bump maintenance for 0.2.1. - Version bump maintenance for 0.2.1.
## v0.2.0 (2026-03-01) ## v0.2.0 (2026-03-01)
- Added task-related release work for the overlay 2.0 cycle. - Added task-related release work for the overlay 2.0 cycle.
- Introduced Overlay 2.0. - Introduced Overlay 2.0.
- Improved release automation reliability. - Improved release automation reliability.
## v0.1.2 (2026-02-24) ## v0.1.2 (2026-02-24)
- Added encrypted AniList token handling and default GNOME keyring support. - Added encrypted AniList token handling and default GNOME keyring support.
- Added launcher passthrough for password-store flows (Jellyfin path). - Added launcher passthrough for password-store flows (Jellyfin path).
- Updated docs for auth and integration behavior. - Updated docs for auth and integration behavior.
- Version bump maintenance for 0.1.2. - Version bump maintenance for 0.1.2.
## v0.1.1 (2026-02-23) ## v0.1.1 (2026-02-23)
- Fixed overlay modal focus handling (`grab input`) behavior. - Fixed overlay modal focus handling (`grab input`) behavior.
- Version bump maintenance for 0.1.1. - Version bump maintenance for 0.1.1.
## v0.1.0 (2026-02-23) ## v0.1.0 (2026-02-23)
- Bootstrapped Electron runtime, services, and composition model. - Bootstrapped Electron runtime, services, and composition model.
- Added runtime asset packaging and dependency vendoring. - Added runtime asset packaging and dependency vendoring.
- Added project docs baseline, setup guides, architecture notes, and submodule/runtime assets. - Added project docs baseline, setup guides, architecture notes, and submodule/runtime assets.

View File

@@ -0,0 +1,71 @@
---
id: TASK-149
title: Cut patch release v0.5.5 for character dictionary updates and release guarding
status: Done
assignee:
- codex
created_date: '2026-03-09 01:10'
updated_date: '2026-03-09 01:14'
labels:
- release
- patch
dependencies:
- TASK-140
- TASK-141
- TASK-142
- TASK-143
- TASK-144
- TASK-145
- TASK-146
- TASK-148
references:
- package.json
- CHANGELOG.md
- scripts/build-changelog.ts
- scripts/build-changelog.test.ts
- docs/RELEASING.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prepare and publish patch release `v0.5.5` after the failed `v0.5.4` tag by aligning package version metadata, generating committed changelog output from the pending release fragments, and hardening release validation so a future tag cannot ship with a mismatched `package.json` version.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repository version metadata is updated to `0.5.5`.
- [x] #2 `CHANGELOG.md` contains the committed `v0.5.5` section and the consumed fragments are removed.
- [x] #3 Release validation rejects a requested release version when it differs from `package.json`.
- [x] #4 Release docs capture the required version/changelog prep before tagging.
- [x] #5 New `v0.5.5` release-prep commit and tag are pushed to `origin/main`.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression test for tagged-release/package version mismatch.
2. Update changelog validation to reject mismatched explicit release versions.
3. Bump `package.json`, generate committed `v0.5.5` changelog output, and remove consumed fragments.
4. Add a short `docs/RELEASING.md` checklist for the prep flow.
5. Run release verification, commit, tag, and push.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added a regression test in `scripts/build-changelog.test.ts` that proves `changelog:check --version ...` rejects tag/package mismatches. Updated `scripts/build-changelog.ts` so tagged release validation now compares the explicit requested version against `package.json` before looking for pending fragments or the committed changelog section.
Bumped `package.json` from `0.5.3` to `0.5.5`, ran `bun run changelog:build --version 0.5.5 --date 2026-03-09`, and committed the generated `CHANGELOG.md` output while removing the consumed task fragments. Added `docs/RELEASING.md` with the required release-prep checklist so version bump + changelog generation happen before tagging.
Verification: `bun run changelog:lint`, `bun run changelog:check --version 0.5.5`, `bun run typecheck`, `bun run test:fast`, and `bun test scripts/build-changelog.test.ts src/release-workflow.test.ts`. `bun run format:check` still reports many unrelated pre-existing repo-wide Prettier warnings, so touched files were checked/formatted separately with `bunx prettier`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared patch release `v0.5.5` after the failed `v0.5.4` release attempt. Release metadata now matches the upcoming tag, the pending character-dictionary/overlay/plugin fragments are committed into `CHANGELOG.md`, and release validation now blocks future tag/package mismatches before publish.
Docs now include a short release checklist in `docs/RELEASING.md`. Validation passed for changelog lint/check, typecheck, targeted workflow tests, and the full fast test suite. Repo-wide Prettier remains noisy from unrelated existing files, but touched release files were formatted and verified.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,4 +0,0 @@
type: changed
area: overlay
- Added `f` as the default overlay fullscreen toggle and changed the default AniSkip intro-jump key to `Tab`.

View File

@@ -1,4 +0,0 @@
type: changed
area: dictionary
- Aligned AniList character dictionary generation more closely with the upstream reference by preserving duplicate shared names across characters, skipping characters without native Japanese names, restoring richer character info fields, and using upstream-style role mapping plus hint-aware kanji readings.

View File

@@ -1,4 +0,0 @@
type: fixed
area: dictionary
- Fixed AniList media guessing for character dictionary auto-sync by using filename-only `guessit` input and preserving multi-part guessit titles instead of truncating them to the first segment.

View File

@@ -1,4 +0,0 @@
type: fixed
area: dictionary
- Refresh the current subtitle after character dictionary auto-sync completes so newly imported character names highlight on the active line instead of waiting for the next subtitle change.

View File

@@ -1,4 +0,0 @@
type: fixed
area: dictionary
- Show character dictionary auto-sync progress on the mpv OSD without sending desktop notifications.

View File

@@ -1,4 +0,0 @@
type: fixed
area: dictionary
- Keep character dictionary auto-sync non-blocking during startup by letting snapshot/build work run in parallel and delaying only the Yomitan import/settings phase until current-media tokenization is already ready.

View File

@@ -1,4 +0,0 @@
type: changed
area: startup
- Ordered startup OSD messages so tokenization loads first, annotation loading appears next if still pending, and character dictionary sync progress waits until annotation loading finishes.

View File

@@ -1,4 +0,0 @@
type: changed
area: dictionary
- Added a visible startup OSD step for merged character-dictionary building so long rebuilds show progress before the later import/upload phase.

View File

@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed visible overlay keyboard handling so pressing `Tab` still reaches mpv and triggers the default AniSkip skip-intro binding while the overlay has focus.

View File

@@ -1,4 +0,0 @@
type: fixed
area: plugin
- Fix Windows mpv plugin binary override lookup so `SUBMINER_BINARY_PATH` still resolves to `SubMiner.exe` when no AppImage override is set.

21
docs/RELEASING.md Normal file
View File

@@ -0,0 +1,21 @@
<!-- read_when: cutting a tagged release or debugging release prep -->
# Releasing
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
2. Bump `package.json` to the release version.
3. Build release metadata before tagging:
`bun run changelog:build --version <version>`
4. Review `CHANGELOG.md`.
5. Run release gate locally:
`bun run changelog:check --version <version>`
`bun run test:fast`
`bun run typecheck`
6. Commit release prep.
7. Tag the commit: `git tag v<version>`.
8. Push commit + tag.
Notes:
- `changelog:check` now rejects tag/package version mismatches.
- Do not tag while `changes/*.md` fragments still exist.

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.5.3", "version": "0.5.5",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -34,12 +34,22 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
const { writeChangelogArtifacts } = await loadModule(); const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('write-artifacts'); const workspace = createWorkspace('write-artifacts');
const projectRoot = path.join(workspace, 'SubMiner'); const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = ['# Changelog', '', '## v0.4.0 (2026-03-01)', '- Existing fix', ''].join('\n'); const existingChangelog = [
'# Changelog',
'',
'## v0.4.0 (2026-03-01)',
'- Existing fix',
'',
].join('\n');
fs.mkdirSync(projectRoot, { recursive: true }); fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', 'README.md'), '# Changelog Fragments\n\nIgnored helper text.\n', 'utf8'); fs.writeFileSync(
path.join(projectRoot, 'changes', 'README.md'),
'# Changelog Fragments\n\nIgnored helper text.\n',
'utf8',
);
fs.writeFileSync( fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'), path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added release fragments.'].join('\n'), ['type: added', 'area: overlay', '', '- Added release fragments.'].join('\n'),
@@ -59,13 +69,10 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
}); });
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]); assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
assert.deepEqual( assert.deepEqual(result.deletedFragmentPaths, [
result.deletedFragmentPaths,
[
path.join(projectRoot, 'changes', '001.md'), path.join(projectRoot, 'changes', '001.md'),
path.join(projectRoot, 'changes', '002.md'), path.join(projectRoot, 'changes', '002.md'),
], ]);
);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true);
@@ -76,7 +83,10 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
/^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n### Added\n- Overlay: Added release fragments\.\n\n### Fixed\n- Release: Fixed release notes generation\.\n\n## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/m, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n### Added\n- Overlay: Added release fragments\.\n\n### Fixed\n- Release: Fixed release notes generation\.\n\n## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/m,
); );
const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8'); const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./); assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./);
assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./); assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
@@ -92,7 +102,11 @@ test('verifyChangelogReadyForRelease ignores README but rejects pending fragment
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', 'README.md'), '# Changelog Fragments\n', 'utf8'); fs.writeFileSync(
path.join(projectRoot, 'changes', 'README.md'),
'# Changelog Fragments\n',
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), '- Pending fragment.\n', 'utf8'); fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), '- Pending fragment.\n', 'utf8');
try { try {
@@ -112,6 +126,33 @@ test('verifyChangelogReadyForRelease ignores README but rejects pending fragment
} }
}); });
test('verifyChangelogReadyForRelease rejects explicit release versions that do not match package.json', async () => {
const { verifyChangelogReadyForRelease } = await loadModule();
const workspace = createWorkspace('verify-release-version-match');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.4.0' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'CHANGELOG.md'),
'# Changelog\n\n## v0.4.1 (2026-03-09)\n- Ready.\n',
'utf8',
);
try {
assert.throws(
() => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }),
/package\.json version \(0\.4\.0\) does not match requested release version \(0\.4\.1\)/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyChangelogFragments rejects invalid metadata', async () => { test('verifyChangelogFragments rejects invalid metadata', async () => {
const { verifyChangelogFragments } = await loadModule(); const { verifyChangelogFragments } = await loadModule();
const workspace = createWorkspace('lint-invalid'); const workspace = createWorkspace('lint-invalid');

View File

@@ -56,7 +56,10 @@ function resolveDate(date?: string): string {
return date ?? new Date().toISOString().slice(0, 10); return date ?? new Date().toISOString().slice(0, 10);
} }
function resolvePackageVersion(cwd: string, readFileSync: (candidate: string, encoding: BufferEncoding) => string): string { function resolvePackageVersion(
cwd: string,
readFileSync: (candidate: string, encoding: BufferEncoding) => string,
): string {
const packageJsonPath = path.join(cwd, 'package.json'); const packageJsonPath = path.join(cwd, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string };
if (!packageJson.version) { if (!packageJson.version) {
@@ -65,22 +68,42 @@ function resolvePackageVersion(cwd: string, readFileSync: (candidate: string, en
return normalizeVersion(packageJson.version); return normalizeVersion(packageJson.version);
} }
function resolveVersion( function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>): string {
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): string {
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
const readFileSync = options.deps?.readFileSync ?? fs.readFileSync; const readFileSync = options.deps?.readFileSync ?? fs.readFileSync;
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync)); return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
} }
function verifyRequestedVersionMatchesPackageVersion(
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): void {
if (!options.version) {
return;
}
const cwd = options.cwd ?? process.cwd();
const existsSync = options.deps?.existsSync ?? fs.existsSync;
const readFileSync = options.deps?.readFileSync ?? fs.readFileSync;
const packageJsonPath = path.join(cwd, 'package.json');
if (!existsSync(packageJsonPath)) {
return;
}
const packageVersion = resolvePackageVersion(cwd, readFileSync);
const requestedVersion = normalizeVersion(options.version);
if (packageVersion !== requestedVersion) {
throw new Error(
`package.json version (${packageVersion}) does not match requested release version (${requestedVersion}).`,
);
}
}
function resolveChangesDir(cwd: string): string { function resolveChangesDir(cwd: string): string {
return path.join(cwd, 'changes'); return path.join(cwd, 'changes');
} }
function resolveFragmentPaths( function resolveFragmentPaths(cwd: string, deps?: ChangelogFsDeps): string[] {
cwd: string,
deps?: ChangelogFsDeps,
): string[] {
const changesDir = resolveChangesDir(cwd); const changesDir = resolveChangesDir(cwd);
const existsSync = deps?.existsSync ?? fs.existsSync; const existsSync = deps?.existsSync ?? fs.existsSync;
const readdirSync = deps?.readdirSync ?? fs.readdirSync; const readdirSync = deps?.readdirSync ?? fs.readdirSync;
@@ -90,7 +113,10 @@ function resolveFragmentPaths(
} }
return readdirSync(changesDir, { withFileTypes: true }) return readdirSync(changesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md') .filter(
(entry) =>
entry.isFile() && entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md',
)
.map((entry) => path.join(changesDir, entry.name)) .map((entry) => path.join(changesDir, entry.name))
.sort(); .sort();
} }
@@ -112,7 +138,10 @@ function normalizeFragmentBullets(content: string): string[] {
return lines; return lines;
} }
function parseFragmentMetadata(content: string, fragmentPath: string): { function parseFragmentMetadata(
content: string,
fragmentPath: string,
): {
area: string; area: string;
body: string; body: string;
type: FragmentType; type: FragmentType;
@@ -144,9 +173,7 @@ function parseFragmentMetadata(content: string, fragmentPath: string): {
const type = metadata.get('type'); const type = metadata.get('type');
if (!type || !CHANGE_TYPES.includes(type as FragmentType)) { if (!type || !CHANGE_TYPES.includes(type as FragmentType)) {
throw new Error( throw new Error(`${fragmentPath} must declare type as one of: ${CHANGE_TYPES.join(', ')}.`);
`${fragmentPath} must declare type as one of: ${CHANGE_TYPES.join(', ')}.`,
);
} }
const area = metadata.get('area'); const area = metadata.get('area');
@@ -166,10 +193,7 @@ function parseFragmentMetadata(content: string, fragmentPath: string): {
}; };
} }
function readChangeFragments( function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragment[] {
cwd: string,
deps?: ChangelogFsDeps,
): ChangeFragment[] {
const readFileSync = deps?.readFileSync ?? fs.readFileSync; const readFileSync = deps?.readFileSync ?? fs.readFileSync;
return resolveFragmentPaths(cwd, deps).map((fragmentPath) => { return resolveFragmentPaths(cwd, deps).map((fragmentPath) => {
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath); const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
@@ -202,7 +226,9 @@ function renderGroupedChanges(fragments: ChangeFragment[]): string {
} }
const bullets = typeFragments const bullets = typeFragments
.flatMap((fragment) => fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet))) .flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n'); .join('\n');
return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`]; return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`];
}); });
@@ -215,9 +241,7 @@ function buildReleaseSection(version: string, date: string, fragments: ChangeFra
throw new Error('No changelog fragments found in changes/.'); throw new Error('No changelog fragments found in changes/.');
} }
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join( return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join('\n');
'\n',
);
} }
function ensureChangelogHeader(existingChangelog: string): string { function ensureChangelogHeader(existingChangelog: string): string {
@@ -231,7 +255,11 @@ function ensureChangelogHeader(existingChangelog: string): string {
return `${CHANGELOG_HEADER}\n\n${trimmed}\n`; return `${CHANGELOG_HEADER}\n\n${trimmed}\n`;
} }
function prependReleaseSection(existingChangelog: string, releaseSection: string, version: string): string { function prependReleaseSection(
existingChangelog: string,
releaseSection: string,
version: string,
): string {
const normalizedExisting = ensureChangelogHeader(existingChangelog); const normalizedExisting = ensureChangelogHeader(existingChangelog);
if (extractReleaseSectionBody(normalizedExisting, version) !== null) { if (extractReleaseSectionBody(normalizedExisting, version) !== null) {
throw new Error(`CHANGELOG already contains a section for v${version}.`); throw new Error(`CHANGELOG already contains a section for v${version}.`);
@@ -263,9 +291,7 @@ function extractReleaseSectionBody(changelog: string, version: string): string |
return body.trim(); return body.trim();
} }
export function resolveChangelogOutputPaths(options?: { export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[] {
cwd?: string;
}): string[] {
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
return [path.join(cwd, 'CHANGELOG.md')]; return [path.join(cwd, 'CHANGELOG.md')];
} }
@@ -290,11 +316,7 @@ function renderReleaseNotes(changes: string): string {
].join('\n'); ].join('\n');
} }
function writeReleaseNotesFile( function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
cwd: string,
changes: string,
deps?: ChangelogFsDeps,
): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH); const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
@@ -359,10 +381,13 @@ export function verifyChangelogFragments(options?: ChangelogOptions): void {
export function verifyChangelogReadyForRelease(options?: ChangelogOptions): void { export function verifyChangelogReadyForRelease(options?: ChangelogOptions): void {
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const version = resolveVersion(options ?? {}); const version = resolveVersion(options ?? {});
const pendingFragments = resolveFragmentPaths(cwd, options?.deps); const pendingFragments = resolveFragmentPaths(cwd, options?.deps);
if (pendingFragments.length > 0) { if (pendingFragments.length > 0) {
throw new Error(`Pending changelog fragments must be released first: ${pendingFragments.join(', ')}`); throw new Error(
`Pending changelog fragments must be released first: ${pendingFragments.join(', ')}`,
);
} }
const changelogPath = path.join(cwd, 'CHANGELOG.md'); const changelogPath = path.join(cwd, 'CHANGELOG.md');
@@ -382,14 +407,14 @@ function isFragmentPath(candidate: string): boolean {
function isIgnoredPullRequestPath(candidate: string): boolean { function isIgnoredPullRequestPath(candidate: string): boolean {
return ( return (
candidate === 'CHANGELOG.md' candidate === 'CHANGELOG.md' ||
|| candidate === 'release/release-notes.md' candidate === 'release/release-notes.md' ||
|| candidate === 'AGENTS.md' candidate === 'AGENTS.md' ||
|| candidate === 'README.md' candidate === 'README.md' ||
|| candidate.startsWith('changes/') candidate.startsWith('changes/') ||
|| candidate.startsWith('docs/') candidate.startsWith('docs/') ||
|| candidate.startsWith('.github/') candidate.startsWith('.github/') ||
|| candidate.startsWith('backlog/') candidate.startsWith('backlog/')
); );
} }
@@ -412,9 +437,7 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
const hasFragment = normalizedEntries.some( const hasFragment = normalizedEntries.some(
(entry) => entry.status !== 'D' && isFragmentPath(entry.path), (entry) => entry.status !== 'D' && isFragmentPath(entry.path),
); );
const requiresFragment = normalizedEntries.some( const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
(entry) => !isIgnoredPullRequestPath(entry.path),
);
if (requiresFragment && !hasFragment) { if (requiresFragment && !hasFragment) {
throw new Error( throw new Error(