Files
nodeagent/.github/workflows/console-windows.yml
T
jkooe 9f95560268 build(ci): 控制台安装包接入 Release —— 修「修复发了但没人拿得到」
## 问题
`scripts/release.mjs` 只产出 agent / cli / mcp,`apps/console` 从来没接进去。
后果很具体:今天修的 6 个真机缺陷(转圈、状态卡死、退出确认、复用、断管、ws/wss)
**只存在于 CI artifact**,而 artifact retention 是 7 天。
用户从 Release 页面下载到的,永远是旧控制台。

## 做法
1. `console-windows.yml` 加 `workflow_call:`,并新增 `publish` job:
   - `if: startsWith(github.ref, 'refs/tags/v')` —— 只有 tag 才发布,日常分支构建跳过
   - 下载两架构产物 → 断言安装包数 ≥ 4(exe+msi × x64/arm64),少了直接报红,
     **绝不静默少发一个架构**
   - `gh release upload --clobber` 挂到 `release.mjs` 建好的那个 Release
   - release.mjs 在本地跑、与本工作流并行,故先轮询等 Release 出现(最多 10 分钟);
     等不到就给 `::warning::` 并跳过,不阻塞流水线
2. 新增 `console-release.yml`:只认 `push: tags: ['v*']`,`uses:` 调用上面那个工作流

## 为什么不直接把 tags 写进 console-windows.yml
它带 `paths` 过滤。`paths` 与 `tags` 并存时,tag 推送会被路径判定挡掉而**静默不触发** ——
失败形态是「什么都没发生」,最难察觉。拆成「日常验证走分支 + 发布只认 tag」最稳。

验证:PyYAML 解析通过;triggers / jobs 结构正确;shell 转义已核对。
(tag 发布路径需真打一次 tag 才能端到端验证,见下文待办)
2026-10-17 23:12:44 +08:00

380 lines
19 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Console Windows
# 控制台(apps/console)的 Windows 安装包构建。
#
# 为什么单独一个工作流、而不塞进 windows-e2e
# ----------------------------------------
# windows-e2e 是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
# 而控制台打包要装 Rust 工具链、编 Tauri(首次冷编 10 分钟级)。两者目标不同、
# 耗时量级不同,混在一起会让「改一行前端也要等 15 分钟」。
#
# ⚠️ 在此之前,控制台的 Windows 侧只被验证到 prepare:node 与自举(见 windows-e2e),
# **从未真跑过 tauri build**。而 Rust 里那些 `cfg!(windows)` 分支
# (taskkill 收进程树、PowerShell 计划任务、node.exe 路径)在 macOS 上
# 只被类型检查、从未被编译成 Windows 目标,更没有产物级验证 —— 故本工作流是它们的第一道真闸。
#
# 架构(为什么必须两套)
# --------------------
# 被控端发布包曾出过「arm64 包里装的是 x64 node.exe」的事故,后果是
# **在 ARM Windows 上能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
# 控制台同理:它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
# 故 x64 与 arm64 必须各出一套,且都要能装到对应架构的 Windows 上。
#
# 这里用 GitHub 的 ARM64 Windows runner(`windows-11-arm`,公共仓库免费)
# **原生构建**,而不是在 x64 runner 上交叉编译 —— 交叉编译需要 VS 的 ARM64 工具链,
# 且 `prepare-console.mjs` 的自检要真执行 node.exe(x64 机器跑不了 arm64 二进制),
# 会逼着引入「跳过自检」这类削弱验证的开关。原生构建则自检照跑、无特殊分支。
on:
push:
branches: [main]
paths:
- 'apps/console/**'
- 'apps/agent/**'
- 'packages/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/console-windows.yml'
pull_request:
branches: [main]
paths:
- 'apps/console/**'
- '.github/workflows/console-windows.yml'
workflow_dispatch:
# 供 `console-release.yml` 在 tag 推送时调用。
#
# 为什么不直接把 `tags` 加进上面的 push:`paths` 与 `tags` 同时存在时,
# tag 推送会因为「路径过滤」判定不通过而**静默不触发**(这是 GitHub Actions
# 的经典坑)。拆成「主工作流只跑分支 + 发布工作流只认 tag」最不容易出错。
workflow_call:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: 控制台安装包 · ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
# 一条架构挂了不要拖停另一条 —— 否则「arm64 失败」会掩盖「x64 到底成没成」
fail-fast: false
matrix:
include:
- arch: x64
runner: windows-latest
# x64 给两种安装形态:msi(企业批量部署)+ nsis(普通用户双击)
bundles: msi,nsis
pe: '0x8664'
- arch: arm64
runner: windows-11-arm
# arm64 只出 nsis:WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性
bundles: nsis
pe: '0xaa64'
steps:
- name: 检出代码
uses: actions/checkout@v5
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 12
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: 安装 Rust 工具链
uses: dtolnay/rust-toolchain@stable
- name: 缓存 Rust 构建产物
uses: Swatinem/rust-cache@v2
with:
# 只缓存 src-tauri(控制台壳),不牵动仓库根的 Rust 工作区
workspaces: apps/console/src-tauri
- name: 安装依赖
run: pnpm install --frozen-lockfile
# ⚠️ 这条断言是必需项,不是「顺手检查」。
# ARM runner 的镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64),
# 一旦 Node 也是模拟版,process.arch 会是 x64 → prepare-console 会把 x64 的 node.exe
# 打进 arm64 安装包 → **装到真机上能启动、TLS 握手不完成**,且本机 CI 全绿。
# 故先断言「我到底在什么架构上编」,再往下走。
- name: 断言构建机架构与目标一致
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$arch = node -p "process.arch"
Write-Host "node process.arch = $arch"
Write-Host "runner = ${{ matrix.runner }}"
if ($arch -ne '${{ matrix.arch }}') {
throw "架构不符:期望 ${{ matrix.arch }},实际 $arch —— 打进包的 node.exe 会是错的架构"
}
- name: 构建 workspace 包(含被控端)
# agent 的 dist 是 prepare-console 打包被控端的前置产物,顺序不能调
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# prepare-console 的前置检查要求 sidecar.mjs 已存在;它自己也会调 esbuild 打 agent.mjs
- name: 构建 sidecar 并准备随包资源(node + agent.mjs)
working-directory: apps/console
run: |
pnpm sidecar:build
pnpm prepare:node
# ⚠️ 交付物级断言(与 pack.mjs 的双架构校验同源思路):
# 不只看「脚本跑完了」,而是直接读最终要打进包的那个 node.exe 的 PE 头。
# 中间逻辑再自洽,也可能因为上游给了错架构的二进制而产出错包。
- name: 断言随包 node.exe 的 PE 架构
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$node = 'apps/console/src-tauri/runtime/node.exe'
if (-not (Test-Path $node)) { throw "缺少随包 Node 运行时:$node" }
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $node))
$peOff = [BitConverter]::ToUInt32($bytes, 0x3C)
$machine = [BitConverter]::ToUInt16($bytes, $peOff + 4)
$actual = '0x{0:x4}' -f $machine
Write-Host "node.exe PE Machine = $actual(体积 $([math]::Round($bytes.Length/1MB,1)) MB)"
if ($actual -ne '${{ matrix.pe }}') {
throw "随包 node.exe 架构错误:期望 ${{ matrix.pe }},实际 $actual —— 严禁发布该包"
}
- name: 打包安装包(${{ matrix.bundles }})
working-directory: apps/console
# beforeBuildCommand(vite build)由 tauri 自己触发;资源靠 --config 注入,
# 默认 conf 里绝不出现 runtime 路径(bundle.resources 是编译期契约,声明了不存在的文件 build.rs 会直接失败)
run: pnpm tauri build --config src-tauri/tauri.bundle.gen.json --bundles ${{ matrix.bundles }}
- name: 验证产物自举
# 真 spawn 产物内的 node 去跑产物内的 sidecar,走一次 JSON-RPC
# (这在 macOS 上抓出过 strip 破坏签名导致 SIGKILL 的问题,Windows 侧同样值得钉住)
run: node apps/console/scripts/verify-bundle.mjs --runtime
# ⚠️ 断言的是**最终交付物**,不是中间产物。
#
# 这一版是本步的第二次实现。首版写成「安装包体积 > 50MB」,理由是
# 「含 Node 运行时(约 80MB),故不可能是个小文件」—— 这个推理把
# **未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积**。
# 2026-10-11 首个 run 两个 job 双双死在这一行:实测 msi 34.2MB、
# nsis 19.5~23.2MB(LZMA 下 node.exe 自身就能压到 1/3~1/4),阈值必然误杀。
#
# 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言** —— 包大
# 不等于 node.exe 真在里面,包小也不等于没在里面(压缩率会随版本漂)。
# 故改为**解开包、查内容**,一次钉死三件事:
# ① 三个随包资源(node.exe / sidecar.mjs / agent.mjs)必须都在 ——
# 缺任一,装到真机上就是「应用能开、但托不起被控端」;
# ② node.exe 的 PE Machine 必须等于目标架构 —— 最终产物的架构断言,
# 直接对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故;
# ③ node.exe 解压后体积必须 > 40MB —— 挡住「塞了个占位文件」。
# 附带收益:真装一遍本身就是安装器冒烟(比只读目录更强)。
- name: 断言安装包内容(随包 node.exe 齐全且架构正确)
shell: pwsh
# 静默安装理论上会自行结束;给个上限,免得万一卡住把 job 拖满 60 分钟
timeout-minutes: 8
run: |
$ErrorActionPreference = 'Stop'
$expect = '${{ matrix.pe }}'
$bundle = 'apps/console/src-tauri/target/release/bundle'
function Get-PeMachine([string]$path) {
$fs = [System.IO.File]::OpenRead($path)
try {
$br = [System.IO.BinaryReader]::new($fs)
$fs.Position = 0x3C
$peOff = $br.ReadInt32()
$fs.Position = $peOff
if ($br.ReadUInt32() -ne 0x00004550) { throw "不是 PE 文件:$path" }
return '0x{0:x4}' -f $br.ReadUInt16()
} finally { $fs.Dispose() }
}
# 在候选目录里找出真正装了 node.exe 的那个(安装位置随安装形态而异)
function Find-PayloadRoot([string[]]$candidates) {
foreach ($c in $candidates) {
if (-not (Test-Path $c)) { continue }
$hit = Get-ChildItem -Path $c -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($hit) { return $c }
}
return $null
}
# 安装器可能「自己先退出、再由子进程继续装」,故给它一段收敛时间
function Wait-PayloadRoot([string[]]$candidates, [int]$seconds = 30) {
$deadline = (Get-Date).AddSeconds($seconds)
do {
$r = Find-PayloadRoot $candidates
if ($r) { return $r }
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $deadline)
return $null
}
# 解开后的目录里必须三样齐全,且 node.exe 架构要对
function Assert-Payload([string]$root, [string]$label) {
$node = Get-ChildItem -Path $root -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
$side = Get-ChildItem -Path $root -Recurse -Filter 'sidecar.mjs' -ErrorAction SilentlyContinue |
Select-Object -First 1
$agent = Get-ChildItem -Path $root -Recurse -Filter 'agent.mjs' -ErrorAction SilentlyContinue |
Select-Object -First 1
$listing = (Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
Select-Object -First 40 -ExpandProperty FullName) -join "`n"
if (-not $side) { throw "$label 内未找到 sidecar.mjs`n实际内容:`n$listing" }
if (-not $agent) {
throw "$label 内未找到 agent.mjs —— 被控端未随包,装上后启动不了本机被控端`n实际内容:`n$listing"
}
if (-not $node) {
throw "$label 内未找到 node.exe —— 随包 Node 运行时未打入,装上后一启动就找不到 node`n实际内容:`n$listing"
}
$mb = [math]::Round($node.Length / 1MB, 1)
$machine = Get-PeMachine $node.FullName
Write-Host " node.exe $mb MB PE Machine=$machine"
Write-Host " sidecar.mjs $([math]::Round($side.Length / 1KB)) KB"
Write-Host " agent.mjs $([math]::Round($agent.Length / 1KB)) KB"
if ($mb -lt 40) { throw "$label 内 node.exe 仅 $mb MB —— 体积异常,疑似占位文件" }
if ($machine -ne $expect) {
throw "$label 内 node.exe 架构错误:期望 $expect,实际 $machine —— 严禁发布该包"
}
}
$msis = @(Get-ChildItem -Path "$bundle/msi/*.msi" -ErrorAction SilentlyContinue)
$nsis = @(Get-ChildItem -Path "$bundle/nsis/*.exe" -ErrorAction SilentlyContinue)
if (($msis.Count + $nsis.Count) -eq 0) { throw "未产出任何安装包($bundle 下为空)" }
$checked = 0
foreach ($m in $msis) {
Write-Host "── MSI:$($m.Name) $([math]::Round($m.Length / 1MB, 1)) MB"
# 管理安装(/a)= 只解包不注册,正合此处用途
$dest = Join-Path $PWD 'pkg-extract/msi'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$log = Join-Path $PWD 'msi-extract.log'
# ⚠️ 必须用 Start-Process -Wait -PassThru 取退出码,**不能**用 `& msiexec.exe`:
# msiexec 是 GUI 子系统程序,脚本里 `&` 调它**既不等待、也不设 `$LASTEXITCODE`**
# (实测:整个步骤 0.8 秒就跑到下一行,`$LASTEXITCODE` 为空 → `$null -ne 0` 判真 → 当场抛错)。
# 参数拼成**单个字符串**传递,避免 -ArgumentList 数组被 PowerShell 自动加引号而破坏 /D= 语义。
$argLine = "/a `"$($m.FullName)`" /qn TARGETDIR=`"$dest`" /l*v `"$log`""
$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $argLine -Wait -PassThru
Write-Host " msiexec 退出码 = $($p.ExitCode)"
# 3010 = 成功但需重启,不是失败
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
$tail = (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue) -join "`n"
throw "msiexec /a 失败(退出码 $($p.ExitCode)):$($m.Name)`n$tail"
}
$root = Wait-PayloadRoot @($dest)
if (-not $root) { throw "MSI 管理安装后未在 $dest 下找到 node.exe" }
Assert-Payload $root $m.Name
$checked++
}
foreach ($n in $nsis) {
Write-Host "── NSIS:$($n.Name) $([math]::Round($n.Length / 1MB, 1)) MB"
# /S 静默、/D 指定目录:两者都由 NSIS **运行时自身**在 .onInit 之前解析
# (核心参数,不依赖 Tauri 模板),故对任何 NSIS 安装包都成立。
# /D= 必须是最后一个参数且**不加引号**,故这里用仓库目录下的相对路径
# (不含空格),而不是通常带空格的用户目录。
$dest = Join-Path $PWD 'pkg-extract/nsis'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dest | Out-Null
# 同 MSI:安装器也是 GUI 子系统程序,必须 Start-Process -Wait 才拿得到退出码。
# 单个字符串传参 → `/D=` 保持原样不加引号(NSIS 的硬要求)。
$p = Start-Process -FilePath $n.FullName -ArgumentList "/S /D=$dest" -Wait -PassThru
Write-Host " 安装器退出码 = $($p.ExitCode)"
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "NSIS 静默安装失败(退出码 $($p.ExitCode)):$($n.Name)"
}
# /D= 理论上一律生效,但若模板在 .onInit 里改写了 $INSTDIR 就未必;
# 故把默认安装位置一并列为候选,避免「装成功了却因为找不到目录而误报」。
$root = Wait-PayloadRoot @(
$dest,
(Join-Path $env:LOCALAPPDATA 'nodeagent-console'),
(Join-Path $env:LOCALAPPDATA 'Programs/nodeagent-console'),
(Join-Path $env:ProgramFiles 'nodeagent-console')
)
if (-not $root) {
throw "NSIS 静默安装后未找到随包 node.exe —— 已查:$dest 与默认安装位置"
}
Write-Host " 安装位置:$root"
Assert-Payload $root $n.Name
$checked++
}
Write-Host ""
Write-Host "✓ 已核对 $checked 个安装包:node.exe / sidecar.mjs / agent.mjs 齐全,node.exe 架构 = $expect"
- name: 上传安装包
uses: actions/upload-artifact@v4
with:
name: nodeagent-console-windows-${{ matrix.arch }}
path: |
apps/console/src-tauri/target/release/bundle/msi/*.msi
apps/console/src-tauri/target/release/bundle/nsis/*.exe
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
if-no-files-found: error
# 交付流水线 §4:CI artifact 公开可下载,留存越久暴露面越大,30 → 7
retention-days: 7
# 发布:tag 推送时把两个架构的安装包补挂到 release.mjs 建好的 Release 上。
#
# 为什么必须有这一步(2026-10-17 真机教训)
# --------------------------------------
# `scripts/release.mjs` 只产出 agent / cli / mcp,控制台的 NSIS/MSI
# **从未进入过 Release** —— 后果是:用户从 Release 页面下载到的永远是旧控制台,
# 而当天修的 6 个真机缺陷只存在于 CI artifact(retention 7 天,到期自动删除)。
# 一句话:**修复发出去了,但没人拿得到。**
publish:
name: 补挂安装包到 Release
# 只有 tag 才发布;分支构建(日常验证)一律跳过
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
steps:
- name: 下载两个架构的安装包
uses: actions/download-artifact@v4
with:
path: dist
pattern: nodeagent-console-windows-*
- name: 列出待发布文件(先看清再上传)
run: |
set -euo pipefail
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -printf '%10s %p\n' | sort -k2
n=$(find dist -type f \( -name '*.exe' -o -name '*.msi' \) | wc -l)
echo "共 $n 个安装包"
# 两架构各一套(exe+msi)= 4 个;少一个说明产物不全,宁可红也不要静默少发
if [ "$n" -lt 4 ]; then echo "::error::安装包数量不足($n < 4),架构可能缺失"; exit 1; fi
- name: 挂到对应 Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# release.mjs 在本地跑、与本工作流并行,故先等 Release 出现(最多 10 分钟)
for i in $(seq 1 60); do
if gh release view "$TAG" >/dev/null 2>&1; then echo "✓ Release $TAG 已存在"; break; fi
echo "等待 Release $TAG 创建… ($i/60)"; sleep 10
done
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "::warning::Release $TAG 尚未创建,安装包仅留在 CI artifact。请先跑 scripts/release.mjs"
exit 0
fi
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -print0 \
| xargs -0 gh release upload "$TAG" --clobber
echo "✓ 已挂上的资产:"
gh release view "$TAG" --json assets -q '.assets[].name'