npm 包与桌面安装包共享部分源码,却有不同版本入口和发布产物。一次脚本成功不能替代 registry 与安装器的最终验收。本文阅读 ChatCrystal 固定版本的发布脚本和工作流,通过离线计划检查标签分流,不执行发布、推送标签或打包应用。
两个版本入口,三种标签范围
根 package.json管理桌面版本;server/package.json管理 npm 包 chatcrystal,bin 将 crystal 映射到 dist/server/src/cli/index.js。它们在这个提交中同为 0.6.1,但可以独立演进。
| 标签 | release.yml 选择的路径 | 版本来源 |
|---|---|---|
| npm-v版本 | npm 发布 | 标签去掉 npm-v |
| electron-v版本 | Electron 发布 | 标签去掉 electron-v |
| v版本 | 两条路径 | Electron 取标签;npm 读取 server/package.json |
最后一行尤其重要:工作流并不是遇到 v0.6.2 就把两边都强制改成 0.6.2。正常 release 脚本会先同步相应清单;若手工构造标签却没有同步 server 版本,npm job 仍可能尝试一个已发布版本。
SemVer 是对兼容性的约定,不能只从数字递增推断兼容性。数据库结构改动是否兼容还取决于迁移与回滚。项目版本脚本识别显式版本的方式也不是完整 SemVer 验证器,应核对后续校验和实际变更范围。
版本脚本会产生真实外部动作
release.mjs根据 RELEASE_TARGET 选择 all、npm 或 electron,修改对应 package.json;npm 路径还同步 server.json 及其中包版本。然后通过 pnpm install –lockfile-only 更新锁文件,提交指定文件、创建附注标签,并向 origin/main 推送。
脚本检查 git diff 和暂存差异,却不检查未跟踪文件;它本身也没有先运行 lint/test。不能把“工作区检查”描述成覆盖所有文件或所有发版前校验。pnpm release、release:npm、release:electron 是实际发布入口,不适合作为阅读教程时随手运行的无副作用练习。
它也没有保证出错后自动撤销先前的清单、提交或标签。恢复时应先查清远端和发布平台的实际状态,再选择后续动作,而不是仅根据本地退出码判断整个发布是否已经发生。
npm job 校验的不只是编译通过
release.yml的 npm job 使用 Node 24.20.0 和 pnpm 锁定安装,依次执行 lint、test、依赖审计与签名检查、服务端构建、版本同步、MCP smoke、包内容检查,再 npm publish –provenance –access public。
包清单的 files 包含 dist/server、dist/shared、README.md 和 server.json。实际包还可能包含 npm 自动加入的文件,因此不能仅靠 files 宣布所有测试和源码必定排除。check-npm-package.mjs读取 npm pack –dry-run –json 清单,检查必要入口、类型和种子数据,拒绝测试产物、map 和非声明 TypeScript,并检查 server.json 名称、版本、stdio 传输及文件数量上限。
工作流声明 npm-publish environment 和 id-token:write,并使用 provenance。真正的 Trusted Publisher 仍需 registry 侧配置与支持条件,参见npm 官方文档。当前文件没有原稿所称的 NODE_AUTH_TOKEN 回退设置;environment 名称也不能证明已经启用人工审批。
Electron 构建有自己的资源与验收
Windows job 构建 server/client,再编译主进程,同步桌面版本并进行 MCP smoke。electron-builder.yml配置 x64 NSIS、可选择安装目录、当前用户安装及快捷方式;sql-wasm.wasm 作为额外资源,sql.js 和 .node 文件另有 asarUnpack 规则。
构建器用 –publish never 打包,随后工作流提取 CHANGELOG 并创建 GitHub Release,上传 exe 与更新清单。缓存键分别关注 Electron 版本和构建器配置,但本文没有实测缓存命中率、安装包节省量或发布分钟数。
固定工作流的打包重试还有一处失败出口问题:它在 if 命令的 fi 之后取 $?。当条件失败且没有 else 分支时,这个 if 语句本身返回 0,可能让最后一次失败以成功状态退出。把构建命令替换为 false、等待替换为空操作的离线检查能复现退出 0;在 else 中保存失败码的建议写法则退出 1。这是固定实现的已核验边界,本文没有修改工作流或执行真实构建器。
因此“有重试就保证失败会阻断”不成立。应验证成功一次、失败后成功、全部失败三个分支,再验收安装器可安装、启动和找到 WASM 等资源;单纯上传文件也不等于桌面可用。
离线检查分流计划与包清单
下面的 lesson.mjs 只处理内存字符串和数组,用 Node 24+ 运行,无依赖。它是教学简化计划,仅接受稳定版本三段数字,不是完整 release 脚本、SemVer 解析器或真实包检查器。0.6.2 是构造样例,不表示已经发布。
import assert from 'node:assert/strict';
function plan(tag, npmManifestVersion) {
if (/^npm-v\d+\.\d+\.\d+$/.test(tag)) {
return { npm: tag.slice(5), electron: null };
}
if (/^electron-v\d+\.\d+\.\d+$/.test(tag)) {
return { npm: null, electron: tag.slice(10) };
}
if (/^v\d+\.\d+\.\d+$/.test(tag)) {
return { npm: npmManifestVersion, electron: tag.slice(1) };
}
throw new Error('unsupported teaching tag');
}
assert.deepEqual(plan('npm-v0.6.2', '0.6.1'), { npm: '0.6.2', electron: null });
assert.deepEqual(plan('electron-v0.6.2', '0.6.1'), { npm: null, electron: '0.6.2' });
assert.deepEqual(plan('v0.6.2', '0.6.1'), { npm: '0.6.1', electron: '0.6.2' });
assert.throws(() => plan('release-latest', '0.6.1'), /unsupported/);
function checkFiles(files) {
assert.ok(files.includes('dist/server/src/cli/index.js'));
assert.ok(files.includes('server.json'));
assert.ok(!files.some((file) => /\.map$|\.test\.js$/.test(file)));
}
const files = ['dist/server/src/cli/index.js', 'server.json'];
checkFiles(files);
assert.throws(() => checkFiles(['server.json']));
assert.throws(() => checkFiles([...files, 'dist/leaked.test.js']));
console.log('release-plan and package-fixture guards pass');
把全量分支的 npmManifestVersion 改成 tag.slice(1),版本来源断言应失败;恢复后通过。删除包内容的禁止测试文件断言,最后一条 assert.throws 也应失败。反例证明这两个教学守卫确实有作用,没有证明真实 registry、OIDC、工作流权限或安装器已经通过。
完成发布要核对最终平台状态
标签或手动 dispatch 决定运行范围;npm 与 Electron job 都依赖 prepare,但可以独立成功或失败。一个失败不自动撤销另一边已经发布的结果。推送标签也不是发布事务:应记录 job 结论、registry 版本和 Release 资产,再按当前状态处理重试或后续版本。
发布流程可以自动化,验收范围仍要具体。包入口、资源、协议 smoke、安装启动和失败出口分别有证据,才支持相应结论;不能从一句“脚本已推送”推导完整发布成功。