/** * generate-changelog.js — 从 git log 生成 CHANGELOG.md * * 逻辑: * 1. 读取 LAST_BUILD 文件(记录上次构建的 commit hash) * 2. 从 LAST_BUILD 到 HEAD 收集 commit messages * 3. 按 conventional-commit 分类 (Feat/Fix/Refactor/Chore/Docs/…) * 4. 追加到 CHANGELOG.md 末尾并更新 LAST_BUILD * * 用法: node scripts/generate-changelog.js */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const rootDir = path.resolve(__dirname, '..'); const version = process.argv[2]; if (!version) { console.error('[generate-changelog] 用法: node scripts/generate-changelog.js '); process.exit(1); } const lastBuildFile = path.join(rootDir, '.last_build_commit'); const changelogFile = path.join(rootDir, 'public', 'CHANGELOG.md'); // 获取上次构建 commit hash let fromHash = ''; try { fromHash = fs.readFileSync(lastBuildFile, 'utf-8').trim(); } catch { // 首次构建:从第一个 commit 开始 try { fromHash = execSync('git rev-list --max-parents=0 HEAD', { cwd: rootDir, encoding: 'utf-8' }).trim(); } catch { fromHash = ''; } } // 获取当前 HEAD const headHash = execSync('git rev-parse HEAD', { cwd: rootDir, encoding: 'utf-8' }).trim(); // 获取 commit 日志 let log = ''; try { const range = fromHash ? `${fromHash}..HEAD` : 'HEAD'; log = execSync( `git log --no-merges --pretty=format:"%s|||%b|||%an|||%ai" ${range}`, { cwd: rootDir, encoding: 'utf-8' } ).trim(); } catch { log = ''; } // 解析 commit const commits = log ? log.split('\n').filter(Boolean).map(line => { const [subject, body, author, date] = line.split('|||'); return { subject: subject || '', body: body || '', author: author || '', date: date || '' }; }) : []; if (commits.length === 0) { console.log('[generate-changelog] 没有新 commit,跳过 changelog 生成'); // 仍然更新 LAST_BUILD fs.writeFileSync(lastBuildFile, headHash, 'utf-8'); return; } const hasMeaningful = commits.some(c => { const cat = categorize(c.subject); return cat !== '工程化'; }); if (!hasMeaningful) { console.log('[generate-changelog] 仅有工程化变更,跳过 changelog 生成'); fs.writeFileSync(lastBuildFile, headHash, 'utf-8'); return; } // 分类 function categorize(subject) { const s = subject.toLowerCase(); if (s.startsWith('feat') || s.startsWith('feature')) return '新功能'; if (s.startsWith('fix') || s.startsWith('bug')) return 'Bug 修复'; if (s.startsWith('refactor')) return '重构'; if (s.startsWith('docs') || s.startsWith('doc')) return '文档'; if (s.startsWith('chore') || s.startsWith('build') || s.startsWith('ci')) return '工程化'; if (s.startsWith('perf') || s.startsWith('optimize')) return '性能优化'; if (s.startsWith('style')) return '样式'; if (s.startsWith('test')) return '测试'; return '其他'; } const grouped = {}; commits.forEach(c => { const cat = categorize(c.subject); if (!grouped[cat]) grouped[cat] = []; // 格式化 subject:去掉 feat/fix: 前缀 let desc = c.subject.replace(/^(feat|feature|fix|refactor|docs?|chore|build|ci|perf|style|test)(\([^)]*\))?[:\s]*/i, ''); if (!desc) desc = c.subject; grouped[cat].push(`- ${desc}`); }); // 构建新版本 changelog 块 const now = new Date().toISOString().slice(0, 10); let block = `\n## v${version} (${now})\n\n`; for (const [cat, items] of Object.entries(grouped)) { if (cat === '工程化') continue; block += `### ${cat}\n${items.join('\n')}\n\n`; } // 追加或新建 CHANGELOG.md let existing = ''; try { existing = fs.readFileSync(changelogFile, 'utf-8'); } catch { existing = '# 更新日志 (Changelog)\n\n此文件记录所有构建版本的变更。\n'; } // 新版本插入到标题之后、旧内容之前 const headerEnd = existing.indexOf('\n## '); if (headerEnd === -1) { existing = existing.trimEnd() + block; } else { existing = existing.slice(0, headerEnd) + block + existing.slice(headerEnd); } fs.writeFileSync(changelogFile, existing, 'utf-8'); // 更新 LAST_BUILD fs.writeFileSync(lastBuildFile, headHash, 'utf-8'); console.log(`[generate-changelog] CHANGELOG.md 已更新,新增 ${commits.length} 条记录`);