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

@@ -34,12 +34,22 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('write-artifacts');
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(path.join(projectRoot, 'changes'), { recursive: true });
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(
path.join(projectRoot, 'changes', '001.md'),
['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.deletedFragmentPaths,
[
path.join(projectRoot, 'changes', '001.md'),
path.join(projectRoot, 'changes', '002.md'),
],
);
assert.deepEqual(result.deletedFragmentPaths, [
path.join(projectRoot, 'changes', '001.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', '002.md')), false);
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,
);
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, /### Fixed\n- Release: Fixed release notes generation\./);
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.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');
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 () => {
const { verifyChangelogFragments } = await loadModule();
const workspace = createWorkspace('lint-invalid');

View File

@@ -56,7 +56,10 @@ function resolveDate(date?: string): string {
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 packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string };
if (!packageJson.version) {
@@ -65,22 +68,42 @@ function resolvePackageVersion(cwd: string, readFileSync: (candidate: string, en
return normalizeVersion(packageJson.version);
}
function resolveVersion(
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): string {
function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>): string {
const cwd = options.cwd ?? process.cwd();
const readFileSync = options.deps?.readFileSync ?? fs.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 {
return path.join(cwd, 'changes');
}
function resolveFragmentPaths(
cwd: string,
deps?: ChangelogFsDeps,
): string[] {
function resolveFragmentPaths(cwd: string, deps?: ChangelogFsDeps): string[] {
const changesDir = resolveChangesDir(cwd);
const existsSync = deps?.existsSync ?? fs.existsSync;
const readdirSync = deps?.readdirSync ?? fs.readdirSync;
@@ -90,7 +113,10 @@ function resolveFragmentPaths(
}
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))
.sort();
}
@@ -112,7 +138,10 @@ function normalizeFragmentBullets(content: string): string[] {
return lines;
}
function parseFragmentMetadata(content: string, fragmentPath: string): {
function parseFragmentMetadata(
content: string,
fragmentPath: string,
): {
area: string;
body: string;
type: FragmentType;
@@ -144,9 +173,7 @@ function parseFragmentMetadata(content: string, fragmentPath: string): {
const type = metadata.get('type');
if (!type || !CHANGE_TYPES.includes(type as FragmentType)) {
throw new Error(
`${fragmentPath} must declare type as one of: ${CHANGE_TYPES.join(', ')}.`,
);
throw new Error(`${fragmentPath} must declare type as one of: ${CHANGE_TYPES.join(', ')}.`);
}
const area = metadata.get('area');
@@ -166,10 +193,7 @@ function parseFragmentMetadata(content: string, fragmentPath: string): {
};
}
function readChangeFragments(
cwd: string,
deps?: ChangelogFsDeps,
): ChangeFragment[] {
function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragment[] {
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
return resolveFragmentPaths(cwd, deps).map((fragmentPath) => {
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
@@ -202,7 +226,9 @@ function renderGroupedChanges(fragments: ChangeFragment[]): string {
}
const bullets = typeFragments
.flatMap((fragment) => fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)))
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
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/.');
}
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join(
'\n',
);
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join('\n');
}
function ensureChangelogHeader(existingChangelog: string): string {
@@ -231,7 +255,11 @@ function ensureChangelogHeader(existingChangelog: string): string {
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);
if (extractReleaseSectionBody(normalizedExisting, version) !== null) {
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();
}
export function resolveChangelogOutputPaths(options?: {
cwd?: string;
}): string[] {
export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[] {
const cwd = options?.cwd ?? process.cwd();
return [path.join(cwd, 'CHANGELOG.md')];
}
@@ -290,11 +316,7 @@ function renderReleaseNotes(changes: string): string {
].join('\n');
}
function writeReleaseNotesFile(
cwd: string,
changes: string,
deps?: ChangelogFsDeps,
): string {
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
@@ -359,10 +381,13 @@ export function verifyChangelogFragments(options?: ChangelogOptions): void {
export function verifyChangelogReadyForRelease(options?: ChangelogOptions): void {
const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const version = resolveVersion(options ?? {});
const pendingFragments = resolveFragmentPaths(cwd, options?.deps);
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');
@@ -382,14 +407,14 @@ function isFragmentPath(candidate: string): boolean {
function isIgnoredPullRequestPath(candidate: string): boolean {
return (
candidate === 'CHANGELOG.md'
|| candidate === 'release/release-notes.md'
|| candidate === 'AGENTS.md'
|| candidate === 'README.md'
|| candidate.startsWith('changes/')
|| candidate.startsWith('docs/')
|| candidate.startsWith('.github/')
|| candidate.startsWith('backlog/')
candidate === 'CHANGELOG.md' ||
candidate === 'release/release-notes.md' ||
candidate === 'AGENTS.md' ||
candidate === 'README.md' ||
candidate.startsWith('changes/') ||
candidate.startsWith('docs/') ||
candidate.startsWith('.github/') ||
candidate.startsWith('backlog/')
);
}
@@ -412,9 +437,7 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
const hasFragment = normalizedEntries.some(
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
);
const requiresFragment = normalizedEntries.some(
(entry) => !isIgnoredPullRequestPath(entry.path),
);
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
if (requiresFragment && !hasFragment) {
throw new Error(