---
title: 发布工程：npm、Electron 与标签驱动的 CI
description: 对照 ChatCrystal 的版本脚本和发布工作流，用离线计划验证标签分流、包内容和失败出口，再明确发布后仍需验收的结果。
image: "https://zengliangyi.com/og/blog/npm-release-ci.png"
---

npm 包与桌面安装包共享部分源码，却有不同版本入口和发布产物。一次脚本成功不能替代 registry 与安装器的最终验收。本文阅读 ChatCrystal 固定版本的发布脚本和工作流，通过离线计划检查标签分流，**不执行发布、推送标签或打包应用**。

## 两个版本入口，三种标签范围

[根 package.json](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/package.json)管理桌面版本；[server/package.json](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/scripts/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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/.github/workflows/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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/scripts/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 官方文档](https://docs.npmjs.com/trusted-publishers/)。当前文件没有原稿所称的 NODE_AUTH_TOKEN 回退设置；environment 名称也不能证明已经启用人工审批。

## Electron 构建有自己的资源与验收

Windows job 构建 server/client，再编译主进程，同步桌面版本并进行 MCP smoke。[electron-builder.yml](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/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 是构造样例，不表示已经发布。



```js
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、安装启动和失败出口分别有证据，才支持相应结论；不能从一句“脚本已推送”推导完整发布成功。

分享：

##  相关文章

[##  AI 对话格式差异：事件、快照与跨库消息定位

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)    [##  五种 AI 对话解析器：字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "发布工程：npm、Electron 与标签驱动的 CI",
  "description": "对照 ChatCrystal 的版本脚本和发布工作流，用离线计划验证标签分流、包内容和失败出口，再明确发布后仍需验收的结果。",
  "url": "https://zengliangyi.com/blog/npm-release-ci/",
  "image": "https://zengliangyi.com/images/blog/npm-release-ci.svg",
  "datePublished": "2026-10-06T00:00:00.000Z",
  "dateModified": "2026-10-06T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/npm-release-ci/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "发布工程：npm、Electron 与标签驱动的 CI",
      "item": "https://zengliangyi.com/blog/npm-release-ci/"
    }
  ]
}
```
