将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出 docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。 ## 为什么要适配而不是照搬 原方案面向 Web/容器服务(有服务端、有镜像、有多环境)。nodeagent 是 跑在用户机器上的跨机接管框架,无服务端、无容器、无多环境。照搬会产生 「跑得很好但守不住任何东西」的假门禁。故每个阶段给三档判定: 适用 / 改造后适用 / 不适用(但必须给替代,不允许只写 N/A)。 关键替换: - 容器镜像扫描 → 交付物 PE 架构断言(arm64 包误装 x64 node.exe 的真实事故) - DAST → 协议健壮性测试(本项目对外输入面是 WebSocket RPC,非 HTTP) - dev/staging/prod → 真机矩阵(Win x64/arm64、macOS、Linux) - 金丝雀 → 设备分批自更新(先一台试点,latest.json 灰度) - Prometheus → system.metrics(闭环率/P95/拦截率)+ 发布后复核清单 ## 本次落地 1. 覆盖率门禁 scripts/ci/check-coverage.mjs - 全量**只观测不阻断**(实测基线 66.9%,照搬 80% 全量不现实) - 变更文件行覆盖率 ≥70% 阻断;单测 import 的是 dist/,需 src→dist 映射 - 无覆盖数据的变更文件不阻断,单列供人工判断(算 0% 会让重构无法合入) 2. 提交信息校验 scripts/ci/check-commit-msg.mjs - 项目实际已在用 Conventional Commits,但无强制 → 变成会红的门禁 - 不引 commitlint+husky:本项目只需类型白名单,几十行即可且 CI 本地都能跑 3. .github/workflows/quality-gate.yml:gitleaks + pnpm audit + typecheck + 覆盖率 + 提交规范 4. CI artifact retention-days 30 → 7(公开可下载,留存越久暴露面越大) 5. LKG(最后已知可用版本)apps/agent/src/lkg.ts - 只在**启动成功后**才写,不在更新写完时写(否则等于把未验证版本当已知可用) - 暴露为 system.info.lkg,matches=false 表示当前版本尚未被证明能启动 - 控制端据此暂缓向其余设备放量 ## 刻意不做的事:自动回滚 远程设备上无法区分「更新坏了」与「网络抖动」;自动回滚可能形成回滚循环; 被控端自作主张改自己会让控制端失去掌控。故 LKG 只提供确定的回滚目标, 决策权留给控制端。已在文档中写明取舍理由。 ## 实测证据 - 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0 - 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过 - LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false) - 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿 —— 这本身是门禁有效的实证 - 全量单测 341 → 349 全绿
321 lines
17 KiB
YAML
321 lines
17 KiB
YAML
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:
|
||
|
||
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
|