# 发布流程 目标:让每个版本号值得点开。发布频率是给用户的信号预算——攒批发、过门槛、高危改动走 rc。 与 dsh-safe 的单包流程不同,本仓库是 monorepo:一次发布可涉及多个包,**tag 锚点是 `@hyzyn/dsh-all`**(用户视角的聚合包,沿用 v0.1.15 起的惯例,workflow 会强校验)。 ## 一次性配置 GitHub 仓库 Settings → Secrets and variables → Actions 新增 `NPM_TOKEN`。token 在 npmjs.com → Access Tokens → Generate New Token(granular)生成: - **Packages and scopes**:勾选要发布的包(或 Select all packages); - **Permissions**:必须 **Read and write**(只读 token 发包会被拒); - **2FA**:选 bypass(CI 没法输动态码)。 未配置时 Release workflow **直接失败**(`::error::` + 退出 1),不会建 GitHub Release。 早期行为是「warning + 跳过发布」,结果是 workflow 全绿、包一个没发上 npm、却照样建出 Release 页——绿条里的假成功比红条难发现得多,所以改成硬失败。补上 secret 后重跑同一个 tag 即可(幂等)。 ### 本地钩子(提交前闸门) ```sh git config core.hooksPath .githooks # 克隆后必做一次,否则钩子不生效 ``` `.githooks/` 下两个钩子: | 钩子 | 作用 | | --- | --- | | `pre-commit` | ① 挡下「源码有未暂存改动、产物却要入库」的提交;② `pnpm -r build` 重建产物并 `git add`(保证入库的 `lib/`、`client.js` 与源码同步);③ 按 index 依次扫五道:i18n(`check-i18n`)→ 公网 IP(`check-no-public-ip --staged`)→ 内部标识(`check-internal-identifiers --staged`)→ README 常量一致性(`check-readme-facts --staged`)→ 中英 README 结构(`check-readme-structure`) | | `commit-msg` | 扫**提交信息**里的公网 IP(`--message-file`)与真实内部标识(`--message-file`) | > 判据与每道的放行口径以 [docs/conventions.md § 提交](./docs/conventions.md#提交) 为准,本表只列动作名。 **为什么提交信息也要扫**:`filter-repo` 的 `--replace-message` 默认不开,只重写工作区内容; 提交信息是要入库的另一份内容,漏进历史只能**重写全部历史**来补救 (所有 sha 变化、全部文档引用失效、远端必须 force push)。重写的代价远高于在入口拦一次。 闸门按用途分类放行:私网 / 回环 / 链路本地 / CGN / ULA / RFC 5737 文档网段(`192.0.2.0/24`、 `198.51.100.0/24`、`203.0.113.0/24`)/ RFC 2544 基准段 / 显式占位符白名单。文档与测试夹具 **请用文档网段**,别写真实地址。 CI 另有两道兜底(`--no-verify`、别的机器、别的工具提交都能绕过钩子):自检分类器 + 全仓扫描, 以及只查本次推送新增提交的信息。 ## 发布门槛(每次 tag 前过一遍) 1. `pnpm -r build && pnpm -r typecheck && pnpm test` 全绿。本地 `pnpm publish` 没有闸, 全靠自觉——CI 在发布前会再跑一遍兜底。 **`pnpm test` 不包含端到端脚本**:`packages/*/scripts/integration.mjs` 这类要真宿主 / 真 PTY / 内存 sshd 的用例是 CI 单独一步跑的(`ci.yml` 的 `dsh-tty end-to-end scripts`), 途经 `lib/` 行为面(工具 output.schema、采集通道的 exec 次数、订阅生命周期)的改动必须 补跑对应包的那一条,否则只能等 CI 红(v0.37.0 → v0.37.1 就是这么出的 hotfix 链: `tty_stats` 漏声明 `swapUsed` / `swapTotal`、Ping 探针在订阅前就开始 exec,全部躲过了 `pnpm test`): ```bash pnpm --filter @hyzyn/dsh-tty run integration # tty:真宿主 / PTY / 内存 sshd ``` **bump 完版本号先同步 lockfile**,且顺序必须是:**bump → `pnpm aggregate`(聚合层 钉新版本)→ `pnpm install --lockfile-only`**。workspace 内部依赖是按版本号写进 lockfile 的(`specifier:` 那一行),只改 package.json 不改 lockfile,CI 第一步 `pnpm install --frozen-lockfile` 就会红——v0.1.22 就是两次卡在这里(两次都只 同步了 package.json,白等两轮 CI);顺序颠倒同样会红:aggregate 晚于 install, 聚合层重钉的 specifier 没进 lockfile(v0.1.24,第一轮 Release 就折在这)。一条 命令过完前三项: ```bash pnpm aggregate && pnpm install --lockfile-only \ && pnpm -r build && pnpm -r typecheck && pnpm test git diff pnpm-lock.yaml # 自查:应能看到本轮 specifier 变化;没有 = 没同步上 ``` 2. `pnpm aggregate` 无 diff。聚合层(根 `cordis.patch.yml`、`packages/all`)必须钉住 本次要发的插件版本;CI 与 Release workflow 都强制检查,过期直接红。 同一步顺带跑 `pnpm package-contents:check`:它真的打一次包,断言 `main` / `exports` / `dsh.bundle.patch` / `client.js` 这些**声明的入口都在 tarball 里**。`files` 是手工清单, 漏一项**发出去就只能靠新版本补救**——而挂载车道走 `link:`、绕过 `files` 字段,看不见它 (2026-10-03 实测:删掉 tty 的 `client.js` 后十三道闸门 + 1700 条用例仍全绿)。 理由与守卫形态见 [docs/conventions.md § 发包内容守卫](docs/conventions.md#发包内容守卫files-字段漏一项不许静默)。 3. 动了插件运行行为的改动,真实装进 DSH 跑一遍:`dsh plugin --profile add @hyzyn/dsh-`(或 link: 路径调试)。build 绿不等于装上没问题。 tty 的 integration/live/ssh-smoke、codegraph 的 5 个 `verify-codegraph-*-smoke`、mcp 的 http-smoke/tools-smoke、rss 的 opml-smoke(以及根 `scripts/verify-*.mjs` 全部 9 条)都需要真机, 按需本地跑——它们**也都不在 CI 里**(真机脚本进不了 CI,见 [docs/conventions.md § 真机脚本与 CI 接线](docs/conventions.md#真机脚本与-ci-接线)); 清单与可粘贴命令见 `pnpm verify:list`。 **能力开关 / 权限 / 文案类改动跑一次 `pnpm live-smoke`**:它从你已有的「link 到本仓」的 profile 复制两份一次性 profile,起两个真宿主(无授权 / 带授权各一),把授权阶梯、路由门控、 agent 工具清单、试连文案、宿主正在服务的 `client.js` 全打一遍,跑完删干净——就是本节 「真实装进 DSH 跑一遍」的自动化形态(未装 DSH 时打印 SKIP,加 `--strict` 可让它变成失败)。 两个实例的**授权目录也用一次性 profile 里的 `.kit-home` 隔离**(`DSH_KIT_HOME`,kit D12): 带外授权是持久的,不隔离的话「无授权」那一半会在你用过提权卡片的机器上继承授权、九条断言 全变成假红。**机器上没有 link profile(干净 VM / 新克隆)时加 `--bootstrap`**:它会从 dsh 自带的 `web` 模板现场造一个(link 本仓的 docker/tty、自证插件真进了阵容),跑完连它一起删。 **加 `--render` 还会用无头浏览器打开界面**,断言本仓插件的 `client.js` 真的被加载、无未捕获 异常——这是唯一能看见「构建绿 / 单测绿 / 用户白屏」那类失败的验收(受限环境要配 `--chrome-arg --no-sandbox`)。 **2026-10-03 起它也在 CI 与发布流水里跑**(`ci.yml` 的 `mount-smoke` job + `release.yml` 对应 step,形态 `--bootstrap --strict --render --chrome-arg --no-sandbox`)——本地手工跑仍是 主要用法,但发布路径不再只依赖「你记得跑」。决策记录见 [docs/conventions.md § 挂载车道](docs/conventions.md#真机脚本与-ci-接线)。 **tag 前建议看一眼 nightly**(`gh run list --workflow=nightly.yml --limit 3`):它每天跑并发 flake 与 coverage 棘轮。两条都不挡发版,但**带着一条已知红状态发版是错的信号**—— 尤其 flake 那条:本仓已经有三次「发了版才发现用例在赌墙钟」。两条车道的分工与为什么不进 CI 见 [docs/conventions.md § 车道分工](docs/conventions.md#车道分工哪条车道管什么)。 4. 各包依赖**不要写 `workspace:*`**。它只在 monorepo 内部有效:`pnpm publish` 会把它 换成真实版本(所以 npm 产物看起来是对的),但用户从 git 子路径安装 (`git+https://github.com/hyzyn/dsh-plugin-kit.git#main&path:packages/tty`)时协议 原样保留,pnpm 报 `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` 装不上——而报错指不到本仓库, 只能靠用户来提 issue。写真实版本(如 `^0.1.2`)即可;本地开发靠根 `.npmrc` 的 `link-workspace-packages=true` 仍然链接到 `packages/*`,体验不变。 CI 与 Release workflow 都会跑 `node scripts/check-publishable.mjs` 兜底。 **例外是 `@hyzyn/dsh-kit`:它必须是精确版本,且全仓一致、等于 `packages/kit` 自己的 版本。** kit 的跨插件一致性(授权存储、能力绑定,`kit D11`)靠**模块级单例**, 而 pnpm 的 hoisted 布局只能把一个版本提升到顶层、另一个嵌进消费者自己的 `node_modules`——两份 kit 就是两套单例,`kit D11` 的症状会回来。所以 bump kit 时必须 跟齐**每一个**消费者,一次发完;`node scripts/check-kit-pins.mjs` 兜底(CI 与 Release workflow 都跑)。 **新包的钉子不用手抄**:`pnpm create-plugin` 生成时从 `packages/kit/package.json#version` **现算**([scripts/create-plugin-manifest.mjs](scripts/create-plugin-manifest.mjs)), 模板 [templates/hello/package.json](templates/hello/package.json) 里的字面值只是给人看的 参考、**不是真相源**。模板仍要在同一轮跟着 bump(见下面「攒批」那一步),但**漏改不会再让新包 带着旧钉子上路**——而那条漂移此前是无人会红的:`templates/` 不在 `collectKitPins()` 的扫描面, 模板也不在 `pnpm-workspace.yaml` / `pnpm-lock.yaml` 里。 **这条规矩的另一半同样成立:`packages/kit` 的 `src` / `lib` 只要有改动,就必须 bump kit ——即使没有任何消费者需要改代码。** 理由见下面第 6 条:npm 上一个版本号只能对应一份内容, 而发布流按版本号跳过;kit 内容变了却没 bump,消费者钉到的就还是旧内容。 5. 可安装插件必须用 `peerDependencies` 声明 DSH 兼容范围。DSH 0.1.7-rc.1 起 **安装前与启动时**都强制校验,且只看 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 的 peer(预发布参与范围匹配)。写法统一为**逐 cohort 写全**: `"@deepseek-ai/dsh": "^ || … || ^"`(当前十三档是 `^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-alpha.5 || ^0.2.1-rc.1 || ^0.2.1-rc.2 || ^0.2.1-rc.3 || ^0.2.1-rc.4 || ^0.2.1-rc.5 || ^0.2.2-alpha.3 || ^0.2.2-rc.1`;单 cohort 时就是 `^`), 并在 `peerDependenciesMeta` 里标 `optional`——只压 pnpm 的 unmet-peer 噪音, DSH 判定器不看它,照旧强制。 被判定不兼容的插件安装时抛 `incompatible-version`、启动时整行 `disabled` (bundle 则整包被 skip);豁免要写进 profile 自己的 `compatibility.json` (`dsh plugin allow-version`)。 同时保留 `dsh.engines.dsh: ">=<最低档 cohort>"`(市场解析器唯一支持的 `>=X.Y.Z[-预发布]` 形式,写 `||` 会被判「无法验证」)作为**展示**位:rc.1 宿主不读它,但插件市场 / 社区条目仍按它展示兼容性——它必须与 peer 覆盖的最低档一致,脚本会拦。 cohort 增删时同步改**全部**包与 `scripts/check-dsh-peers.mjs` 里的 `DSH_COHORTS` (列表,旧 → 新;新插件从 `templates/hello` 复制,模板也要跟着改)。**每加一档都必须 实测过**:用该 cohort 的 `app-boot` 判定器逐包核对,并至少跑一遍真宿主加载 (`dsh plugin add` 不需要豁免 + 启动后无 `skipping profile bundle`), 写进对应 README 的兼容性段。注意加档分两种:**修边界**(老段本来就判死它,例如 `0.2.0-rc.1` 落在 `>=0.1.7-rc.2 <0.2.0-0` 之外)和**显式化**(老段其实已覆盖它, 例如 `0.2.0-rc.2` 落在 `^0.2.0-rc.1` 内、`0.2.1-alpha.1` 与 `0.2.1-alpha.2` 落在 `^0.2.0-rc.2` 内、 `0.2.1-rc.1` 落在 `^0.2.1-alpha.5` 内)——后者通常在**宿主行为上零差异**,但同样要走完 实测,否则那一段就是一句没有证据的声明。⚠️ **「零差异」不是显式化的定义、只是常态**: `0.2.1-alpha.5` 也落在 `^0.2.1-alpha.2` 内(显式化),可它那一档**真的改了宿主行为**—— 判定器字段名 `peers` → `requirements`、顶层 `engines.dsh` 开始短路 peer、凭据层级翻面、 macOS picker 换 JXA。加这种档不能照抄前几档的「只改声明」说法:要么按修边界的力度逐条比对 API 面与本仓引用,要么别加(逐条见 `scripts/check-dsh-peers.mjs` 头部的 cohort 说明)。 退役 cohort 前先确认没有宿主还在服役。 CI 与 Release workflow 都会跑 `node scripts/check-dsh-peers.mjs` 兜底;本机装了对应 cohort 时可用 `--app-boot ` 让 DSH 自己的判定器对**每一档**再核一遍。 6. **已发布版本的内容必须与本仓一致**。`pnpm release:preflight`(`release:publish` 在真正 publish 之前会自动跑一遍,所有包都还没发)对每个**已在 registry** 的「包名@版本」下载 tarball,把 `lib/**`(不含 `.map`)/ `client.js` / `cordis.patch.yml` 与本仓逐字节比对; 不一致立即中止,并点名包、版本与文件。判据与实现见 [`scripts/lib/published-content.mjs`](scripts/lib/published-content.mjs) 头注释。 这一条补的是发布流的**版本级跳过**:跳过只看「版本在不在」,而 npm 上一个版本号只能对应 一份内容——**内容变了就必须 bump**,否则消费者钉到的还是旧内容。 **2026-10-17 的现场(外部 issue #12)**:CG67 修复往 `@hyzyn/dsh-kit` 加了 `stripEmptyFlowSequenceRoot` / `…RootLines` 两个导出并让 codegraph / mcp / env / prompt 直接消费,但 kit 的版本仍停在 0.5.2(10-07 发的那份),`lib/` 却已重建入库。发版提交只 bump 了消费者,发布流对 kit@0.5.2 打印「已在 registry,跳过」,四个消费者带着新 import 发了出去。用户装到 codegraph 0.6.9 + kit 0.5.2 就是 `The requested module '@hyzyn/dsh-kit' does not provide an export named 'stripEmptyFlowSequenceRootLines'`,整条 entry 不激活。**当时十几道闸门全绿**: kit 钉子闸只比版本号是否一致(本地 kit 的版本就是 0.5.2,自洽)、开发与测试走 workspace link 永远解析到本地新 kit、`artifacts:check` 只比「入库产物 vs 重新构建」、 `package-contents:check` 只比「声明的入口在不在 tarball 里」。没有一道闸门问过 「npm 上这个版本里的代码,和本仓这份代码是不是同一份」。 **推论(写进日常)**:只要动了 `packages/kit` 的 `src` / `lib`,就顺手 bump kit 版本; 发 tag 前先跑 `pnpm release:preflight`,红了先 bump 再打 tag。 ## 攒批 - 多个 fix 攒一个版本发;feature 单独发。目标一天 ≤ 1–2 个版本。 - 版本号语义(各包独立):**patch** = 不改行为的修复;**minor** = 新能力或行为变化。 用户靠这个决定要不要更。发版时受影响的包各自 bump;只要有插件变化, `@hyzyn/dsh-all` 和根 `@hyzyn/dsh-plugin-kit` 也要 bump。 - 出 hotfix 链(x.y.1 → x.y.2 → x.y.3)通常说明上一版发布前没过门槛——回看是哪步省了, 而不是接着发下一版。 ## 正式发布 ```bash git switch main && git pull # 1. bump 受影响包的 package.json 版本号 # 2. pnpm aggregate # 重新生成聚合层(钉新版本) # 3. pnpm install --lockfile-only # 必须在 aggregate 之后:聚合层重钉要进 lockfile # 4. 提交全部改动并推送 main(CI 先绿) # 5. pnpm release:preflight # 已发布版本的内容必须与本仓一致;红了先 bump,别打 tag git tag "v$(node -p "require('./packages/all/package.json').version")" git push origin main --tags ``` tag 推上去后 Release workflow 接管:校验 tag 与 `@hyzyn/dsh-all` 版本一致 → build + typecheck + 聚合检查 → 按依赖序发布全部包(**先对 registry 上已存在的「包名@版本」做 内容预检,内容与本仓不一致即整体中止;一致的跳过,未 bump 的包不拦路**)→ 逐包回查 registry 核实 → 建 GitHub Release(Highlights 自动过滤 chore 提交,并附全部 workspace 包的版本清单)。 发布中途失败:修复后删除并重打同一个 tag 再推即可,已发出的包重跑时自动跳过(幂等)。 兜底的手动发布:`node scripts/release-publish-all.mjs [6位OTP] [only=]包目录,...` (bypass-2FA token 不需要 OTP;经典 token 传 6 位动态码)。 ## 排错(CI 发布出错时对照) | 症状 | 原因与处理 | | --- | --- | | 用户报 `The requested module '@hyzyn/dsh-kit' does not provide an export named …`(或整条 entry 报 `failed to import`) | **kit 的内容变了但版本号没 bump**:发布流按「版本已在 registry」跳过,消费者钉到的还是旧 kit 内容(2026-10-17 事故,外部 issue #12:codegraph 0.6.9 / env 0.3.9 / mcp 0.3.5 / prompt 0.2.8 四个包中招)。修法:bump kit → 全仓消费者钉子跟齐 → `pnpm aggregate` → 发新版;旧包只能靠回滚(codegraph 0.6.8 / env 0.3.8 / mcp 0.3.4 / prompt 0.2.7)。`pnpm release:preflight` 现在会在发布前拦下这一类(发布门槛第 6 条) | | workflow 绿了、但日志里有 `::warning::` 说「窗口内未核实 / 无法判定」 | publish 命令已退出 0(真失败会在发布那一步直接红),只是回查没在窗口内读到目标版本。**先别删 tag 重打**:`curl -s https://registry.npmjs.org/<包名>/<版本>` 直查,在就无需处理;确实不在才重跑(幂等)。回查用单版本小端点 + 随机 `?cb=`(详见 `scripts/release-publish-tag.mjs` 顶部注释),只有当权威文档**已刷新到本次发布之后**却仍没有该版本时才判红 | | `ERR_PNPM_OUTDATED_LOCKFILE ... specifiers in the lockfile don't match` | lockfile 与 package.json 脱节:aggregate 晚于 `install --lockfile-only`,聚合层重钉的 specifier 没进 lockfile(v0.1.24)。按「bump → aggregate → install --lockfile-only」顺序重跑;已发包的修复直接追加 lockfile 提交、删 tag 重打(幂等) | | `403 ... Two-factor authentication or granular access token with bypass 2fa` | 缺动态码或 token 不是 bypass-2FA:换 bypass granular token | | `404 Not found - PUT <包名>` / `404 ... install from a tarball` | token 对该包无发布权(npm 故意 404 隐藏存在性):检查 granular token 的 Packages and scopes 是否勾到该包、权限是否 Read and write | | 用户报 `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND ... "@hyzyn/dsh-kit@workspace:*"` | 某包依赖残留 `workspace:*`:从 git 子路径安装必炸(npm 安装正常,因为 publish 期已转换)。改真实版本 + bump 受影响包重发;本地先跑 `node scripts/check-publishable.mjs` 确认 | | `check-kit-pins` 报「全仓出现 N 个不同的 kit 钉子」或「全仓钉子 X ≠ packages/kit 的版本 Y」 | 本轮的 bump 只跟了部分消费者。把点名的包改成本轮 kit 的精确版本、**同一轮一起发**(顺序照门槛 1:bump → `pnpm aggregate` → `pnpm install --lockfile-only`)。为什么不能只发一半:hoisted 布局下两份 kit 会让 `kit D11` 的模块级单例失效 | | 手动兜底发布时 `pnpm publish` 说 `This command requires you to be logged in to https://registry.npmmirror.com/`,或包发完在 npmjs 上查不到 | **本机 registry 指向了镜像**(`npm config get registry`,2026-10-06 实测本机为 `registry.npmmirror.com`):手动流会把包往镜像发、真 registry 上自然没有。**CI 路径不受影响**——runner 上是干净的 npmjs 配置,脚本又显式把 token 写进 `~/.npmrc`、回查直接打 `registry.npmjs.org`。本地手动发布前改用 `pnpm publish --registry https://registry.npmjs.org`(或临时 `npm config set registry`),发完确认改回 | 校验 token 身份(不泄露值): ```sh curl -s -H "Authorization: Bearer $TOKEN" https://registry.npmjs.org/-/whoami # → {"username":"hyzyn"} 即为正确账号 ``` ## 高危改动走 prerelease 版本号带 `-` 的包(如 `0.16.0-rc.1`)workflow 自动以 `--tag next` 发布, GitHub Release 标记为 prerelease: - `next` dist-tag 不进用户的每日版本检查(检查只读 npm `latest`),正式用户无感。 - 自测:`dsh plugin --profile add @hyzyn/dsh-tty@next`,真实环境跑一天; 稳定后去掉 `-rc.n` 发 latest。