name: changelog-generator description: 从git提交自动生成变更日志,遵循常规提交、语义版本控制和最佳实践。
变更日志生成器技能
自动从git提交生成变更日志,遵循常规提交、语义版本控制和最佳实践。
说明
您是一位变更日志生成专家。当调用时:
-
分析提交历史:
- 解析git提交消息
- 识别常规提交类型
- 分组相关更改
- 确定版本提升(主版本、次版本、补丁)
-
生成变更日志条目:
- 遵循Keep a Changelog格式
- 按更改类型分类
- 突出显示重大更改
- 添加相关元数据(日期、版本、作者)
-
格式化输出:
- 使用markdown格式
- 创建清晰的章节标题
- 添加提交和PR的链接
- 包含重大更改的迁移指南
-
版本管理:
- 建议语义版本号
- 识别重大更改
- 跟踪废弃功能
- 处理预发布版本
常规提交类型
- feat: 新功能(次版本提升)
- fix: 错误修复(补丁版本提升)
- docs: 文档更改
- style: 代码风格更改(格式化等)
- refactor: 代码重构
- perf: 性能改进
- test: 测试添加或更改
- build: 构建系统更改
- ci: CI/CD更改
- chore: 维护任务
- revert: 恢复先前更改
重大更改: 任何在正文中包含BREAKING CHANGE:或类型后带有!的提交(主版本提升)
使用示例
@changelog-generator
@changelog-generator --since v1.2.0
@changelog-generator --unreleased
@changelog-generator --version 2.0.0
@changelog-generator --format keep-a-changelog
@changelog-generator --include-authors
变更日志格式
Keep a Changelog格式
# 变更日志
本项目所有显著更改都将记录在此文件中。
格式基于[Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
并且本项目遵循[语义版本控制](https://semver.org/spec/v2.0.0.html)。
## [未发布]
### 新增
- 新功能X以改善用户体验
- 支持配置选项Y
### 更改
- 更新依赖Z至版本2.0
- 改进数据处理性能
### 废弃
- 函数`oldMethod()` - 使用`newMethod()`代替
### 移除
- 移除废弃的API端点`/api/v1/old`
### 修复
- 修复缓存实现中的内存泄漏
- 修正日期格式化程序中的时区处理
### 安全
- 修复用户输入处理中的XSS漏洞
- 更新加密库以解决CVE-2024-1234
## [1.5.0] - 2024-01-15
### 新增
- 用户认证与OAuth2
- 报告导出功能
- 深色模式主题支持
### 更改
- 重新设计仪表板UI
- 优化数据库查询
### 修复
- 修复分页逻辑中的错误
- 解决API的CORS问题
## [1.4.2] - 2024-01-10
### 修复
- 修复支付处理中的关键错误
- WebSocket连接中的内存泄漏
### 安全
- 修复认证绕过漏洞
## [1.4.1] - 2024-01-05
### 修复
- 部署脚本的热修复
- 修复错误消息中的拼写错误
## [1.4.0] - 2024-01-01
### 新增
- 实时通知
- 拖放文件上传
- 高级搜索过滤器
### 更改
- 从REST迁移到GraphQL
- 更新UI组件库
### 废弃
- 旧的REST API端点(将在2.0中移除)
[未发布]: https://github.com/user/repo/compare/v1.5.0...HEAD
[1.5.0]: https://github.com/user/repo/compare/v1.4.2...v1.5.0
[1.4.2]: https://github.com/user/repo/compare/v1.4.1...v1.4.2
[1.4.1]: https://github.com/user/repo/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/user/repo/releases/tag/v1.4.0
自动化变更日志生成
使用Git提交
#!/bin/bash
# generate-changelog.sh - 从git提交生成变更日志
VERSION=${1:-"Unreleased"}
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "# 变更日志"
echo ""
echo "## [$VERSION] - $(date +%Y-%m-%d)"
echo ""
# 获取上次标签后的提交
if [ -z "$PREV_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s|||%h|||%an" --reverse)
else
COMMITS=$(git log ${PREV_TAG}..HEAD --pretty=format:"%s|||%h|||%an" --reverse)
fi
# 不同类别的数组
declare -a features=()
declare -a fixes=()
declare -a breaking=()
declare -a docs=()
declare -a chores=()
declare -a other=()
# 解析提交
while IFS='|||' read -r message hash author; do
case "$message" in
feat:*|feat\(*\):*)
features+=("- ${message#feat*: } ([${hash}](../../commit/${hash}))")
;;
fix:*|fix\(*\):*)
fixes+=("- ${message#fix*: } ([${hash}](../../commit/${hash}))")
;;
*BREAKING*|*\!:*)
breaking+=("- ${message} ([${hash}](../../commit/${hash}))")
;;
docs:*)
docs+=("- ${message#docs: } ([${hash}](../../commit/${hash}))")
;;
chore:*|build:*|ci:*)
chores+=("- ${message#*: } ([${hash}](../../commit/${hash}))")
;;
*)
other+=("- ${message} ([${hash}](../../commit/${hash}))")
;;
esac
done <<< "$COMMITS"
# 输出章节
if [ ${#breaking[@]} -gt 0 ]; then
echo "### ⚠️ 重大更改"
echo ""
printf '%s
' "${breaking[@]}"
echo ""
fi
if [ ${#features[@]} -gt 0 ]; then
echo "### 新增"
echo ""
printf '%s
' "${features[@]}"
echo ""
fi
if [ ${#fixes[@]} -gt 0 ]; then
echo "### 修复"
echo ""
printf '%s
' "${fixes[@]}"
echo ""
fi
if [ ${#docs[@]} -gt 0 ]; then
echo "### 文档"
echo ""
printf '%s
' "${docs[@]}"
echo ""
fi
if [ ${#chores[@]} -gt 0 ]; then
echo "### 内部"
echo ""
printf '%s
' "${chores[@]}"
echo ""
fi
if [ ${#other[@]} -gt 0 ]; then
echo "### 其他更改"
echo ""
printf '%s
' "${other[@]}"
echo ""
fi
使用conventional-changelog
# 安装
npm install -g conventional-changelog-cli
# 生成变更日志
conventional-changelog -p angular -i CHANGELOG.md -s
# 首次发布
conventional-changelog -p angular -i CHANGELOG.md -s -r 0
# 指定版本
conventional-changelog -p angular -i CHANGELOG.md -s --release-count 0 \
--tag-prefix v --preset angular
package.json配置:
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"version": "npm run changelog && git add CHANGELOG.md"
},
"devDependencies": {
"conventional-changelog-cli": "^4.1.0"
}
}
使用standard-version
# 安装
npm install -D standard-version
# 生成变更日志并提升版本
npx standard-version
# 预览而不提交
npx standard-version --dry-run
# 首次发布
npx standard-version --first-release
# 指定版本
npx standard-version --release-as minor
npx standard-version --release-as 1.1.0
# 预发布
npx standard-version --prerelease alpha
package.json:
{
"scripts": {
"release": "standard-version",
"release:minor": "standard-version --release-as minor",
"release:major": "standard-version --release-as major",
"release:alpha": "standard-version --prerelease alpha"
},
"standard-version": {
"types": [
{"type": "feat", "section": "Features"},
{"type": "fix", "section": "Bug Fixes"},
{"type": "chore", "hidden": true},
{"type": "docs", "section": "Documentation"},
{"type": "style", "hidden": true},
{"type": "refactor", "section": "Code Refactoring"},
{"type": "perf", "section": "Performance Improvements"},
{"type": "test", "hidden": true}
]
}
}
使用release-please(GitHub Action)
.github/workflows/release.yml:
name: 发布
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v3
id: release
with:
release-type: node
package-name: my-package
- uses: actions/checkout@v3
if: ${{ steps.release.outputs.release_created }}
- uses: actions/setup-node@v3
if: ${{ steps.release.outputs.release_created }}
with:
node-version: 18
registry-url: 'https://registry.npmjs.org'
- run: npm ci
if: ${{ steps.release.outputs.release_created }}
- run: npm publish
if: ${{ steps.release.outputs.release_created }}
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
高级变更日志生成
Node.js脚本带完整功能
#!/usr/bin/env node
// generate-changelog.js
const { execSync } = require('child_process');
const fs = require('fs');
const COMMIT_PATTERN = /^(\w+)(\([\w-]+\))?(!)?:\s(.+)$/;
const TYPES = {
feat: { section: 'Added', bump: 'minor' },
fix: { section: 'Fixed', bump: 'patch' },
docs: { section: 'Documentation', bump: null },
style: { section: 'Style', bump: null },
refactor: { section: 'Changed', bump: null },
perf: { section: 'Performance', bump: 'patch' },
test: { section: 'Tests', bump: null },
build: { section: 'Build System', bump: null },
ci: { section: 'CI', bump: null },
chore: { section: 'Chores', bump: null },
revert: { section: 'Reverts', bump: 'patch' },
};
function getCommitsSinceTag(tag) {
const cmd = tag
? `git log ${tag}..HEAD --pretty=format:"%H|||%s|||%b|||%an|||%ae|||%ai"`
: `git log --pretty=format:"%H|||%s|||%b|||%an|||%ae|||%ai"`;
try {
const output = execSync(cmd, { encoding: 'utf-8' });
return output.split('
').filter(Boolean);
} catch (error) {
return [];
}
}
function parseCommit(commitLine) {
const [hash, subject, body, author, email, date] = commitLine.split('|||');
const match = subject.match(COMMIT_PATTERN);
if (!match) {
return {
hash,
subject,
body,
author,
email,
date,
type: 'other',
scope: null,
breaking: false,
description: subject,
};
}
const [, type, scope, breaking, description] = match;
return {
hash,
subject,
body,
author,
email,
date,
type,
scope: scope ? scope.slice(1, -1) : null,
breaking: Boolean(breaking) || body.includes('BREAKING CHANGE'),
description,
};
}
function groupCommits(commits) {
const groups = {};
const breaking = [];
for (const commit of commits) {
if (commit.breaking) {
breaking.push(commit);
}
const typeInfo = TYPES[commit.type] || { section: 'Other Changes' };
const section = typeInfo.section;
if (!groups[section]) {
groups[section] = [];
}
groups[section].push(commit);
}
return { groups, breaking };
}
function generateMarkdown(version, date, groups, breaking, options = {}) {
const lines = [];
lines.push(`## [${version}] - ${date}`);
lines.push('');
// 重大更改优先
if (breaking.length > 0) {
lines.push('### ⚠️ 重大更改');
lines.push('');
for (const commit of breaking) {
lines.push(`- **${commit.description}** ([${commit.hash.slice(0, 7)}](../../commit/${commit.hash}))`);
if (commit.body) {
const breakingNote = commit.body.match(/BREAKING CHANGE:\s*(.+)/);
if (breakingNote) {
lines.push(` ${breakingNote[1]}`);
}
}
}
lines.push('');
}
// 其他章节
const sectionOrder = [
'Added',
'Changed',
'Deprecated',
'Removed',
'Fixed',
'Security',
'Performance',
'Documentation',
];
for (const section of sectionOrder) {
if (groups[section] && groups[section].length > 0) {
lines.push(`### ${section}`);
lines.push('');
const commits = groups[section];
const grouped = {};
// 如果有范围则分组
for (const commit of commits) {
const key = commit.scope || '_default';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(commit);
}
for (const [scope, scopeCommits] of Object.entries(grouped)) {
if (scope !== '_default') {
lines.push(`#### ${scope}`);
lines.push('');
}
for (const commit of scopeCommits) {
let line = `- ${commit.description}`;
if (options.includeHash) {
line += ` ([${commit.hash.slice(0, 7)}](../../commit/${commit.hash}))`;
}
if (options.includeAuthor) {
line += ` - @${commit.author}`;
}
lines.push(line);
}
}
lines.push('');
}
}
return lines.join('
');
}
function getLatestTag() {
try {
return execSync('git describe --tags --abbrev=0', { encoding: 'utf-8' }).trim();
} catch (error) {
return null;
}
}
function suggestVersion(breaking, groups) {
const latestTag = getLatestTag();
if (!latestTag) return '1.0.0';
const [major, minor, patch] = latestTag.replace('v', '').split('.').map(Number);
if (breaking.length > 0) {
return `${major + 1}.0.0`;
}
const hasFeatures = groups['Added'] && groups['Added'].length > 0;
if (hasFeatures) {
return `${major}.${minor + 1}.0`;
}
return `${major}.${minor}.${patch + 1}`;
}
// 主执行
function main() {
const args = process.argv.slice(2);
const options = {
includeHash: !args.includes('--no-hash'),
includeAuthor: args.includes('--author'),
version: args.find(a => a.startsWith('--version='))?.split('=')[1],
since: args.find(a => a.startsWith('--since='))?.split('=')[1],
};
const latestTag = options.since || getLatestTag();
const commits = getCommitsSinceTag(latestTag);
const parsed = commits.map(parseCommit);
const { groups, breaking } = groupCommits(parsed);
const version = options.version || suggestVersion(breaking, groups);
const date = new Date().toISOString().split('T')[0];
const changelog = generateMarkdown(version, date, groups, breaking, options);
// 读取现有变更日志或创建新的
let existingChangelog = '';
try {
existingChangelog = fs.readFileSync('CHANGELOG.md', 'utf-8');
} catch (error) {
existingChangelog = '# 变更日志
本项目所有显著更改都将记录在此文件中。
';
}
// 在标题后插入新版本
const lines = existingChangelog.split('
');
const headerEnd = lines.findIndex(l => l.startsWith('## '));
const newLines = [
...lines.slice(0, headerEnd === -1 ? lines.length : headerEnd),
changelog,
...lines.slice(headerEnd === -1 ? lines.length : headerEnd),
];
fs.writeFileSync('CHANGELOG.md', newLines.join('
'));
console.log(`✓ 生成版本 ${version} 的变更日志`);
console.log(` - 处理 ${commits.length} 个提交`);
console.log(` - ${breaking.length} 个重大更改`);
console.log(` - 建议版本: ${version}`);
}
if (require.main === module) {
main();
}
module.exports = { parseCommit, groupCommits, generateMarkdown };
使用:
# 使可执行
chmod +x generate-changelog.js
# 运行
./generate-changelog.js
# 带选项
./generate-changelog.js --version=2.0.0 --author --since=v1.5.0
Python版本
#!/usr/bin/env python3
# generate_changelog.py
import re
import subprocess
from datetime import datetime
from collections import defaultdict
from typing import List, Dict, Tuple
COMMIT_PATTERN = re.compile(r'^(\w+)(\([\w-]+\))?(!)?:\s(.+)$')
TYPES = {
'feat': {'section': 'Added', 'bump': 'minor'},
'fix': {'section': 'Fixed', 'bump': 'patch'},
'docs': {'section': 'Documentation', 'bump': None},
'style': {'section': 'Style', 'bump': None},
'refactor': {'section': 'Changed', 'bump': None},
'perf': {'section': 'Performance', 'bump': 'patch'},
'test': {'section': 'Tests', 'bump': None},
'build': {'section': 'Build System', 'bump': None},
'ci': {'section': 'CI', 'bump': None},
'chore': {'section': 'Chores', 'bump': None},
}
def get_commits_since_tag(tag: str = None) -> List[str]:
cmd = ['git', 'log', '--pretty=format:%H|||%s|||%b|||%an|||%ae|||%ai']
if tag:
cmd.insert(2, f'{tag}..HEAD')
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return [line for line in result.stdout.split('
') if line]
except subprocess.CalledProcessError:
return []
def parse_commit(commit_line: str) -> Dict:
parts = commit_line.split('|||')
if len(parts) < 6:
return None
hash_val, subject, body, author, email, date = parts
match = COMMIT_PATTERN.match(subject)
if not match:
return {
'hash': hash_val,
'subject': subject,
'body': body,
'author': author,
'type': 'other',
'scope': None,
'breaking': False,
'description': subject,
}
type_val, scope, breaking, description = match.groups()
return {
'hash': hash_val,
'subject': subject,
'body': body,
'author': author,
'type': type_val,
'scope': scope[1:-1] if scope else None,
'breaking': bool(breaking) or 'BREAKING CHANGE' in body,
'description': description,
}
def group_commits(commits: List[Dict]) -> Tuple[Dict, List]:
groups = defaultdict(list)
breaking = []
for commit in commits:
if commit['breaking']:
breaking.append(commit)
type_info = TYPES.get(commit['type'], {'section': 'Other Changes'})
section = type_info['section']
groups[section].append(commit)
return dict(groups), breaking
def generate_markdown(version: str, groups: Dict, breaking: List) -> str:
lines = [f"## [{version}] - {datetime.now().strftime('%Y-%m-%d')}", ""]
if breaking:
lines.append("### ⚠️ 重大更改")
lines.append("")
for commit in breaking:
lines.append(f"- **{commit['description']}** ([{commit['hash'][:7]}](../../commit/{commit['hash']}))")
lines.append("")
section_order = ['Added', 'Changed', 'Fixed', 'Security', 'Performance', 'Documentation']
for section in section_order:
if section in groups and groups[section]:
lines.append(f"### {section}")
lines.append("")
for commit in groups[section]:
lines.append(f"- {commit['description']} ([{commit['hash'][:7]}](../../commit/{commit['hash']}))")
lines.append("")
return '
'.join(lines)
def main():
commits = get_commits_since_tag()
parsed = [parse_commit(c) for c in commits if parse_commit(c)]
groups, breaking = group_commits(parsed)
version = input("输入版本号(或按Enter键自动生成): ").strip() or "1.0.0"
changelog = generate_markdown(version, groups, breaking)
print(changelog)
# 可选写入文件
with open('CHANGELOG.md', 'r+') as f:
content = f.read()
f.seek(0, 0)
f.write(changelog + '
' + content)
if __name__ == '__main__':
main()
GitHub发布说明
自动化发布带说明
# .github/workflows/release.yml
name: 发布
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: 生成变更日志
id: changelog
run: |
# 获取前一个标签
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
# 生成变更日志
if [ -z "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --reverse)
else
CHANGELOG=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --reverse)
fi
# 设置输出
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: 创建发布
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: 发布 ${{ github.ref }}
body: |
## 此版本中的更改
${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
最佳实践
提交消息
- 使用常规提交: 启用自动化变更日志生成
- 描述清晰: 简洁、清晰地描述更改
- 包含上下文: 说明为什么更改,而不仅仅是做了什么
- 引用问题: 链接到相关问题编号
变更日志组织
- 按类型分组: 功能、修复、重大更改等
- 按时间顺序排序: 最新更改优先
- 包含日期: 每个版本应有发布日期
- 链接到提交: 提供详细信息的链接
版本管理
- 遵循semver: 主版本.次版本.补丁版本控制
- 记录重大更改: 突出显示重大更改
- 提供迁移指南: 帮助用户升级
- 跟踪废弃功能: 在移除功能前警告
维护
- 定期更新: 每次发布时生成变更日志
- 发布前审核: 手动验证自动化输出
- 保持格式一致: 使用相同的结构
- 归档旧版本: 保持完整历史可用
备注
- 始终使用常规提交进行自动化变更日志生成
- 在变更日志顶部包含重大更改
- 为主版本提升提供迁移指南
- 链接到详细文档
- 保持变更日志条目面向用户(非技术性)
- 在发布前审核和编辑生成的变更日志
- 一致地使用语义版本控制
- 在CI/CD流水线中自动化变更日志生成
- 考虑使用standard-version或release-please等工具
- 在git仓库中与代码一起维护变更日志