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'; const token = process.env.GITHUB_TOKEN || process.env.GITEA_TOKEN; const repo = process.env.GITHUB_REPOSITORY || 'zzzjy/hatty'; let tag = process.env.GITHUB_REF_NAME || 'v1.0.0'; // 规范化 tag 名称(移除可能的 refs/tags/ 前缀) tag = tag.replace(/^refs\/tags\//, ''); if (!token) { console.error('Error: GITHUB_TOKEN or GITEA_TOKEN is required.'); process.exit(1); } const [owner, repoName] = repo.split('/'); 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. 查找 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: { Authorization: `token ${token}`, Accept: 'application/json', }, }); if (tagCheckRes.ok) { const existing = await tagCheckRes.json(); releaseId = existing.id; 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`, { method: 'POST', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ tag_name: tag, name: `hatty ${tag}`, body: releaseNotes, draft: false, prerelease: false, }), }); if (!createRes.ok) { const errText = await createRes.text(); console.error(`Failed to create release: ${createRes.status} ${errText}`); process.exit(1); } const created = await createRes.json(); releaseId = created.id; console.log(`Release created successfully with id ${releaseId}`); } // 3. 检查已存在的附件,若同名则先删除以支持重试 try { const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, { headers: { Authorization: `token ${token}`, Accept: 'application/json', }, }); if (assetsRes.ok) { const existingAssets = await assetsRes.json(); for (const asset of existingAssets) { if (files.includes(asset.name)) { console.log(`Deleting existing asset ${asset.name} (id: ${asset.id}) to replace...`); await fetch(`${apiBase}/releases/${releaseId}/assets/${asset.id}`, { method: 'DELETE', headers: { Authorization: `token ${token}` }, }); } } } } catch (e) { console.warn('Checking existing assets notice:', e.message); } // 4. 上传安装包到 Release 附件 for (const fileName of files) { const filePath = path.join(distDir, fileName); const fileStat = fs.statSync(filePath); console.log(`Uploading ${fileName} (${(fileStat.size / (1024 * 1024)).toFixed(2)} MB)...`); const fileBuffer = fs.readFileSync(filePath); const formData = new FormData(); const blob = new Blob([fileBuffer], { type: 'application/octet-stream' }); formData.append('attachment', blob, fileName); const uploadRes = await fetch(`${apiBase}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`, { method: 'POST', headers: { Authorization: `token ${token}`, }, body: formData, }); if (uploadRes.ok) { console.log(`✔ Successfully uploaded ${fileName}`); } else { const err = await uploadRes.text(); console.warn(`Warning: Upload ${fileName} returned status ${uploadRes.status}: ${err}`); } } console.log('All release operations completed successfully!'); } main().catch((err) => { console.error('Unexpected error:', err); process.exit(1); });