feat(ci): add automated dynamic release notes generation with git changelog and checksums

This commit is contained in:
2026-09-24 00:38:09 +08:00
parent b351057f5f
commit 2191ef79d3
3 changed files with 149 additions and 19 deletions
+2
View File
@@ -18,6 +18,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
+2
View File
@@ -18,6 +18,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
+145 -19
View File
@@ -1,5 +1,107 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import crypto from 'crypto';
import { execSync } from 'child_process';
/**
* 动态提取 Git Commit 日志并生成规范化 Release Notes
*/
function generateReleaseNotes({ tag, files, distDir, repoUrl }) {
let prevTag = '';
try {
const tags = execSync('git tag --sort=-creatordate', { encoding: 'utf8' })
.split('\n')
.map((t) => t.trim())
.filter(Boolean);
const currIdx = tags.indexOf(tag);
if (currIdx !== -1 && currIdx < tags.length - 1) {
prevTag = tags[currIdx + 1];
} else if (tags.length > 0 && currIdx === -1) {
prevTag = tags[0];
}
} catch (e) {
console.warn('[Release] Unable to determine previous git tag:', e.message);
}
let commits = [];
try {
const range = prevTag ? `${prevTag}..HEAD` : 'HEAD';
const logOutput = execSync(`git log ${range} --pretty=format:"%h%x09%s"`, { encoding: 'utf8' });
commits = logOutput
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [hash, ...rest] = line.split('\t');
return { hash, subject: rest.join('\t') };
});
} catch (e) {
console.warn('[Release] Unable to retrieve git log:', e.message);
}
// 按照 Conventional Commits 规范分类
const categories = {
features: { title: '🚀 新功能 (Features)', items: [] },
ui: { title: '🎨 界面与主题 (UI & Themes)', items: [] },
fixes: { title: '🐛 问题修复 (Bug Fixes)', items: [] },
docs: { title: '📖 文档与说明 (Documentation)', items: [] },
ci: { title: '🔧 构建与 CI (Build & CI)', items: [] },
other: { title: '📝 其他变动 (Other Changes)', items: [] },
};
for (const c of commits) {
const s = c.subject;
const item = repoUrl
? `* [${c.hash}](${repoUrl}/commit/${c.hash}) - ${s}`
: `* ${c.hash} - ${s}`;
if (/^(style|theme)(\(.*\))?:/i.test(s) || /theme|主题|配色/i.test(s)) {
categories.ui.items.push(item);
} else if (/^feat(\(.*\))?:/i.test(s)) {
categories.features.items.push(item);
} else if (/^fix(\(.*\))?:/i.test(s)) {
categories.fixes.items.push(item);
} else if (/^docs?(\(.*\))?:/i.test(s)) {
categories.docs.items.push(item);
} else if (/^(ci|chore|build|refactor|perf)(\(.*\))?:/i.test(s)) {
categories.ci.items.push(item);
} else {
categories.other.items.push(item);
}
}
let changelogSection = '';
let hasItems = false;
for (const key of Object.keys(categories)) {
const cat = categories[key];
if (cat.items.length > 0) {
hasItems = true;
changelogSection += `#### ${cat.title}\n\n${cat.items.join('\n')}\n\n`;
}
}
if (!hasItems) {
changelogSection = '暂无详细更新记录。\n\n';
}
// 产物文件大小与 SHA-256 校验和表格
let checksumSection = '';
if (files && files.length > 0 && distDir) {
checksumSection = '### 📦 安装包校验信息 (Checksums)\n\n| 文件名 | 大小 | SHA-256 校验和 |\n| :--- | :--- | :--- |\n';
for (const file of files) {
const filePath = path.join(distDir, file);
if (fs.existsSync(filePath)) {
const stat = fs.statSync(filePath);
const sizeStr = (stat.size / (1024 * 1024)).toFixed(2) + ' MB';
const hash = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
checksumSection += `| \`${file}\` | ${sizeStr} | \`${hash}\` |\n`;
}
}
checksumSection += '\n';
}
return `## ⚡ hatty ${tag} 发布说明\n\n### 🌟 更新日志 (What's Changed)\n\n${prevTag && repoUrl ? `> 对比基准版本:[${prevTag}](${repoUrl}/src/tag/${prevTag}) → **${tag}**\n\n` : ''}${changelogSection}### 💻 安装方法 (Installation)\n\n下载下方附件中的 \`.deb\` 安装包,并在终端执行安装:\n\n\`\`\`bash\n# 安装 deb 安装包\nsudo dpkg -i hatty_*.deb\n\n# 如遇到缺少系统依赖,可执行自动修复:\nsudo apt-get install -f -y\n\`\`\`\n\n${checksumSection}---\n*由 Gitea Actions 自动化流水线构建并发布。*`;
}
async function main() { async function main() {
const serverUrl = process.env.GITHUB_SERVER_URL || process.env.GITEA_SERVER_URL || 'https://git.zzzjy.cloud'; const serverUrl = process.env.GITHUB_SERVER_URL || process.env.GITEA_SERVER_URL || 'https://git.zzzjy.cloud';
@@ -16,11 +118,31 @@ async function main() {
} }
const [owner, repoName] = repo.split('/'); const [owner, repoName] = repo.split('/');
const apiBase = `${serverUrl.replace(/\/+$/, '')}/api/v1/repos/${owner}/${repoName}`; const normalizedServer = serverUrl.replace(/\/+$/, '');
const repoUrl = `${normalizedServer}/${owner}/${repoName}`;
const apiBase = `${normalizedServer}/api/v1/repos/${owner}/${repoName}`;
console.log(`Connecting to Gitea at ${serverUrl} for repository ${repo}...`); console.log(`Connecting to Gitea at ${serverUrl} for repository ${repo}...`);
// 1. 检查或创建 Release // 1. 查找 dist/ 下的 .deb / .AppImage 安装包并生成 Release Notes
const distDir = path.resolve('dist');
if (!fs.existsSync(distDir)) {
console.error('dist directory does not exist.');
process.exit(1);
}
const files = fs.readdirSync(distDir).filter((f) => f.endsWith('.deb') || f.endsWith('.AppImage'));
if (files.length === 0) {
console.error('No .deb or .AppImage files found in dist directory.');
process.exit(1);
}
const releaseNotes = generateReleaseNotes({ tag, files, distDir, repoUrl });
console.log('--- Generated Release Notes ---');
console.log(releaseNotes);
console.log('-------------------------------');
// 2. 检查或创建 / 更新 Release
let releaseId = null; let releaseId = null;
const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, { const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, {
headers: { headers: {
@@ -32,7 +154,24 @@ async function main() {
if (tagCheckRes.ok) { if (tagCheckRes.ok) {
const existing = await tagCheckRes.json(); const existing = await tagCheckRes.json();
releaseId = existing.id; releaseId = existing.id;
console.log(`Found existing release for tag ${tag} (id: ${releaseId})`); console.log(`Found existing release for tag ${tag} (id: ${releaseId}), updating release notes...`);
const updateRes = await fetch(`${apiBase}/releases/${releaseId}`, {
method: 'PATCH',
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
name: `hatty ${tag}`,
body: releaseNotes,
}),
});
if (!updateRes.ok) {
console.warn(`Warning: Failed to update release body: ${updateRes.status}`);
} else {
console.log(`Release notes updated successfully for id ${releaseId}`);
}
} else { } else {
console.log(`Creating new release for tag ${tag}...`); console.log(`Creating new release for tag ${tag}...`);
const createRes = await fetch(`${apiBase}/releases`, { const createRes = await fetch(`${apiBase}/releases`, {
@@ -45,7 +184,7 @@ async function main() {
body: JSON.stringify({ body: JSON.stringify({
tag_name: tag, tag_name: tag,
name: `hatty ${tag}`, name: `hatty ${tag}`,
body: `### ⚡ hatty ${tag} 现代化 Linux 终端新版本发布\n\n- **架构**:Electron + xterm.js (WebGL 硬件加速) + Linux PTY\n- **特性**:无边框磨砂毛玻璃、5 套现代暗黑主题、多标签页、灵活多窗格分屏\n- **安装说明**:下载下方附件 \`.deb\` 文件,在终端运行 \`sudo dpkg -i hatty_*.deb\` 即可完成安装。`, body: releaseNotes,
draft: false, draft: false,
prerelease: false, prerelease: false,
}), }),
@@ -62,20 +201,7 @@ async function main() {
console.log(`Release created successfully with id ${releaseId}`); console.log(`Release created successfully with id ${releaseId}`);
} }
// 2. 查找 dist/ 下的 .deb 安装包 // 3. 检查已存在的附件,若同名则先删除以支持重试
const distDir = path.resolve('dist');
if (!fs.existsSync(distDir)) {
console.error('dist directory does not exist.');
process.exit(1);
}
const files = fs.readdirSync(distDir).filter((f) => f.endsWith('.deb') || f.endsWith('.AppImage'));
if (files.length === 0) {
console.error('No .deb or .AppImage files found in dist directory.');
process.exit(1);
}
// 检查已存在的附件,若同名则先删除以支持重试
try { try {
const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, { const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, {
headers: { headers: {
@@ -99,7 +225,7 @@ async function main() {
console.warn('Checking existing assets notice:', e.message); console.warn('Checking existing assets notice:', e.message);
} }
// 3. 上传安装包到 Release 附件 // 4. 上传安装包到 Release 附件
for (const fileName of files) { for (const fileName of files) {
const filePath = path.join(distDir, fileName); const filePath = path.join(distDir, fileName);
const fileStat = fs.statSync(filePath); const fileStat = fs.statSync(filePath);