chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 Review(createReview) | 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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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' : ''})`);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user