262 lines
9.2 KiB
JavaScript
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);
|
|
});
|