From 2191ef79d3c3e7efd056647c633e22112fc22b97 Mon Sep 17 00:00:00 2001 From: zzzjy Date: Thu, 24 Sep 2026 00:38:09 +0800 Subject: [PATCH] feat(ci): add automated dynamic release notes generation with git changelog and checksums --- .gitea/workflows/release.yaml | 2 + .github/workflows/release.yaml | 2 + scripts/gitea-release.mjs | 164 +++++++++++++++++++++++++++++---- 3 files changed, 149 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 88d3607..6a7cf4c 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 88d3607..6a7cf4c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs index 6824a4f..d2fd0c9 100644 --- a/scripts/gitea-release.mjs +++ b/scripts/gitea-release.mjs @@ -1,5 +1,107 @@ import fs from 'fs'; 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() { 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 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}...`); - // 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; const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, { headers: { @@ -32,7 +154,24 @@ async function main() { if (tagCheckRes.ok) { const existing = await tagCheckRes.json(); 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 { console.log(`Creating new release for tag ${tag}...`); const createRes = await fetch(`${apiBase}/releases`, { @@ -45,7 +184,7 @@ async function main() { body: JSON.stringify({ tag_name: 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, prerelease: false, }), @@ -62,20 +201,7 @@ async function main() { console.log(`Release created successfully with id ${releaseId}`); } - // 2. 查找 dist/ 下的 .deb 安装包 - 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); - } - - // 检查已存在的附件,若同名则先删除以支持重试 + // 3. 检查已存在的附件,若同名则先删除以支持重试 try { const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, { headers: { @@ -99,7 +225,7 @@ async function main() { console.warn('Checking existing assets notice:', e.message); } - // 3. 上传安装包到 Release 附件 + // 4. 上传安装包到 Release 附件 for (const fileName of files) { const filePath = path.join(distDir, fileName); const fileStat = fs.statSync(filePath);