Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions .github/workflows/desktop-release-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: Desktop preview release

on:
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
target: windows-x64
- os: macos-15
target: macos-arm64
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Assert native architecture matches target
shell: bash
run: |
echo "runner.arch=${RUNNER_ARCH}"
echo "uname -m: $(uname -m)"
node -v
case "${{ matrix.target }}" in
windows-x64)
[ "$(node -p process.arch)" = x64 ] || { echo "node arch mismatch" >&2; exit 1; }
[ "$PROCESSOR_ARCHITECTURE" = AMD64 ] || { echo "PROCESSOR_ARCHITECTURE mismatch" >&2; exit 1; }
;;
macos-arm64)
[ "$(uname -m)" = arm64 ] || { echo "uname mismatch" >&2; exit 1; }
[ "$(node -p process.arch)" = arm64 ] || { echo "node arch mismatch" >&2; exit 1; }
;;
esac

- name: Install dependencies
shell: bash
run: |
corepack enable
corepack yarn install --immutable

- name: Install Godot 4.7.2 (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$archive = Join-Path $env:RUNNER_TEMP 'godot.zip'
$destination = Join-Path $env:RUNNER_TEMP 'godot-4.7.2'
Invoke-WebRequest 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_win64.exe.zip' -OutFile $archive
if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne '731980f9608d61333e5baf54a2ef17210acc7a538446c0cb9969f002aca1e953') {
throw 'Godot archive checksum mismatch'
}
Expand-Archive $archive -DestinationPath $destination
Add-Content -Path $env:GITHUB_ENV -Value "GODOT_BIN=$(Join-Path $destination 'Godot_v4.7.2-stable_win64_console.exe')"
$tpz = Join-Path $env:RUNNER_TEMP 'godot-templates.tpz'
Invoke-WebRequest 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz' -OutFile $tpz
if ((Get-FileHash $tpz -Algorithm SHA512).Hash.ToLower() -ne 'ca4d71c4d7b81dfc15d1a98baa07534aa95b03fdda78a0075b06672e1648d2e5f40980c9adc28d23e1b92e732ee7bf3461997aa804af74ec2fcd7a93ccb84079') { throw 'Godot export templates checksum mismatch' }
$templates = Join-Path $env:APPDATA 'Godot\export_templates\4.7.2.stable'
New-Item -ItemType Directory -Force -Path $templates | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($tpz)
try {
foreach ($name in @('web_debug.zip', 'web_release.zip', 'web_nothreads_debug.zip', 'web_nothreads_release.zip')) {
$entry = $zip.Entries | Where-Object { $_.FullName -eq "templates/$name" }
if (-not $entry) { throw "template entry $name missing from tpz" }
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry, (Join-Path $templates $name), $true)
}
} finally { $zip.Dispose() }

- name: Install Godot 4.7.2 (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
archive="$RUNNER_TEMP/godot.zip"
curl -fsSL -o "$archive" 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_macos.universal.zip'
echo "38aa16e5bba2083941fc5b3e54be0089bd4cc35e32415f5b9fd9a8a6a7b9818255d44532ea8ef94b5aef56c4b407c2d634fa4f657e4ebe681ebbf59b7bac69ca $archive" | shasum -a 512 -c -
unzip -q "$archive" -d "$RUNNER_TEMP/godot-4.7.2"
echo "GODOT_BIN=$RUNNER_TEMP/godot-4.7.2/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV"
tpz="$RUNNER_TEMP/godot-templates.tpz"
curl -fsSL -o "$tpz" 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz'
echo "ca4d71c4d7b81dfc15d1a98baa07534aa95b03fdda78a0075b06672e1648d2e5f40980c9adc28d23e1b92e732ee7bf3461997aa804af74ec2fcd7a93ccb84079 $tpz" | shasum -a 512 -c -
templates="$HOME/Library/Application Support/Godot/export_templates/4.7.2.stable"
mkdir -p "$templates"
unzip -q -j "$tpz" 'templates/web_debug.zip' 'templates/web_release.zip' 'templates/web_nothreads_debug.zip' 'templates/web_nothreads_release.zip' -d "$templates"

- name: Build Web plugin and world
shell: bash
run: |
"$GODOT_BIN" --headless --path games/mosslight --editor --import
corepack yarn build:world
corepack yarn build:web
test -f games/mosslight/build/web/index.wasm

- name: Build portable package (Windows)
if: runner.os == 'Windows'
shell: bash
run: node apps/desktop/build.mjs

- name: Verify installer and run from distribution (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$build = (Get-Content dist/desktop-latest.txt -Raw).Trim()
pwsh -NoProfile -File apps/desktop/verify.ps1 -BuildDirectory $build
$work = Join-Path $env:RUNNER_TEMP ('zip-smoke-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
Expand-Archive -Path (Join-Path $build 'qcode-windows-x64.zip') -DestinationPath $work
$exe = Get-ChildItem -LiteralPath $work -Recurse -Filter qcode.exe | Select-Object -First 1
if (-not $exe) { throw 'Portable launcher executable missing from zip' }
$data = Join-Path $env:RUNNER_TEMP ('zip-smoke-data-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$env:QCODE_DATA_HOME = $data
try {
$proc = Start-Process -FilePath $exe.FullName -WindowStyle Hidden -PassThru
$urlFile = Join-Path $data 'browser-url.txt'
$deadline = (Get-Date).AddMinutes(4)
while (-not (Test-Path $urlFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500 }
if (-not (Test-Path $urlFile)) { throw 'Service did not become ready from unpacked zip' }
$url = [uri](Get-Content $urlFile -Raw)
$response = Invoke-WebRequest $url.AbsoluteUri -UseBasicParsing
if ($response.StatusCode -ne 200) { throw "Unpacked zip smoke got HTTP $($response.StatusCode)" }
Stop-Process -Id $proc.Id -Force
$proc.WaitForExit()
Start-Sleep -Seconds 2
if (Get-NetTCPConnection -LocalPort $url.Port -State Listen -ErrorAction SilentlyContinue) { throw 'Service survived launcher termination' }
} finally {
Remove-Item Env:QCODE_DATA_HOME -ErrorAction SilentlyContinue
}

- name: Build portable package (macOS)
if: runner.os == 'macOS'
shell: bash
run: corepack yarn build:desktop:darwin

- name: Verify from unpacked distribution (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
build="$(cat dist/desktop-darwin-latest.txt)"
work="$RUNNER_TEMP/zip-smoke"
mkdir -p "$work"
ditto -x -k "$build/qcode-darwin-arm64.zip" "$work"
corepack yarn verify:desktop:darwin "$work"
(cd "$build" && shasum -a 256 -c SHA256SUMS.txt)

- name: Checksums
shell: bash
run: |
build="$(cat dist/desktop-latest.txt 2>/dev/null || cat dist/desktop-darwin-latest.txt)"
echo "distribution dir: $build"
ls -la "$build"

- name: Upload distribution artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}-preview
path: |
dist/desktop-*/qcode-*.zip
dist/desktop-*/qcode-setup-x64.exe
dist/desktop-*/SHA256SUMS.txt
if-no-files-found: error

summary:
name: Coverage summary
needs: build
if: always()
runs-on: ubuntu-latest
steps:
- name: Record matrix coverage
shell: bash
run: |
{
echo "## Desktop preview release coverage"
echo "- windows-x64: ${{ needs.build.result }}"
echo "- macos-arm64: ${{ needs.build.result }}"
echo "- **darwin-x64: 未覆盖** — GitHub 无真实 Intel macOS runner;按约定不使用 Rosetta 代产。"
echo "- 触发方式:workflow_dispatch 手动,不自动发布;冒烟通过不替代 #19 真实模型与干净机器人工验收。"
} >> "$GITHUB_STEP_SUMMARY"
7 changes: 6 additions & 1 deletion apps/desktop/macos/Launcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
let alert = NSAlert()
alert.messageText = "QCode 无法启动"
alert.informativeText = error.localizedDescription
if !smoke { alert.runModal() }
if smoke {
// NSApp.terminate 会以 0 退出:冒烟模式必须把失败如实传给调用方,否则冲突检测形同虚设。
FileHandle.standardError.write(Data(("QCode 无法启动: " + error.localizedDescription + "\n").utf8))
exit(1)
}
alert.runModal()
NSApp.terminate(nil)
return
}
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/pack-app.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// 桌面发行:把 Web 插件、世界资源与 node_modules 打进安装根目录(Win/Mac 共用)。
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { assertWorldExportCurrent } from '../../scripts/world-export-state.mjs'
Expand Down Expand Up @@ -56,6 +56,16 @@ export function materializeAppTree(app) {
export async function embedNodeRuntime(app, { binaryName }) {
mkdirSync(path.join(app, 'runtime'), { recursive: true })
cpSync(process.execPath, path.join(app, 'runtime', binaryName))
// 官方 darwin 发行版 node 动态链接 libnode(rpath 含 @loader_path),
// 只拷二进制会得到坏包;构建用 node 静态链接时该目录不存在,自然跳过。
if (process.platform === 'darwin') {
const libDir = path.join(path.dirname(realpathSync(process.execPath)), '..', 'lib')
if (existsSync(libDir)) {
for (const lib of readdirSync(libDir).filter(name => /^libnode\.\d+\.dylib$/.test(name))) {
cpSync(path.join(libDir, lib), path.join(app, 'runtime', lib))
}
}
}
const licenseCache = path.join(root, 'dist', `node-${process.version}-LICENSE`)
if (!existsSync(licenseCache)) {
mkdirSync(path.join(root, 'dist'), { recursive: true })
Expand Down
2 changes: 2 additions & 0 deletions docs/construction-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 产品目标

桌面预览发行 CI(2026-09-22,已实现待验收):新增 `desktop-release-preview.yml`,仅手动触发、不自动发布;`windows-2022` / `macos-15` 矩阵在 job 开始断言 `uname -m` 与 `process.arch` 与目标一致,复用各平台现有打包与验证脚本,并从解包后的 ZIP 直接启动做原生冒烟(token 化 URL、退出清端口),上传 ZIP、安装包与 SHA256SUMS;darwin-x64 在汇总中如实标记未覆盖,不以 Rosetta 代产。fork 矩阵首跑两平台全绿,并暴露修复了官方动态链接 Node 坏包与冒烟冲突静默退出 0 两个真实缺陷;上游内首跑与评审待验收。自动冒烟不替代 #19 真实模型与干净机器验收。阶段、约束与首跑证据见 [桌面预览发行 CI 施工](desktop-release-preview-plan.md)。

macOS 原生架构门禁(2026-09-20,已实现待验收):`build:desktop:darwin` 与 `verify-darwin.sh` 在产出构建目录或启动验证进程前,按硬件级 `hw.optional.arm64` 与 Node 自身架构判定执行环境,拒绝 Apple Silicon 上的 Rosetta 翻译执行并给出手工切换指引,不静默更改输出架构;架构判定收敛到 `apps/desktop/darwin-target.mjs` 单一来源,验证脚本经其 `--print-shell-env` 复用同一规则。11 项目标解析测试(含注入式 sysctl 分支)通过;Apple Silicon 实机覆盖原生通过、翻译 x86_64 Node 拒绝(官方 darwin-x64 Node 实测)与 Rosetta shell 内原生 arm64 Node 正常放行三条路径;Intel Mac 真机与完整重新打包未覆盖。详见 [桌面启动器说明](../apps/desktop/README.md)。

macOS 便携启动器应用图标(2026-09-20,已实现待验收):`apps/desktop/darwin-icon.mjs` 在打包时用系统 `sips` 与 `iconutil` 从 `assets/brand/android-chrome-512x512.png`(512px、透明)生成 16–512px 含 Retina 的 `AppIcon.icns` 写入 bundle Resources,`Info.plist` 声明 `CFBundleIconFile`;替换原先静默复制 `favicon.ico` 的路径,源图缺失时在产生任何构建产物前立即失败。5 项注入式图标测试通过并加入 CI;Apple M5 Pro / macOS 26.5.1 / arm64 实机重新导出世界并完成完整打包,产出 743,818 字节 ICNS,`iconutil` 反解确认 9 档完整,ZIP 解压往返后清单与图标一致,缺失源图构建即时拒绝。Finder 与应用信息目视截图见 PR 记录;Intel 机器未覆盖(图标流水线与架构无关,未在真机重复执行)。详见 [桌面启动器说明](../apps/desktop/README.md)。
Expand Down
28 changes: 28 additions & 0 deletions docs/desktop-release-preview-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 桌面预览发行 CI 施工

状态:已实现待验收。工作流已落地并在 fork 完成 windows-2022 / macos-15 矩阵首跑(见下文证据);上游仓库内的首跑需维护者(admin)触发,评审与合并待验收。

## 阶段与范围

- `.github/workflows/desktop-release-preview.yml`:仅 `workflow_dispatch` 手动触发,不自动发布、不创建 GitHub Release、不推送任何渠道。
- 矩阵:`windows-2022` → `windows-x64`;`macos-15` → `macos-arm64`。`fail-fast: false`,两平台独立判定,任何一方的通过都不替代另一方。
- 架构守卫:job 开始即以 `uname -m`、`node -p process.arch`(Windows 另加 `PROCESSOR_ARCHITECTURE` 与 `RUNNER_ARCH`)断言与目标矩阵一致,不一致立即失败;不凭 runner 标签推断 CPU。macOS 标签的实际架构以首跑守卫结果为准,通过后在本文件与 workflow 注释中固定。
- 构建:immutable 安装 → Godot 4.7.2(win64 / universal 固定 SHA-256)+ 官方校验和的 Web 导出模板(tpz 仅解出四个 web 变体,runner 默认不带模板)→ `build:world` + `build:web` → 各平台现有打包脚本(`apps/desktop/build.mjs` / `build:desktop:darwin`)。
- 冒烟:Windows 运行现有 `verify.ps1`(安装提取、外部链接检查、隔离数据、单实例、退出清端口),并额外从解包后的 `qcode-windows-x64.zip` 直接启动 `QCode.exe`,轮询 token 化 `browser-url.txt` 请求完整 URL(200/303),终止后断言端口释放;macOS 解包 zip 后把 `app/` 交给 `verify-darwin.sh`(含 Rosetta 拒绝门禁),并 `shasum -c SHA256SUMS.txt`。
- 产物:白名单上传 `qcode-*.zip`、`qcode-setup-x64.exe`、`SHA256SUMS.txt`;失败日志仅来自 runner 临时目录(`RUNNER_TEMP`),`QCODE_DATA_HOME` / 隔离数据全部位于临时目录。
- 汇总 job:输出各矩阵结果,并如实记录 **darwin-x64 未覆盖**(无真实 Intel runner,禁止 Rosetta 代产)。

## 验收证据

fork 首跑(https://github.com/ruijayfeng/agent-isles/actions/runs/35699586487 ,head `f778d5613e91f347303890f9c11d9a16283f31f1`):

- 架构守卫:macos-15 输出 `runner.arch=ARM64`、`uname -m: arm64`、`process.arch=arm64`;windows-2022 输出 `runner.arch=X64`、`uname -m: x86_64`、`PROCESSOR_ARCHITECTURE=AMD64`。macOS arm64 标签按首跑结果固定为 `macos-15`。
- Windows(Microsoft Windows Server 2022):`verify.ps1` 全过(安装提取、无外部链接、认证页面、正常 / 崩溃清理、单实例、原生模块、旧数据迁移 / 冲突),并从解包 ZIP 启动 `QCode.exe` 跟随 token 认证跳转得到 200,终止后端口释放。
- macOS:`verify-darwin.sh` 对解包 ZIP 全过(原生架构、冒烟就绪、退出清理、单实例、原生模块、迁移 / 冲突),`shasum -c SHA256SUMS.txt` 通过。
- 产物:`windows-x64-preview` 287,523,735 字节、`macos-arm64-preview` 139,974,843 字节(ZIP / 安装包 / SHA256SUMS 白名单)。
- 首跑发现并修复两个真实缺陷:`embedNodeRuntime` 只拷二进制导致官方动态链接 Node 产出坏包(补 `libnode.*.dylib`);`Launcher.swift` 冒烟模式数据冲突经 `NSApp.terminate` 静默以 0 退出(改 `exit(1)`,否则 verify 冲突断言形同虚设)。

## 限制

- 自动冒烟不替代 #19 的真实模型端到端与干净机器人工验收。
- Dependabot / 第三方 action 版本固定为当前 major(checkout@v4、upload-artifact@v4)。
Loading