Files
hatty/scripts/gitea-release.mjs

262 lines
9.2 KiB
JavaScript

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);
});