Windows 安装器与标签发布流水线:让「出一个正式安装包」变成推一条标签

这篇钻进发布工程的细节:品牌图标怎么从一张源图确定性地产出,assisted 安装器和 NSIS 自定义脚本有哪些坑,版本号怎么收口成一个真源,标签触发的流水线长什么样,以及一个 dev 下一切正常、真机装包启动即挂的原生绑定问题。

图标:品牌资产必须脚本化可复现

最早的 exe 挂着 Electron 默认图标。换上自己的源图后更尴尬------设计师给的图在大尺寸下很好看,直接缩到 16px 就糊成一团紫块:源图主体只占画面约 88%,四周留白在小尺寸下把主体挤没了。

在画图工具里手工导七八个尺寸是绝对不走的路:没法评审、没法复现、换一张源图就要重来一遍。源图本身一并入仓,不依赖作者本机上的任何设计文件;入仓的生成脚本基于 Python + Pillow,做三件事:

  1. 按主体包围盒紧裁(源图 636×612,裁掉四周留白后方形化),并断言裁切结果确为方形;
  2. 从紧裁后的图一次产出 ico 多帧(16 到 256)、安装器用的 164×314 侧栏 BMP、web 端 favicon;
  3. 幂等:同一份源图重跑,产物逐字节一致。
text 复制代码
源图入仓 → 紧裁(主体包围盒)→ 16/32/48/.../256 多帧 ico
                          → 164×314 NSIS 侧栏 BMP
                          → web favicon

图标从此和代码一样走评审、走 diff、可重算。品牌资产的标准不是「我机器上有个设计稿」,而是「全新克隆仓库后跑一条命令,能得到同样的字节」。

assisted 安装器:把选择权和数据边界都摆到台面上

安装器从一键包改成 assisted 形态(electron-builder 的 oneClick 关闭):用户可以改安装目录、选程序组、决定要不要桌面和开始菜单快捷方式;界面简中默认、可切英文;安装前有一页 GPL 许可;控制面板的卸载项显示名直接带上版本号,多个版本共存时一眼分得清。

卸载策略是这个产品最重要的决策之一,三档行为写死:

卸载方式 行为
交互式卸载(用户从控制面板发起) 默认保留数据;勾选删除时弹二次询问,确认后才清理配置目录与用户主目录下的两个真实数据位置
静默卸载(更新器跨版本以静默参数调旧卸载器) 永不进删除分支,不询问,数据原样保留
安装目录本身 自定义卸载钩子兜底删空壳目录(见下面的竞态),非空绝不强删

本地优先应用的数据删除不可逆,「默认保留 + 危险路径二次确认 + 静默永不删」是三道独立保险。

NSIS 自定义脚本被编译两次

安装器的自定义逻辑放在 installer.nsh 里,这个文件有个反直觉的特性:它同时参与安装器和卸载器的构建。平台相关的变量声明与钩子必须包在平台 ifdef 内,否则安装器目标会报「Var 声明但未引用」的警告(warning 6001),而构建配置开了 warningsAsErrors------警告直接变成构建失败。

第二个坑是卸载器的自删除竞态。卸载器删除自己所在的安装目录时,卸载进程本身还在那个目录里,目录删除与进程退出赛跑,真机上稳定残留一个空壳目录。修法是在自定义卸载钩子里对安装目录再做一次兜底删除:空目录才删,非空(用户往里放过自己的文件)绝不递归强删。

这类问题只有真机装一遍、再卸一遍才会出现,也正是「安装器配置写完」和「安装器真的能用」之间的距离。

版本号:四个手写值收成一个真源

版本号曾经同时手写在根 package.json、web 端、桌面壳和 shared 包四处。后果很实际:health 接口在安装版上报的是一个旧版本号,排查问题时连用户跑的是哪一版都对不齐。

收口方案是一个同步脚本,以根 package.json 为唯一真源,把版本写向其余三处:

bash 复制代码
node scripts/sync-version.mjs          # 写入四处
node scripts/sync-version.mjs --check  # 漂移即非零退出,CI 里跑

shared 包同时导出一个 APP_VERSION 常量,MCP 握手的 serverInfo/clientInfo、health 与 system 信息接口全部读它。从此版本号要么四处一致,要么 CI 直接红,不存在第三种状态。

标签触发的发布流水线

流水线两个入口:推送 v* 标签走完整发布,手动触发只取包不发布。手动入口供日常验证打包流程使用:测试与打包一步不少,但不碰任何 Release,产物只作为流水线 artifact 留存,改了打包配置可以放心反复跑。

标签入口的阶段顺序:

  1. 硬校验:标签号与根 package.json 的版本必须一致,不一致直接失败;再跑版本同步脚本的 --check;
  2. 全量单测(Windows runner 上);
  3. 构建 web 端 standalone 产物;
  4. 构建桌面壳、归集服务端文件;
  5. electron-builder 打包,标签触发时直接创建 draft Release(约 132MB 的安装包加 blockmap、更新描述文件三件套,draft 不对外可见,发行说明人工填好再转正);
  6. 产物自检脚本:校验解包目录里 exe 的版本资源与图标;配置了证书时加验签名链;
  7. 产物上传 artifact,供手动入口下载留档。

流水线首跑踩了两个实打实的坑,最后都修在流水线配置里:

  • CRLF 假漂移:Windows runner 检出时换行被转成 CRLF,版本检查逐字节比较时误判四处版本不一致。检查前先规范换行,让比较只对内容负责;
  • 创建 Release 403 :工作流默认没有 contents 写权限,调创建 Release 的接口稳定 403。在工作流里显式声明 contents: write 权限后通过。

两个都是「本地脚本全绿、一上 CI 就挂」的经典环境问题。流水线的原则由此很明确:一切前置条件硬失败,不把模糊状态带进打包阶段。

真机热修:dev 正常、装包启动即挂的原生绑定

流水线跑通后,真机安装包第一次启动直接崩,开发模式下却完全正常。根因在 sqlite-vec 的原生绑定:向量扩展的 vec0.dll 由一个按平台命名的包在运行时按平台 require,而 Next.js standalone 的文件追踪默认追踪不到这个条件加载的原生模块------产物里压根没有这个 dll,dev 时模块从 node_modules 直接加载,当然一切正常。

修法是在服务端归集脚本(prepare-server)里把该 Windows 平台包加入显式白名单,强制收进 standalone 产物。这类问题没有任何静态检查能替你发现,所以它被固化进发布链路:每次出包都经过归集脚本,产物自检再核对一遍,而不是靠作者记得。

教训也泛化了一条:凡是运行时按平台条件加载的原生模块,都要对着真实打包产物核对文件清单,dev 的 node_modules 解析路径给不了任何保证。

单人项目的流水线三原则

回头看,这条流水线的设计原则只有三条:

  1. 一切产物可重算------图标、版本号、安装包,删光重来,跑同一套命令得到同样的东西;
  2. 一切前置条件硬失败------标签不符、版本漂移、测试不过,立刻停,绝不产出半可疑的包;
  3. 「可能永远没有」的东西做成双态------签名证书也许很久都不会买,但无证书路径必须同样完整、同样经过自检,而不是留一个 TODO 占位。
相关推荐
flash俊杰2 小时前
深色模式、无障碍门禁与零依赖自研 i18n:发布前的完检三件套
electron
不可能片场7 小时前
Electron 发布进程被回收 用脱离进程组托住
前端·electron
月走乂山2 天前
DSH 桌面版用 DeepSeek 报 REQUEST_EXTENSION 的根因定位与修复
electron·node.js·插件系统·deepseek·bug排查·dsh·deepseek harness
mont evergreen2 天前
一封从外部世界寄来的信:二维码数据如何穿越三重门,抵达你的屏幕
electron·vue·事件
fellow993 天前
DSH Desktop:把 DeepSeek Harness 变成真正的桌面应用
electron·agent·ai编程·deepseek
CDwenhuohuo4 天前
electron pc项目打包成桌面端
前端·javascript·electron
不可能片场5 天前
命令行中文变问号 我用环境变量救了场
前端·electron
不可能片场5 天前
AI视频口型对齐 用音频驱动说话镜头
前端·electron