chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:22 +08:00
commit 4c395b06e4
1964 changed files with 333726 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
# CI/CD 设置指南
## 概述
这个项目配置了完整的 GitHub Actions CI/CD 流水线,支持自动构建、测试和发布到多个平台。
## 工作流说明
### 1. `build-and-release.yml` - 主构建和发布流
- **触发时机**: 仅推送到 `main` 分支
- **功能**:
- 代码质量检查 (ESLint, Prettier, TypeScript)
- 多平台构建 (macOS Intel/Apple Silicon, Windows, Linux)
- 自动创建版本标签
- 创建 Draft Release (需要手动审批和发布)
- **流程**:
1. 代码质量检查
2. 三平台并行构建
3. 自动创建基于 package.json 版本的标签
4. 等待环境审批
5. 创建 Draft Release (需要手动编辑和发布)
## 必需的 GitHub Secrets 配置
在 GitHub 仓库的 Settings → Secrets and variables → Actions 中配置以下 Secrets
### macOS 应用签名 (可选,用于发布到 Mac App Store)
```
APPLE_ID=你的苹果开发者账号邮箱
APPLE_ID_PASSWORD=应用专用密码
TEAM_ID=苹果开发者团队ID
IDENTITY=签名证书名称
```
### GitHub Token
```
GH_TOKEN=你的Personal Access Token (github_pat_开头)
```
**注意**: 需要手动配置,因为需要 `contents: write` 权限来创建 releases。
### Environment Secrets
在 Settings → Environments → release 中也需要配置:
```
GH_TOKEN=相同的Personal Access Token
```
## 如何获取 Apple 签名配置
### 1. Apple ID App-Specific Password
1. 访问 [appleid.apple.com](https://appleid.apple.com)
2. 登录你的 Apple ID
3. 在"Sign-In and Security"部分点击"App-Specific Passwords"
4. 生成新的应用专用密码
5. 复制生成的密码作为 `APPLE_ID_PASSWORD`
### 2. Team ID
1. 访问 [Apple Developer Portal](https://developer.apple.com/account/)
2. 在"Membership Details"中找到 Team ID
3. 复制 Team ID 作为 `TEAM_ID`
### 3. 签名证书 Identity
1. 打开 Xcode 或 Keychain Access
2. 查看已安装的开发者证书
3. 证书名称类似:"Developer ID Application: Your Name (TEAM_ID)"
4. 复制完整证书名称作为 `IDENTITY`
## 使用方法
### 推荐发布流程 (使用 release.sh)
1. 确保代码质量符合要求
2. 使用发布脚本升级版本:
```bash
# 修复版本
./scripts/release.sh patch
# 功能版本
./scripts/release.sh minor
# 重大版本
./scripts/release.sh major
# 预发布版本
./scripts/release.sh prerelease
```
3. 脚本会自动:
- 运行代码质量检查
- 升级版本号
- 创建 git tag
- 推送到 main 分支
4. GitHub Actions 自动触发构建
5. 在 Deployments 页面审批发布
6. 编辑 Draft Release 内容
7. 手动发布给用户
### 直接推送发布
1. 手动修改 `package.json` 中的版本号
2. 提交并推送到 `main` 分支
3. GitHub Actions 将自动构建并创建 Draft Release
### 版本管理规范
- `patch`: 修复bug (1.0.0 → 1.0.1)
- `minor`: 新功能 (1.0.0 → 1.1.0)
- `major`: 重大更新 (1.0.0 → 2.0.0)
- `prerelease`: 预发布版本 (1.0.0 → 1.0.1-beta.0)
## 构建产物
成功构建后,将生成以下文件:
### macOS
- `.dmg` 文件 (Intel 和 Apple Silicon 版本)
- 应用程序包
### Windows
- `.exe` NSIS 安装程序 (x64/arm64)
- `.zip` 便携版应用 (x64/arm64)
### Linux
- `.deb` 安装包 (x64/arm64/armv7l)
- `.AppImage` 便携式应用 (x64/arm64/armv7l)
## 故障排查
### 常见问题
1. **Release创建失败 (403错误)**
- 检查 GH_TOKEN 是否正确配置
- 确认 token 格式为 `github_pat_` 开头
- 验证 repository 和 environment 中都有 GH_TOKEN
2. **macOS 签名失败**
- 检查 Apple ID 和密码是否正确
- 确认 Team ID 和证书名称准确
- 验证苹果开发者账号状态
3. **构建超时 (Windows)**
- Windows 构建通常最慢 (可能40分钟+)
- 考虑禁用 MSI target 加速构建
4. **重复tag错误**
- CI/CD 会检查并跳过已存在的 tag
- 如果手动创建了 tag,CI/CD 不会重复创建
### 调试方法
1. 查看 GitHub Actions 日志
2. 本地运行相同的构建命令测试
3. 检查 package.json 中的构建脚本
## 安全建议
1. 定期更新 GitHub Actions 版本
2. 使用最小权限原则配置 Secrets
3. 定期审查和清理未使用的 Secrets
4. 监控构建日志,避免敏感信息泄露
## 进阶配置
### 自动更新检查
可以集成应用内自动更新功能,配合 GitHub Releases API 实现自动更新提醒。
### 多环境部署
可以扩展工作流支持开发、测试、生产环境的分别部署。
### 性能优化
- 使用构建缓存加速构建
- 并行构建不同平台
- 优化依赖安装速度
+72
View File
@@ -0,0 +1,72 @@
name: 🐛 Bug Report
description: Report a bug or unexpected behavior in AionUi
title: '[Bug]: '
labels: ['bug']
body:
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform are you using?
options:
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: AionUi Version
description: What version of AionUi are you using?
placeholder: e.g., 1.2.2
validations:
required: true
- type: textarea
id: bug_description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe what happened...
validations:
required: true
- type: textarea
id: steps_to_reproduce
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: Expected Behavior
description: What should have happened?
placeholder: What should have happened?
validations:
required: true
- type: textarea
id: actual_behavior
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: What actually happened?
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional Context
description: Add any other context about the problem here.
placeholder: Screenshots, error logs, etc.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Community Discussions
url: https://github.com/iOfficeAI/AionUi/discussions
about: Ask questions, share ideas, and discuss AionUi with the community
- name: 📖 Documentation
url: https://github.com/iOfficeAI/AionUi/wiki
about: Check our documentation for setup guides, configuration, and troubleshooting
- name: 🔒 Security Issues
url: mailto:security@aionui.com
about: Report security vulnerabilities privately (DO NOT create public issues)
@@ -0,0 +1,53 @@
name: ✨ Feature Request
description: Suggest an idea for AionUi
title: '[Feature]: '
labels: ['enhancement']
body:
- type: textarea
id: feature_description
attributes:
label: Feature Description
description: A clear and concise description of the feature you'd like to see.
placeholder: Describe the feature you'd like to see...
validations:
required: true
- type: textarea
id: problem_statement
attributes:
label: Problem Statement
description: What problem does this feature solve?
placeholder: What problem or limitation would this feature address?
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see implemented.
placeholder: How would you like this feature to work?
validations:
required: true
- type: dropdown
id: feature_category
attributes:
label: Feature Category
description: Which category does this feature belong to?
options:
- UI/UX Improvement
- AI Model Integration
- File Management
- Multi-Agent Support
- Performance
- Other
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional Context
description: Add any other context about the feature request here.
placeholder: Use cases, examples, mockups, etc.
+47
View File
@@ -0,0 +1,47 @@
name: ❓ Question
description: Ask a question about AionUi
title: '[Question]: '
labels: ['question']
body:
- type: textarea
id: question
attributes:
label: Question
description: What would you like to know?
placeholder: Ask your question here...
validations:
required: true
- type: dropdown
id: question_category
attributes:
label: Question Category
description: What type of question is this?
options:
- Installation & Setup
- Configuration
- Usage & Features
- Troubleshooting
- Development
- Other
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: Provide context about your question. What are you trying to achieve?
placeholder: Describe what you're trying to do or understand...
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform are you using?
options:
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Linux
- Not applicable
+146
View File
@@ -0,0 +1,146 @@
name: 'Call OpenAI'
description: 'Call OpenAI Chat Completions API with retry logic. Reads system_prompt.txt and user_prompt.txt from RUNNER_TEMP.'
inputs:
openai_api_key:
description: 'OpenAI API key'
required: true
output_file:
description: 'Output filename in RUNNER_TEMP for the GPT response'
required: false
default: 'gpt_response.txt'
diff_truncated:
description: 'Whether the diff was truncated (for appending note)'
required: false
default: 'false'
contents_truncated:
description: 'Whether file contents were truncated (for appending note)'
required: false
default: 'false'
runs:
using: 'composite'
steps:
- id: 'call'
uses: 'actions/github-script@v7'
env:
OPENAI_API_KEY: '${{ inputs.openai_api_key }}'
OUTPUT_FILE: '${{ inputs.output_file }}'
DIFF_TRUNCATED: '${{ inputs.diff_truncated }}'
CONTENTS_TRUNCATED: '${{ inputs.contents_truncated }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
core.setFailed('OPENAI_API_KEY is not configured');
return;
}
// Read prompts from temp files
let systemPrompt, userPrompt;
try {
systemPrompt = fs.readFileSync(`${tmpDir}/system_prompt.txt`, 'utf-8');
userPrompt = fs.readFileSync(`${tmpDir}/user_prompt.txt`, 'utf-8');
} catch (err) {
core.setFailed(`Failed to read prompt files: ${err.message}`);
return;
}
// Call OpenAI Chat Completions API with retry logic
async function callOpenAI(retries = 2) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-5.2',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.1,
max_completion_tokens: 16384,
}),
});
if (response.status === 429 || response.status >= 500) {
if (attempt < retries) {
const delay = Math.pow(2, attempt + 1) * 1000;
core.warning(`OpenAI API returned ${response.status}, retrying in ${delay}ms (attempt ${attempt + 1}/${retries})`);
await new Promise(r => setTimeout(r, delay));
continue;
}
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI API error ${response.status}: ${errorText}`);
}
const data = await response.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content.trim().length === 0) {
throw new Error(
`OpenAI response missing choices[0].message.content. Payload keys: ${Object.keys(data || {}).join(', ')}`
);
}
return content;
} catch (error) {
const isRetryable = attempt < retries && (
error.cause?.code === 'ECONNRESET' ||
error.cause?.code === 'ETIMEDOUT' ||
error.cause?.code === 'ENOTFOUND' ||
error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('socket')
);
if (isRetryable) {
const delay = Math.pow(2, attempt + 1) * 1000;
core.warning(`Network error, retrying in ${delay}ms: ${error.message}`);
await new Promise(r => setTimeout(r, delay));
continue;
}
throw error;
}
}
}
const gptContent = await callOpenAI();
if (!gptContent) {
core.setFailed('GPT returned empty response');
return;
}
// Append truncation warning if inputs were truncated
const diffTruncated = process.env.DIFF_TRUNCATED === 'true';
const contentsTruncated = process.env.CONTENTS_TRUNCATED === 'true';
let truncationNote = '';
if (diffTruncated || contentsTruncated) {
const parts = [];
if (diffTruncated) parts.push('diff (>100K chars)');
if (contentsTruncated) parts.push('file contents (>80K chars)');
truncationNote = [
'',
'> **Note**: The following inputs were truncated due to size limits: ' + parts.join(', ') + '.',
'> Please review the omitted portions manually.',
].join('\n');
}
const withNote = gptContent + truncationNote;
// Truncate if exceeds GitHub's 65536 char limit
const MAX_LENGTH = 65000;
const finalContent = withNote.length > MAX_LENGTH
? withNote.substring(0, MAX_LENGTH) + '\n\n---\n*[Output truncated due to length]*'
: withNote;
const outputFile = process.env.OUTPUT_FILE || 'gpt_response.txt';
fs.writeFileSync(`${tmpDir}/${outputFile}`, finalContent);
core.info(`GPT response written to ${outputFile} (${finalContent.length} chars)`);
+28
View File
@@ -0,0 +1,28 @@
name: '🔍 Resolve PR Context'
description: 'Resolve the correct ref and base branch for pull_request and workflow_dispatch triggers. Must be called after actions/checkout.'
inputs:
pr_number:
description: 'PR number (only used when triggered via workflow_dispatch)'
required: false
default: ''
github_token:
description: 'GitHub token for gh API calls'
required: true
runs:
using: 'composite'
steps:
- name: Resolve PR context
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_INFO=$(gh api repos/${{ github.repository }}/pulls/${{ inputs.pr_number }})
HEAD_SHA=$(echo "$PR_INFO" | jq -r '.head.sha')
BASE_REF=$(echo "$PR_INFO" | jq -r '.base.ref')
git fetch origin "refs/pull/${{ inputs.pr_number }}/head"
git checkout "$HEAD_SHA"
echo "PR_BASE_REF=$BASE_REF" >> $GITHUB_ENV
else
echo "PR_BASE_REF=${{ github.base_ref }}" >> $GITHUB_ENV
fi
+117
View File
@@ -0,0 +1,117 @@
name: 'Gather PR Diff'
description: 'Fetch PR diff, changed file list, and statistics via GitHub API'
inputs:
pr_number:
description: 'PR number (required for workflow_dispatch triggers)'
required: false
default: ''
outputs:
skip:
description: 'Whether to skip processing (empty diff)'
value: '${{ steps.gather.outputs.skip }}'
pr_number:
description: 'Resolved PR number'
value: '${{ steps.gather.outputs.pr_number }}'
additions:
description: 'Total lines added'
value: '${{ steps.gather.outputs.additions }}'
deletions:
description: 'Total lines deleted'
value: '${{ steps.gather.outputs.deletions }}'
total_lines:
description: 'Total lines changed (additions + deletions)'
value: '${{ steps.gather.outputs.total_lines }}'
file_count:
description: 'Number of changed files'
value: '${{ steps.gather.outputs.file_count }}'
diff_truncated:
description: 'Whether the diff was truncated'
value: '${{ steps.gather.outputs.diff_truncated }}'
runs:
using: 'composite'
steps:
- id: 'gather'
uses: 'actions/github-script@v7'
env:
INPUT_PR_NUMBER: '${{ inputs.pr_number }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
// Support both PR event trigger and manual workflow_dispatch
const prNumber = context.payload.pull_request?.number
|| Number(process.env.INPUT_PR_NUMBER)
|| null;
if (!prNumber) {
core.setFailed('No PR number found. Provide pr_number input for manual trigger.');
return;
}
core.setOutput('pr_number', String(prNumber));
// Fetch diff (returns raw text when using diff media type)
const { data: diff } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
mediaType: { format: 'diff' },
});
if (!diff || String(diff).trim().length === 0) {
core.warning('PR has an empty diff, skipping');
core.setOutput('skip', 'true');
return;
}
// Fetch all changed files (paginated)
const allFiles = [];
let page = 1;
while (true) {
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
allFiles.push(...files);
if (files.length < 100) break;
page++;
}
const additions = allFiles.reduce((s, f) => s + f.additions, 0);
const deletions = allFiles.reduce((s, f) => s + f.deletions, 0);
const totalLines = additions + deletions;
// Truncate diff to 100K characters
const MAX_DIFF = 100000;
const diffStr = String(diff);
const diffTruncated = diffStr.length > MAX_DIFF;
const truncatedDiff = diffTruncated
? diffStr.substring(0, MAX_DIFF) + '\n\n... [diff truncated at 100K chars]'
: diffStr;
// Build compact file list
const fileList = allFiles.map(f => ({
filename: f.filename,
status: f.status,
additions: f.additions,
deletions: f.deletions,
}));
// Write large data to temp files (avoid output size limits)
fs.writeFileSync(`${tmpDir}/pr_diff.txt`, truncatedDiff);
fs.writeFileSync(`${tmpDir}/file_list.json`, JSON.stringify(fileList));
// Only small values as step outputs
core.setOutput('skip', 'false');
core.setOutput('additions', String(additions));
core.setOutput('deletions', String(deletions));
core.setOutput('total_lines', String(totalLines));
core.setOutput('file_count', String(allFiles.length));
core.setOutput('diff_truncated', String(diffTruncated));
@@ -0,0 +1,78 @@
name: 'Read File Contents'
description: 'Read changed file contents for cross-file analysis. Requires checkout and gather-pr-diff to have run first.'
outputs:
contents_truncated:
description: 'Whether file contents were truncated'
value: '${{ steps.read.outputs.contents_truncated }}'
runs:
using: 'composite'
steps:
- id: 'read'
uses: 'actions/github-script@v7'
with:
script: |
const fs = require('fs');
const path = require('path');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
const fileList = JSON.parse(fs.readFileSync(`${tmpDir}/file_list.json`, 'utf-8'));
const SKIP_PATTERNS = [
/package-lock\.json$/,
/yarn\.lock$/,
/pnpm-lock\.yaml$/,
/\.lock$/,
/\.png$/i, /\.jpg$/i, /\.jpeg$/i, /\.gif$/i, /\.svg$/i, /\.ico$/i, /\.webp$/i,
/\.woff2?$/i, /\.ttf$/i, /\.eot$/i, /\.otf$/i,
/\.mp3$/i, /\.mp4$/i, /\.wav$/i, /\.ogg$/i, /\.webm$/i,
/\.zip$/i, /\.tar$/i, /\.gz$/i, /\.br$/i,
/\.pdf$/i, /\.wasm$/i,
/\.map$/,
/dist\//,
/\.min\./,
/node_modules\//,
];
// Priority scoring: lower = more important, read first
function filePriority(filename) {
if (/^src\/(process|agent|webserver)\//.test(filename)) return 0;
if (/^src\/channels\//.test(filename)) return 1;
if (/^src\/common\//.test(filename)) return 2;
if (/^src\/worker\//.test(filename)) return 3;
if (/^src\/renderer\//.test(filename)) return 4;
if (/\.(ts|tsx|js|jsx)$/.test(filename)) return 5;
return 6;
}
const MAX_CONTENT = 80000;
let totalSize = 0;
const contents = [];
const sortedFiles = fileList
.filter(f => f.status !== 'removed')
.filter(f => !SKIP_PATTERNS.some(p => p.test(f.filename)))
.sort((a, b) => filePriority(a.filename) - filePriority(b.filename));
for (const file of sortedFiles) {
if (totalSize >= MAX_CONTENT) {
contents.push(`\n--- [remaining files omitted, ${MAX_CONTENT / 1000}K content limit reached] ---`);
break;
}
const filePath = path.join(process.cwd(), file.filename);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const remaining = MAX_CONTENT - totalSize;
const truncated = content.length > remaining
? content.substring(0, remaining) + '\n... [file truncated]'
: content;
contents.push(`=== FILE: ${file.filename} ===\n${truncated}\n`);
totalSize += truncated.length;
} catch {
// File might not exist in checkout (e.g. binary or renamed from)
}
}
const contentsTruncated = totalSize >= MAX_CONTENT;
fs.writeFileSync(`${tmpDir}/file_contents.txt`, contents.join('\n'));
core.setOutput('contents_truncated', String(contentsTruncated));
+61
View File
@@ -0,0 +1,61 @@
# Pull Request
> Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting. PRs that ignore the rules below may be closed and asked to resubmit.
## Description
<!-- Provide a clear and concise description of what this PR does and why. -->
## Related Issues
<!-- Link related issues. "Closes #123" / "Fixes #123" will auto-close the issue on merge. -->
- Closes #
## Type of Change
- [ ] `fix` — Bug fix (non-breaking change which fixes an issue)
- [ ] `feat` — New feature (non-breaking change which adds functionality)
- [ ] `perf` — Performance improvement
- [ ] `refactor` — Code restructuring (no behavior change)
- [ ] Breaking change (fix or feature that would break existing functionality)
- [ ] `docs` — Documentation update
## Atomic PR Checklist (Rule 1)
- [ ] This PR contains **exactly one** feature or bug fix that cannot be further decomposed
- [ ] The PR title follows Conventional Commit format: `<type>(<scope>): <subject>` (English)
## Local Checks (Rule 3)
<!-- Run these before pushing — CI will reject the PR if they fail. -->
- [ ] `bun run format` — formatting passes
- [ ] `bun run lint` — no lint errors (skip if no `.ts`/`.tsx` changed)
- [ ] `bunx tsc --noEmit` — no type errors (skip if no `.ts`/`.tsx` changed)
- [ ] `bunx vitest run` — tests pass
- [ ] i18n validated (`bun run i18n:types` + `node scripts/check-i18n.js`) — only if `src/renderer/`, `locales/`, or `src/common/config/i18n/` changed; N/A otherwise
- [ ] New/changed user-facing text uses i18n keys (no hardcoded strings)
## Runtime Verification
<!-- Which platforms did you actually run and verify on? -->
- [ ] Verified on macOS
- [ ] Verified on Windows
- [ ] Verified on Linux
- [ ] I have performed a self-review of my own code
## Screenshots
<!-- If applicable, add screenshots or recordings to help explain your changes. -->
## Additional Context
<!-- Add any other context about the pull request here. -->
---
<!-- Commits and PR titles must NOT contain AI signatures (Co-Authored-By, "Generated with", etc.). -->
**Thank you for contributing to AionUi! 🎉**
+166
View File
@@ -0,0 +1,166 @@
# GPT Workflows
本项目使用 GPT 驱动的 GitHub Actions 工作流辅助 PR 审查。
## 架构概览
```
.github/
├── actions/ # 公共 Composite Actions
│ ├── gather-pr-diff/action.yml # 收集 PR diff 和变更文件列表
│ ├── read-file-contents/action.yml # 按优先级读取变更文件内容
│ └── call-openai/action.yml # 调用 OpenAI API(含重试逻辑)
└── workflows/
├── gpt-review.yml # 代码质量审查
├── gpt-pr-assessment.yml # PR 价值评估
└── pr-checks.yml # PR 检查入口(触发 gpt-review + gpt-pr-assessment
```
## 两个 GPT 工作流对比
| | GPT Review | GPT PR Assessment |
| ------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **目的** | 代码质量审查(bug、安全、性能) | 维护者价值评估(合并优先级、风险) |
| **角色** | 代码审查专家 | 项目维护者 / 技术负责人 |
| **触发** | PR 创建时自动触发(via pr-checks.yml+ 手动触发(workflow_dispatch | 外部贡献者 PR 自动触发(via pr-checks.yml+ 手动触发(workflow_dispatch |
| **额外数据** | 无 | 关联 Issue 内容 |
| **输出方式** | PR ReviewcreateReview | Issue Comment(可更新,不重复创建) |
| **输出模板** | 按严重性分级的问题列表 | 7 维度结构化评估报告 |
## 公共 Actions
三个 Composite Action 封装了两个工作流的公共逻辑,避免重复代码:
### `gather-pr-diff`
收集 PR 的 diff 和变更文件列表。
- **输入**: `pr_number`(手动触发时需要)
- **输出**: `skip`, `pr_number`, `additions`, `deletions`, `total_lines`, `file_count`, `diff_truncated`
- **临时文件**: `pr_diff.txt`, `file_list.json`(写入 `RUNNER_TEMP`
### `read-file-contents`
按优先级顺序读取变更文件的完整内容,供 GPT 做跨文件分析。
- **输出**: `contents_truncated`
- **临时文件**: `file_contents.txt`(写入 `RUNNER_TEMP`
- **前置条件**: 需要先运行 `checkout``gather-pr-diff`
- **限制**: 跳过锁文件/二进制文件,总内容上限 80K 字符
文件读取优先级(由高到低):
1. `packages/desktop/src/process/`, `packages/desktop/src/process/agent/`, `packages/desktop/src/process/webserver/` — 核心后端
2. `packages/desktop/src/process/channels/` — Agent 通信
3. `packages/desktop/src/common/` — 公共模块
4. `packages/desktop/src/process/worker/` — Worker 进程
5. `packages/desktop/src/renderer/` — 前端 UI
6. 其他 `.ts/.tsx/.js/.jsx` 文件
7. 其余文件
### `call-openai`
调用 OpenAI Chat Completions API,包含自动重试、截断提示和长度限制处理。
- **输入**: `openai_api_key`, `output_file`, `diff_truncated`, `contents_truncated`
- **临时文件读取**: `system_prompt.txt`, `user_prompt.txt`(从 `RUNNER_TEMP` 读取)
- **临时文件写入**: `{output_file}`(写入 `RUNNER_TEMP`
- **模型**: `gpt-5.2`
- **重试策略**: 最多 2 次重试,指数退避(429/5xx 状态码和网络错误)
## 数据流
```
┌─────────────────────┐
│ gather-pr-diff │
│ (GitHub API) │
└────────┬────────────┘
pr_diff.txt, file_list.json
┌──────────────┼──────────────┐
│ │ │
▼ │ ▼
┌────────────────┐ │ ┌───────────────────┐
│ read-file- │ │ │ fetch PR metadata │
│ contents │ │ │ + linked issues │
│ (本地文件系统) │ │ │ (assessment only) │
└───────┬────────┘ │ └────────┬──────────┘
│ │ │
file_contents.txt │ pr_meta.json,
│ │ linked_issues.json
└──────┬───────┘ │
│ │
▼ │
┌──────────────────┐ │
│ Construct GPT │◄───────────┘
│ Prompts │
│ (各工作流自定义) │
└────────┬─────────┘
system_prompt.txt, user_prompt.txt
┌──────────────────┐
│ call-openai │
│ (OpenAI API) │
└────────┬─────────┘
{output_file}
┌──────────────────┐
│ Post Comment │
│ (各工作流自定义) │
└──────────────────┘
```
## 语言检测
两个工作流都会自动检测 PR 标题和描述的语言,并使用相同语言输出回复:
- PR 内容为中文 → 中文评论
- PR 内容为英文 → 英文评论
- 混合或无法判断 → 默认英文
## 使用方式
### GPT Review(自动 + 手动触发)
**自动触发**:通过 `pr-checks.yml` 在 PR 首次提交时自动触发,无需手动操作。
**手动触发**
1. 进入 GitHub 仓库 → Actions 页面
2. 左侧选择 **GPT Review**
3. 点击 **Run workflow**
4. 输入 PR number
5. 等待执行完成,审查结果将以 PR Review 形式出现
### GPT PR Assessment(自动 + 手动触发)
**自动触发**:当非项目成员(即 `author_association` 既不是 `OWNER` 也不是 `MEMBER`)首次提交 PR 时,`pr-checks.yml` 会在代码质量检查通过后自动触发评估。
**手动触发**
1. 进入 GitHub 仓库 → Actions 页面
2. 左侧选择 **GPT PR Assessment**
3. 点击 **Run workflow**
4. 输入 PR number
5. 等待执行完成,评估报告将作为评论出现在 PR 中
重复对同一 PR 触发时,评论会被**更新**而非重复创建。
## Secrets 配置
| Secret | 用途 |
| ---------------- | ----------------------------------- |
| `OPENAI_API_KEY` | OpenAI API 访问密钥,两个工作流共用 |
## 修改指南
- **更换模型**: 修改 `.github/actions/call-openai/action.yml` 中的 `model` 字段
- **调整重试逻辑**: 修改 `.github/actions/call-openai/action.yml` 中的 `callOpenAI` 函数
- **修改文件优先级**: 修改 `.github/actions/read-file-contents/action.yml` 中的 `filePriority` 函数
- **修改 Review prompt**: 修改 `gpt-review.yml` 中的 "Construct GPT prompts" step
- **修改 Assessment prompt**: 修改 `gpt-pr-assessment.yml` 中的 "Construct GPT prompts" step
+687
View File
@@ -0,0 +1,687 @@
name: Build Pipeline (Reusable)
on:
workflow_call:
inputs:
matrix:
description: 'Build matrix as JSON string (passed via fromJSON in caller)'
type: string
required: true
ref:
description: 'Git ref to checkout (leave empty to use the triggering commit)'
type: string
default: ''
skip_code_quality:
description: 'Skip code quality checks (faster builds for debugging)'
type: boolean
default: false
append_commit_hash:
description: 'Append short commit hash to artifact names'
type: boolean
default: false
upload_installers_only:
description: 'Only upload primary installers (exe/msi/dmg/deb), skip zip/yml/blockmap'
type: boolean
default: false
aioncore_run_id:
description: 'Optional AionCore Manual Build workflow run id to bundle instead of the pinned release'
type: string
default: ''
env:
BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/'
AIONUI_HUB_TAG: 'dist-ebc6a2b'
jobs:
code-quality:
name: Code Quality
runs-on: ubuntu-latest
if: ${{ !inputs.skip_code_quality }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: bun run postinstall || true
- name: Run ESLint
run: bun run lint
- name: Check Prettier formatting
run: bun run format:check
- name: TypeScript type check
run: bunx tsc --noEmit
- name: Run unit tests
run: bunx vitest run
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
needs: code-quality
if: ${{ always() && (inputs.skip_code_quality || needs.code-quality.result == 'success') }}
outputs:
commit-short: ${{ steps.commit.outputs.short }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.matrix) }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Get commit hash
id: commit
shell: bash
run: |
SHORT=$(git rev-parse --short HEAD)
echo "short=$SHORT" >> $GITHUB_OUTPUT
echo "Commit: $SHORT"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Restore Electron/Electron-Builder caches before any build-related install
- name: Cache Electron artifacts
id: electron-cache
uses: actions/cache@v4
with:
path: |
${{ runner.temp }}/.cache/electron
${{ runner.temp }}/.cache/electron-builder
~/.cache/electron
~/.cache/electron-builder
${{ env.LOCALAPPDATA }}\electron-builder\Cache
key: electron-cache-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('package.json', 'bun.lock') }}
restore-keys: |
electron-cache-${{ matrix.platform }}-${{ matrix.arch }}-
electron-cache-${{ matrix.platform }}-
- name: Cache status
run: echo "electron-cache-hit=${{ steps.electron-cache.outputs.cache-hit }}"
- name: Install dependencies
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: bun install --frozen-lockfile
- name: Run postinstall
run: bun run postinstall || true
- name: Setup Windows native dependencies (for Windows only)
if: startsWith(matrix.platform, 'windows')
run: |
bun install --global node-gyp
- name: Install MSVC ARM64 toolchain (Windows ARM64 only)
if: matrix.platform == 'windows-arm64'
continue-on-error: true
uses: nick-fields/retry@v3
with:
timeout_minutes: 20
max_attempts: 3
retry_wait_seconds: 60
shell: pwsh
command: |
choco install visualstudio2022buildtools --yes --params "'--add Microsoft.VisualStudio.Component.VC.Tools.ARM64 --add Microsoft.VisualStudio.Component.Windows11SDK.22000 --includeRecommended --includeOptional'"
- name: Setup Windows build environment (for Windows only)
if: startsWith(matrix.platform, 'windows')
run: |
echo "Setting Windows SDK version for ConPty support"
echo "WindowsTargetPlatformVersion=10.0.19041.0" >> $env:GITHUB_ENV
- name: Setup MSBuild (for Windows only)
if: startsWith(matrix.platform, 'windows')
uses: microsoft/setup-msbuild@v2
with:
vs-version: '17.0'
- name: Rebuild native modules for Electron
if: "!startsWith(matrix.platform, 'windows')"
run: bunx electron-builder install-app-deps
env:
npm_config_runtime: electron
npm_config_disturl: https://electronjs.org/headers
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
- name: Print build configuration
shell: bash
run: |
echo "=========================================="
echo "BUILD CONFIGURATION"
echo "=========================================="
echo "Platform: ${{ matrix.platform }}"
echo "Target Architecture: ${{ matrix.arch }}"
echo "Build Command: ${{ matrix.command }}"
echo "Runner OS: ${{ runner.os }}"
echo "Runner Arch: ${{ runner.arch }}"
echo "Ref: ${{ inputs.ref || '(default)' }}"
echo "Commit: ${{ steps.commit.outputs.short }}"
echo "=========================================="
- name: Resolve Sentry release name
shell: bash
run: |
set -euo pipefail
VERSION=$(node -p "require('./package.json').version")
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
RELEASE_NAME="${GITHUB_REF_NAME}"
elif [[ "$GITHUB_REF_NAME" == "dev" ]]; then
RELEASE_NAME="v${VERSION}-dev-${{ steps.commit.outputs.short }}"
else
RELEASE_NAME="v${VERSION}-${{ steps.commit.outputs.short }}"
fi
echo "SENTRY_RELEASE=$RELEASE_NAME" >> "$GITHUB_ENV"
echo "Sentry release: $RELEASE_NAME"
- name: Configure Sentry source map upload owner
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.platform }}" == "linux-x64" ]]; then
echo "SENTRY_UPLOAD_SOURCE_MAPS=true" >> "$GITHUB_ENV"
echo "Sentry source maps will be uploaded by linux-x64"
else
echo "SENTRY_UPLOAD_SOURCE_MAPS=false" >> "$GITHUB_ENV"
echo "Sentry source map upload skipped for ${{ matrix.platform }}"
fi
- name: Validate Sentry source map upload configuration
if: matrix.platform == 'linux-x64'
shell: bash
run: |
set -euo pipefail
missing=0
for name in SENTRY_AUTH_TOKEN SENTRY_ORG SENTRY_PROJECT SENTRY_RELEASE; do
if [ -z "${!name:-}" ]; then
echo "::error title=Sentry configuration missing::$name is required for desktop source map uploads"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
exit 1
fi
echo "Sentry source map upload configured for $SENTRY_ORG/$SENTRY_PROJECT ($SENTRY_RELEASE)"
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
# macOS code signing certificate installation
- name: Setup macOS code signing (macOS only)
if: startsWith(matrix.platform, 'macos')
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD || 'temp-keychain-password' }}
run: |
if [ -n "$BUILD_CERTIFICATE_BASE64" ]; then
echo "Installing code signing certificate..."
# Create certificate file
echo $BUILD_CERTIFICATE_BASE64 | base64 --decode > certificate.p12
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up certificate file
rm certificate.p12
echo "Certificate imported successfully"
else
echo "No certificate provided - building unsigned application"
echo "To enable code signing, add BUILD_CERTIFICATE_BASE64 and P12_PASSWORD secrets"
fi
- name: Install Linux dependencies
if: startsWith(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y build-essential python3 python3-pip pkg-config libsqlite3-dev fakeroot dpkg-dev rpm libnss3-dev libatk-bridge2.0-dev libdrm2 libxkbcommon-dev libxss1 libatspi2.0-dev libgtk-3-dev libxrandr2 libasound2-dev
- name: Rebuild native modules for Electron (Windows)
if: startsWith(matrix.platform, 'windows')
shell: pwsh
continue-on-error: false
run: |
$arch = "${{ matrix.arch }}"
$electronVer = (node -p "require('./package.json').devDependencies.electron.replace(/[\^~]/g, '')").Trim()
Write-Host "=========================================="
Write-Host "Rebuilding native modules for Electron $electronVer ($arch)"
Write-Host "=========================================="
# Strategy: Try prebuild-install first (faster, more reliable)
# Fallback to electron-rebuild only if prebuild-install fails
$success = $false
# Step 1: Try prebuild-install (downloads prebuilt binaries)
Write-Host ""
Write-Host "[Step 1] Trying prebuild-install..."
$prebuildResult = $null
try {
$prebuildResult = bun x --yes prebuild-install --runtime=electron --target=$electronVer --platform=win32 --arch=$arch --force 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ prebuild-install succeeded"
$success = $true
} else {
Write-Host "⚠️ prebuild-install exited with code $LASTEXITCODE"
}
} catch {
Write-Host "⚠️ prebuild-install failed: $_"
}
# Step 2: Verify or fallback to electron-rebuild
$sqliteNode = "node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if (-not (Test-Path $sqliteNode)) {
Write-Host ""
Write-Host "[Step 2] prebuild-install didn't produce binary, trying electron-rebuild..."
try {
bunx electron-rebuild -f -w better-sqlite3
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ electron-rebuild succeeded"
$success = $true
}
} catch {
Write-Host "❌ electron-rebuild failed: $_"
}
}
# Final verification
Write-Host ""
Write-Host "Verifying native modules..."
if (Test-Path $sqliteNode) {
$size = [math]::Round((Get-Item $sqliteNode).Length / 1MB, 1)
Write-Host "✅ better-sqlite3 found: $sqliteNode ($size MB)"
} else {
Write-Error "❌ better-sqlite3 native module not found at: $sqliteNode"
exit 1
}
Write-Host ""
Write-Host "=========================================="
Write-Host "✅ All critical native modules verified"
Write-Host "=========================================="
exit 0
env:
npm_config_runtime: electron
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
npm_config_disturl: https://electronjs.org/headers
# Don't force build_from_source - let prebuild-install try first
MSVS_VERSION: 2022
GYP_MSVS_VERSION: 2022
- name: Prepare aioncore binary
shell: bash
run: node scripts/prepareAioncore.js
env:
# Version is pinned in repo-root package.json (aioncoreVersion).
# Set AIONUI_BACKEND_VERSION here only to override for ad-hoc builds.
AIONUI_BACKEND_RUN_ID: ${{ inputs.aioncore_run_id }}
AIONUI_BACKEND_ARCH: ${{ matrix.arch }}
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build with electron-builder (Windows)
if: startsWith(matrix.platform, 'windows')
id: windows-build
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$Command = $env:WINDOWS_BUILD_COMMAND
$BuildFailed = $false
try {
Write-Host "Running Windows build command: $Command"
iex $Command
Write-Host "Windows build completed successfully"
} catch {
$BuildFailed = $true
Write-Warning "${{ matrix.platform }} build failed but will not block the workflow"
Write-Host "::notice title=Windows build skipped::${{ matrix.platform }} build failed (see logs above) but workflow is allowed to continue."
}
if ($BuildFailed) {
"result=failure" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
} else {
"result=success" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
}
env:
WINDOWS_BUILD_COMMAND: ${{ matrix.command }}
AIONUI_BACKEND_RUN_ID: ${{ inputs.aioncore_run_id }}
# Node.js memory limit for vite bundling (prevents OOM during build)
# Node.js 内存限制,防止 vite 打包时内存不足
NODE_OPTIONS: '--max-old-space-size=8192'
# Target arch / 目标架构
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
# Native module compilation / 原生模块编译
npm_config_runtime: electron
npm_config_disturl: https://electronjs.org/headers
npm_config_build_from_source: true
npm_config_cache: ${{ runner.temp }}/.npm
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
# Signing & IDs / 签名与应用 ID 配置
APP_ID: ${{ secrets.APP_ID }}
appleId: ${{ secrets.APPLE_ID }}
appleIdPassword: ${{ secrets.APPLE_ID_PASSWORD }}
teamId: ${{ secrets.TEAM_ID }}
identity: ${{ secrets.IDENTITY }}
CSC_NAME: ${{ secrets.IDENTITY }}
CSC_IDENTITY_AUTO_DISCOVERY: false
# Windows build flags / Windows 构建参数
MSVS_VERSION: 2022
GYP_MSVS_VERSION: 2022
WindowsTargetPlatformVersion: 10.0.19041.0
_WIN32_WINNT: 0x0A00
# Sentry DSN (injected at build time via vite define)
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
# CI flag & GitHub token / CI 标识与 GitHub Token
CI: true
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Clean up any stale disk images before DMG creation (macOS only)
# 清理可能残留的磁盘镜像,避免 hdiutil "Device not configured" 错误
- name: Clean up stale disk images (macOS only)
if: startsWith(matrix.platform, 'macos')
run: |
echo "Cleaning up any stale disk images..."
# Detach any mounted disk images
hdiutil info 2>/dev/null | grep '/dev/disk' | awk '{print $1}' | while read disk; do
echo "Detaching $disk..."
hdiutil detach "$disk" -force 2>/dev/null || true
done
# Clean up any temporary DMG files
rm -f /tmp/*.dmg 2>/dev/null || true
echo "Cleanup complete"
# macOS: Build with notarization - DMG failure = CI failure, notarization failure = warning only
# macOS: 构建并公证 - DMG 失败 = CI 失败,公证失败 = 仅警告
- name: Build with electron-builder (macOS)
if: startsWith(matrix.platform, 'macos')
id: macos-build
shell: bash
run: |
set +e # Handle errors manually
# Run build command
${{ matrix.command }} 2>&1 | tee "${RUNNER_TEMP}/build.log"
BUILD_EXIT_CODE=${PIPESTATUS[0]}
# Check if DMG was created (most important artifact)
DMG_EXISTS=false
if ls out/*.dmg 1>/dev/null 2>&1; then
DMG_EXISTS=true
echo "✅ DMG created successfully"
fi
# If build succeeded completely
if [ $BUILD_EXIT_CODE -eq 0 ]; then
echo "✅ Build completed successfully"
echo "notarization_status=success" >> $GITHUB_OUTPUT
exit 0
fi
# Build failed - check if it's just notarization failure
if [ "$DMG_EXISTS" = true ]; then
# DMG exists but build failed - likely notarization issue
if grep -qiE "notariz|staple" "${RUNNER_TEMP}/build.log"; then
echo "⚠️ DMG created but notarization failed"
echo "notarization_status=failed" >> $GITHUB_OUTPUT
echo "::warning title=Notarization Failed::DMG was built and signed successfully, but **notarization failed**."
echo "" >> $GITHUB_STEP_SUMMARY
echo "## ⚠️ Notarization Warning" >> $GITHUB_STEP_SUMMARY
echo "DMG was built and signed successfully, but **notarization failed**." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Users may see Gatekeeper warnings when opening the app for the first time." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To fix: Right-click the app → Open → Open" >> $GITHUB_STEP_SUMMARY
exit 0 # Allow CI to continue
fi
fi
# DMG doesn't exist or other fatal error - fail CI
echo "❌ Build failed - DMG was not created"
echo "notarization_status=build_failed" >> $GITHUB_OUTPUT
exit 1
env:
NODE_OPTIONS: '--max-old-space-size=8192'
AIONUI_BACKEND_RUN_ID: ${{ inputs.aioncore_run_id }}
npm_config_arch: ${{ matrix.arch }}
APP_ID: ${{ secrets.APP_ID }}
appleId: ${{ secrets.APPLE_ID }}
appleIdPassword: ${{ secrets.APPLE_ID_PASSWORD }}
teamId: ${{ secrets.TEAM_ID }}
identity: ${{ secrets.IDENTITY }}
CSC_NAME: ${{ secrets.IDENTITY }}
CSC_IDENTITY_AUTO_DISCOVERY: false
npm_config_cache: ${{ runner.temp }}/.npm
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
ELECTRON_MIRROR: ''
npm_config_disturl: https://electronjs.org/headers
npm_config_runtime: electron
npm_config_target_arch: ${{ matrix.arch }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
CI: true
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Linux: Standard build without special error handling
# Linux: 标准构建,无特殊错误处理
- name: Build with electron-builder (Linux)
if: startsWith(matrix.platform, 'linux')
uses: nick-fields/retry@v3
with:
timeout_minutes: 60
max_attempts: 2
retry_wait_seconds: 60
command: ${{ matrix.command }}
env:
NODE_OPTIONS: '--max-old-space-size=8192'
AIONUI_BACKEND_RUN_ID: ${{ inputs.aioncore_run_id }}
npm_config_arch: ${{ matrix.arch }}
npm_config_cache: ${{ runner.temp }}/.npm
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
ELECTRON_MIRROR: ''
npm_config_disturl: https://electronjs.org/headers
npm_config_runtime: electron
npm_config_target_arch: ${{ matrix.arch }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
CI: true
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS 钥匙串清理
- name: Clean up keychain (macOS only)
if: startsWith(matrix.platform, 'macos') && always()
run: |
if security list-keychains | grep -q build.keychain; then
security delete-keychain build.keychain
echo "Temporary keychain deleted"
fi
# macOS 构建失败通知
# macOS build failure notification
- name: Notify on macOS build failure
if: startsWith(matrix.platform, 'macos') && failure()
run: |
echo "::error title=macOS Build Failed::Build failed. Check the logs for details."
echo ""
echo "❌ macOS Build Failed"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Common causes:"
echo " • hdiutil/DMG creation failure (Device not configured)"
echo " • Code signing issues"
echo " • Notarization timeout"
echo " • Out of memory (OOM)"
echo ""
echo "Check the build logs above for the specific error."
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- name: List build artifacts
if: success() || failure()
shell: bash
run: |
echo "=========================================="
echo "BUILD ARTIFACTS"
echo "=========================================="
if [ -d "out" ]; then
ls -lh out/ || true
echo "=========================================="
echo "Total artifacts:"
find out/ -type f \( -name "*.exe" -o -name "*.msi" -o -name "*.dmg" -o -name "*.deb" -o -name "*.zip" -o -name "*.yml" \) -exec basename {} \; || true
else
echo "No out directory found!"
fi
# Verify desktop asar does not contain packages/web-cli/ contamination
# 验证桌面 asar 没有被 web-cli 代码污染
- name: Verify desktop asar isolation
if: >-
success() &&
(!startsWith(matrix.platform, 'windows') || steps.windows-build.outputs.result == 'success')
shell: bash
run: |
set -euo pipefail
ASAR=$(find out -type f -name 'app.asar' -not -path '*node_modules*' | head -n 1 || true)
if [ -z "$ASAR" ]; then
echo "::notice::No app.asar found for ${{ matrix.platform }}, skipping isolation check"
exit 0
fi
echo "Inspecting $ASAR for web-cli leakage..."
LEAK_COUNT=$(bunx @electron/asar list "$ASAR" 2>/dev/null | grep -cE '^/packages/web-cli(/|$)' || true)
if [ "$LEAK_COUNT" != "0" ]; then
echo "::error::Desktop asar contains $LEAK_COUNT web-cli entries:"
bunx @electron/asar list "$ASAR" | grep -E '^/packages/web-cli(/|$)' | head -20
exit 1
fi
echo "✅ asar isolation verified: no packages/web-cli entries"
# Upload artifacts when:
# - Build succeeded (all platforms)
# - macOS: notarization timeout but artifacts exist (handled in build step)
# - Windows: only if windows-build step succeeded
# 上传产物条件:
# - 构建成功(所有平台)
# - macOS: 公证超时但有产物(已在构建步骤中处理)
# - Windows: 仅当 windows-build 步骤成功
- name: Clean up non-installer artifacts
if: >-
inputs.upload_installers_only && success() &&
(!startsWith(matrix.platform, 'windows') || steps.windows-build.outputs.result == 'success')
shell: bash
run: |
echo "Removing non-installer files from out/ ..."
rm -f out/*.blockmap out/*.zip out/*.yml out/builder-debug.yml 2>/dev/null || true
echo "Remaining artifacts:"
ls -lh out/ || true
- name: Upload build artifacts
uses: actions/upload-artifact@v6
if: >-
success() &&
(!startsWith(matrix.platform, 'windows') || steps.windows-build.outputs.result == 'success')
with:
name: ${{ inputs.append_commit_hash && format('{0}-{1}', matrix.artifact-name, steps.commit.outputs.short) || matrix.artifact-name }}
path: |
out/*.exe
out/*.msi
out/*.dmg
out/*.deb
out/AionUi-*-mac-*.zip
out/*.yml
if-no-files-found: warn
retention-days: 7
build-summary:
name: Build Summary
runs-on: ubuntu-latest
needs: [code-quality, build]
if: always()
steps:
- name: Write build summary
env:
MATRIX: ${{ inputs.matrix }}
run: |
PLATFORMS=$(echo "$MATRIX" | jq -r '[.include[].platform] | join(", ")')
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Key | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-----|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Ref | \`${{ inputs.ref || '(default)' }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Commit | \`${{ needs.build.outputs.commit-short }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Platforms | $PLATFORMS |" >> $GITHUB_STEP_SUMMARY
echo "| Code Quality | ${{ needs.code-quality.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Build | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY
+314
View File
@@ -0,0 +1,314 @@
name: Build and Release
permissions:
contents: write
issues: read
pull-requests: read
actions: write
on:
push:
branches: [dev]
tags:
- '*'
env:
BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/'
jobs:
# 独立 code-quality job,供桌面构建和 web-cli 打包共用,避免重复跑
code-quality:
name: Code Quality
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/dev' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-'))
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: bun run postinstall || true
- name: Run ESLint
run: bun run lint
- name: Check Prettier formatting
run: bun run format:check
- name: TypeScript type check
run: bunx tsc --noEmit
- name: Run unit tests
run: bunx vitest run
build-pipeline:
name: Build Pipeline
uses: ./.github/workflows/_build-reusable.yml
needs: code-quality
# dev 分支或正式 tag 触发构建(排除 -dev- tag,避免重复构建)
if: needs.code-quality.result == 'success' && (github.ref == 'refs/heads/dev' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-')))
with:
skip_code_quality: true
matrix: >-
{"include":[
{"platform":"macos-arm64","os":"macos-14","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"},
{"platform":"macos-x64","os":"macos-14","command":"node scripts/build-with-builder.js x64 --mac --x64","artifact-name":"macos-build-x64","arch":"x64"},
{"platform":"windows-x64","os":"windows-2022","command":"node scripts/build-with-builder.js x64 --win --x64","artifact-name":"windows-build-x64","arch":"x64"},
{"platform":"windows-arm64","os":"windows-11-arm","command":"node scripts/build-with-builder.js arm64 --win --arm64","artifact-name":"windows-build-arm64","arch":"arm64"},
{"platform":"linux-x64","os":"ubuntu-latest","command":"node scripts/build-with-builder.js x64 --linux --x64","artifact-name":"linux-build-x64","arch":"x64"},
{"platform":"linux-arm64","os":"ubuntu-24.04-arm","command":"node scripts/build-with-builder.js arm64 --linux --arm64","artifact-name":"linux-build-arm64","arch":"arm64"}
]}
secrets: inherit
pack-web-cli:
name: Pack Web CLI
uses: ./.github/workflows/pack-web-cli.yml
needs: code-quality
# 与桌面构建同频触发:dev 分支或正式 tag(排除 -dev- tag
if: needs.code-quality.result == 'success' && (github.ref == 'refs/heads/dev' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-')))
with:
ref: ''
append_commit_hash: false
skip_code_quality: true
secrets: inherit
# 自动重试 workflow(当构建失败时)
auto-retry-workflow:
name: Auto Retry on Build Failure
runs-on: ubuntu-latest
needs: build-pipeline
# 关键:只在首次失败时触发,避免无限循环
if: |
failure() &&
github.run_attempt == 1 &&
(github.event_name == 'push' || github.event_name == 'schedule')
steps:
- name: Log retry information
run: |
echo "=========================================="
echo "🔄 Auto retry triggered (first failure)"
echo "=========================================="
echo "Build failed on first attempt, preparing auto retry..."
echo "Current attempt: ${{ github.run_attempt }}"
echo "Wait strategy: 5 minutes cooldown before retry"
echo "=========================================="
- name: Wait before retry (5 min cooldown)
run: |
echo "⏳ Waiting 5 minutes before retry..."
echo "Start: $(date)"
sleep 300
echo "End: $(date)"
echo "Triggering retry..."
- name: Trigger workflow rerun
run: |
echo "🔄 Triggering full workflow rerun (attempt 2)..."
# Use re-run API (not rerun-failed-jobs, to avoid loops)
response=$(curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-w "\n%{http_code}" \
https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/rerun)
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" = "201" ]; then
echo ""
echo "✅ Retry triggered successfully"
echo "This will be attempt 2"
echo "Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
else
echo ""
echo "❌ Retry trigger failed, HTTP status: $http_code"
echo "Response:"
echo "$response" | head -n-1
exit 1
fi
# 自动创建tag(仅 dev 分支推送时)
create-tag:
name: Create Tag from Branch
runs-on: ubuntu-latest
needs: [build-pipeline]
if: success() && github.ref == 'refs/heads/dev'
outputs:
tag_name: ${{ steps.create_tag.outputs.tag_name }}
is_dev: ${{ steps.create_tag.outputs.is_dev }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
# GH_TOKEN is a PAT with `repo` + `workflow` scopes — required because
# tag pushes whose reachable history contains .github/workflows/
# changes are rejected for the built-in GITHUB_TOKEN (lacks the
# `workflow` scope, which GITHUB_TOKEN can never grant).
token: ${{ secrets.GH_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Create and push tag
id: create_tag
run: |
VERSION=$(node -p "require('./package.json').version")
COMMIT_SHORT=$(git rev-parse --short HEAD)
BRANCH_NAME=${GITHUB_REF#refs/heads/}
# 根据分支确定 tag 格式
if [ "$BRANCH_NAME" = "dev" ]; then
TAG_NAME="v${VERSION}-dev-${COMMIT_SHORT}"
IS_DEV="true"
echo "🔧 Development release: $TAG_NAME"
else
TAG_NAME="v$VERSION"
IS_DEV="false"
echo "🚀 Production release: $TAG_NAME"
fi
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
echo "is_dev=$IS_DEV" >> $GITHUB_OUTPUT
# 检查远程tag是否已存在
if git ls-remote --tags origin | grep -q "refs/tags/$TAG_NAME$"; then
if [ "$IS_DEV" = "false" ]; then
# Main 分支:需要递增版本并修改 package.json
echo "⚠️ Tag $TAG_NAME already exists, auto-incrementing version..."
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "📝 Updating package.json version to $NEW_VERSION"
# 更新 package.json 版本号
bun pm version $NEW_VERSION
# 配置git用户信息
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# 提交版本更新
git add package.json bun.lock
git commit -m "chore: bump version to $NEW_VERSION"
git remote set-url origin https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git
git push origin $BRANCH_NAME
# 获取新的 commit ID
COMMIT_SHORT=$(git rev-parse --short HEAD)
TAG_NAME="v${NEW_VERSION}-${COMMIT_SHORT}"
echo "🚀 New tag with updated version: $TAG_NAME"
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
else
TAG_NAME="v${VERSION}-${COMMIT_SHORT}"
echo "⚠️ Fallback: creating tag with commit ID: $TAG_NAME"
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
fi
else
# Dev 分支 tag 已存在则失败(不应该发生)
echo "⚠️ Dev tag $TAG_NAME already exists, this shouldn't happen"
exit 1
fi
fi
# 配置git用户信息(如果前面没配置)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# 创建并推送tag
echo "Creating tag: $TAG_NAME"
git tag $TAG_NAME
git remote set-url origin https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git
git push origin $TAG_NAME
echo "✅ Successfully created and pushed tag: $TAG_NAME"
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# 发布到 GitHub Releases
release:
name: Create Release
runs-on: ubuntu-latest
needs: [build-pipeline, create-tag, pack-web-cli]
# Tag 推送时 create-tag 会被跳过,所以需要检查两种情况(排除 -dev- tag 避免重复)
if: always() && needs.build-pipeline.result == 'success' && needs.pack-web-cli.result == 'success' && (needs.create-tag.result == 'success' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-')))
# dev 分支使用 dev-release 环境,正式 tag 使用 release 环境
environment: ${{ needs.create-tag.outputs.is_dev == 'true' && 'dev-release' || 'release' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Get version for release
id: version
run: |
# 判断触发来源:正式 tag 推送 or dev 分支
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
# 正式 Tag 推送触发(-dev- tag 已被排除,不会到达这里)
TAG_NAME="${GITHUB_REF#refs/tags/}"
IS_DEV="false"
echo "🚀 Creating stable release from tag: $TAG_NAME"
else
# dev 分支触发,使用 create-tag job 的输出
TAG_NAME="${{ needs.create-tag.outputs.tag_name }}"
IS_DEV="${{ needs.create-tag.outputs.is_dev }}"
echo "🔧 Creating development release: $TAG_NAME"
fi
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
echo "is_dev=$IS_DEV" >> $GITHUB_OUTPUT
- name: Download all build artifacts
uses: actions/download-artifact@v7
with:
path: build-artifacts
- name: Prepare release assets (normalize updater metadata)
shell: bash
run: bash scripts/prepare-release-assets.sh build-artifacts release-assets
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag_name }}
name: ${{ steps.version.outputs.is_dev == 'true' && format('Development Build {0}', steps.version.outputs.tag_name) || steps.version.outputs.tag_name }}
files: |
release-assets/**/*.exe
release-assets/**/*.msi
release-assets/**/*.dmg
release-assets/**/*.deb
release-assets/**/*.zip
release-assets/**/*.yml
release-assets/**/*.tar.gz
release-assets/**/*.sha256
release-assets/install-web.sh
generate_release_notes: true
draft: true
prerelease: ${{ steps.version.outputs.is_dev == 'true' || contains(steps.version.outputs.tag_name, 'beta') || contains(steps.version.outputs.tag_name, 'alpha') || contains(steps.version.outputs.tag_name, 'rc') }}
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
+93
View File
@@ -0,0 +1,93 @@
name: '🔨 Manual Build'
permissions:
contents: write
actions: write
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to build from'
required: true
default: 'main'
type: string
platform:
description: 'Platform to build'
required: true
type: choice
options:
- macos-arm64
- macos-x64
- windows-x64
- windows-arm64
- linux-x64
- linux-arm64
- all
default: 'macos-arm64'
skip_code_quality:
description: 'Skip code quality checks (faster builds for debugging)'
required: false
type: boolean
default: false
aioncore_run_id:
description: 'Optional AionCore Manual Build workflow run id to bundle instead of the pinned release'
required: false
type: string
default: ''
env:
BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/'
jobs:
prepare-matrix:
name: Prepare Build Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Generate build matrix
id: set-matrix
shell: bash
run: |
PLATFORM="${{ inputs.platform }}"
MACOS_ARM64='{"platform":"macos-arm64","os":"macos-14","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"}'
MACOS_X64='{"platform":"macos-x64","os":"macos-14","command":"node scripts/build-with-builder.js x64 --mac --x64","artifact-name":"macos-build-x64","arch":"x64"}'
WINDOWS_X64='{"platform":"windows-x64","os":"windows-2022","command":"node scripts/build-with-builder.js x64 --win --x64","artifact-name":"windows-build-x64","arch":"x64"}'
WINDOWS_ARM64='{"platform":"windows-arm64","os":"windows-11-arm","command":"node scripts/build-with-builder.js arm64 --win --arm64","artifact-name":"windows-build-arm64","arch":"arm64"}'
LINUX_X64='{"platform":"linux-x64","os":"ubuntu-latest","command":"node scripts/build-with-builder.js x64 --linux --x64","artifact-name":"linux-build-x64","arch":"x64"}'
LINUX_ARM64='{"platform":"linux-arm64","os":"ubuntu-24.04-arm","command":"node scripts/build-with-builder.js arm64 --linux --arm64","artifact-name":"linux-build-arm64","arch":"arm64"}'
if [ "$PLATFORM" = "all" ]; then
MATRIX="{\"include\":[$MACOS_ARM64,$MACOS_X64,$WINDOWS_X64,$WINDOWS_ARM64,$LINUX_X64,$LINUX_ARM64]}"
elif [ "$PLATFORM" = "macos-arm64" ]; then
MATRIX="{\"include\":[$MACOS_ARM64]}"
elif [ "$PLATFORM" = "macos-x64" ]; then
MATRIX="{\"include\":[$MACOS_X64]}"
elif [ "$PLATFORM" = "windows-x64" ]; then
MATRIX="{\"include\":[$WINDOWS_X64]}"
elif [ "$PLATFORM" = "windows-arm64" ]; then
MATRIX="{\"include\":[$WINDOWS_ARM64]}"
elif [ "$PLATFORM" = "linux-x64" ]; then
MATRIX="{\"include\":[$LINUX_X64]}"
elif [ "$PLATFORM" = "linux-arm64" ]; then
MATRIX="{\"include\":[$LINUX_ARM64]}"
fi
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
echo "Generated matrix for platform '$PLATFORM':"
echo "$MATRIX" | python3 -m json.tool
build-pipeline:
needs: prepare-matrix
uses: ./.github/workflows/_build-reusable.yml
with:
matrix: ${{ needs.prepare-matrix.outputs.matrix }}
ref: ${{ inputs.branch }}
skip_code_quality: ${{ inputs.skip_code_quality }}
aioncore_run_id: ${{ inputs.aioncore_run_id }}
append_commit_hash: true
upload_installers_only: true
secrets: inherit
+132
View File
@@ -0,0 +1,132 @@
name: Verify Homebrew Cask
# The aionui cask is on Homebrew's autobump list — BrewTestBot automatically
# creates version-bump PRs every ~3 hours. This workflow only *verifies* that
# the cask has been updated, it no longer submits bump PRs itself.
on:
# Daily check at 08:00 UTC
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
inputs:
version:
description: 'Version to verify (e.g., 1.8.20). Leave empty to use latest release.'
required: false
type: string
permissions:
contents: read
jobs:
verify-cask:
name: Verify Homebrew Cask
runs-on: ubuntu-latest
steps:
- name: Get expected version
id: release
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
VERSION="${VERSION#v}"
else
# Fetch latest non-prerelease release tag
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName --jq '.tagName' 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
echo "::error::Could not determine latest release tag"
exit 1
fi
VERSION="${TAG#v}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Expected Homebrew cask version: $VERSION"
- name: Verify cask version
id: verify
run: |
EXPECTED="${{ steps.release.outputs.version }}"
# Fetch the cask file directly from the Homebrew repo
CASK_CONTENT=$(curl -fsSL "https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/a/aionui.rb" 2>/dev/null || echo "")
if [ -z "$CASK_CONTENT" ]; then
echo "::error::Failed to fetch cask file from Homebrew repo"
echo "status=fetch_failed" >> $GITHUB_OUTPUT
exit 1
fi
# Extract version from cask file
CASK_VERSION=$(echo "$CASK_CONTENT" | grep -oP 'version\s+"\K[^"]+' | head -1)
echo " Expected: $EXPECTED"
echo " Homebrew: $CASK_VERSION"
echo "cask_version=$CASK_VERSION" >> $GITHUB_OUTPUT
if [ "$CASK_VERSION" = "$EXPECTED" ]; then
echo "status=match" >> $GITHUB_OUTPUT
else
echo "status=mismatch" >> $GITHUB_OUTPUT
fi
- name: Check for pending PR
if: steps.verify.outputs.status == 'mismatch'
id: check_pr
env:
GH_TOKEN: ${{ github.token }}
run: |
EXPECTED="${{ steps.release.outputs.version }}"
# Search for open PRs updating aionui in homebrew-cask
PR_LIST=$(gh pr list --repo Homebrew/homebrew-cask \
--search "aionui $EXPECTED in:title" --state open \
--json number,title,url --limit 5 2>/dev/null || echo "[]")
PR_COUNT=$(echo "$PR_LIST" | jq length)
if [ "$PR_COUNT" -gt 0 ]; then
echo "Found pending PR(s):"
echo "$PR_LIST" | jq -r '.[] | " #\(.number) \(.title) — \(.url)"'
echo "status=pr_pending" >> $GITHUB_OUTPUT
else
echo "status=no_pr" >> $GITHUB_OUTPUT
fi
- name: Report result
if: always() && steps.release.outputs.version != ''
run: |
EXPECTED="${{ steps.release.outputs.version }}"
STATUS="${{ steps.verify.outputs.status }}"
CASK_VERSION="${{ steps.verify.outputs.cask_version }}"
PR_STATUS="${{ steps.check_pr.outputs.status }}"
echo ""
echo "======================================="
echo " Homebrew Cask Verification Report"
echo "======================================="
echo ""
if [ "$STATUS" = "match" ]; then
echo "Homebrew cask is up to date!"
echo " Version: $CASK_VERSION"
echo " Install: brew install --cask aionui"
elif [ "$STATUS" = "mismatch" ] && [ "$PR_STATUS" = "pr_pending" ]; then
echo "Homebrew bump PR is pending review."
echo " Current cask: $CASK_VERSION"
echo " Expected: $EXPECTED"
echo " BrewTestBot has created a PR, awaiting Homebrew maintainer merge."
elif [ "$STATUS" = "mismatch" ]; then
echo "::warning::Homebrew cask has NOT been updated to $EXPECTED (current: $CASK_VERSION)"
echo " Current cask: $CASK_VERSION"
echo " Expected: $EXPECTED"
echo ""
echo " If this persists, manually create a PR:"
echo " 1. Fork https://github.com/Homebrew/homebrew-cask"
echo " 2. Update Casks/a/aionui.rb with new version and SHA256"
echo " 3. Submit PR to Homebrew/homebrew-cask"
exit 1
else
echo "::error::Failed to verify — could not fetch cask file from Homebrew repo"
exit 1
fi
+389
View File
@@ -0,0 +1,389 @@
name: '📋 GPT PR Assessment'
on:
workflow_call:
inputs:
pr_number:
description: 'PR number to assess'
required: false
type: string
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to assess'
required: true
type: string
concurrency:
group: 'gpt-pr-assessment-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
jobs:
assess:
runs-on: 'ubuntu-latest'
timeout-minutes: 10
permissions:
contents: 'read'
pull-requests: 'write'
issues: 'read'
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@v4'
- name: 'Gather PR diff and changed files'
id: 'gather'
uses: './.github/actions/gather-pr-diff'
with:
pr_number: '${{ inputs.pr_number }}'
- name: 'Fetch PR metadata and linked issues'
id: 'metadata'
if: steps.gather.outputs.skip != 'true'
uses: 'actions/github-script@v7'
env:
INPUT_PR_NUMBER: '${{ inputs.pr_number }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
const prNumber = context.payload.pull_request?.number
|| Number(process.env.INPUT_PR_NUMBER);
// Fetch full PR data via API (needed for manual triggers too)
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const prMeta = {
title: pr.title || '',
body: pr.body || '',
author: pr.user.login || '',
labels: (pr.labels || []).map(l => l.name),
head_branch: pr.head.ref || '',
base_branch: pr.base.ref || '',
state: pr.state,
created_at: pr.created_at,
updated_at: pr.updated_at,
};
fs.writeFileSync(`${tmpDir}/pr_meta.json`, JSON.stringify(prMeta));
// Parse linked issues from PR body
const prBody = prMeta.body;
const linkedIssueNumbers = new Set();
const referencedIssueNumbers = new Set();
// Explicit linking keywords: Fixes #N, Closes #N, Resolves #N
const explicitPatterns = [
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi,
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi,
];
for (const pattern of explicitPatterns) {
let match;
while ((match = pattern.exec(prBody)) !== null) {
linkedIssueNumbers.add(Number(match[1]));
}
}
// General #N references (not already captured)
const refPattern = /#(\d+)/g;
let refMatch;
while ((refMatch = refPattern.exec(prBody)) !== null) {
const num = Number(refMatch[1]);
if (!linkedIssueNumbers.has(num)) {
referencedIssueNumbers.add(num);
}
}
// Fetch issue details, filtering out PRs
const EXPLICIT_BODY_LIMIT = 5000;
const REFERENCE_BODY_LIMIT = 3000;
async function fetchIssue(issueNum, bodyLimit) {
try {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNum,
});
// Skip if this is actually a PR (issues API returns PRs too)
if (issue.pull_request) {
return null;
}
const body = (issue.body || '').substring(0, bodyLimit);
const truncated = (issue.body || '').length > bodyLimit;
return {
number: issue.number,
title: issue.title,
state: issue.state,
labels: (issue.labels || []).map(l => typeof l === 'string' ? l : l.name),
body: truncated ? body + '\n... [truncated]' : body,
};
} catch (err) {
core.warning(`Failed to fetch issue #${issueNum}: ${err.message}`);
return null;
}
}
const linkedIssues = [];
for (const num of linkedIssueNumbers) {
const issue = await fetchIssue(num, EXPLICIT_BODY_LIMIT);
if (issue) linkedIssues.push({ ...issue, relation: 'explicit' });
}
for (const num of referencedIssueNumbers) {
const issue = await fetchIssue(num, REFERENCE_BODY_LIMIT);
if (issue) linkedIssues.push({ ...issue, relation: 'referenced' });
}
fs.writeFileSync(`${tmpDir}/linked_issues.json`, JSON.stringify(linkedIssues));
core.info(`Found ${linkedIssues.length} linked issue(s)`);
- name: 'Read changed file contents'
id: 'read_contents'
if: steps.gather.outputs.skip != 'true'
uses: './.github/actions/read-file-contents'
- name: 'Construct GPT prompts'
if: steps.gather.outputs.skip != 'true'
uses: 'actions/github-script@v7'
env:
FILE_COUNT: '${{ steps.gather.outputs.file_count }}'
ADDITIONS: '${{ steps.gather.outputs.additions }}'
DELETIONS: '${{ steps.gather.outputs.deletions }}'
TOTAL_LINES: '${{ steps.gather.outputs.total_lines }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
const prMeta = JSON.parse(fs.readFileSync(`${tmpDir}/pr_meta.json`, 'utf-8'));
const linkedIssues = JSON.parse(fs.readFileSync(`${tmpDir}/linked_issues.json`, 'utf-8'));
const diff = fs.readFileSync(`${tmpDir}/pr_diff.txt`, 'utf-8');
const fileListRaw = fs.readFileSync(`${tmpDir}/file_list.json`, 'utf-8');
let fileContents = '';
try {
fileContents = fs.readFileSync(`${tmpDir}/file_contents.txt`, 'utf-8');
} catch {
core.warning('Could not read file contents, proceeding with diff only');
}
const fileCount = process.env.FILE_COUNT || '0';
const additions = process.env.ADDITIONS || '0';
const deletions = process.env.DELETIONS || '0';
const totalLines = process.env.TOTAL_LINES || '0';
// Build linked issues section for user prompt
let issuesSection = '';
if (linkedIssues.length > 0) {
const issueTexts = linkedIssues.map(issue => {
const relation = issue.relation === 'explicit' ? 'Explicitly linked' : 'Referenced';
return [
`### Issue #${issue.number}: ${issue.title}`,
`- **Relation**: ${relation}`,
`- **State**: ${issue.state}`,
`- **Labels**: ${issue.labels.length > 0 ? issue.labels.join(', ') : 'None'}`,
'',
'**Body**:',
issue.body || '(No body)',
].join('\n');
});
issuesSection = [
'## Linked Issues',
'',
...issueTexts,
].join('\n');
} else {
issuesSection = [
'## Linked Issues',
'',
'No linked issues found. The PR does not reference any issues via "Fixes #N", "Closes #N", "Resolves #N", or "#N" patterns.',
'Please assess the PR based on its title, description, and code changes.',
].join('\n');
}
const systemPrompt = [
'You are a senior project maintainer and technical lead for the AionUi project — a cross-platform Electron desktop application that provides a unified AI agent graphical interface.',
'',
'Your role is to provide a structured PR assessment to help maintainers quickly evaluate the value, correctness, and merge priority of pull requests.',
'',
'## Tech Stack',
'- Electron 37.x + React 19.x + TypeScript 5.8.x (strict mode)',
'- Express 5.x (WebUI server), Better SQLite3 (local DB)',
'- Arco Design 2.x (UI), UnoCSS 66.x (atomic CSS), Monaco Editor 4.x',
'- Anthropic SDK, Google GenAI, OpenAI SDK, MCP SDK',
'- Webpack 6.x, Electron Forge 7.8.x',
'',
'## Architecture',
'- Multi-process: Main (Electron + DB + IPC), Renderer (React UI), Worker (background AI tasks)',
'- IPC via secure contextBridge isolation',
'- WebUI: Express + WebSocket + JWT auth',
'- Agent system: channels/, agent/ directories',
'',
'## Priority Definitions',
'',
'| Priority | Meaning | Examples |',
'|----------|---------|----------|',
'| P0 Critical | Must merge immediately | Crash fix, data loss, security vulnerability |',
'| P1 High | Should merge within days | Core bug fix, urgently needed feature |',
'| P2 Medium | Merge in regular cycle | Enhancement, minor bug, UX improvement |',
'| P3 Low | Merge when convenient | Cosmetic, docs, minor refactor |',
'',
'## Output Format',
'',
'**CRITICAL: Detect the language of the PR title and body. Write your ENTIRE assessment in that same language.** If the PR is in Chinese, write in Chinese. If in English, write in English. If mixed or unclear, default to English.',
'',
'Use this EXACT structure:',
'',
'# PR Assessment',
'',
'## Change Overview',
'[2-4 sentences summarizing what this PR does and why]',
'',
'## Linked Issue Analysis',
'[If linked issues exist]:',
'| Issue | Status | Addressed? |',
'|-------|--------|------------|',
'| #N: [title] | Open/Closed | Yes/Partially/No |',
'',
'[For each issue, analyze whether the PR actually solves the problem described]',
'',
'[If no linked issues]:',
'State that no issues are linked, then infer the PR\'s purpose and motivation from its title, description, and code changes.',
'',
'## Change Type',
'- **Type**: Bug Fix / New Feature / Enhancement / Refactor / Docs / Build / Test / ...',
'- **Scope**: Core / UI / Backend / Build / Config / ...',
'- **Breaking Changes**: Yes/No [explain if yes]',
'',
'## Merge Recommendation',
'One of: ✅ Recommend Merge / ⚠️ Conditional Merge / ❌ Do Not Merge',
'',
'**Rationale**: [Specific reasons for the recommendation]',
'',
'## Merge Priority',
'**Priority**: P0-P3 — Critical/High/Medium/Low',
'',
'**Rationale**: [Why this priority level]',
'',
'## Risk Assessment',
'| Risk | Level | Details |',
'|------|-------|---------|',
'| Regression risk | Low/Medium/High | ... |',
'| Security impact | None/Low/Medium/High | ... |',
'| Performance impact | None/Low/Medium/High | ... |',
'| Compatibility | None/Low/Medium/High | ... |',
'',
'## Reviewer Tips',
'- [Advice for human reviewers]',
'- [Key files or logic to focus on]',
'',
'## Rules',
'- Be objective and evidence-based. Base your assessment on actual code changes and issue content.',
'- Do NOT inflate risk or severity. If the PR is straightforward and safe, say so.',
'- Do NOT fabricate concerns. It is fine to have a short, positive assessment for clean PRs.',
'- For Linked Issue Analysis, carefully compare the issue description with the actual code changes to determine if the problem is truly addressed.',
'- Be specific in Reviewer Tips — point to exact files, functions, or patterns that need attention.',
'',
'## Footer',
'After the Reviewer Tips section, ALWAYS append this exact footer:',
'',
'---',
'',
'*🤖 This assessment was generated by AI and serves as a reference for maintainers. Please use your own judgment for final merge decisions.*',
].join('\n');
const userPrompt = [
'## Pull Request',
'',
`**Title**: ${prMeta.title}`,
`**Author**: ${prMeta.author}`,
`**Labels**: ${prMeta.labels.length > 0 ? prMeta.labels.join(', ') : 'None'}`,
`**Branch**: ${prMeta.head_branch} → ${prMeta.base_branch}`,
`**Stats**: ${fileCount} files changed, +${additions} -${deletions} (${totalLines} total lines)`,
'',
'**Description**:',
prMeta.body || '(No description provided)',
'',
issuesSection,
'',
'**Changed Files**:',
fileListRaw,
'',
'## Diff',
'',
diff,
'',
'## Full File Contents (for cross-file analysis)',
'',
fileContents || '(No file contents available)',
].join('\n');
fs.writeFileSync(`${tmpDir}/system_prompt.txt`, systemPrompt);
fs.writeFileSync(`${tmpDir}/user_prompt.txt`, userPrompt);
- name: 'Call GPT for PR assessment'
if: steps.gather.outputs.skip != 'true'
uses: './.github/actions/call-openai'
with:
openai_api_key: '${{ secrets.OPENAI_API_KEY }}'
output_file: 'assessment_body.txt'
diff_truncated: '${{ steps.gather.outputs.diff_truncated }}'
contents_truncated: '${{ steps.read_contents.outputs.contents_truncated }}'
- name: 'Post or update assessment comment'
if: steps.gather.outputs.skip != 'true' && success()
uses: 'actions/github-script@v7'
env:
INPUT_PR_NUMBER: '${{ inputs.pr_number }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
const assessmentBody = fs.readFileSync(`${tmpDir}/assessment_body.txt`, 'utf-8');
const prNumber = context.payload.pull_request?.number
|| Number(process.env.INPUT_PR_NUMBER);
const MARKER = '<!-- gpt-pr-assessment-bot -->';
const fullBody = `${MARKER}\n\n${assessmentBody}`;
// Check for existing assessment comment to update
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existingComment = comments.data.find(comment =>
comment.body.includes(MARKER)
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: fullBody,
});
core.info(`Updated existing assessment comment (id: ${existingComment.id}) on PR #${prNumber}`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: fullBody,
});
core.info(`Created new assessment comment on PR #${prNumber}`);
}
+294
View File
@@ -0,0 +1,294 @@
name: '🤖 GPT Review'
on:
workflow_call:
inputs:
pr_number:
description: 'PR number to review'
required: false
type: string
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: string
concurrency:
group: 'gpt-review-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
jobs:
review:
runs-on: 'ubuntu-latest'
timeout-minutes: 10
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@v4'
- name: 'Gather PR diff and changed files'
id: 'gather'
uses: './.github/actions/gather-pr-diff'
with:
pr_number: '${{ inputs.pr_number }}'
- name: 'Read changed file contents'
id: 'read_contents'
if: steps.gather.outputs.skip != 'true'
uses: './.github/actions/read-file-contents'
- name: 'Construct GPT prompts'
id: 'prompts'
if: steps.gather.outputs.skip != 'true'
uses: 'actions/github-script@v7'
env:
INPUT_PR_NUMBER: '${{ inputs.pr_number }}'
FILE_COUNT: '${{ steps.gather.outputs.file_count }}'
ADDITIONS: '${{ steps.gather.outputs.additions }}'
DELETIONS: '${{ steps.gather.outputs.deletions }}'
TOTAL_LINES: '${{ steps.gather.outputs.total_lines }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
// Read PR metadata: from event context or fetch via API for manual trigger
let prTitle, prBody, prAuthor;
if (context.payload.pull_request) {
prTitle = context.payload.pull_request.title || '';
prBody = context.payload.pull_request.body || '';
prAuthor = context.payload.pull_request.user.login || '';
} else {
const prNum = Number(process.env.INPUT_PR_NUMBER);
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNum,
});
prTitle = pr.title || '';
prBody = pr.body || '';
prAuthor = pr.user.login || '';
}
const fileCount = process.env.FILE_COUNT || '0';
const additions = process.env.ADDITIONS || '0';
const deletions = process.env.DELETIONS || '0';
const totalLines = process.env.TOTAL_LINES || '0';
// Read large data from temp files
const diff = fs.readFileSync(`${tmpDir}/pr_diff.txt`, 'utf-8');
const fileListRaw = fs.readFileSync(`${tmpDir}/file_list.json`, 'utf-8');
let fileContents = '';
try {
fileContents = fs.readFileSync(`${tmpDir}/file_contents.txt`, 'utf-8');
} catch {
core.warning('Could not read file contents, proceeding with diff only');
}
const systemPrompt = [
'You are a world-class code review expert for the AionUi project — a cross-platform Electron desktop application that provides a unified AI agent graphical interface.',
'',
'## Tech Stack',
'- Electron 37.x + React 19.x + TypeScript 5.8.x (strict mode)',
'- Express 5.x (WebUI server), Better SQLite3 (local DB)',
'- Arco Design 2.x (UI), UnoCSS 66.x (atomic CSS), Monaco Editor 4.x',
'- Anthropic SDK, Google GenAI, OpenAI SDK, MCP SDK',
'- Webpack 6.x, Electron Forge 7.8.x',
'',
'## Architecture',
'- Multi-process: Main (Electron + DB + IPC), Renderer (React UI), Worker (background AI tasks)',
'- IPC via secure contextBridge isolation',
'- WebUI: Express + WebSocket + JWT auth',
'- Agent system: channels/, agent/ directories',
'- Security-sensitive paths: packages/desktop/src/process/, packages/desktop/src/process/agent/, packages/desktop/src/process/webserver/auth/',
'',
'## Code Conventions',
'- TypeScript strict mode, prefer `type` over `interface`',
'- Functional React components only, hooks with `use*` prefix',
'- IMMUTABILITY: always create new objects, never mutate',
'- Path aliases: @/*, @process/*, @renderer/*, @worker/*',
'- UnoCSS atomic classes + CSS modules',
'- English code comments, conventional commits',
'',
'## ESLint Key Rules',
'- @typescript-eslint/consistent-type-definitions: prefer type',
'- @typescript-eslint/no-explicit-any: warn',
'- no-console: warn (no console.log in production code — flag any NEW console.log added by the PR)',
'- react-hooks/rules-of-hooks: error',
'- react-hooks/exhaustive-deps: warn',
'',
'## Review Dimensions (Priority Order)',
'1. **Correctness** — Logic errors, unhandled edge cases, race conditions, incorrect API usage',
'2. **Security** — Injection, insecure storage, access control, secrets exposure, OWASP Top 10',
'3. **Performance** — Bottlenecks, memory leaks, unnecessary computation, inefficient data structures',
'4. **Maintainability** — Readability, modularity, naming, adherence to project conventions',
'5. **Immutability** — Object mutations, array mutations, state mutations (CRITICAL for this project)',
'6. **Error Handling** — Missing try/catch, swallowed errors, unhelpful error messages',
'7. **Type Safety** — any usage, missing types, incorrect type assertions, unsafe casts',
'8. **Debug Hygiene** — New console.log/console.debug statements left in production code (flag as HIGH if clearly debug-only, MEDIUM if arguable)',
'',
'## Cross-File Analysis',
'You are provided with both the diff AND the full content of changed files. Use the full file content to:',
'- Trace function call chains across files',
'- Verify exported/imported symbols exist and are correct',
'- Check that cache/state management is consistent',
'- Identify dead code introduced by the changes',
'- Verify error handling propagation across module boundaries',
'',
'## Output Format',
'',
'**CRITICAL: Detect the language of the PR title and body. Write your ENTIRE review in that same language.** If the PR is in Chinese, write in Chinese. If in English, write in English. If mixed or unclear, default to English.',
'',
'Structure your review as a single comprehensive comment using this EXACT format:',
'',
'# Code Review',
'',
'## CRITICAL Issues',
'',
'### 1. [Issue Title]',
'',
'**File**: `path/to/file.ts:LINE-LINE`',
'',
'```typescript',
'// problematic code snippet',
'```',
'',
'**Problem**: [Detailed explanation of why this is critical]',
'',
'**Fix**: [Concrete fix suggestion with code if applicable]',
'',
'---',
'',
'## HIGH Issues',
'',
'### N. [Issue Title]',
'',
'**File**: `path/to/file.ts:LINE-LINE`',
'',
'**Problem**: [Explanation]',
'',
'**Fix**: [Suggestion]',
'',
'---',
'',
'## MEDIUM Issues',
'',
'### N. [Issue Title]',
'',
'**File**: `path/to/file.ts`',
'',
'[Description of the issue and recommendation]',
'',
'---',
'',
'## Summary',
'',
'| Level | Count |',
'|-------|-------|',
'| CRITICAL | N |',
'| HIGH | N |',
'| MEDIUM | N |',
'',
'## Rules',
'- ONLY report issues you are **highly confident** about. If you are unsure whether something is a real problem, DO NOT report it.',
'- **CRITICAL** means the code WILL break, crash, or cause data loss/security breach in production. Theoretical edge cases or defensive programming suggestions are NOT critical.',
'- **HIGH** means the code has a clear bug or significant problem that will likely manifest in normal usage. Stylistic preferences or "could be improved" suggestions are NOT high.',
'- **MEDIUM** is for genuine improvements, not hypothetical concerns.',
'- Do NOT inflate severity. If you cannot point to a specific, realistic scenario where the issue causes a failure, lower the severity or omit it.',
'- Do NOT fabricate issues to fill sections. It is perfectly fine — and preferred — to report fewer issues or none at all.',
'- If a section has no issues, OMIT that section entirely (do not write "None found").',
'- Include code snippets from the diff to pinpoint exact locations.',
'- Each issue must have a concrete, actionable fix suggestion.',
'- Number issues sequentially across all sections (1, 2, 3...).',
'- For CRITICAL and HIGH issues, always include the specific file path and line numbers.',
'- Do NOT comment on: lock files, auto-generated files, license headers, formatting-only changes, or well-known API parameters used correctly.',
'- If the PR has NO issues at any level, output exactly: "# Code Review\\n\\nNo issues found. The changes are clean and well-implemented."',
'',
'## Footer',
'After the Summary section, ALWAYS append this exact footer:',
'',
'---',
'',
'*🤖 This review was generated by AI and may contain inaccuracies. Please focus on issues you agree with and feel free to disregard any that seem incorrect. Thank you for your contribution!*',
].join('\n');
const userPrompt = [
'## Pull Request',
'',
`**Title**: ${prTitle}`,
`**Author**: ${prAuthor}`,
`**Stats**: ${fileCount} files changed, +${additions} -${deletions} (${totalLines} total lines)`,
'',
'**Description**:',
prBody || '(No description provided)',
'',
'**Changed Files**:',
fileListRaw,
'',
'## Diff',
'',
diff,
'',
'## Full File Contents (for cross-file analysis)',
'',
fileContents || '(No file contents available)',
].join('\n');
fs.writeFileSync(`${tmpDir}/system_prompt.txt`, systemPrompt);
fs.writeFileSync(`${tmpDir}/user_prompt.txt`, userPrompt);
- name: 'Call OpenAI GPT for code review'
if: steps.gather.outputs.skip != 'true'
uses: './.github/actions/call-openai'
with:
openai_api_key: '${{ secrets.OPENAI_API_KEY }}'
output_file: 'review_body.txt'
diff_truncated: '${{ steps.gather.outputs.diff_truncated }}'
contents_truncated: '${{ steps.read_contents.outputs.contents_truncated }}'
- name: 'Submit review to PR'
if: steps.gather.outputs.skip != 'true' && success()
uses: 'actions/github-script@v7'
env:
INPUT_PR_NUMBER: '${{ inputs.pr_number }}'
with:
script: |
const fs = require('fs');
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
const reviewBody = fs.readFileSync(`${tmpDir}/review_body.txt`, 'utf-8');
const prNumber = context.payload.pull_request?.number
|| Number(process.env.INPUT_PR_NUMBER);
core.info(`Submitting COMMENT review to PR #${prNumber}`);
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
event: 'COMMENT',
body: reviewBody,
});
core.info('Review submitted successfully via createReview');
} catch (reviewError) {
core.warning(`createReview failed: ${reviewError.message}, falling back to comment`);
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `<!-- gpt-review-bot -->\n\n${reviewBody}`,
});
core.info('Review posted as comment (fallback)');
} catch (commentError) {
core.setFailed(`Failed to post review: ${commentError.message}`);
}
}
+249
View File
@@ -0,0 +1,249 @@
name: Pack Web CLI
on:
workflow_call:
inputs:
ref:
description: 'Git ref to checkout (leave empty to use the triggering commit)'
type: string
default: ''
append_commit_hash:
description: 'Append short commit hash to artifact names'
type: boolean
default: false
skip_code_quality:
description: 'Skip code quality checks (caller already ran them)'
type: boolean
default: false
workflow_dispatch:
inputs:
ref:
description: 'Git ref to checkout (leave empty to use the triggering commit)'
type: string
default: ''
skip_code_quality:
description: 'Skip code quality checks'
type: boolean
default: false
env:
BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/'
jobs:
code-quality:
name: Code Quality
runs-on: ubuntu-latest
if: ${{ !inputs.skip_code_quality }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: bun run postinstall || true
- name: Run ESLint
run: bun run lint
- name: Check Prettier formatting
run: bun run format:check
- name: TypeScript type check
run: bunx tsc --noEmit
- name: Run unit tests
run: bunx vitest run
pack-web-cli:
name: Pack web-cli ${{ matrix.platform }}-${{ matrix.arch }}
runs-on: ${{ matrix.os }}
needs: code-quality
if: ${{ always() && (inputs.skip_code_quality || needs.code-quality.result == 'success') }}
strategy:
fail-fast: false
matrix:
include:
- { platform: darwin, arch: arm64, os: macos-14 }
- { platform: darwin, arch: x64, os: macos-14 }
- { platform: linux, arch: x64, os: ubuntu-latest }
- { platform: linux, arch: arm64, os: ubuntu-24.04-arm }
- { platform: win32, arch: x64, os: windows-2022 }
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Get commit hash
id: commit
shell: bash
run: |
SHORT=$(git rev-parse --short HEAD)
echo "short=$SHORT" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build desktop renderer (for static files)
run: bunx electron-vite build --config packages/desktop/electron.vite.config.ts
env:
NODE_OPTIONS: '--max-old-space-size=8192'
- name: Pack web-cli tarball
shell: bash
run: node scripts/pack-web-cli.js
env:
PACK_PLATFORM: ${{ matrix.platform }}
PACK_ARCH: ${{ matrix.arch }}
# Version is pinned in repo-root package.json (aioncoreVersion).
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload tarball artifact
uses: actions/upload-artifact@v6
with:
name: ${{ inputs.append_commit_hash && format('web-cli-{0}-{1}-{2}', matrix.platform, matrix.arch, steps.commit.outputs.short) || format('web-cli-{0}-{1}', matrix.platform, matrix.arch) }}
path: |
dist-web-cli/*.tar.gz
dist-web-cli/*.sha256
retention-days: 7
prepare-install-script:
name: Prepare install-web.sh for release
runs-on: ubuntu-latest
needs: pack-web-cli
if: ${{ always() && needs.pack-web-cli.result == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Get commit hash
id: commit
shell: bash
run: |
SHORT=$(git rev-parse --short HEAD)
echo "short=$SHORT" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Get version from package.json
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Replace __VERSION__ placeholder in install-web.sh
run: |
mkdir -p dist-scripts
sed "s/__VERSION__/${{ steps.version.outputs.version }}/g" scripts/install-web.sh > dist-scripts/install-web.sh
chmod +x dist-scripts/install-web.sh
- name: Upload install-web.sh artifact
uses: actions/upload-artifact@v6
with:
name: ${{ inputs.append_commit_hash && format('install-web-script-{0}', steps.commit.outputs.short) || 'install-web-script' }}
path: dist-scripts/install-web.sh
retention-days: 7
smoke-test:
name: Smoke test web-cli tarball (Linux x86_64)
runs-on: ubuntu-latest
needs: pack-web-cli
if: ${{ always() && needs.pack-web-cli.result == 'success' }}
container:
image: debian:bookworm-slim
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install dependencies
run: |
apt-get update
apt-get install -y curl tar gzip nodejs
- name: Download linux-x86_64 tarball
uses: actions/download-artifact@v7
with:
name: web-cli-linux-x64
path: dist-web-cli
- name: Run smoke test
shell: bash
run: |
chmod +x scripts/smoke-test-web-cli.sh
TARBALL=$(ls dist-web-cli/*.tar.gz | head -1)
bash scripts/smoke-test-web-cli.sh "$TARBALL"
smoke-test-install:
name: Smoke test install-web.sh (Linux x86_64)
runs-on: ubuntu-latest
needs: [pack-web-cli, prepare-install-script]
if: ${{ always() && needs.pack-web-cli.result == 'success' && needs.prepare-install-script.result == 'success' }}
container:
image: debian:bookworm-slim
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install dependencies
run: |
apt-get update
apt-get install -y curl tar gzip nodejs coreutils
- name: Resolve version
id: version
shell: bash
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Resolved version: $VERSION"
- name: Download linux-x86_64 tarball
uses: actions/download-artifact@v7
with:
name: web-cli-linux-x64
path: /tmp/releases/v${{ steps.version.outputs.version }}
- name: Download install-web.sh
uses: actions/download-artifact@v7
with:
name: install-web-script
path: /tmp/releases
- name: Run smoke test
shell: bash
run: |
chmod +x scripts/smoke-test-install-web.sh
bash scripts/smoke-test-install-web.sh file:///tmp/releases ${{ steps.version.outputs.version }}
+66
View File
@@ -0,0 +1,66 @@
name: 'PR Checks (docs only)'
on:
pull_request:
types: [opened, synchronize, edited, ready_for_review]
branches: [main, dev]
paths:
- '**/*.md'
- 'docs/**'
- '.vscode/**'
- '.github/ISSUE_TEMPLATE/**'
permissions:
contents: read
jobs:
code-quality:
name: Code Quality
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Install prek
run: npm install -g @j178/prek
- name: Run prek checks
run: prek run --from-ref origin/${{ github.base_ref }} --to-ref HEAD
unit-tests-ubuntu:
name: Unit Tests (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- run: echo "Docs-only PR, skipping unit tests."
unit-tests-macos:
name: Unit Tests (macos-14)
runs-on: ubuntu-latest
steps:
- run: echo "Docs-only PR, skipping unit tests."
unit-tests-windows:
name: Unit Tests (windows-2022)
runs-on: ubuntu-latest
steps:
- run: echo "Docs-only PR, skipping unit tests."
+535
View File
@@ -0,0 +1,535 @@
name: 'PR Checks'
on:
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to run checks on'
required: true
type: string
skip_build_test:
description: 'Skip the build test job (saves ~45 min)'
type: boolean
required: false
default: false
pull_request:
types: [opened, synchronize, edited, closed, ready_for_review]
branches: [main, dev]
paths-ignore:
- '**/*.md'
- 'docs/**'
- '.vscode/**'
- '.github/ISSUE_TEMPLATE/**'
concurrency:
group: pr-checks-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
issues: read
checks: write
id-token: write
env:
BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/'
jobs:
# Cancel in-progress runs when PR is closed (merged or manually closed)
cancel-if-closed:
name: Cancel if PR closed
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: echo "PR closed, cancelling in-progress runs via concurrency."
# Job 1: Code quality checks (TypeScript, Oxlint, Oxfmt)
code-quality:
name: Code Quality
if: github.event_name == 'workflow_dispatch' || (github.event.action != 'closed' && github.event.pull_request.draft == false)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve PR context
uses: ./.github/actions/checkout-pr
with:
pr_number: ${{ inputs.pr_number }}
github_token: ${{ github.token }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Install prek
run: npm install -g @j178/prek
- name: Run prek checks
run: prek run --from-ref origin/${{ env.PR_BASE_REF }} --to-ref HEAD
# Job 2: Unit tests across all platforms
unit-tests:
name: Unit Tests (${{ matrix.os }})
if: github.event_name == 'workflow_dispatch' || (github.event.action != 'closed' && github.event.pull_request.draft == false)
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14, windows-2022]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Resolve PR context
uses: ./.github/actions/checkout-pr
with:
pr_number: ${{ inputs.pr_number }}
github_token: ${{ github.token }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true
- name: Disable Windows Defender (Windows only)
if: matrix.os == 'windows-2022'
shell: pwsh
continue-on-error: true
run: |
try { Set-MpPreference -DisableRealtimeMonitoring $true } catch { Write-Host "::warning::Failed to disable real-time monitoring: $_" }
try { Add-MpPreference -ExclusionPath "${{ github.workspace }}" } catch { Write-Host "::warning::Failed to add exclusion path: $_" }
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Run extension system tests
run: bunx vitest run
# Job 3: Coverage test (Linux only)
coverage-tests:
name: Coverage Test
if: github.event_name == 'workflow_dispatch' || (github.event.action != 'closed' && github.event.pull_request.draft == false)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Resolve PR context
uses: ./.github/actions/checkout-pr
with:
pr_number: ${{ inputs.pr_number }}
github_token: ${{ github.token }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Run coverage tests
id: coverage
continue-on-error: true
run: bun run test:coverage
- name: Upload coverage to Codecov (Linux coverage only)
if: always() && hashFiles('coverage/lcov.info') != '' && github.repository == 'iOfficeAI/AionUi'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
use_oidc: ${{ secrets.CODECOV_TOKEN == '' }}
files: coverage/lcov.info
fail_ci_if_error: false
verbose: true
- name: Skip Codecov upload when preconditions are not met
if: always() && hashFiles('coverage/lcov.info') != '' && github.repository != 'iOfficeAI/AionUi'
shell: bash
run: |
echo 'Skipping Codecov upload due to unmet preconditions.'
echo "Repository: ${{ github.repository }}"
- name: Coverage result summary
if: always()
shell: bash
run: |
if [ "${{ steps.coverage.outcome }}" = "failure" ]; then
echo "::warning::Coverage tests failed (non-blocking). Check logs for failed test details."
echo "## Coverage check (non-blocking warning)" >> $GITHUB_STEP_SUMMARY
echo "Coverage command failed in this run. Please review test failures in logs." >> $GITHUB_STEP_SUMMARY
if [ -f coverage/lcov.info ]; then
echo "lcov.info exists, Codecov upload can still run." >> $GITHUB_STEP_SUMMARY
else
echo "lcov.info not found, Codecov upload was skipped." >> $GITHUB_STEP_SUMMARY
fi
else
echo "## Coverage check" >> $GITHUB_STEP_SUMMARY
echo "Coverage command passed." >> $GITHUB_STEP_SUMMARY
if [ -f coverage/lcov.info ]; then
echo "Uploaded Linux (ubuntu-latest) coverage to Codecov." >> $GITHUB_STEP_SUMMARY
else
echo "Coverage passed but lcov.info is missing; Codecov upload was skipped." >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Upload coverage artifacts
if: always() && hashFiles('coverage/lcov.info') != ''
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if-no-files-found: warn
i18n-check:
name: I18n Check
if: github.event_name == 'workflow_dispatch' || (github.event.action != 'closed' && github.event.pull_request.draft == false)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Run i18n validation (warning-only)
id: i18n
shell: bash
run: |
set -o pipefail
node scripts/check-i18n.js 2>&1 | tee i18n-check.log
- name: Publish i18n summary
if: always()
shell: bash
run: |
echo "## i18n validation" >> $GITHUB_STEP_SUMMARY
if grep -q "⚠️" i18n-check.log; then
echo "Missing/incomplete translations found. Please review warnings below." >> $GITHUB_STEP_SUMMARY
else
echo "No i18n warnings detected." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>i18n log</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```text' >> $GITHUB_STEP_SUMMARY
cat i18n-check.log >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
# Job 4: Build test across representative PR platforms (parallel with code-quality and unit-tests)
build-test:
name: Build Test (${{ matrix.platform }})
if: github.event.action != 'closed' && github.event.pull_request.draft == false && inputs.skip_build_test != true
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-arm64'
os: 'macos-14'
arch: 'arm64'
target: '--mac'
build_args: '--mac --arm64'
unpacked_dir: 'mac-arm64'
- platform: 'windows-x64'
os: 'windows-2022'
arch: 'x64'
target: '--win'
build_args: '--win --x64'
unpacked_dir: 'win-unpacked'
- platform: 'linux-x64'
os: 'ubuntu-latest'
arch: 'x64'
target: '--linux'
build_args: '--linux --x64'
unpacked_dir: 'linux-unpacked'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Install Linux dependencies
if: matrix.platform == 'linux-x64'
run: |
sudo apt-get update
sudo apt-get install -y build-essential python3 python3-pip pkg-config libsqlite3-dev fakeroot dpkg-dev rpm libnss3-dev libatk-bridge2.0-dev libdrm2 libxkbcommon-dev libxss1 libatspi2.0-dev libgtk-3-dev libxrandr2 libasound2-dev
- name: Get Electron version
id: electron-version
shell: bash
run: |
ELECTRON_VERSION=$(node -p "require('./package.json').devDependencies.electron.replace(/[\^~]/g, '')")
echo "version=$ELECTRON_VERSION" >> $GITHUB_OUTPUT
echo "Electron version: $ELECTRON_VERSION"
# Restore Electron/Electron-Builder caches before install-app-deps
- name: Cache Electron artifacts
id: electron-cache
uses: actions/cache@v4
with:
path: |
${{ runner.temp }}/.cache/electron
${{ runner.temp }}/.cache/electron-builder
~/.cache/electron
~/.cache/electron-builder
${{ env.LOCALAPPDATA }}\electron-builder\Cache
key: electron-cache-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('package.json', 'bun.lock') }}
restore-keys: |
electron-cache-${{ matrix.platform }}-${{ matrix.arch }}-
electron-cache-${{ matrix.platform }}-
- name: Cache status
run: echo "electron-cache-hit=${{ steps.electron-cache.outputs.cache-hit }}"
- name: Rebuild native modules for Electron (non-Windows)
if: "!startsWith(matrix.platform, 'windows')"
run: bunx electron-builder install-app-deps
env:
npm_config_runtime: electron
npm_config_disturl: https://electronjs.org/headers
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
- name: Setup MSBuild (Windows only)
if: startsWith(matrix.platform, 'windows')
uses: microsoft/setup-msbuild@v2
with:
vs-version: '17.0'
- name: Build test (Windows x64)
if: matrix.platform == 'windows-x64'
shell: pwsh
run: |
Write-Host "=========================================="
Write-Host "BUILD TEST: ${{ matrix.platform }}"
Write-Host "=========================================="
node scripts/build-with-builder.js x64 --win --x64
Write-Host "✓Build test passed for ${{ matrix.platform }}"
env:
NODE_OPTIONS: '--max-old-space-size=8192'
npm_config_runtime: electron
npm_config_arch: x64
npm_config_target_arch: x64
npm_config_disturl: https://electronjs.org/headers
npm_config_build_from_source: true
MSVS_VERSION: 2022
GYP_MSVS_VERSION: 2022
WindowsTargetPlatformVersion: 10.0.19041.0
CI: true
GH_TOKEN: ${{ github.token }}
- name: Build test (non-Windows)
if: "!startsWith(matrix.platform, 'windows')"
shell: bash
run: |
echo "=========================================="
echo "BUILD TEST: ${{ matrix.platform }}"
echo "=========================================="
node scripts/build-with-builder.js auto ${{ matrix.build_args }}
echo "✓Build test passed for ${{ matrix.platform }}"
env:
NODE_OPTIONS: '--max-old-space-size=8192'
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
npm_config_runtime: electron
npm_config_target: ${{ steps.electron-version.outputs.version }}
npm_config_disturl: https://electronjs.org/headers
CI: true
GH_TOKEN: ${{ github.token }}
- name: Verify build artifacts exist
shell: bash
run: |
echo "=========================================="
echo "VERIFY ARTIFACTS: ${{ matrix.platform }}"
echo "=========================================="
ls -lah out || true
case "${{ matrix.platform }}" in
windows-*)
ls out/*.exe out/*latest*.yml >/dev/null
;;
macos-*)
ls out/*.dmg out/*.zip out/*latest*.yml >/dev/null
;;
linux-x64)
ls out/*.deb out/*latest*.yml >/dev/null
;;
esac
- name: Silent install smoke test (Windows x64)
if: matrix.platform == 'windows-x64'
shell: pwsh
run: |
Write-Host "=========================================="
Write-Host "SMOKE INSTALL: windows-x64"
Write-Host "=========================================="
$installer = Get-ChildItem -Path out -Filter "AionUi-*-win-*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $installer) {
throw "No Windows installer found in out/"
}
Write-Host "Using installer: $($installer.FullName)"
Start-Process -FilePath $installer.FullName -ArgumentList '/S' -Wait -NoNewWindow
$candidates = @(
"$env:LOCALAPPDATA\\Programs\\AionUi\\AionUi.exe",
"$env:ProgramFiles\\AionUi\\AionUi.exe",
"$env:ProgramFiles(x86)\\AionUi\\AionUi.exe"
)
$installedExe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $installedExe) {
throw "Silent install finished but app executable not found in expected locations"
}
Write-Host "Installed executable: $installedExe"
- name: Install smoke test (macOS arm64)
if: matrix.platform == 'macos-arm64'
shell: bash
run: |
set -euo pipefail
echo "=========================================="
echo "SMOKE INSTALL: macos-arm64"
echo "=========================================="
DMG_FILE=$(ls out/*.dmg | head -n 1)
MOUNT_POINT="/tmp/aionui-smoke-mount"
APP_DIR="/tmp/aionui-smoke-app"
rm -rf "$MOUNT_POINT" "$APP_DIR"
mkdir -p "$MOUNT_POINT" "$APP_DIR"
hdiutil attach "$DMG_FILE" -nobrowse -mountpoint "$MOUNT_POINT"
cp -R "$MOUNT_POINT"/*.app "$APP_DIR"/
hdiutil detach "$MOUNT_POINT"
APP_PATH=$(ls -d "$APP_DIR"/*.app | head -n 1)
APP_BIN="$APP_PATH/Contents/MacOS/AionUi"
test -x "$APP_BIN"
"$APP_BIN" --version || true
- name: Install smoke test (Linux)
if: matrix.platform == 'linux-x64'
shell: bash
run: |
set -euo pipefail
echo "=========================================="
echo "SMOKE INSTALL: linux-x64"
echo "=========================================="
DEB_FILE=$(ls out/*.deb | head -n 1)
PKG_NAME=$(dpkg-deb -f "$DEB_FILE" Package)
sudo dpkg -i "$DEB_FILE" || sudo apt-get install -f -y
INSTALLED_BIN=$(dpkg -L "$PKG_NAME" | grep -Ei '/(bin|opt)/.*(aionui)$' | head -n 1 || true)
if [ -z "$INSTALLED_BIN" ]; then
echo "Package files:"
dpkg -L "$PKG_NAME" | head -n 50
echo "No installed executable path matched expected pattern"
exit 1
fi
test -x "$INSTALLED_BIN"
# Job 5: Test release scripts (fast, no build required)
release-script-test:
name: Release Script Test
if: github.event.action != 'closed' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create mock build artifacts
shell: bash
run: bash scripts/create-mock-release-artifacts.sh build-artifacts
- name: Run prepare-release-assets script
shell: bash
env:
MOCK_VERSION: '1.0.0'
run: bash scripts/prepare-release-assets.sh build-artifacts release-assets
- name: Validate script output
shell: bash
run: bash scripts/verify-release-assets.sh release-assets
+68
View File
@@ -0,0 +1,68 @@
name: '✓PR E2E Artifacts'
on:
workflow_dispatch:
concurrency:
group: pr-e2e-artifacts-${{ github.run_id }}
cancel-in-progress: true
permissions:
contents: read
env:
BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/'
jobs:
e2e-artifacts:
name: E2E Artifacts (Linux)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup just
uses: extractions/setup-just@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run postinstall
run: npm run postinstall || true
- name: Install Linux display dependencies (E2E)
run: |
sudo apt-get update
sudo apt-get install -y libgbm-dev xvfb
- name: Run E2E tests (with screenshots)
run: xvfb-run --auto-servernum just e2e-test
env:
CI: true
E2E_DEV: '1'
E2E_SCREENSHOTS: '1'
AIONUI_EXTENSIONS_PATH: ${{ github.workspace }}/examples
- name: Upload E2E report (self-contained with screenshots & traces)
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-report-linux-pr
path: |
tests/e2e/report/
tests/e2e/results/
tests/e2e/screenshots/
retention-days: 14
if-no-files-found: ignore
+355
View File
@@ -0,0 +1,355 @@
name: '📋 Project Automation'
on:
workflow_dispatch: # disabled: requires GitHub App credentials (APP_ID + APP_PRIVATE_KEY)
# Minimal permissions at workflow level, jobs use PROJECT_TOKEN secret
permissions:
contents: 'read'
env:
PROJECT_NUMBER: 5
ORG_NAME: iOfficeAI
PROJECT_ID: PVT_kwDOCKhK-M4BN1Ah
STATUS_FIELD_ID: PVTSSF_lADOCKhK-M4BN1Ahzg8tcak
# Status option IDs
STATUS_TO_TRIAGE: 8e8f2d59
STATUS_BLOCKED: cd8368e3
STATUS_READY_FOR_WORK: 864da50e
STATUS_IN_PROGRESS: a5c1c67b
STATUS_CLOSED: 91437030
STATUS_DONE: 1aa2425f
jobs:
# ============================================
# Issue Events: Add to Project & manage status
# ============================================
add-issue-to-project:
name: Add Issue to Project
runs-on: ubuntu-latest
if: github.event_name == 'issues' && github.event.action == 'opened'
permissions:
contents: 'read'
outputs:
item_id: ${{ steps.add.outputs.itemId }}
steps:
- name: Mint identity token
id: mint_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Add issue to project
id: add
uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/iOfficeAI/projects/5
github-token: ${{ steps.mint_token.outputs.token }}
set-issue-initial-status:
name: Set Issue Initial Status
runs-on: ubuntu-latest
needs: add-issue-to-project
if: github.event_name == 'issues' && github.event.action == 'opened' && needs.add-issue-to-project.outputs.item_id
permissions:
contents: 'read'
steps:
- name: Mint identity token
id: mint_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Set status to To Triage
uses: actions/github-script@v7
with:
github-token: ${{ steps.mint_token.outputs.token }}
script: |
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`;
await github.graphql(mutation, {
projectId: process.env.PROJECT_ID,
itemId: '${{ needs.add-issue-to-project.outputs.item_id }}',
fieldId: process.env.STATUS_FIELD_ID,
optionId: process.env.STATUS_TO_TRIAGE
});
core.info('Issue added to project with status: To Triage');
update-issue-on-close:
name: Update Issue on Close
runs-on: ubuntu-latest
if: github.event_name == 'issues' && github.event.action == 'closed'
permissions:
contents: 'read'
steps:
- name: Mint identity token
id: mint_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Update issue status to Done
uses: actions/github-script@v7
with:
github-token: ${{ steps.mint_token.outputs.token }}
script: |
const contentId = context.payload.issue.node_id;
// Find the item in project
const query = `
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
items(first: 100) {
nodes {
id
content { ... on Issue { id } }
}
}
}
}
}
`;
const result = await github.graphql(query, { org: 'iOfficeAI', number: 5 });
const item = result.organization.projectV2.items.nodes.find(i => i.content?.id === contentId);
if (!item) {
core.info('Issue not found in project, skipping');
return;
}
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`;
await github.graphql(mutation, {
projectId: process.env.PROJECT_ID,
itemId: item.id,
fieldId: process.env.STATUS_FIELD_ID,
optionId: process.env.STATUS_DONE
});
core.info('Issue closed, status updated to Done');
update-issue-on-reopen:
name: Update Issue on Reopen
runs-on: ubuntu-latest
if: github.event_name == 'issues' && github.event.action == 'reopened'
permissions:
contents: 'read'
steps:
- name: Mint identity token
id: mint_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Update issue status to To Triage
uses: actions/github-script@v7
with:
github-token: ${{ steps.mint_token.outputs.token }}
script: |
const contentId = context.payload.issue.node_id;
const query = `
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
items(first: 100) {
nodes {
id
content { ... on Issue { id } }
}
}
}
}
}
`;
const result = await github.graphql(query, { org: 'iOfficeAI', number: 5 });
const item = result.organization.projectV2.items.nodes.find(i => i.content?.id === contentId);
if (!item) {
core.info('Issue not found in project, skipping');
return;
}
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`;
await github.graphql(mutation, {
projectId: process.env.PROJECT_ID,
itemId: item.id,
fieldId: process.env.STATUS_FIELD_ID,
optionId: process.env.STATUS_TO_TRIAGE
});
core.info('Issue reopened, status updated to To Triage');
# ============================================
# PR Events: Update linked Issue status
# ============================================
pr-update-linked-issue:
name: Update Linked Issue Status
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
contents: 'read'
pull-requests: 'read'
steps:
- name: Mint identity token
id: mint_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Update linked issue status based on PR event
uses: actions/github-script@v7
with:
github-token: ${{ steps.mint_token.outputs.token }}
script: |
const prBody = context.payload.pull_request.body || '';
const prNumber = context.payload.pull_request.number;
const action = context.payload.action;
const isMerged = context.payload.pull_request.merged === true;
// Extract linked issue numbers from PR body
const issuePatterns = [
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi,
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/gi
];
const linkedIssues = new Set();
for (const pattern of issuePatterns) {
let match;
while ((match = pattern.exec(prBody)) !== null) {
linkedIssues.add(parseInt(match[1], 10));
}
}
if (linkedIssues.size === 0) {
core.info('No linked issues found in PR body, skipping');
return;
}
core.info(`Found linked issues: ${[...linkedIssues].join(', ')}`);
// Determine target status based on PR action
let targetStatusId;
let statusName;
if (action === 'opened' || action === 'reopened') {
// PR opened/reopened -> Issue is In Progress
targetStatusId = process.env.STATUS_IN_PROGRESS;
statusName = 'In Progress';
} else if (action === 'closed') {
if (isMerged) {
// PR merged -> Issue is Done
targetStatusId = process.env.STATUS_DONE;
statusName = 'Done';
} else {
// PR closed without merge -> Issue back to Ready for Work
targetStatusId = process.env.STATUS_READY_FOR_WORK;
statusName = 'Ready for Work';
}
}
if (!targetStatusId) {
core.info(`No status change needed for action: ${action}`);
return;
}
// Query to find issues in project
const query = `
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
items(first: 100) {
nodes {
id
content {
... on Issue {
id
number
}
}
}
}
}
}
}
`;
const result = await github.graphql(query, { org: 'iOfficeAI', number: 5 });
const projectItems = result.organization.projectV2.items.nodes;
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`;
// Update each linked issue
for (const issueNumber of linkedIssues) {
const item = projectItems.find(i => i.content?.number === issueNumber);
if (!item) {
core.info(`Issue #${issueNumber} not found in project, skipping`);
continue;
}
await github.graphql(mutation, {
projectId: process.env.PROJECT_ID,
itemId: item.id,
fieldId: process.env.STATUS_FIELD_ID,
optionId: targetStatusId
});
core.info(`Issue #${issueNumber} status updated to ${statusName} (PR #${prNumber} ${action}${isMerged ? ' merged' : ''})`);
}
+188
View File
@@ -0,0 +1,188 @@
name: Distribute Release Assets
# Triggered after a GitHub Release is officially published.
# Mirrors installer assets to the configured distribution endpoint for external download.
#
# Why `published` (not `released`)?
# - `released` is supposed to fire when a non-prerelease release is published,
# but GitHub sometimes only emits `published` (not `released`) when a pre-existing
# draft is publicly released, causing the workflow to be silently skipped.
# - `published` reliably fires on every draft → public transition, including
# prereleases. The `if` guard on the job filters out prereleases explicitly.
#
# All credentials / identifiers are read from repository secrets:
# - AWS_REGION
# - AWS_ROLE_ARN
# - AWS_S3_BUCKET
# Do NOT hardcode these values in this file.
on:
release:
types: [published]
# Manual trigger for smoke testing or for retrying a missed release:
# pick any existing release tag to re-run the distribution against.
workflow_dispatch:
inputs:
tag:
description: 'Existing release tag to redistribute (e.g. v1.9.21)'
required: true
type: string
permissions:
id-token: write # required for OIDC token
contents: read
env:
S3_RELEASE_PREFIX: releases
jobs:
distribute:
name: Distribute release assets
runs-on: ubuntu-latest
# Skip prereleases: manual dispatch always runs, automatic event runs only when not a prerelease.
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
steps:
- name: Extract version from tag
id: version
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
TAG="${EVENT_TAG:-$INPUT_TAG}"
if [ -z "$TAG" ]; then
echo "::error::No tag provided (event did not supply one)."
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Tag: $TAG / Version: $VERSION"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Guard against same-version overwrite
env:
S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
PREFIX="${S3_RELEASE_PREFIX}/${VERSION}/"
DEST="s3://${S3_BUCKET}/${PREFIX}"
COUNT=$(aws s3api list-objects-v2 \
--bucket "$S3_BUCKET" \
--prefix "$PREFIX" \
--no-paginate \
--query 'KeyCount' \
--output text)
if [ "$COUNT" -gt 0 ]; then
echo "::error::Version directory already contains $COUNT file(s). Refusing to overwrite."
echo "::error::Same-version re-publish is not allowed (downstream caches would serve stale files). Release a new version instead."
aws s3 ls "$DEST"
exit 1
fi
- name: Download release assets from GitHub
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p dist
gh release download "${{ steps.version.outputs.tag }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern "*.dmg" \
--pattern "*.exe" \
--pattern "*.msi" \
--pattern "*.deb" \
--pattern "*.zip" \
--pattern "latest*.yml"
echo "Downloaded files:"
ls -la dist
- name: Validate updater metadata
id: metadata
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
metadata_files=(
dist/latest.yml
dist/latest-win-arm64.yml
dist/latest-mac.yml
dist/latest-arm64-mac.yml
dist/latest-linux.yml
dist/latest-linux-arm64.yml
)
for file in "${metadata_files[@]}"; do
if [ ! -f "$file" ]; then
echo "::error::Missing updater metadata file: $file."
exit 1
fi
metadata_version=$(grep -E "^version:" "$file" | head -n 1 | sed -E "s/^version:[[:space:]]*['\"]?([^'\"]+).*/\1/")
if [ "$metadata_version" != "$VERSION" ]; then
echo "::error::$file version is $metadata_version, expected $VERSION."
exit 1
fi
done
{
echo "files<<EOF"
for file in "${metadata_files[@]}"; do
basename "$file"
done
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Upload assets
env:
S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
aws s3 cp dist/ "s3://${S3_BUCKET}/${S3_RELEASE_PREFIX}/${VERSION}/" \
--recursive
echo "Uploaded release ${VERSION}"
- name: Upload updater metadata
env:
S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
run: |
set -euo pipefail
aws s3 cp dist/ "s3://${S3_BUCKET}/${S3_RELEASE_PREFIX}/" \
--recursive \
--exclude "*" \
--include "latest*.yml" \
--content-type "text/yaml" \
--cache-control "public, max-age=60"
echo "Uploaded updater metadata"
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
FILES: ${{ steps.metadata.outputs.files }}
run: |
{
echo "### Release assets distributed"
echo ""
echo "- Tag: \`${TAG}\`"
echo "- Version: \`${VERSION}\`"
echo "- Asset prefix: \`${S3_RELEASE_PREFIX}/${VERSION}/\`"
echo "- Metadata prefix: \`${S3_RELEASE_PREFIX}/\`"
echo ""
echo "Uploaded metadata:"
while IFS= read -r file; do
[ -n "$file" ] && echo "- \`${S3_RELEASE_PREFIX}/$file\`"
done <<< "$FILES"
} >> $GITHUB_STEP_SUMMARY