SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37
26 包 monorepo(TS 全源码): - snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包 - snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线 - snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI) - snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports) - snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时 - 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏) - 工具件: 解包器/启动器/便携制作器/doctor GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
commit
b9655a5ab1
1048 files changed
+130851
No files matched your search
@@ -0,0 +1,85 @@
|
||||
name: Bug 反馈
|
||||
description: 报告可能的 NapCat 异常行为
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎来到 NapCat 的 Issue Tracker!请填写以下表格来提交 Bug。
|
||||
在提交新的 Bug 反馈前,请确保您:
|
||||
* 已经搜索了现有的 issues,并且没有找到可以解决您问题的方法
|
||||
* 不与现有的某一 issue 重复
|
||||
* **不接受因发送不当内容而导致的问题报告**
|
||||
- 包括但不限于:多媒体发送失败、转发消息失败、消息被拦截等因 18+ 内容、违规内容或触发风控的问题
|
||||
- 提交 issue 前,请确认您发送的多媒体内容、链接、文本等均为正常合规内容,不会触发平台风控机制
|
||||
- 因违规内容导致的问题,一律不予受理
|
||||
- type: input
|
||||
id: system-version
|
||||
attributes:
|
||||
label: 系统版本
|
||||
description: 运行 QQNT 的系统版本
|
||||
placeholder: Windows 11 24H2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: qqnt-version
|
||||
attributes:
|
||||
label: QQNT 版本
|
||||
description: 可在 QQNT 的「关于」的设置页中找到
|
||||
placeholder: 9.9.16-29927
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: napcat-version
|
||||
attributes:
|
||||
label: NapCat 版本
|
||||
description: 可在 WebUI 的「系统信息」页中找到
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: onebot-client-version
|
||||
attributes:
|
||||
label: OneBot 客户端
|
||||
description: 连接至 NapCat 的客户端版本信息
|
||||
placeholder: Karin 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 发生了什么?
|
||||
description: 填写你认为的 NapCat 的异常行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: how-reproduce
|
||||
attributes:
|
||||
label: 如何复现
|
||||
description: 填写应当如何操作才能触发这个异常行为
|
||||
placeholder: |
|
||||
1. xxx
|
||||
2. xxx
|
||||
3. xxx
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-expected
|
||||
attributes:
|
||||
label: 期望的结果?
|
||||
description: 填写你认为 NapCat 应当执行的正常行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: napcat-log
|
||||
attributes:
|
||||
label: NapCat 运行日志
|
||||
description: 粘贴相关日志内容到此处
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: onebot-client-log
|
||||
attributes:
|
||||
label: OneBot 客户端运行日志
|
||||
description: 粘贴 OneBot 客户端的相关日志内容到此处
|
||||
render: shell
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Feat 请求
|
||||
description: 提交新的 NapCat 功能或改进建议
|
||||
title: '[FEAT] '
|
||||
labels: enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎来到 NapCat 的 Issue Tracker!请填写以下表格来提交功能请求。
|
||||
在提交新的功能请求前,请确保您:
|
||||
* 已经搜索了现有的 issues,并且没有找到类似的建议
|
||||
* 不与现有的某一 issue 重复
|
||||
- type: input
|
||||
id: system-version
|
||||
attributes:
|
||||
label: 系统版本
|
||||
description: 运行 QQNT 的系统版本
|
||||
placeholder: Windows 11 24H2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: qqnt-version
|
||||
attributes:
|
||||
label: QQNT 版本
|
||||
description: 可在 QQNT 的「关于」的设置页中找到
|
||||
placeholder: 9.9.16-29927
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: napcat-version
|
||||
attributes:
|
||||
label: NapCat 版本
|
||||
description: 可在 WebUI 的「系统信息」页中找到
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 功能描述
|
||||
description: 请详细描述你希望添加的功能或改进
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-reason
|
||||
attributes:
|
||||
label: 需求背景与理由
|
||||
description: 请说明为什么需要这个功能,解决了什么问题
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-expected
|
||||
attributes:
|
||||
label: 期望的实现方式或效果
|
||||
description: 请描述你期望的功能实现方式或最终效果
|
||||
- type: textarea
|
||||
id: other-info
|
||||
attributes:
|
||||
label: 其他补充信息
|
||||
description: 你还想补充什么?
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,33 @@
|
||||
# {VERSION}
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## 警告
|
||||
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
|
||||
**默认WebUi密钥为随机密码 控制台查看**
|
||||
|
||||
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)**
|
||||
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb)
|
||||
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm)
|
||||
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb)
|
||||
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm)
|
||||
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
|
||||
## 如果WinX64缺少运行库或者xxx.dll?
|
||||
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 更新
|
||||
|
||||
### 🐛 修复
|
||||
1. 修复 WebUI 主题配置在有未保存更改时卸载组件导致字体重置的问题 (ae42eed6)
|
||||
|
||||
### ✨ 新增
|
||||
1. 文件上传相关接口(UploadGroupFile/UploadPrivateFile)新增 `upload_file` 参数支持 (91e0839e)
|
||||
2. 消息发送逻辑支持 PTT(语音)元素过滤,确保语音消息正确独立发送 (47983e29)
|
||||
|
||||
### 🔧 优化
|
||||
1. 优化合并转发消息(GetForwardMsg)的获取与解析逻辑,提高兼容性 (334c4233)
|
||||
2. 改进消息发送方法中发送者 UIN 的处理逻辑 (71bb4f68)
|
||||
3. 增强 WebUI 系统信息界面中对构建产物的处理与展示 (cb061890)
|
||||
|
||||
---
|
||||
|
||||
**完整更新日志**: [v4.10.6...v4.10.7](https://github.com/NapNeko/NapCatQQ/compare/v4.10.6...v4.10.7)
|
||||
@@ -0,0 +1,111 @@
|
||||
# NapCat Release Note Generator
|
||||
|
||||
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号,如果版本号是小写 v 开头(如 v4.10.2),必须转换为大写 V(如 V4.10.2)
|
||||
2. **语言**:全部使用简体中文
|
||||
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
|
||||
|
||||
## Commit 分析规则
|
||||
|
||||
将 commit 分类为以下类型:
|
||||
- 🐛 **修复**:bug fix、修复、fix 相关
|
||||
- ✨ **新增**:新功能、feat、add 相关
|
||||
- 🔧 **优化**:优化、重构、refactor、improve、perf 相关
|
||||
- 📦 **依赖**:deps、依赖更新(通常可以忽略或合并)
|
||||
- 🔨 **构建**:ci、build、workflow 相关(通常可以忽略)
|
||||
|
||||
## 合并和筛选
|
||||
|
||||
- **合并相似项**:同一功能的多个 commit 合并为一条
|
||||
- **忽略琐碎项**:合并冲突、格式化、typo 等可忽略
|
||||
- **控制数量**:最终保持 5-15 条更新要点
|
||||
- **保留 commit hash**:每条末尾附上短 hash,格式 `(a1b2c3d)`
|
||||
|
||||
## 输出模板 - 必须严格遵守以下格式,不要使用代码区域包裹
|
||||
|
||||
```
|
||||
# {VERSION}
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
NapCat.Shell.Windows.OneKey.zip (无头)
|
||||
|
||||
启动后可自动化部署一键包,教程参考使用文档安装部分
|
||||
|
||||
## 警告
|
||||
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
|
||||
**默认WebUi密钥为随机密码 控制台查看**
|
||||
|
||||
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)**
|
||||
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb)
|
||||
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm)
|
||||
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb)
|
||||
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm)
|
||||
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
|
||||
## 如果WinX64缺少运行库或者xxx.dll?
|
||||
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 更新
|
||||
|
||||
### 🐛 修复
|
||||
1. 修复 xxx 问题 (a1b2c3d)
|
||||
2. 修复 yyy 崩溃 (b2c3d4e)
|
||||
|
||||
### ✨ 新增
|
||||
1. 新增 xxx 功能 (c3d4e5f)
|
||||
2. 支持 yyy 特性 (d4e5f6g)
|
||||
|
||||
### 🔧 优化
|
||||
1. 优化 xxx 性能 (e5f6g7h)
|
||||
2. 重构 yyy 模块 (f6g7h8i)
|
||||
|
||||
---
|
||||
|
||||
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
|
||||
```
|
||||
|
||||
**格式要求 - 务必严格遵守:**
|
||||
- "Windows 一键包"部分的文本必须完全一致,不要修改任何措辞
|
||||
- "警告"部分必须包含所有 QQ 版本下载链接,保持原有格式
|
||||
- "如果WinX64缺少运行库或者xxx.dll?"这一行必须保持原样
|
||||
- QQ 版本号和下载链接保持不变(40990 版本)
|
||||
- 只有"## 更新"部分下面的内容需要根据实际 commit 生成
|
||||
|
||||
## 重要约束
|
||||
|
||||
1. 如果某个分类没有内容,则完全省略该分类
|
||||
2. 不要编造不存在的更新
|
||||
3. 保持简洁,每条更新控制在一行内
|
||||
4. 使用用户友好的语言,避免过于技术化的描述
|
||||
5. 重大变更(Breaking Changes)需要在注意事项中加粗提示
|
||||
|
||||
## 文件变化分析
|
||||
|
||||
用户会提供文件变化统计和具体代码diff,帮助你理解变更内容:
|
||||
|
||||
### 目录含义
|
||||
- `packages/napcat-core/` → 核心功能、消息处理、QQ接口
|
||||
- `packages/napcat-onebot/` → OneBot 协议实现、API、事件
|
||||
- `packages/napcat-webui-backend/` → WebUI 后端接口
|
||||
- `packages/napcat-webui-frontend/` → WebUI 前端界面
|
||||
- `packages/napcat-shell/` → Shell 启动器
|
||||
|
||||
### 代码diff阅读指南
|
||||
- `+` 开头的行是新增代码
|
||||
- `-` 开头的行是删除代码
|
||||
- 关注函数名、类名的变化来理解功能变更
|
||||
- 关注 `fix`、`bug`、`error` 等关键词识别修复项
|
||||
- 关注 `add`、`new`、`feature` 等关键词识别新功能
|
||||
- 忽略纯重构(代码移动但功能不变)和格式化变更
|
||||
|
||||
### 截断说明
|
||||
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
|
||||
- 根据已有信息推断完整变更意图即可
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 构建状态评论模板
|
||||
*/
|
||||
|
||||
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
|
||||
export const DOCKER_COMMENT_MARKER = '<!-- napcat-pr-docker -->';
|
||||
|
||||
// 默认代理前缀(加速中国大陆访问 GitHub Release)
|
||||
// 格式:https://ghfast.top/{full-github-url}
|
||||
export const PROXY_BASE = 'https://gh.llkk.cc';
|
||||
|
||||
/** 将 GitHub 直链转换为 ghfast.top 加速链接 */
|
||||
export function toProxyUrl (directUrl: string): string {
|
||||
return `${PROXY_BASE}/${directUrl}`;
|
||||
}
|
||||
|
||||
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
|
||||
|
||||
export interface BuildTarget {
|
||||
name: string;
|
||||
status: BuildStatus;
|
||||
error?: string;
|
||||
downloadUrl?: string; // Artifact 直接下载链接
|
||||
}
|
||||
|
||||
// ============== 辅助函数 ==============
|
||||
|
||||
function formatSha (sha: string): string {
|
||||
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
|
||||
}
|
||||
|
||||
function escapeCodeBlock (text: string): string {
|
||||
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
|
||||
return text.replace(/```/g, '\\`\\`\\`');
|
||||
}
|
||||
|
||||
function getTimeString (): string {
|
||||
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
}
|
||||
|
||||
// ============== 状态图标 ==============
|
||||
|
||||
export function getStatusIcon (status: BuildStatus): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '✅ 成功';
|
||||
case 'pending':
|
||||
return '⏳ 构建中...';
|
||||
case 'cancelled':
|
||||
return '⚪ 已取消';
|
||||
case 'failure':
|
||||
return '❌ 失败';
|
||||
default:
|
||||
return '❓ 未知';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusEmoji (status: BuildStatus): string {
|
||||
switch (status) {
|
||||
case 'success': return '✅';
|
||||
case 'pending': return '⏳';
|
||||
case 'cancelled': return '⚪';
|
||||
case 'failure': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 构建中评论 ==============
|
||||
|
||||
export function generateBuildingComment (prSha: string, targets: string[]): string {
|
||||
const time = getTimeString();
|
||||
const shortSha = formatSha(prSha);
|
||||
|
||||
const lines: string[] = [
|
||||
COMMENT_MARKER,
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
'# 🔨 NapCat 构建中',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📦 构建目标',
|
||||
'',
|
||||
'| 包名 | 状态 | 说明 |',
|
||||
'| :--- | :---: | :--- |',
|
||||
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📋 构建信息',
|
||||
'',
|
||||
'| 项目 | 值 |',
|
||||
'| :--- | :--- |',
|
||||
`| 📝 提交 | \`${shortSha}\` |`,
|
||||
`| 🕐 开始时间 | ${time} |`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⏳ **构建进行中,请稍候...**',
|
||||
'>',
|
||||
'> 构建完成后将自动更新此评论',
|
||||
'',
|
||||
'</div>',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ============== 构建结果评论 ==============
|
||||
|
||||
export interface InstallInfo {
|
||||
installUrl: string;
|
||||
releaseUrl: string;
|
||||
}
|
||||
|
||||
export function generateResultComment (
|
||||
targets: BuildTarget[],
|
||||
prSha: string,
|
||||
runId: string,
|
||||
repository: string,
|
||||
version?: string,
|
||||
installInfo?: InstallInfo,
|
||||
publishFailed?: boolean
|
||||
): string {
|
||||
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
|
||||
const shortSha = formatSha(prSha);
|
||||
const time = getTimeString();
|
||||
|
||||
const allSuccess = targets.every(t => t.status === 'success');
|
||||
const anyCancelled = targets.some(t => t.status === 'cancelled');
|
||||
const anyFailure = targets.some(t => t.status === 'failure');
|
||||
|
||||
// 状态徽章
|
||||
let statusBadge: string;
|
||||
let headerTitle: string;
|
||||
if (allSuccess) {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ✅ NapCat 构建成功';
|
||||
} else if (anyCancelled && !anyFailure) {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ⚪ NapCat 构建已取消';
|
||||
} else {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ❌ NapCat 构建失败';
|
||||
}
|
||||
|
||||
const downloadLink = (target: BuildTarget) => {
|
||||
if (target.status !== 'success') return '—';
|
||||
if (target.downloadUrl) {
|
||||
return `[📥 下载](${target.downloadUrl})`;
|
||||
}
|
||||
return `[📥 下载](${runUrl}#artifacts)`;
|
||||
};
|
||||
|
||||
const lines: string[] = [
|
||||
COMMENT_MARKER,
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
headerTitle,
|
||||
'',
|
||||
statusBadge,
|
||||
'',
|
||||
'</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📦 构建产物',
|
||||
'',
|
||||
'| 包名 | 状态 | 下载 |',
|
||||
'| :--- | :---: | :---: |',
|
||||
...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📋 构建信息',
|
||||
'',
|
||||
'| 项目 | 值 |',
|
||||
'| :--- | :--- |',
|
||||
...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
|
||||
`| 📝 提交 | \`${shortSha}\` |`,
|
||||
`| 🔗 构建日志 | [查看详情](${runUrl}) |`,
|
||||
`| 🕐 完成时间 | ${time} |`,
|
||||
];
|
||||
|
||||
// 添加快速安装命令
|
||||
if (installInfo) {
|
||||
const directUrl = installInfo.installUrl;
|
||||
const proxyInstallUrl = toProxyUrl(directUrl);
|
||||
lines.push(
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 🚀 快速安装 (Linux)',
|
||||
'',
|
||||
'**直连(需要能访问 GitHub)**',
|
||||
'```bash',
|
||||
`curl -sSL ${directUrl} | bash`,
|
||||
'```',
|
||||
'',
|
||||
'**加速(国内推荐,ghfast.top)**',
|
||||
'```bash',
|
||||
`curl -sSL ${proxyInstallUrl} | bash`,
|
||||
'```',
|
||||
'',
|
||||
`> 📦 [查看 Release 页面](${installInfo.releaseUrl})`,
|
||||
'> ⚠️ 此为 PR 测试版本,约 5 天后会被自动清理。'
|
||||
);
|
||||
} else if (publishFailed) {
|
||||
lines.push(
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## ⚠️ Release 发布失败',
|
||||
'',
|
||||
'> ❌ PR Release 发布过程中出现错误,无法提供快速安装命令。',
|
||||
`> 🔗 [查看构建日志](${runUrl}) 了解详情。`
|
||||
);
|
||||
}
|
||||
|
||||
// 添加错误详情
|
||||
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
|
||||
if (failedTargets.length > 0) {
|
||||
lines.push('', '---', '', '## ⚠️ 错误详情', '');
|
||||
for (const target of failedTargets) {
|
||||
lines.push(
|
||||
'<details>',
|
||||
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
|
||||
'',
|
||||
'```',
|
||||
escapeCodeBlock(target.error!),
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加底部提示
|
||||
lines.push('---', '');
|
||||
if (allSuccess) {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> 🎉 **所有构建均已成功完成!**',
|
||||
'>',
|
||||
'> 点击上方下载链接获取构建产物进行测试',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
} else if (anyCancelled && !anyFailure) {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⚪ **构建已被取消**',
|
||||
'>',
|
||||
'> 可能是由于新的提交触发了新的构建',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⚠️ **部分构建失败**',
|
||||
'>',
|
||||
'> 请查看上方错误详情或点击构建日志查看完整输出',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ============== Docker 构建评论 ==============
|
||||
|
||||
export function generateDockerBuildingComment (prNumber: string, prSha: string): string {
|
||||
const time = getTimeString();
|
||||
const shortSha = formatSha(prSha);
|
||||
|
||||
const lines: string[] = [
|
||||
DOCKER_COMMENT_MARKER,
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
'# 🐳 Docker 测试镜像构建中',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📋 构建信息',
|
||||
'',
|
||||
'| 项目 | 值 |',
|
||||
'| :--- | :--- |',
|
||||
`| 📝 提交 | \`${shortSha}\` |`,
|
||||
`| 🔗 PR | #${prNumber} |`,
|
||||
`| 🕐 触发时间 | ${time} |`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⏳ **Docker 镜像正在构建中,请稍候...**',
|
||||
'>',
|
||||
'> 构建完成后将自动更新此评论',
|
||||
'',
|
||||
'</div>',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function generateDockerResultComment (
|
||||
prNumber: string,
|
||||
prSha: string,
|
||||
success: boolean,
|
||||
dockerImage?: string,
|
||||
error?: string
|
||||
): string {
|
||||
const time = getTimeString();
|
||||
const shortSha = formatSha(prSha);
|
||||
|
||||
const statusBadge = success
|
||||
? ''
|
||||
: '';
|
||||
const headerTitle = success ? '# ✅ Docker 测试镜像就绪' : '# ❌ Docker 测试镜像构建失败';
|
||||
|
||||
const lines: string[] = [
|
||||
DOCKER_COMMENT_MARKER,
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
headerTitle,
|
||||
'',
|
||||
statusBadge,
|
||||
'',
|
||||
'</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📋 构建信息',
|
||||
'',
|
||||
'| 项目 | 值 |',
|
||||
'| :--- | :--- |',
|
||||
`| 📝 提交 | \`${shortSha}\` |`,
|
||||
`| 🔗 PR | #${prNumber} |`,
|
||||
`| 🕐 完成时间 | ${time} |`,
|
||||
];
|
||||
|
||||
if (success && dockerImage) {
|
||||
lines.push(
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 🚀 快速使用',
|
||||
'',
|
||||
'```bash',
|
||||
`docker pull ${dockerImage}`,
|
||||
'```',
|
||||
'',
|
||||
'> ⚠️ 此为 PR 测试镜像,保留 5 天后自动清理。'
|
||||
);
|
||||
}
|
||||
|
||||
if (!success && error) {
|
||||
lines.push(
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>🔴 <b>错误详情</b></summary>',
|
||||
'',
|
||||
'```',
|
||||
escapeCodeBlock(error),
|
||||
'```',
|
||||
'',
|
||||
'</details>'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('', '---', '');
|
||||
if (success) {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> 🎉 **Docker 测试镜像已就绪!**',
|
||||
'>',
|
||||
'> 使用上方命令拉取镜像进行测试',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⚠️ **Docker 镜像构建失败**',
|
||||
'>',
|
||||
'> 请检查错误详情',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* GitHub API 工具库
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
state: string;
|
||||
head: {
|
||||
sha: string;
|
||||
ref: string;
|
||||
repo: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
owner: {
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
name: string;
|
||||
size_in_bytes: number;
|
||||
archive_download_url: string;
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
upload_url: string;
|
||||
html_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
id: number;
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
// ============== GitHub API Client ==========================
|
||||
|
||||
export class GitHubAPI {
|
||||
private token: string;
|
||||
private baseUrl = 'https://api.github.com';
|
||||
|
||||
constructor (token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 204 No Content(DELETE 等)不解析 body
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
|
||||
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
|
||||
}
|
||||
|
||||
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
|
||||
const data = await this.request<{ permission: string; }>(
|
||||
`/repos/${owner}/${repo}/collaborators/${username}/permission`
|
||||
);
|
||||
return data.permission;
|
||||
}
|
||||
|
||||
async getRepository (owner: string, repo: string): Promise<Repository> {
|
||||
return this.request(`/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async checkOrgMembership (org: string, username: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/orgs/${org}/members/${username}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
|
||||
const data = await this.request<{ artifacts: Artifact[]; }>(
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
|
||||
);
|
||||
return data.artifacts;
|
||||
}
|
||||
|
||||
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (page <= 10) { // 最多检查 1000 条评论
|
||||
const comments = await this.request<Array<{ id: number, body: string; }>>(
|
||||
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
|
||||
);
|
||||
|
||||
if (comments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = comments.find(c => c.body.includes(marker));
|
||||
if (found) {
|
||||
return found.id;
|
||||
}
|
||||
|
||||
if (comments.length < perPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ body }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async createOrUpdateComment (
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
marker: string
|
||||
): Promise<void> {
|
||||
const existingId = await this.findComment(owner, repo, issueNumber, marker);
|
||||
if (existingId) {
|
||||
await this.updateComment(owner, repo, existingId, body);
|
||||
console.log(`✓ Updated comment #${existingId}`);
|
||||
} else {
|
||||
await this.createComment(owner, repo, issueNumber, body);
|
||||
console.log('✓ Created new comment');
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Release API ==============
|
||||
|
||||
async getReleaseByTag (owner: string, repo: string, tag: string): Promise<Release | null> {
|
||||
try {
|
||||
return await this.request<Release>(`/repos/${owner}/${repo}/releases/tags/${tag}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelease (owner: string, repo: string, releaseId: number): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/releases/${releaseId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async deleteRef (owner: string, repo: string, ref: string): Promise<void> {
|
||||
try {
|
||||
await this.request(`/repos/${owner}/${repo}/git/refs/${ref}`, { method: 'DELETE' });
|
||||
} catch {
|
||||
// ref may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async createRelease (
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
name: string,
|
||||
body: string,
|
||||
prerelease = true
|
||||
): Promise<Release> {
|
||||
return this.request<Release>(`/repos/${owner}/${repo}/releases`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tag_name: tag, name, body, prerelease }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async uploadReleaseAsset (
|
||||
uploadUrl: string,
|
||||
fileName: string,
|
||||
data: Buffer,
|
||||
contentType = 'application/octet-stream'
|
||||
): Promise<ReleaseAsset> {
|
||||
const url = uploadUrl.replace(/\{[^}]*\}/g, '') + `?name=${encodeURIComponent(fileName)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'Content-Type': contentType,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload ${fileName} failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<ReleaseAsset>;
|
||||
}
|
||||
|
||||
// ============== Dispatch API ==============
|
||||
|
||||
async repositoryDispatch (
|
||||
owner: string,
|
||||
repo: string,
|
||||
eventType: string,
|
||||
clientPayload: Record<string, string>
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/dispatches`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ event_type: eventType, client_payload: clientPayload }),
|
||||
});
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error(`Dispatch failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createWorkflowDispatch (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflowFile: string,
|
||||
ref: string,
|
||||
inputs: Record<string, string>
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/actions/workflows/${workflowFile}/dispatches`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ref, inputs }),
|
||||
});
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error(`Workflow dispatch failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Release Cleanup API ==============
|
||||
|
||||
async listReleases (owner: string, repo: string, perPage = 100, page = 1): Promise<Release[]> {
|
||||
return this.request<Release[]>(
|
||||
`/repos/${owner}/${repo}/releases?per_page=${perPage}&page=${page}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Output 工具 ==============
|
||||
|
||||
export function setOutput (name: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
appendFileSync(outputFile, `${name}=${value}\n`);
|
||||
}
|
||||
console.log(` ${name}=${value}`);
|
||||
}
|
||||
|
||||
export function setMultilineOutput (name: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
const delimiter = `EOF_${Date.now()}`;
|
||||
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 环境变量工具 ==============
|
||||
|
||||
// Note: 不使用函数重载——Node.js --experimental-strip-types 不支持 TS overload signatures
|
||||
export function getEnv (name: string, required = false): string | undefined {
|
||||
const value = process.env[name];
|
||||
if (required && !value) {
|
||||
throw new Error(`Environment variable ${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getRepository (): { owner: string, repo: string; } {
|
||||
const repository = getEnv('GITHUB_REPOSITORY', true)!;
|
||||
const [owner, repo] = repository.split('/');
|
||||
return { owner, repo };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* PR Build - 更新构建中状态评论
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - PR_NUMBER: PR 编号
|
||||
* - PR_SHA: PR 提交 SHA
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
|
||||
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
|
||||
|
||||
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🔨 Updating building status comment\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
|
||||
const prSha = getEnv('PR_SHA', true);
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`PR: #${prNumber}`);
|
||||
console.log(`SHA: ${prSha}`);
|
||||
console.log(`Repo: ${owner}/${repo}\n`);
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
|
||||
|
||||
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* PR Build Check Script
|
||||
* 检查 PR 构建触发条件和用户权限
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - GITHUB_EVENT_NAME: 事件名称
|
||||
* - GITHUB_EVENT_PATH: 事件 payload 文件路径
|
||||
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
|
||||
* - GITHUB_OUTPUT: 输出文件路径
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
|
||||
import type { PullRequest } from './lib/github.ts';
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
interface GitHubPayload {
|
||||
pull_request?: PullRequest;
|
||||
issue?: {
|
||||
number: number;
|
||||
pull_request?: object;
|
||||
};
|
||||
comment?: {
|
||||
body: string;
|
||||
user: { login: string; };
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
should_build: boolean;
|
||||
pr_number?: number;
|
||||
pr_sha?: string;
|
||||
pr_head_repo?: string;
|
||||
pr_head_ref?: string;
|
||||
}
|
||||
|
||||
// ============== 权限检查 ==============
|
||||
|
||||
async function checkUserPermission (
|
||||
github: GitHubAPI,
|
||||
owner: string,
|
||||
repo: string,
|
||||
username: string
|
||||
): Promise<boolean> {
|
||||
// 方法1:检查仓库协作者权限
|
||||
try {
|
||||
const permission = await github.getCollaboratorPermission(owner, repo, username);
|
||||
if (['admin', 'write', 'maintain'].includes(permission)) {
|
||||
console.log(`✓ User ${username} has ${permission} permission`);
|
||||
return true;
|
||||
}
|
||||
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
|
||||
} catch (e) {
|
||||
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// 方法2:检查组织成员身份
|
||||
try {
|
||||
const repoInfo = await github.getRepository(owner, repo);
|
||||
if (repoInfo.owner.type === 'Organization') {
|
||||
const isMember = await github.checkOrgMembership(owner, username);
|
||||
if (isMember) {
|
||||
console.log(`✓ User ${username} is organization member`);
|
||||
return true;
|
||||
}
|
||||
console.log(`✗ User ${username} is not organization member`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============== 事件处理 ==============
|
||||
|
||||
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
|
||||
const pr = payload.pull_request;
|
||||
|
||||
if (!pr) {
|
||||
console.log('✗ No pull_request in payload');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
if (pr.state !== 'open') {
|
||||
console.log(`✗ PR is not open (state: ${pr.state})`);
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`✓ PR #${pr.number} is open, triggering build`);
|
||||
return {
|
||||
should_build: true,
|
||||
pr_number: pr.number,
|
||||
pr_sha: pr.head.sha,
|
||||
pr_head_repo: pr.head.repo.full_name,
|
||||
pr_head_ref: pr.head.ref,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleIssueComment (
|
||||
payload: GitHubPayload,
|
||||
github: GitHubAPI,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<CheckResult> {
|
||||
const { issue, comment } = payload;
|
||||
|
||||
if (!issue || !comment) {
|
||||
console.log('✗ No issue or comment in payload');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查是否是 PR 的评论
|
||||
if (!issue.pull_request) {
|
||||
console.log('✗ Comment is not on a PR');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查是否是 /build 命令
|
||||
if (!comment.body.trim().startsWith('/build')) {
|
||||
console.log('✗ Comment is not a /build command');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`→ /build command from @${comment.user.login}`);
|
||||
|
||||
// 获取 PR 详情
|
||||
const pr = await github.getPullRequest(owner, repo, issue.number);
|
||||
|
||||
// 检查 PR 状态
|
||||
if (pr.state !== 'open') {
|
||||
console.log(`✗ PR is not open (state: ${pr.state})`);
|
||||
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
const username = comment.user.login;
|
||||
const hasPermission = await checkUserPermission(github, owner, repo, username);
|
||||
|
||||
if (!hasPermission) {
|
||||
console.log(`✗ User ${username} has no permission`);
|
||||
await github.createComment(
|
||||
owner,
|
||||
repo,
|
||||
issue.number,
|
||||
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
|
||||
);
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`✓ Build triggered by @${username}`);
|
||||
return {
|
||||
should_build: true,
|
||||
pr_number: issue.number,
|
||||
pr_sha: pr.head.sha,
|
||||
pr_head_repo: pr.head.repo.full_name,
|
||||
pr_head_ref: pr.head.ref,
|
||||
};
|
||||
}
|
||||
|
||||
// ============== 主函数 ==============
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🔍 PR Build Check\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const eventName = getEnv('GITHUB_EVENT_NAME', true);
|
||||
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`Event: ${eventName}`);
|
||||
console.log(`Repository: ${owner}/${repo}\n`);
|
||||
|
||||
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
|
||||
const github = new GitHubAPI(token);
|
||||
|
||||
let result: CheckResult;
|
||||
|
||||
switch (eventName) {
|
||||
case 'pull_request_target':
|
||||
result = handlePullRequestTarget(payload);
|
||||
break;
|
||||
case 'issue_comment':
|
||||
result = await handleIssueComment(payload, github, owner, repo);
|
||||
break;
|
||||
default:
|
||||
console.log(`✗ Unsupported event: ${eventName}`);
|
||||
result = { should_build: false };
|
||||
}
|
||||
|
||||
// 输出结果
|
||||
console.log('\n=== Outputs ===');
|
||||
setOutput('should_build', String(result.should_build));
|
||||
setOutput('pr_number', String(result.pr_number ?? ''));
|
||||
setOutput('pr_sha', result.pr_sha ?? '');
|
||||
setOutput('pr_head_repo', result.pr_head_repo ?? '');
|
||||
setOutput('pr_head_ref', result.pr_head_ref ?? '');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* PR Build - Dispatch untrusted build workflow
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - PR_NUMBER: PR 编号
|
||||
* - PR_SHA: PR HEAD SHA
|
||||
* - PR_HEAD_REPO: PR 源仓库
|
||||
* - PR_HEAD_REF: PR 源分支
|
||||
* - WORKFLOW_FILE: 工作流文件名,默认 build-pr.yml
|
||||
* - DISPATCH_REF: 触发分支,默认当前分支
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
|
||||
|
||||
declare const process: { exit(code?: number): never; };
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🚀 Dispatching PR build workflow\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true)!;
|
||||
const prNumber = getEnv('PR_NUMBER', true)!;
|
||||
const prSha = getEnv('PR_SHA', true)!;
|
||||
const prHeadRepo = getEnv('PR_HEAD_REPO', true)!;
|
||||
const prHeadRef = getEnv('PR_HEAD_REF') || '';
|
||||
const workflowFile = getEnv('WORKFLOW_FILE') || 'build-pr.yml';
|
||||
const dispatchRef = getEnv('DISPATCH_REF') || getEnv('GITHUB_REF_NAME') || 'main';
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`PR: #${prNumber}`);
|
||||
console.log(`SHA: ${prSha}`);
|
||||
console.log(`Head repo: ${prHeadRepo}`);
|
||||
console.log(`Workflow: ${workflowFile}`);
|
||||
console.log(`Ref: ${dispatchRef}\n`);
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
await github.createWorkflowDispatch(owner, repo, workflowFile, dispatchRef, {
|
||||
pr_number: prNumber,
|
||||
pr_sha: prSha,
|
||||
pr_head_repo: prHeadRepo,
|
||||
pr_head_ref: prHeadRef,
|
||||
});
|
||||
|
||||
console.log('✅ Dispatch created');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* PR Build - 更新构建结果评论
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - PR_NUMBER: PR 编号
|
||||
* - PR_SHA: PR 提交 SHA
|
||||
* - RUN_ID: GitHub Actions Run ID
|
||||
* - NAPCAT_VERSION: 构建版本号
|
||||
* - FRAMEWORK_STATUS: Framework 构建状态
|
||||
* - FRAMEWORK_ERROR: Framework 构建错误信息
|
||||
* - SHELL_STATUS: Shell 构建状态
|
||||
* - SHELL_ERROR: Shell 构建错误信息
|
||||
* - INSTALL_URL: PR Release 安装脚本 URL (可选)
|
||||
* - RELEASE_URL: PR Release 页面 URL (可选)
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
|
||||
import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
|
||||
import type { BuildTarget, BuildStatus, InstallInfo } from './lib/comment.ts';
|
||||
|
||||
function parseStatus (value: string | undefined): BuildStatus {
|
||||
if (value === 'success' || value === 'failure' || value === 'cancelled') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('📝 Updating build result comment\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
|
||||
const prSha = getEnv('PR_SHA') || 'unknown';
|
||||
const runId = getEnv('RUN_ID', true);
|
||||
const version = getEnv('NAPCAT_VERSION') || '';
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
|
||||
const frameworkError = getEnv('FRAMEWORK_ERROR');
|
||||
const shellStatus = parseStatus(getEnv('SHELL_STATUS'));
|
||||
const shellError = getEnv('SHELL_ERROR');
|
||||
|
||||
console.log(`PR: #${prNumber}`);
|
||||
console.log(`SHA: ${prSha}`);
|
||||
console.log(`Version: ${version}`);
|
||||
console.log(`Run: ${runId}`);
|
||||
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
|
||||
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
|
||||
|
||||
// PR Release 信息(由 publish-pr-release job 传入)
|
||||
const installUrl = getEnv('INSTALL_URL');
|
||||
const releaseUrl = getEnv('RELEASE_URL');
|
||||
const publishStatus = getEnv('PUBLISH_STATUS'); // 'success' | 'failure' | 'skipped' | ''
|
||||
const installInfo: InstallInfo | undefined =
|
||||
installUrl && releaseUrl ? { installUrl, releaseUrl } : undefined;
|
||||
const publishFailed = publishStatus === 'failure';
|
||||
|
||||
if (installInfo) {
|
||||
console.log(`Install URL: ${installUrl}`);
|
||||
console.log(`Release URL: ${releaseUrl}\n`);
|
||||
} else if (publishFailed) {
|
||||
console.log('Publish status: FAILED\n');
|
||||
}
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
const repository = `${owner}/${repo}`;
|
||||
|
||||
// 获取 artifacts 列表,生成直接下载链接
|
||||
const artifactMap: Record<string, string> = {};
|
||||
try {
|
||||
const artifacts = await github.getRunArtifacts(owner, repo, runId);
|
||||
console.log(`Found ${artifacts.length} artifacts`);
|
||||
for (const artifact of artifacts) {
|
||||
// 生成直接下载链接:https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
|
||||
const downloadUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`;
|
||||
artifactMap[artifact.name] = downloadUrl;
|
||||
console.log(` - ${artifact.name}: ${downloadUrl}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Warning: Failed to get artifacts: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const targets: BuildTarget[] = [
|
||||
{
|
||||
name: 'NapCat.Framework',
|
||||
status: frameworkStatus,
|
||||
error: frameworkError,
|
||||
downloadUrl: artifactMap['NapCat.Framework'],
|
||||
},
|
||||
{
|
||||
name: 'NapCat.Shell',
|
||||
status: shellStatus,
|
||||
error: shellError,
|
||||
downloadUrl: artifactMap['NapCat.Shell'],
|
||||
},
|
||||
];
|
||||
|
||||
const comment = generateResultComment(targets, prSha, runId, repository, version, installInfo, publishFailed);
|
||||
|
||||
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* PR Build Runner
|
||||
* 执行构建步骤
|
||||
*
|
||||
* 用法: node pr-build-run.ts <target>
|
||||
* target: framework | shell
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { setOutput } from './lib/github.ts';
|
||||
|
||||
type BuildTarget = 'framework' | 'shell';
|
||||
|
||||
interface BuildStep {
|
||||
name: string;
|
||||
command: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
// ============== 构建步骤 ==============
|
||||
|
||||
function getCommonSteps (): BuildStep[] {
|
||||
return [
|
||||
// pnpm 已通过 GitHub Action (pnpm/action-setup) 预安装并固定 v9, 此处无需再次安装
|
||||
{
|
||||
name: 'Install dependencies',
|
||||
command: 'pnpm i',
|
||||
errorMessage: 'Failed to install dependencies',
|
||||
},
|
||||
{
|
||||
name: 'Type check',
|
||||
command: 'pnpm run typecheck',
|
||||
errorMessage: 'Type check failed',
|
||||
},
|
||||
{
|
||||
name: 'Test',
|
||||
command: 'pnpm test',
|
||||
errorMessage: 'Tests failed',
|
||||
},
|
||||
{
|
||||
name: 'Build WebUI',
|
||||
command: 'pnpm --filter napcat-webui-frontend run build',
|
||||
errorMessage: 'WebUI build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getTargetSteps (target: BuildTarget): BuildStep[] {
|
||||
if (target === 'framework') {
|
||||
return [
|
||||
{
|
||||
name: 'Build Framework',
|
||||
command: 'pnpm run build:framework',
|
||||
errorMessage: 'Framework build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Build Shell',
|
||||
command: 'pnpm run build:shell',
|
||||
errorMessage: 'Shell build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ============== 执行器 ==============
|
||||
|
||||
function runStep (step: BuildStep): boolean {
|
||||
console.log(`\n::group::${step.name}`);
|
||||
console.log(`> ${step.command}\n`);
|
||||
|
||||
try {
|
||||
execSync(step.command, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
|
||||
});
|
||||
console.log('::endgroup::');
|
||||
console.log(`✓ ${step.name}`);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
console.log('::endgroup::');
|
||||
console.log(`✗ ${step.name}`);
|
||||
setOutput('error', step.errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function postBuild (target: BuildTarget): void {
|
||||
const srcDir = target === 'framework'
|
||||
? 'packages/napcat-framework/dist'
|
||||
: 'packages/napcat-shell/dist';
|
||||
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
|
||||
|
||||
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
|
||||
|
||||
if (!existsSync(srcDir)) {
|
||||
throw new Error(`Build output not found: ${srcDir}`);
|
||||
}
|
||||
|
||||
renameSync(srcDir, destDir);
|
||||
|
||||
// Install production dependencies
|
||||
console.log('→ Installing production dependencies');
|
||||
execSync('npm install --omit=dev', {
|
||||
cwd: destDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
|
||||
});
|
||||
|
||||
// Remove package-lock.json
|
||||
const lockFile = `${destDir}/package-lock.json`;
|
||||
if (existsSync(lockFile)) {
|
||||
unlinkSync(lockFile);
|
||||
}
|
||||
|
||||
console.log(`✓ Build output ready at ${destDir}`);
|
||||
}
|
||||
|
||||
// ============== 主函数 ==============
|
||||
|
||||
function main (): void {
|
||||
const target = process.argv[2] as BuildTarget;
|
||||
|
||||
if (!target || !['framework', 'shell'].includes(target)) {
|
||||
console.error('Usage: node pr-build-run.ts <framework|shell>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
|
||||
|
||||
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
|
||||
|
||||
for (const step of steps) {
|
||||
if (!runStep(step)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
postBuild(target);
|
||||
console.log('\n✅ Build completed successfully!');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* PR Build - 触发 NapCat-Docker 仓库构建 PR 测试镜像
|
||||
*
|
||||
* 功能:
|
||||
* 1. 在 PR 中创建 Docker 构建中评论
|
||||
* 2. 触发 NapCat-Docker 仓库的 repository_dispatch 事件
|
||||
*
|
||||
* 环境变量:
|
||||
* DISPATCH_PAT - 具备目标仓库 dispatch 权限的 PAT
|
||||
* GITHUB_TOKEN - GitHub API Token (用于写 PR 评论)
|
||||
* PR_NUMBER - PR 编号
|
||||
* PR_SHA - PR HEAD 提交 SHA
|
||||
* PR_HEAD_REPO - PR 源仓库 (owner/repo)
|
||||
* SOURCE_REPO - 当前仓库 (默认 NapNeko/NapCatQQ)
|
||||
* RELEASE_REPO - PR release 仓库 (默认 NapNeko/napcat-pr-release)
|
||||
* TARGET_REPO - Docker 仓库 (默认 NapNeko/NapCat-Docker)
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv } from './lib/github.ts';
|
||||
import { generateDockerBuildingComment, DOCKER_COMMENT_MARKER } from './lib/comment.ts';
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🐳 Dispatching Docker build\n');
|
||||
|
||||
const token = getEnv('DISPATCH_PAT', true);
|
||||
const githubToken = getEnv('GITHUB_TOKEN', true);
|
||||
const prNumber = getEnv('PR_NUMBER', true);
|
||||
const prSha = getEnv('PR_SHA', true);
|
||||
const prHeadRepo = getEnv('PR_HEAD_REPO') || '';
|
||||
const sourceRepo = getEnv('SOURCE_REPO') || 'NapNeko/NapCatQQ';
|
||||
const releaseRepo = getEnv('RELEASE_REPO') || 'NapNeko/napcat-pr-release';
|
||||
const targetRepo = getEnv('TARGET_REPO') || 'NapNeko/NapCat-Docker';
|
||||
|
||||
const [targetOwner, targetRepoName] = targetRepo.split('/');
|
||||
const [sourceOwner, sourceRepoName] = sourceRepo.split('/');
|
||||
const shortSha = prSha.substring(0, 7);
|
||||
|
||||
console.log(` PR: #${prNumber}`);
|
||||
console.log(` SHA: ${shortSha}`);
|
||||
console.log(` Target: ${targetRepo}`);
|
||||
console.log(` Release: ${releaseRepo}\n`);
|
||||
|
||||
// 在 PR 中创建 Docker 构建中评论
|
||||
console.log('💬 Creating Docker building comment...');
|
||||
const prGithub = new GitHubAPI(githubToken);
|
||||
const dockerComment = generateDockerBuildingComment(prNumber, prSha);
|
||||
await prGithub.createOrUpdateComment(
|
||||
sourceOwner, sourceRepoName, parseInt(prNumber, 10), dockerComment, DOCKER_COMMENT_MARKER
|
||||
);
|
||||
|
||||
// 触发 Docker 仓库构建
|
||||
const github = new GitHubAPI(token);
|
||||
|
||||
await github.repositoryDispatch(targetOwner, targetRepoName, 'napcatqq-pr-build', {
|
||||
pr_number: prNumber,
|
||||
pr_sha: prSha,
|
||||
pr_short_sha: shortSha,
|
||||
pr_head_repo: prHeadRepo,
|
||||
source_repo: sourceRepo,
|
||||
release_repo: releaseRepo,
|
||||
});
|
||||
|
||||
console.log('✅ Dispatch succeeded');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* PR Build - 发布构建产物到 napcat-pr-release 仓库
|
||||
*
|
||||
* 功能:
|
||||
* 1. 将 CI 构建产物(Framework / Shell)打包为 zip
|
||||
* 2. 从 NapCat-Installer 仓库拉取 install.sh 并替换下载链接
|
||||
* 3. 发布到 napcat-pr-release 仓库的 GitHub Release
|
||||
*
|
||||
* 环境变量:
|
||||
* RELEASE_PAT - 具备目标仓库 release 写权限的 PAT
|
||||
* PR_NUMBER - PR 编号
|
||||
* PR_SHA - PR HEAD 提交 SHA
|
||||
* NAPCAT_VERSION - 构建版本号
|
||||
* ARTIFACTS_DIR - 构建产物目录 (默认 ./artifacts)
|
||||
* RELEASE_REPO - 发布仓库 (默认 NapNeko/napcat-pr-release)
|
||||
* INSTALLER_REPO - 安装脚本仓库 (默认 NapNeko/NapCat-Installer)
|
||||
* INSTALLER_BRANCH - 安装脚本分支 (默认 main)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { GitHubAPI, getEnv, setOutput } from './lib/github.ts';
|
||||
import { toProxyUrl } from './lib/comment.ts';
|
||||
|
||||
interface AssetEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('📦 PR Release Publisher\n');
|
||||
|
||||
const token = getEnv('RELEASE_PAT', true);
|
||||
const prNumber = getEnv('PR_NUMBER', true);
|
||||
const prSha = getEnv('PR_SHA', true);
|
||||
const version = getEnv('NAPCAT_VERSION') || 'unknown';
|
||||
const artifactsDir = getEnv('ARTIFACTS_DIR') || './artifacts';
|
||||
const releaseRepo = getEnv('RELEASE_REPO') || 'NapNeko/napcat-pr-release';
|
||||
const installerRepo = getEnv('INSTALLER_REPO') || 'NapNeko/NapCat-Installer';
|
||||
const installerBranch = getEnv('INSTALLER_BRANCH') || 'main';
|
||||
|
||||
const [releaseOwner, releaseRepoName] = releaseRepo.split('/');
|
||||
const shortSha = prSha.substring(0, 7);
|
||||
const tag = `pr-${prNumber}-${shortSha}`;
|
||||
const releaseName = `PR #${prNumber} (${shortSha})`;
|
||||
const downloadBase = `https://github.com/${releaseRepo}/releases/download/${tag}`;
|
||||
|
||||
console.log(` Tag: ${tag}`);
|
||||
console.log(` Repo: ${releaseRepo}`);
|
||||
console.log(` Version: ${version}`);
|
||||
console.log(` SHA: ${shortSha}\n`);
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
const releaseDir = resolve('./release-assets'); // 使用绝对路径,避免 zip cwd 切换后相对路径解析错误
|
||||
mkdirSync(releaseDir, { recursive: true });
|
||||
|
||||
// ---- 打包构建产物 ----
|
||||
const artifacts = ['NapCat.Framework', 'NapCat.Shell'];
|
||||
for (const name of artifacts) {
|
||||
const dir = join(artifactsDir, name);
|
||||
if (existsSync(dir)) {
|
||||
const zipPath = join(releaseDir, `${name}.zip`);
|
||||
console.log(`📦 Packing ${name}...`);
|
||||
execSync(`zip -qr "${zipPath}" .`, { cwd: dir, stdio: 'inherit' });
|
||||
console.log(` ✓ ${name}.zip\n`);
|
||||
} else {
|
||||
console.log(` ⚠ ${name} 目录不存在,跳过\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 拉取并修改安装脚本 ----
|
||||
console.log('📜 Fetching install.sh...');
|
||||
const scriptUrl = `https://raw.githubusercontent.com/${installerRepo}/${installerBranch}/script/install.sh`;
|
||||
try {
|
||||
const response = await fetch(scriptUrl);
|
||||
if (response.ok) {
|
||||
let script = await response.text();
|
||||
// 替换下载链接(保留代理前缀 ${target_proxy:+${target_proxy}/})
|
||||
const shellUrl = `${downloadBase}/NapCat.Shell.zip`;
|
||||
const originalScript = script;
|
||||
script = script.replace(
|
||||
/napcat_download_url="\$\{target_proxy:\+\$\{target_proxy\}\/\}https:\/\/github\.com\/NapNeko\/NapCatQQ\/releases\/latest\/download\/NapCat\.Shell\.zip"/g,
|
||||
`napcat_download_url="\${target_proxy:+\${target_proxy}/}${shellUrl}"`
|
||||
);
|
||||
if (script === originalScript) {
|
||||
console.log(' ⚠ Warning: URL replacement did not match - install.sh format may have changed');
|
||||
}
|
||||
const scriptPath = join(releaseDir, 'install.sh');
|
||||
writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
console.log(` ✓ Patched download URL -> ${shellUrl}\n`);
|
||||
} else {
|
||||
console.log(` ⚠ Failed to fetch install.sh: ${response.status}\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ⚠ Failed to fetch install.sh: ${(e as Error).message}\n`);
|
||||
}
|
||||
|
||||
// ---- 收集所有要上传的文件 ----
|
||||
const assets: AssetEntry[] = [];
|
||||
for (const file of readdirSync(releaseDir)) {
|
||||
const ext = file.split('.').pop()?.toLowerCase();
|
||||
const contentType = ext === 'zip' ? 'application/zip' : ext === 'sh' ? 'application/x-sh' : 'application/octet-stream';
|
||||
assets.push({ name: file, path: join(releaseDir, file), contentType });
|
||||
}
|
||||
console.log(`📋 Assets to upload: ${assets.map(a => a.name).join(', ')}\n`);
|
||||
|
||||
// ---- 删除旧 release ----
|
||||
console.log('🗑️ Checking for existing release...');
|
||||
const existing = await github.getReleaseByTag(releaseOwner, releaseRepoName, tag);
|
||||
if (existing) {
|
||||
console.log(` Deleting existing release (id=${existing.id})...`);
|
||||
await github.deleteRelease(releaseOwner, releaseRepoName, existing.id);
|
||||
await github.deleteRef(releaseOwner, releaseRepoName, `tags/${tag}`);
|
||||
console.log(' ✓ Deleted\n');
|
||||
} else {
|
||||
console.log(' No existing release\n');
|
||||
}
|
||||
|
||||
// ---- 创建 Release ----
|
||||
const directInstallUrl = `${downloadBase}/install.sh`;
|
||||
const proxyInstallUrl = toProxyUrl(directInstallUrl);
|
||||
const releaseBody = [
|
||||
`## NapCat PR #${prNumber} 测试版本`,
|
||||
'',
|
||||
`- **版本**: \`${version}\``,
|
||||
`- **提交**: \`${prSha}\``,
|
||||
`- **来源**: [NapCatQQ PR #${prNumber}](https://github.com/NapNeko/NapCatQQ/pull/${prNumber})`,
|
||||
'',
|
||||
'### 快速安装 (Linux)',
|
||||
'',
|
||||
'**直连(需要能访问 GitHub)**',
|
||||
'```bash',
|
||||
`curl -sSL ${directInstallUrl} | bash`,
|
||||
'```',
|
||||
'',
|
||||
'**加速(国内推荐,ghfast.top)**',
|
||||
'```bash',
|
||||
`curl -sSL ${proxyInstallUrl} | bash`,
|
||||
'```',
|
||||
'',
|
||||
'> ⚠️ 此为 PR 测试版本,约 5 天后会被自动清理。',
|
||||
].join('\n');
|
||||
|
||||
console.log('🚀 Creating release...');
|
||||
const release = await github.createRelease(
|
||||
releaseOwner, releaseRepoName, tag, releaseName, releaseBody, true
|
||||
);
|
||||
console.log(` ✓ Release created: ${release.html_url}\n`);
|
||||
|
||||
// ---- 上传资产 ----
|
||||
console.log('📤 Uploading assets...');
|
||||
for (const asset of assets) {
|
||||
const data = readFileSync(asset.path);
|
||||
const uploaded = await github.uploadReleaseAsset(
|
||||
release.upload_url, asset.name, data, asset.contentType
|
||||
);
|
||||
console.log(` ✓ ${asset.name} -> ${uploaded.browser_download_url}`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Release published: ${release.html_url}`);
|
||||
|
||||
// 输出供后续 job 使用
|
||||
setOutput('release_url', release.html_url);
|
||||
setOutput('release_tag', tag);
|
||||
setOutput('install_url', `${downloadBase}/install.sh`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* publish-release-hash.mjs
|
||||
*
|
||||
* 读取 release.json(不存在则创建),追加新版本的 Shell/Framework napcat.mjs SHA512,
|
||||
* 保留最近 100 条后写回文件。
|
||||
*
|
||||
* 用法:
|
||||
* node .github/scripts/publish-release-hash.mjs \
|
||||
* --version v4.9.0 \
|
||||
* --shell ./artifacts/NapCat.Shell/napcat.mjs \
|
||||
* --framework ./artifacts/NapCat.Framework/napcat.mjs \
|
||||
* --file ./hash-repo/release.json
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
// ─── CLI 参数解析 ────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs () {
|
||||
const args = process.argv.slice(2);
|
||||
const result = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
result[args[i].replace(/^--/, '')] = args[i + 1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const { version, shell: shellPath, framework: frameworkPath, file: outputFile } = parseArgs();
|
||||
|
||||
if (!version || !shellPath || !frameworkPath || !outputFile) {
|
||||
console.error('Usage: node publish-release-hash.mjs --version <v> --shell <path> --framework <path> --file <path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha512File (filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return crypto.createHash('sha512').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
// ─── 主逻辑 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
// 1. 计算哈希
|
||||
console.log(`Computing SHA512 for Shell: ${shellPath}`);
|
||||
const shellHash = sha512File(shellPath);
|
||||
console.log(`Computing SHA512 for Framework: ${frameworkPath}`);
|
||||
const frameworkHash = sha512File(frameworkPath);
|
||||
|
||||
// 2. 读取现有 release.json,不存在则创建空数组
|
||||
let entries = [];
|
||||
if (fs.existsSync(outputFile)) {
|
||||
entries = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
||||
console.log(`Loaded existing ${outputFile} with ${entries.length} entries.`);
|
||||
} else {
|
||||
console.log(`${outputFile} not found, will create it.`);
|
||||
}
|
||||
|
||||
// 3. 追加新条目,去除同版本旧条目,滚动保留最近 MAX_ENTRIES 条
|
||||
const newEntry = {
|
||||
version,
|
||||
shell: { sha512: shellHash },
|
||||
framework: { sha512: frameworkHash },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updated = [
|
||||
...entries.filter(e => e.version !== version),
|
||||
newEntry,
|
||||
].slice(-MAX_ENTRIES);
|
||||
|
||||
// 4. 写回文件
|
||||
fs.writeFileSync(outputFile, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
|
||||
console.log(`✅ Written ${updated.length} entries to ${outputFile}`);
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Build NapCat Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
typecheck-and-test:
|
||||
name: Type Check & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install Dependencies
|
||||
run: pnpm i
|
||||
- name: Type Check
|
||||
run: pnpm run typecheck
|
||||
- name: Run Tests
|
||||
run: pnpm test
|
||||
|
||||
build:
|
||||
name: Build NapCat.${{ matrix.target.name }}
|
||||
needs: typecheck-and-test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target:
|
||||
- name: Framework
|
||||
build_cmd: build:framework
|
||||
src_dir: packages/napcat-framework/dist
|
||||
dist_dir: framework-dist
|
||||
- name: Shell
|
||||
build_cmd: build:shell
|
||||
src_dir: packages/napcat-shell/dist
|
||||
dist_dir: shell-dist
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Generate Version
|
||||
run: |
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
- name: Build NapCat.${{ matrix.target.name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: |
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run ${{ matrix.target.build_cmd }}
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv ${{ matrix.target.src_dir }} ${{ matrix.target.dist_dir }}
|
||||
cd ${{ matrix.target.dist_dir }}
|
||||
npm install --omit=dev
|
||||
rm ./package-lock.json || exit 0
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: NapCat.${{ matrix.target.name }}
|
||||
path: ${{ matrix.target.dist_dir }}
|
||||
@@ -0,0 +1,424 @@
|
||||
# =============================================================================
|
||||
# PR 构建工作流
|
||||
# =============================================================================
|
||||
# 安全模型:
|
||||
# 1. pull_request_target / issue_comment 入口只读取元数据、检查权限、更新评论并触发构建。
|
||||
# 2. 真正执行 PR 代码的构建流程只在 workflow_dispatch 中运行。
|
||||
# 3. 构建流程不具备写仓库/写评论/读 secrets 权限,显式清空 GITHUB_TOKEN,不持久化 checkout 凭据。
|
||||
# 4. 版本号在 Action 中提前生成,通过 NAPCAT_VERSION 传给 Vite 插件,插件无需 GitHub token。
|
||||
# 5. 发布 Release、触发 Docker 等可信操作只消费构建 artifact,不执行 PR artifact 中的代码。
|
||||
# =============================================================================
|
||||
|
||||
name: Build PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: PR 编号
|
||||
required: true
|
||||
type: string
|
||||
pr_sha:
|
||||
description: PR HEAD SHA
|
||||
required: true
|
||||
type: string
|
||||
pr_head_repo:
|
||||
description: PR 源仓库
|
||||
required: true
|
||||
type: string
|
||||
pr_head_ref:
|
||||
description: PR 源分支
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/build') && github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# 特权入口:只检查条件、写入“构建中”评论、触发无权限构建
|
||||
# ---------------------------------------------------------------------------
|
||||
check-build:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
pr_number: ${{ steps.check.outputs.pr_number }}
|
||||
pr_sha: ${{ steps.check.outputs.pr_sha }}
|
||||
pr_head_repo: ${{ steps.check.outputs.pr_head_repo }}
|
||||
pr_head_ref: ${{ steps.check.outputs.pr_head_ref }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check trigger condition
|
||||
id: check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-check.ts
|
||||
|
||||
update-comment-building:
|
||||
needs: check-build
|
||||
if: needs.check-build.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Update building comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
|
||||
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-building.ts
|
||||
|
||||
dispatch-build:
|
||||
needs: [check-build, update-comment-building]
|
||||
if: needs.check-build.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Dispatch untrusted build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
|
||||
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
|
||||
PR_HEAD_REPO: ${{ needs.check-build.outputs.pr_head_repo }}
|
||||
PR_HEAD_REF: ${{ needs.check-build.outputs.pr_head_ref }}
|
||||
WORKFLOW_FILE: build-pr.yml
|
||||
DISPATCH_REF: ${{ github.event.repository.default_branch }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-dispatch.ts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 无权限构建:只由 workflow_dispatch 执行 PR 代码
|
||||
# ---------------------------------------------------------------------------
|
||||
build-framework:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
status: ${{ steps.build.outcome }}
|
||||
error: ${{ steps.build.outputs.error }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout scripts from base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
path: _scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.pr_head_repo }}
|
||||
ref: ${{ inputs.pr_sha }}
|
||||
path: workspace
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Generate Version
|
||||
id: version
|
||||
working-directory: workspace
|
||||
run: |
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${{ inputs.pr_sha }}"
|
||||
SHORT_SHA="${SHORT_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-pr.${{ inputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
working-directory: workspace
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Artifact
|
||||
if: steps.build.outcome == 'success'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: workspace/framework-dist
|
||||
retention-days: 5
|
||||
|
||||
build-shell:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
status: ${{ steps.build.outcome }}
|
||||
error: ${{ steps.build.outputs.error }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout scripts from base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
path: _scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.pr_head_repo }}
|
||||
ref: ${{ inputs.pr_sha }}
|
||||
path: workspace
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Generate Version
|
||||
id: version
|
||||
working-directory: workspace
|
||||
run: |
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${{ inputs.pr_sha }}"
|
||||
SHORT_SHA="${SHORT_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-pr.${{ inputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
working-directory: workspace
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Artifact
|
||||
if: steps.build.outcome == 'success'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: workspace/shell-dist
|
||||
retention-days: 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 可信后处理:只下载/打包/发布 artifact,不执行 artifact 中的代码
|
||||
# ---------------------------------------------------------------------------
|
||||
publish-pr-release:
|
||||
needs: [build-framework, build-shell]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && (needs.build-framework.outputs.status == 'success' || needs.build-shell.outputs.status == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
release_url: ${{ steps.publish.outputs.release_url }}
|
||||
release_tag: ${{ steps.publish.outputs.release_tag }}
|
||||
install_url: ${{ steps.publish.outputs.install_url }}
|
||||
publish_status: ${{ steps.publish.outcome }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download Framework artifact
|
||||
if: needs.build-framework.outputs.status == 'success'
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: ./artifacts/NapCat.Framework
|
||||
|
||||
- name: Download Shell artifact
|
||||
if: needs.build-shell.outputs.status == 'success'
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: ./artifacts/NapCat.Shell
|
||||
|
||||
- name: Publish release
|
||||
id: publish
|
||||
continue-on-error: true
|
||||
env:
|
||||
RELEASE_PAT: ${{ secrets.PR_RELEASE_PAT }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
PR_SHA: ${{ inputs.pr_sha }}
|
||||
NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
|
||||
ARTIFACTS_DIR: ./artifacts
|
||||
run: |
|
||||
if [ -z "${RELEASE_PAT}" ]; then
|
||||
echo "::warning::PR_RELEASE_PAT secret 未配置,跳过 PR Release 发布"
|
||||
exit 0
|
||||
fi
|
||||
node --experimental-strip-types .github/scripts/pr-publish-release.ts
|
||||
|
||||
- name: Upload install script as artifact
|
||||
if: steps.publish.outcome == 'success'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: install-script
|
||||
path: ./release-assets/install.sh
|
||||
retention-days: 5
|
||||
if-no-files-found: ignore
|
||||
|
||||
update-comment-result:
|
||||
needs: [build-framework, build-shell, publish-pr-release]
|
||||
if: always() && github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Update result comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
PR_SHA: ${{ inputs.pr_sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
|
||||
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
|
||||
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}
|
||||
SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }}
|
||||
SHELL_ERROR: ${{ needs.build-shell.outputs.error }}
|
||||
INSTALL_URL: ${{ needs.publish-pr-release.outputs.install_url || '' }}
|
||||
RELEASE_URL: ${{ needs.publish-pr-release.outputs.release_url || '' }}
|
||||
PUBLISH_STATUS: ${{ needs.publish-pr-release.outputs.publish_status || '' }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-result.ts
|
||||
|
||||
trigger-docker:
|
||||
needs: [build-shell, publish-pr-release]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.build-shell.outputs.status == 'success' && needs.publish-pr-release.outputs.release_tag != ''
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Dispatch docker build
|
||||
continue-on-error: true
|
||||
env:
|
||||
DISPATCH_PAT: ${{ secrets.PR_RELEASE_PAT }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
PR_SHA: ${{ inputs.pr_sha }}
|
||||
PR_HEAD_REPO: ${{ inputs.pr_head_repo }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
RELEASE_REPO: NapNeko/napcat-pr-release
|
||||
run: |
|
||||
if [ -z "${DISPATCH_PAT}" ]; then
|
||||
echo "::warning::PR_RELEASE_PAT secret 未配置,跳过 Docker 测试镜像构建"
|
||||
exit 0
|
||||
fi
|
||||
node --experimental-strip-types .github/scripts/pr-docker-dispatch.ts
|
||||
@@ -0,0 +1,153 @@
|
||||
name: Release Downstream
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Release tag name (e.g., v4.10.7)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
QQ_URL_X86_64: 'https://qqdl.gtimg.cn/qqfile/QQNT/9.9.32/beta/fd40a3ec/linuxqq_3.2.30-50969_x86_64.AppImage'
|
||||
QQ_URL_ARM64: 'https://qqdl.gtimg.cn/qqfile/QQNT/9.9.32/beta/fd40a3ec/linuxqq_3.2.30-50969_arm64.AppImage'
|
||||
|
||||
jobs:
|
||||
get-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
latest_tag: ${{ steps.get_version.outputs.latest_tag }}
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Get Latest NapCat Version
|
||||
id: get_version
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag_name }}" ]; then
|
||||
latest_tag="${{ inputs.tag_name }}"
|
||||
else
|
||||
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
fi
|
||||
version=${latest_tag#v}
|
||||
echo "Debug: Latest NapCat Version is ${latest_tag}, version is ${version}"
|
||||
echo "latest_tag=${latest_tag}" >> $GITHUB_OUTPUT
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
publish-schema:
|
||||
needs: get-version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build napcat-schema
|
||||
run: |
|
||||
cd packages/napcat-schema
|
||||
pnpm run build:openapi
|
||||
|
||||
- name: Checkout NapCatDocs
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: NapNeko/NapCatDocs
|
||||
token: ${{ secrets.NAPCAT_BUILD }}
|
||||
path: napcat-docs
|
||||
|
||||
- name: Copy OpenAPI Schema
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
mkdir -p napcat-docs/src/api/${VERSION}
|
||||
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${VERSION}/openapi.json
|
||||
echo "OpenAPI schema copied to napcat-docs/src/api/${VERSION}/openapi.json"
|
||||
|
||||
- name: Commit and Push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
cd napcat-docs
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add src/api/${VERSION}/openapi.json
|
||||
git commit -m "chore: update OpenAPI schema for version ${VERSION}" || echo "No changes to commit"
|
||||
git push
|
||||
|
||||
shell-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger NapCat-Docker docker-publish workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d '{"ref":"main"}'
|
||||
|
||||
framework-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger NapCat-Framework-Docker docker-publish workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCat.Docker.Framework/actions/workflows/docker-image.yml/dispatches \
|
||||
-d '{"ref":"main"}'
|
||||
|
||||
appimage:
|
||||
needs: get-version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger NapCat AppImage build workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
NAPCAT_VERSION: ${{ needs.get-version.outputs.latest_tag }}
|
||||
run: |
|
||||
echo "Debug: Triggering NapCat AppImage with napcat_version=${NAPCAT_VERSION}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatAppImageBuild/actions/workflows/release.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_version_x86_64\":\"${{ env.QQ_URL_X86_64 }}\",\"qq_version_arm64\":\"${{ env.QQ_URL_ARM64 }}\"}}"
|
||||
|
||||
node-shell:
|
||||
needs: get-version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
NAPCAT_VERSION: ${{ needs.get-version.outputs.latest_tag }}
|
||||
steps:
|
||||
- name: Trigger NapCat NodeLoader release workflow
|
||||
run: |
|
||||
echo "Debug: Triggering NapCat NodeLoader release with napcat_version=${NAPCAT_VERSION}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${{ env.QQ_URL_X86_64 }}\",\"qq_url_arm64\":\"${{ env.QQ_URL_ARM64 }}\"}}"
|
||||
|
||||
- name: Trigger NapCat NodeLoader docker workflow
|
||||
run: |
|
||||
echo "Debug: Triggering NapCat NodeLoader docker with napcat_version=${NAPCAT_VERSION}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${{ env.QQ_URL_X86_64 }}\",\"qq_url_arm64\":\"${{ env.QQ_URL_ARM64 }}\"}}"
|
||||
@@ -0,0 +1,505 @@
|
||||
name: Release Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version tag (e.g., v4.10.7)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: write-all
|
||||
|
||||
env:
|
||||
OPENAI_API_URL: https://openrouter.ai/api/v1/chat/completions
|
||||
OPENAI_MODEL: "google/gemma-4-26b-a4b-it:free"
|
||||
RELEASE_NAME: "NapCat"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build NapCat.${{ matrix.target.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target:
|
||||
- name: Framework
|
||||
build_cmd: build:framework
|
||||
src_dir: packages/napcat-framework/dist
|
||||
dist_dir: framework-dist
|
||||
- name: Shell
|
||||
build_cmd: build:shell
|
||||
src_dir: packages/napcat-shell/dist
|
||||
dist_dir: shell-dist
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.version }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.${{ matrix.target.name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run ${{ matrix.target.build_cmd }}
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv ${{ matrix.target.src_dir }} ${{ matrix.target.dist_dir }}
|
||||
cd ${{ matrix.target.dist_dir }}
|
||||
npm install --omit=dev
|
||||
rm ./package-lock.json || exit 0
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: NapCat.${{ matrix.target.name }}
|
||||
path: ${{ matrix.target.dist_dir }}
|
||||
|
||||
assemble-windows-node:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# QQ 9.9.31.49738; SHA256 matches the microsoft/winget-pkgs installer manifest.
|
||||
QQ_URL: https://github.com/Rodert/qq-versions/releases/download/qq-packages-20260528-3e8913a2/QQ_9.9.31_260528_x64_01.exe
|
||||
QQ_SHA256: 0beb5cb4ff776cba822caa0abadbd53f5892f12628a86dabea1b372c82c086bc
|
||||
NODE_VERSION: '22.11.0'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.version }}
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Setup tools
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y aria2 unzip zip p7zip-full curl jq
|
||||
|
||||
- name: Generate download cache key
|
||||
id: download-cache-key
|
||||
run: |
|
||||
DOWNLOAD_HASH=$(printf '%s\n' "$QQ_URL" "$QQ_SHA256" "$NODE_VERSION" | sha256sum | cut -d ' ' -f 1)
|
||||
echo "key=napcat-downloads-v2-$DOWNLOAD_HASH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache QQ and Node.js downloads
|
||||
id: cache-downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/napcat-downloads
|
||||
key: ${{ steps.download-cache-key.outputs.key }}
|
||||
|
||||
- name: Download QQ and Node.js
|
||||
if: steps.cache-downloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/napcat-downloads
|
||||
cd /tmp/napcat-downloads
|
||||
|
||||
echo "Downloading QQ installer..."
|
||||
# Retry download errors, including HTTP 404 responses.
|
||||
curl --fail --location --retry 5 --retry-all-errors --retry-delay 5 \
|
||||
--connect-timeout 30 --max-time 600 --user-agent "Mozilla/5.0" \
|
||||
--output "QQ_x64.exe" "$QQ_URL"
|
||||
|
||||
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-win-x64.zip"
|
||||
echo "Downloading Node.js $NODE_VERSION..."
|
||||
aria2c -x1 -s1 -k1M -o "node-win-x64.zip" "$NODE_URL"
|
||||
|
||||
- name: Verify QQ installer
|
||||
run: |
|
||||
echo "$QQ_SHA256 /tmp/napcat-downloads/QQ_x64.exe" | sha256sum --check --strict -
|
||||
|
||||
- name: Assemble NapCat.Shell.Windows.Node.zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMPDIR=$(mktemp -d)
|
||||
CACHE_DIR="/tmp/napcat-downloads"
|
||||
|
||||
# -----------------------------
|
||||
# 1) 解压 QQ x64
|
||||
# -----------------------------
|
||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||
mkdir -p "$QQ_EXTRACT"
|
||||
7z x -y -o"$QQ_EXTRACT" "$CACHE_DIR/QQ_x64.exe" >/dev/null
|
||||
|
||||
# -----------------------------
|
||||
# 2) 解压 Node.js Windows x64
|
||||
# -----------------------------
|
||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
||||
mkdir -p "$NODE_EXTRACT"
|
||||
unzip -q "$CACHE_DIR/node-win-x64.zip" -d "$NODE_EXTRACT"
|
||||
|
||||
# -----------------------------
|
||||
# 3) 创建输出目录
|
||||
# -----------------------------
|
||||
OUT_DIR="$GITHUB_WORKSPACE/NapCat.Shell.Windows.Node"
|
||||
mkdir -p "$OUT_DIR/NapCat.Shell.Windows.Node"
|
||||
|
||||
# -----------------------------
|
||||
# 4) 解压 NapCat.Shell.zip 到 napcat
|
||||
# -----------------------------
|
||||
cp -a "$GITHUB_WORKSPACE/artifacts/NapCat.Shell/." "$OUT_DIR/napcat/"
|
||||
|
||||
# -----------------------------
|
||||
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||
# -----------------------------
|
||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
|
||||
for name in "${QQ_TARGETS[@]}"; do
|
||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||
done
|
||||
|
||||
# -----------------------------
|
||||
# 5.1) 拷贝 win64 目录下的文件
|
||||
# -----------------------------
|
||||
mkdir -p "$OUT_DIR/win64"
|
||||
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||
|
||||
# -----------------------------
|
||||
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||
# -----------------------------
|
||||
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/napcat.bat" "$OUT_DIR/" || true
|
||||
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/index.js" "$OUT_DIR/" || true
|
||||
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/QQNT.dll" "$OUT_DIR/" || true
|
||||
|
||||
# -----------------------------
|
||||
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||
# -----------------------------
|
||||
cp -a "$NODE_EXTRACT/node-v$NODE_VERSION-win-x64/node.exe" "$OUT_DIR/" || true
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: NapCat.Shell.Windows.Node
|
||||
path: NapCat.Shell.Windows.Node
|
||||
|
||||
release-napcat:
|
||||
needs: [build, assemble-windows-node]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download NapCat.Shell.Windows.OneKey.zip
|
||||
run: |
|
||||
curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
|
||||
|
||||
- name: Zip Artifacts
|
||||
run: |
|
||||
cd artifacts
|
||||
[ -d NapCat.Framework ] && (cd NapCat.Framework && zip -qr ../../NapCat.Framework.zip .)
|
||||
[ -d NapCat.Shell ] && (cd NapCat.Shell && zip -qr ../../NapCat.Shell.zip .)
|
||||
[ -d NapCat.Shell.Windows.Node ] && (cd NapCat.Shell.Windows.Node && zip -qr ../../NapCat.Shell.Windows.Node.zip .)
|
||||
cd ..
|
||||
|
||||
- name: Generate release note
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI_API_URL: ${{ env.OPENAI_API_URL }}
|
||||
OPENAI_MODEL: ${{ env.OPENAI_MODEL }}
|
||||
GITHUB_OWNER: "NapNeko"
|
||||
GITHUB_REPO: "NapCatQQ"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 当前 tag
|
||||
CURRENT_TAG="${{ inputs.version }}"
|
||||
echo "Current tag: $CURRENT_TAG"
|
||||
|
||||
# 从 GitHub API 获取 tag 列表
|
||||
TAGS_JSON=$(curl -s "https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/tags?per_page=100")
|
||||
TAGS=( $(echo "$TAGS_JSON" | jq -r '.[].name' | sort -V) )
|
||||
|
||||
# 找到上一个 tag
|
||||
PREV_TAG=""
|
||||
for i in "${!TAGS[@]}"; do
|
||||
if [ "${TAGS[$i]}" = "$CURRENT_TAG" ]; then
|
||||
if [ $i -gt 0 ]; then
|
||||
PREV_TAG="${TAGS[$((i-1))]}"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Could not find previous tag for $CURRENT_TAG, using first commit"
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
|
||||
fi
|
||||
|
||||
echo "Previous tag: $PREV_TAG"
|
||||
|
||||
# 强制拉取上一个 tag 和当前 tag
|
||||
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
|
||||
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
|
||||
|
||||
# 获取 commit,使用更清晰的格式
|
||||
COMMITS=$(git log --pretty=format:'- %s (%h)' "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || git log --pretty=format:'- %s (%h)' -20)
|
||||
|
||||
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
|
||||
echo "$COMMITS"
|
||||
|
||||
# 获取文件变化统计
|
||||
echo "Getting file change statistics..."
|
||||
FILE_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || echo "")
|
||||
|
||||
# 获取总体统计(最后一行)
|
||||
SUMMARY_LINE=$(echo "$FILE_STATS" | tail -1)
|
||||
echo "Summary: $SUMMARY_LINE"
|
||||
|
||||
# 获取每个文件的变化(去掉最后一行汇总)
|
||||
# 截断过长的输出(最多50个文件,每行最多80字符)
|
||||
FILE_CHANGES=$(echo "$FILE_STATS" | head -n -1 | head -50 | cut -c1-80)
|
||||
|
||||
# 如果文件变化太多,进一步精简:只保留主要目录的变化
|
||||
FILE_COUNT=$(echo "$FILE_STATS" | head -n -1 | wc -l)
|
||||
if [ "$FILE_COUNT" -gt 50 ]; then
|
||||
echo "Too many files ($FILE_COUNT), grouping by directory..."
|
||||
# 按目录分组统计
|
||||
DIR_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | head -n -1 | \
|
||||
sed 's/|.*//g' | \
|
||||
awk -F'/' '{if(NF>1) print $1"/"$2; else print $1}' | \
|
||||
sort | uniq -c | sort -rn | head -20)
|
||||
FILE_CHANGES="[按目录分组统计 - 共 $FILE_COUNT 个文件变更]
|
||||
$DIR_STATS"
|
||||
fi
|
||||
|
||||
echo "File changes:"
|
||||
echo "$FILE_CHANGES"
|
||||
|
||||
# 获取具体代码变化(关键文件的diff)
|
||||
echo "Getting code diff for key files..."
|
||||
|
||||
# 定义关键目录(优先展示这些目录的变化)
|
||||
KEY_DIRS="packages/napcat-core packages/napcat-onebot packages/napcat-webui-backend"
|
||||
|
||||
# 获取变更的关键文件列表(排除测试、配置等)
|
||||
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
||||
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" || true | \
|
||||
grep -E "\.(ts|js)$" || true | \
|
||||
grep -v -E "(test|spec|\.d\.ts|config)" || true | \
|
||||
head -15) || true
|
||||
|
||||
CODE_DIFF=""
|
||||
DIFF_CHAR_LIMIT=6000
|
||||
CURRENT_CHARS=0
|
||||
|
||||
if [ -n "$KEY_FILES" ]; then
|
||||
for file in $KEY_FILES; do
|
||||
if [ "$CURRENT_CHARS" -ge "$DIFF_CHAR_LIMIT" ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
[... 更多文件变化已截断 ...]"
|
||||
break
|
||||
fi
|
||||
|
||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50) || true
|
||||
FILE_DIFF_LEN=${#FILE_DIFF}
|
||||
|
||||
if [ "$FILE_DIFF_LEN" -gt 1500 ]; then
|
||||
FILE_DIFF=$(echo "$FILE_DIFF" | head -c 1500)
|
||||
FILE_DIFF="$FILE_DIFF
|
||||
[... 文件 $file 变化已截断 ...]"
|
||||
fi
|
||||
|
||||
if [ -n "$FILE_DIFF" ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
|
||||
### $file
|
||||
\`\`\`diff
|
||||
$FILE_DIFF
|
||||
\`\`\`"
|
||||
CURRENT_CHARS=$((CURRENT_CHARS + FILE_DIFF_LEN))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 如果没有关键文件变化,获取前5个变更文件的diff
|
||||
if [ -z "$CODE_DIFF" ]; then
|
||||
echo "No key files changed, getting top changed files..."
|
||||
TOP_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
||||
grep -E "\.(ts|js|yml|md)$" | head -5) || true
|
||||
|
||||
if [ -n "$TOP_FILES" ]; then
|
||||
for file in $TOP_FILES; do
|
||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30) || true
|
||||
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
|
||||
### $file
|
||||
\`\`\`diff
|
||||
$FILE_DIFF
|
||||
\`\`\`"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# 如果仍然没有代码变化,添加说明
|
||||
if [ -z "$CODE_DIFF" ]; then
|
||||
CODE_DIFF="[本次更新主要涉及配置文件和文档变更,无核心代码变化]"
|
||||
fi
|
||||
|
||||
echo "Code diff preview:"
|
||||
echo "$CODE_DIFF" | head -50
|
||||
|
||||
# 读取 prompt
|
||||
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
|
||||
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
|
||||
|
||||
# 构建用户内容,传递更多上下文(包含文件变化和代码diff)
|
||||
USER_CONTENT="当前版本: $CURRENT_TAG
|
||||
上一版本: $PREV_TAG
|
||||
|
||||
## 提交列表
|
||||
$COMMITS
|
||||
|
||||
## 文件变化统计
|
||||
$SUMMARY_LINE
|
||||
|
||||
## 变更文件列表
|
||||
$FILE_CHANGES
|
||||
|
||||
## 关键代码变化
|
||||
$CODE_DIFF"
|
||||
|
||||
# 构建请求 JSON,增加 max_tokens 以获取更完整的输出
|
||||
BODY=$(jq -n \
|
||||
--arg system "$SYSTEM_PROMPT" \
|
||||
--arg user "$USER_CONTENT" \
|
||||
--arg model "$OPENAI_MODEL" \
|
||||
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:5000}')
|
||||
|
||||
echo "=== request body ==="
|
||||
echo "$BODY" | jq .
|
||||
|
||||
if RESPONSE=$(curl -s -X POST "$OPENAI_API_URL" \
|
||||
-H "Authorization: Bearer $OPENAI_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"); then
|
||||
echo "=== raw response ==="
|
||||
echo "$RESPONSE"
|
||||
echo "=== parsed response ==="
|
||||
if echo "$RESPONSE" | jq . >/dev/null 2>&1; then
|
||||
echo "$RESPONSE" | jq .
|
||||
else
|
||||
echo "jq failed to parse response"
|
||||
fi
|
||||
|
||||
# 提取生成内容
|
||||
RELEASE_BODY=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // .choices[0].text // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$RELEASE_BODY" ]; then
|
||||
echo "LLM failed to generate release note, using default.md"
|
||||
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
|
||||
else
|
||||
# 后处理:确保版本号正确,并添加比较链接
|
||||
echo -e "$RELEASE_BODY" > CHANGELOG.md
|
||||
sed -i "s/{VERSION}/$CURRENT_TAG/g" CHANGELOG.md
|
||||
sed -i "s/{PREV_VERSION}/$PREV_TAG/g" CHANGELOG.md
|
||||
fi
|
||||
else
|
||||
echo "Curl failed, using default.md"
|
||||
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
|
||||
fi
|
||||
echo "=== generated release note ==="
|
||||
cat CHANGELOG.md
|
||||
|
||||
- name: Create Release and Upload Artifacts
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ inputs.version }}
|
||||
name: NapCat ${{ inputs.version }}
|
||||
body_path: CHANGELOG.md
|
||||
files: |
|
||||
NapCat.Shell.Windows.Node.zip
|
||||
NapCat.Framework.zip
|
||||
NapCat.Shell.zip
|
||||
NapCat.Shell.Windows.OneKey.zip
|
||||
draft: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Update apifox
|
||||
env:
|
||||
APIFOX_TOKEN: ${{ secrets.APIFOX_TOKEN }}
|
||||
run: |
|
||||
pnpm i --ignore-scripts
|
||||
pnpm run build:openapi
|
||||
|
||||
jq -n --rawfile input packages/napcat-schema/dist/openapi.json \
|
||||
'{
|
||||
input: $input,
|
||||
options: {
|
||||
"endpointOverwriteBehavior": "OVERWRITE_EXISTING",
|
||||
"schemaOverwriteBehavior": "OVERWRITE_EXISTING",
|
||||
"updateFolderOfChangedEndpoint": true,
|
||||
"moduleId": 1140714,
|
||||
"deleteUnmatchedResources": true
|
||||
}
|
||||
}' > apifox_payload.json
|
||||
|
||||
curl --location -g --request POST 'https://api.apifox.com/v1/projects/5348325/import-openapi?locale=zh-CN' \
|
||||
--header 'X-Apifox-Api-Version: 2024-03-28' \
|
||||
--header "Authorization: Bearer $APIFOX_TOKEN" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary @apifox_payload.json
|
||||
|
||||
- name: Publish release hash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ inputs.version }}"
|
||||
HASH_REPO="NapNeko/napcat-release-hash"
|
||||
|
||||
gh auth setup-git
|
||||
gh repo clone "$HASH_REPO" hash-repo
|
||||
|
||||
node .github/scripts/publish-release-hash.mjs \
|
||||
--version "$VERSION" \
|
||||
--shell "./artifacts/NapCat.Shell/napcat.mjs" \
|
||||
--framework "./artifacts/NapCat.Framework/napcat.mjs" \
|
||||
--file "./hash-repo/release.json"
|
||||
|
||||
cd hash-repo
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add release.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: update release hash for ${VERSION}"
|
||||
git push origin HEAD:main || git push origin HEAD:master
|
||||
fi
|
||||
|
||||
- name: Trigger downstream release workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
echo "Triggering release-downstream workflow with tag_name=${VERSION}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/${{ github.repository }}/actions/workflows/release-downstream.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"tag_name\":\"${VERSION}\"}}"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Release Tag
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
valid: ${{ steps.check.outputs.valid }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Validate semantic version
|
||||
id: check
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "Checking tag: $TAG"
|
||||
|
||||
SEMVER_REGEX="^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?$"
|
||||
|
||||
if [[ "$TAG" =~ $SEMVER_REGEX ]]; then
|
||||
echo "Valid semantic version: $TAG"
|
||||
echo "valid=true" >> $GITHUB_OUTPUT
|
||||
echo "version=$TAG" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Invalid version format: $TAG"
|
||||
echo "Expected format: vX.Y.Z or vX.Y.Z-prerelease"
|
||||
echo "Examples: v1.0.0, v1.2.3-beta, v2.0.0-rc.1"
|
||||
echo "valid=false" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trigger-release:
|
||||
needs: [validate-version]
|
||||
if: needs.validate-version.outputs.valid == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger release-publish workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
VERSION: ${{ needs.validate-version.outputs.version }}
|
||||
run: |
|
||||
echo "Triggering release-publish workflow with version=${VERSION}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/${{ github.repository }}/actions/workflows/release-publish.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"version\":\"${VERSION}\"}}"
|
||||
Reference in new issue
Block a user