feat(release): implement custom changelog generator for merged beta versions

- Create custom-changelog.mjs script to generate single consolidated changelog entry
- Disable conventional-changelog's built-in changelog generation (infile: false)
- Use custom script in after:bump hook to generate merged changelog
- Properly groups all commits from last stable version by category
- Filters out beta release commits automatically
- Supports breaking changes with detailed descriptions
This commit is contained in:
Quorafind 2025-09-04 22:48:17 +08:00
parent af101fd6b0
commit 9bd0d53b67
2 changed files with 203 additions and 50 deletions

View file

@ -69,6 +69,8 @@ module.exports = {
"after:bump": [
"node esbuild.config.mjs production",
"node ./scripts/zip.mjs",
// 使用自定义 changelog 生成器替代 conventional-changelog
"node ./scripts/custom-changelog.mjs ${version}",
"git add .",
],
"after:release":
@ -98,56 +100,10 @@ module.exports = {
{type: "revert", section: "Reverts"}
]
},
infile: "CHANGELOG.md",
header: "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n",
// 只生成当前版本的 changelog但包含从上一个正式版以来的所有提交
releaseCount: 1,
// 限制 git log 的提交范围,从上一个正式版开始(排除 beta 版本)
gitRawCommitsOpts: {
from: lastStableTag // 使用已经获取的值
},
writerOpts: {
// 自定义提交转换,过滤掉 beta 相关的 chore commits
transform: (commit, context) => {
// 过滤掉 beta 版本的 release commits
if (commit.type === 'chore' && commit.subject) {
// 过滤包含 beta 的 release commits
if (commit.subject.includes('beta') ||
commit.subject.includes('-beta.') ||
commit.subject.match(/v\d+\.\d+\.\d+-beta/)) {
return null;
}
// 过滤 release scope 的 commits通常是版本发布相关
if (commit.scope === 'release' &&
(commit.subject.includes('bump version') ||
commit.subject.includes('v9.8.0'))) {
return null;
}
}
// 创建一个新的 commit 对象副本,避免修改原始对象
const transformedCommit = Object.assign({}, commit);
// 确保 commit 有短 hash 用于显示
if (transformedCommit.hash && !transformedCommit.shortHash) {
transformedCommit.shortHash = transformedCommit.hash.substring(0, 7);
}
// 返回转换后的提交
return transformedCommit;
},
// 确保比较链接使用正确的版本范围
finalizeContext: (context) => {
// 使用已经获取的值,避免重复调用
if (lastStableTag) {
context.previousTag = lastStableTag;
context.currentTag = context.version;
// 更新比较 URL
context.compareUrl = `https://github.com/Quorafind/Obsidian-Task-Genius/compare/${lastStableTag}...${context.version}`;
}
return context;
}
}
// 禁用 conventional-changelog 的 changelog 生成,使用我们的自定义脚本
infile: false,
// 仍然使用 conventional commits 来推荐版本号
strictSemVer: false
},
"./scripts/ob-bumper.mjs": {
indent: 2,

197
scripts/custom-changelog.mjs Executable file
View file

@ -0,0 +1,197 @@
#!/usr/bin/env node
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import semver from 'semver';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* 自定义 changelog 生成器合并所有 beta 版本的提交到单个版本条目
*/
export function generateMergedChangelog(targetVersion, options = {}) {
const { dryRun = false } = options;
// 获取上一个正式版本标签
function getLastStableTag() {
try {
const allTags = execSync('git tag -l', { encoding: 'utf8' })
.trim()
.split('\n')
.filter(Boolean);
const stableTags = [];
for (const tag of allTags) {
try {
execSync(`git merge-base --is-ancestor ${tag} HEAD`, { encoding: 'utf8' });
const versionString = tag.replace(/^v/, '');
const version = semver.valid(versionString);
if (version && !semver.prerelease(version)) {
stableTags.push({ tag, version });
}
} catch (e) {
// 标签不在当前分支历史中,跳过
}
}
if (stableTags.length === 0) {
return 'HEAD~30';
}
const sortedTags = stableTags.sort((a, b) => {
return semver.rcompare(a.version, b.version);
});
return sortedTags[0].tag;
} catch (error) {
return 'HEAD~30';
}
}
const lastStableTag = getLastStableTag();
console.log(`📦 Generating changelog from ${lastStableTag} to ${targetVersion}`);
// 获取所有提交
const rawCommits = execSync(
`git log ${lastStableTag}..HEAD --pretty=format:"%H|||%s|||%b|||%an|||%ae|||%ad" --no-merges`,
{ encoding: 'utf8' }
).trim();
const commits = rawCommits ? rawCommits.split('\n').filter(Boolean) : [];
// 按类型分组提交
const groupedCommits = {
'Features': [],
'Bug Fixes': [],
'Performance': [],
'Refactors': [],
'Documentation': [],
'Styles': [],
'Tests': [],
'Reverts': [],
'Breaking Changes': []
};
const typeMap = {
'feat': 'Features',
'fix': 'Bug Fixes',
'perf': 'Performance',
'refactor': 'Refactors',
'docs': 'Documentation',
'style': 'Styles',
'test': 'Tests',
'revert': 'Reverts'
};
// 解析提交
commits.forEach(commit => {
const parts = commit.split('|||');
if (parts.length < 2) return;
const [hash, subject, body] = parts;
if (!subject) return;
// 解析 conventional commit 格式
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
if (!match) return;
const [, type, scope, description] = match;
// 过滤掉 beta release commits
if (type === 'chore' && description && (
description.includes('beta') ||
description.includes('-beta.') ||
description.match(/v\d+\.\d+\.\d+-beta/))) {
return;
}
// 检查是否有 BREAKING CHANGE
if (body && body.includes('BREAKING CHANGE')) {
groupedCommits['Breaking Changes'].push({
hash: hash.substring(0, 7),
scope,
description,
body
});
}
const section = typeMap[type];
if (section) {
groupedCommits[section].push({
hash: hash.substring(0, 7),
scope,
description
});
}
});
// 生成 changelog 内容
const date = new Date().toISOString().split('T')[0];
const compareUrl = `https://github.com/Quorafind/Obsidian-Task-Genius/compare/${lastStableTag}...${targetVersion}`;
let newChangelog = `## [${targetVersion}](${compareUrl}) (${date})\n\n`;
// 按顺序输出各个分组Breaking Changes 优先)
const sectionOrder = ['Breaking Changes', 'Features', 'Bug Fixes', 'Performance', 'Refactors', 'Documentation', 'Tests', 'Styles', 'Reverts'];
sectionOrder.forEach(section => {
const sectionCommits = groupedCommits[section];
if (sectionCommits && sectionCommits.length > 0) {
newChangelog += `### ${section}\n\n`;
sectionCommits.forEach(commit => {
const commitUrl = `https://github.com/Quorafind/Obsidian-Task-Genius/commit/${commit.hash}`;
const scopePrefix = commit.scope ? `**${commit.scope}:** ` : '';
newChangelog += `* ${scopePrefix}${commit.description} ([${commit.hash}](${commitUrl}))\n`;
if (commit.body && section === 'Breaking Changes') {
// 添加 breaking change 详细说明
const breakingDetail = commit.body.split('BREAKING CHANGE:')[1]?.trim();
if (breakingDetail) {
newChangelog += ` ${breakingDetail}\n`;
}
}
});
newChangelog += '\n';
}
});
// 读取现有的 changelog
const changelogPath = path.join(__dirname, '..', 'CHANGELOG.md');
let existingChangelog = '';
try {
existingChangelog = readFileSync(changelogPath, 'utf8');
} catch (e) {
// 文件不存在,创建新的
existingChangelog = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n';
}
// 确保不重复添加相同版本
if (existingChangelog.includes(`## [${targetVersion}]`)) {
console.log(`⚠️ Version ${targetVersion} already exists in CHANGELOG.md`);
return existingChangelog;
}
// 插入新的 changelog 到文件开头(在 header 之后)
const header = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n';
const restContent = existingChangelog.replace(header, '').trim();
const updatedChangelog = header + '\n' + newChangelog + restContent;
if (!dryRun) {
writeFileSync(changelogPath, updatedChangelog);
console.log(`✅ CHANGELOG.md updated with version ${targetVersion}`);
} else {
console.log('📋 Preview (dry-run mode):');
console.log(newChangelog);
}
return updatedChangelog;
}
// 如果直接运行此脚本
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const version = process.argv[2] || '9.8.0';
const dryRun = process.argv.includes('--dry-run');
generateMergedChangelog(version, { dryRun });
}