From 7044f4e42852f99d1195ab1ddab7de3f48490af7 Mon Sep 17 00:00:00 2001 From: Windsander Date: Sun, 25 Oct 2026 21:11:28 +0800 Subject: [PATCH 1/3] chore(release): mechanize version SSOT and npm visibility gate - bump-version handles six packages, both locks, desktop APP_VERSION and the single MCP version source - verify:release-version checks the same SSOT locally and in publish.yml - add post-publish npm visibility gate before GitHub Release - document release-prep -> dry-run -> release -> back-merge flow and safe branch cleanup - make dsh-plugin / MCP release checks version-agnostic; add fixture tests --- .github/GITHUB_MAINTENANCE.md | 10 + .github/workflows/publish.yml | 105 +++---- CHANGELOG.md | 24 +- RELEASE.md | 11 +- docs/operations/DESKTOP_RELEASE.md | 6 +- docs/operations/RELEASE.md | 197 +++++++++---- package.json | 3 + packages/dsh-plugin/README.md | 8 +- packages/mcp/scripts/publish-manifest.mjs | 13 +- packages/mcp/src/client.mjs | 4 +- packages/mcp/src/serve.mjs | 7 +- packages/mcp/src/server.mjs | 3 +- packages/mcp/src/version.mjs | 8 + scripts/bump-version.mjs | 272 ++++++++++++++++++ scripts/bump_version.sh | 135 +-------- scripts/prune-merged-branches.mjs | 249 +++++++++++++++++ scripts/verify-dsh-plugin.mjs | 4 +- scripts/verify-mcp-publish.mjs | 18 +- scripts/verify-release-version.mjs | 252 +++++++++++++++++ scripts/verify-release-visibility.mjs | 324 ++++++++++++++++++++++ tests/dsh/manifest.test.mjs | 2 +- tests/mcp/console-embed.test.mjs | 11 +- tests/release/branches.test.mjs | 36 +++ tests/release/version-ssot.test.mjs | 208 ++++++++++++++ tests/release/visibility.test.mjs | 143 ++++++++++ 25 files changed, 1781 insertions(+), 272 deletions(-) create mode 100644 packages/mcp/src/version.mjs create mode 100644 scripts/bump-version.mjs create mode 100644 scripts/prune-merged-branches.mjs create mode 100644 scripts/verify-release-version.mjs create mode 100644 scripts/verify-release-visibility.mjs create mode 100644 tests/release/branches.test.mjs create mode 100644 tests/release/version-ssot.test.mjs create mode 100644 tests/release/visibility.test.mjs diff --git a/.github/GITHUB_MAINTENANCE.md b/.github/GITHUB_MAINTENANCE.md index d751c31a..05b33bd1 100644 --- a/.github/GITHUB_MAINTENANCE.md +++ b/.github/GITHUB_MAINTENANCE.md @@ -37,6 +37,16 @@ head 分支,便于本地回看与后续跟进。请不要开启该设置。 未开启自动合并(auto-merge)。 +已合并分支的清理只走 dry-run 清单,确认后再删: + +```bash +npm run branches:merged # 只列已合并进 origin/main 的候选 +npm run branches:merged -- --apply --yes # 明确确认后才删除 +``` + +脚本保护 `main` / `release/*` / `release-prep/*` / `site*`、当前分支、worktree 分支和 +open PR head;细节见 [`docs/operations/RELEASE.md`](../docs/operations/RELEASE.md#81-已合并远端分支清理)。 + ## Dependabot - minor/patch 分组自动提 PR;`electron` 与 `electron-builder` 的 **major 一律人工处理**(`version-update:semver-major` ignore),避免打包/签名链路被自动跨大版本。 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f0e49b51..e47fb84a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,11 +6,12 @@ name: Publish # → desktop-macos / desktop-windows / desktop-linux # (同一批 tarball → runtime → 安装包 → packaged runtime smoke) # → publish(直接发布 verify 产出的 5 个 lockstep tarball) -# → publish-dsh(独立版本插件 tarball) +# → publish-dsh(dsh-plugin tarball) +# → visibility(六包均可 npm view + latest 指向本次版本才继续) # → release(三平台安装包 + SHA256SUMS.txt,幂等上传) # # workflow_dispatch 是 packaging dry-run:只跑 verify + desktop matrix 并上传 artifacts, -# publish / publish-dsh / release 全部跳过;TAG 从根 package.json 版本生成。 +# publish / publish-dsh / visibility / release 全部跳过;TAG 从根 package.json 版本生成。 run-name: Publish ${{ github.ref_name }} · ${{ github.event_name == 'push' && 'release' || 'dry-run (no publish)' }} on: @@ -86,65 +87,12 @@ jobs: if [ "$MODE" = "dry-run" ]; then echo "::notice title=Packaging dry-run::$TAG · 只构建三平台安装包并上传 artifacts;不发布 npm、不创建 GitHub Release。" else - echo "::notice title=Release::$TAG · 安装包全部通过后发布 npm,最后上传 GitHub Release 资产。" + echo "::notice title=Release::$TAG · 安装包全部通过后发布 npm,六包可见后上传 GitHub Release 资产。" fi - - name: Verify version consistency (branch / 6 manifests / desktop / CHANGELOG) + - name: Verify version SSOT (branch / 6 manifests / locks / MCP / CHANGELOG) shell: bash - run: | - node - <<'NODE' - const fs = require('node:fs'); - const version = process.env.VERSION; - const tag = process.env.TAG; - const targets = [ - '.', - 'packages/fleet', - 'packages/mcp', - 'packages/service', - 'packages/skill', - 'apps/desktop', - ]; - let ok = true; - for (const rel of targets) { - const pkg = JSON.parse(fs.readFileSync(`${rel}/package.json`, 'utf8')); - if (pkg.version !== version) { - console.error(`ERROR: ${rel}/package.json version=${pkg.version} != ${version}`); - ok = false; - } - } - - // 桌面 App 独立 lockfile(不属于根 workspaces)也必须与发布版本一致。 - const lock = JSON.parse(fs.readFileSync('apps/desktop/package-lock.json', 'utf8')); - if (lock.version !== version || lock.packages?.['']?.version !== version) { - console.error( - `ERROR: apps/desktop/package-lock.json version=${lock.version} / packages[""].version=` + - `${lock.packages?.['']?.version} != ${version}`, - ); - ok = false; - } - - // 打包态 / 诊断共用的 APP_VERSION。 - const daemon = fs.readFileSync('apps/desktop/src/main/daemon.mjs', 'utf8'); - const appVersion = /export const APP_VERSION = '([^']+)'/.exec(daemon)?.[1]; - if (appVersion !== version) { - console.error(`ERROR: apps/desktop/src/main/daemon.mjs APP_VERSION=${String(appVersion)} != ${version}`); - ok = false; - } - - const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); - if (!changelog.split('\n').some((line) => line.startsWith(`## [${tag}]`))) { - console.error(`ERROR: CHANGELOG.md 缺少 [${tag}] 章节(release notes 取自该章节)`); - ok = false; - } - - if (!ok) { - console.error(`修复:在 release 分支上运行 \`bash scripts/bump_version.sh ${tag}\`,或重命名分支。`); - process.exit(1); - } - console.log( - `version consistency OK: ${tag}(branch + 6×package.json + desktop lock/APP_VERSION + CHANGELOG)`, - ); - NODE + run: node scripts/verify-release-version.mjs --version "$VERSION" --tag "$TAG" - name: Install dependencies run: npm ci @@ -518,7 +466,7 @@ jobs: publish "release-pack/mebular-mcp-${VERSION}.tgz" "@mebular/mcp" publish "release-pack/mebular-fleet-${VERSION}.tgz" "@mebular/fleet" - # 插件独立版本:不参与 lockstep,版本只取 packages/dsh-plugin/package.json;已发布则跳过。 + # 插件仍由独立 job 发布;发布版本必须与六包 SSOT 一致(verify:release-version 校验)。 publish-dsh: name: npm publish dsh-plugin tarball (push only) needs: publish @@ -568,10 +516,45 @@ jobs: npm publish "$(realpath "$FILE")" --access public --tag latest fi + # 发布后 gate:六包都能 npm view,且 dist-tags.latest 指向本次版本后才创建 GitHub Release。 + visibility: + name: npm visibility gate (6 packages + latest, push only) + needs: [verify, publish, publish-dsh] + if: github.event_name == 'push' && needs.publish.result == 'success' && needs.publish-dsh.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Wait for six npm packages + latest tag + shell: bash + run: | + set -euo pipefail + DSH_VERSION="$(node -p "require('./packages/dsh-plugin/package.json').version")" + node scripts/verify-release-visibility.mjs \ + --version "$VERSION" \ + --dsh-version "$DSH_VERSION" \ + --timeout-seconds 600 \ + --interval-seconds 10 + release: name: GitHub Release installers + checksums (push only) - needs: [verify, publish, publish-dsh, desktop-macos, desktop-windows, desktop-linux] - if: github.event_name == 'push' && needs.publish.result == 'success' && needs.publish-dsh.result == 'success' + needs: [verify, publish, publish-dsh, visibility, desktop-macos, desktop-windows, desktop-linux] + if: >- + github.event_name == 'push' + && needs.publish.result == 'success' + && needs.publish-dsh.result == 'success' + && needs.visibility.result == 'success' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea3ec52..120c391f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,35 @@ # Changelog All notable changes to Mebular are documented here. -The five lockstep packages always share one version; `@mebular/dsh-plugin` keeps its own -version line. +The six release packages share one release version; `@mebular/dsh-plugin` is still published +by its own npm job, but its manifest is bumped and verified with the rest of the train. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > Version headings carry the `v` prefix (`## [v0.1.1]`) because it is the release SSOT: -> the branch name (`release/v0.1.1`), the five lockstep `package.json` versions and the Git -> tag must all agree with this section. `@mebular/dsh-plugin` keeps its own version line but -> ships `0.1.1` with this release. +> the branch name (`release/vX.Y.Z`), the six `package.json` versions, the locks, the MCP +> version source and the Git tag must all agree with this section. The GitHub Release body is extracted from this file: everything between `## [v0.1.1]` and the next `## [` heading. Keep that section self-contained. ## [Unreleased] -Nothing yet. +### Changed + +- **Release engineering** — `scripts/bump_version.sh` now bumps all six release packages, + both lockfiles, the desktop `APP_VERSION` and the single MCP version source in + `packages/mcp/src/version.mjs`. `npm run verify:release-version` checks the same SSOT + locally and in the publish workflow. +- **npm visibility gate** — after `publish` / `publish-dsh`, the workflow polls + `npm view @ version` and `dist-tags.latest` for all six packages before + creating the GitHub Release. The previous v0.1.1 run could create the Release while a + package was still propagating. +- **Release SOP** — the fixed flow is now `release-prep/vX.Y.Z` → packaging dry-run → + `release/vX.Y.Z` → back-merge `main` → delete release/prep branches. A dry-run-only + branch cleanup helper is documented for merged remote branches. + ## [v0.1.1] - 2026-10-25 diff --git a/RELEASE.md b/RELEASE.md index 0bc1e662..fe61bdee 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,12 +3,15 @@ The release operations for this repository live under `docs/operations/`: - [Release SOP](docs/operations/RELEASE.md) — npm six packages, the `verify → preflight → - desktop matrix → publish → publish-dsh → release` pipeline, dry-run, checksums and rollback. + desktop matrix → publish → publish-dsh → visibility → release` pipeline, the + `release-prep → dry-run → release → back-merge` branch flow, checksums and rollback. - [Desktop release](docs/operations/DESKTOP_RELEASE.md) — runtime packaging from release tarballs, installer asset names, unsigned install steps, and the signing/notarization plan. - [Changelog](CHANGELOG.md) — release notes; the `## [vX.Y.Z]` section is the source of the GitHub Release body. -The current release line is **v0.1.1**: five lockstep npm packages plus the independently -versioned `@mebular/dsh-plugin@0.1.1`, and unsigned desktop installers for macOS, Windows -and Linux. Signing, notarization, and auto-update are not part of this release. +The current release line is **v0.1.1**: six npm packages at `0.1.1` (five lockstep packages +plus `@mebular/dsh-plugin`), and unsigned desktop installers for macOS, Windows and Linux. +From the next release on, `bump_version.sh` updates all six packages and +`verify:release-version` / the publish workflow enforce the same version. Signing, +notarization, and auto-update are not part of this release. diff --git a/docs/operations/DESKTOP_RELEASE.md b/docs/operations/DESKTOP_RELEASE.md index 06c60dd4..a787e255 100644 --- a/docs/operations/DESKTOP_RELEASE.md +++ b/docs/operations/DESKTOP_RELEASE.md @@ -226,9 +226,10 @@ AppImage 需要 FUSE;没有 FUSE 时可使用 `--appimage-extract-and-run`。d | preflight(npm whoami) | ❌ | ✅ | | desktop matrix(三平台) | ✅ | ✅ | | npm publish(5 + 1) | ❌ | ✅ | +| npm visibility gate(6 包 + latest) | ❌ | ✅ | | GitHub Release + 资产 | ❌ | ✅ | | `SHA256SUMS.txt` | ❌(仅 artifacts) | ✅ | -| 版本来源 | 根 `package.json` | 分支名 `release/vX.Y.Z` | +| 版本来源 | prep 分支根 `package.json` | 分支名 `release/vX.Y.Z` | dry-run 的 artifacts: @@ -238,7 +239,8 @@ dry-run 的 artifacts: - `desktop-linux`:AppImage + deb。 正式流水线中,`publish` / `publish-dsh` 依赖三个 desktop job 全部成功,因此**安装包没全绿 -就不会发 npm**。`release` job 再下载三平台 artifacts,校验每种扩展名恰好 1 个,生成 +就不会发 npm**。`visibility` job 再确认六包均可 `npm view` 且 `dist-tags.latest` 指向本次 +版本;只有它通过后,`release` job 才下载三平台 artifacts,校验每种扩展名恰好 1 个,生成 `SHA256SUMS.txt`,创建/更新 GitHub Release 并 `--clobber` 上传。 本地或 CI 都可以对解包目录跑 packaged runtime smoke: diff --git a/docs/operations/RELEASE.md b/docs/operations/RELEASE.md index f9ed1bc6..4122e455 100644 --- a/docs/operations/RELEASE.md +++ b/docs/operations/RELEASE.md @@ -3,12 +3,15 @@ 面向 `@mebular/*` npm 包与三平台桌面安装包的发布操作手册。核心约束:**分支名即版本号 (SSOT)**、**先构建安装包再发 npm**、**幂等发布**、**按 ref 串行**。 -当前 v0.1.1 交付: +当前发布线 v0.1.1(历史记录): -- 5 个 lockstep npm 包 + 1 个独立版本插件; +- 6 个 npm 包统一为 `0.1.1`(5 个 lockstep 包 + `@mebular/dsh-plugin`); - macOS Universal DMG、Windows NSIS x64、Linux AppImage + deb x64(unsigned); - `SHA256SUMS.txt`(仅 GitHub Release 资产生成)。 +从下一版本起,`@mebular/dsh-plugin` 仍由独立 npm job 发布,但版本号随发布列车统一 bump +并由发布 gate 校验;不再需要手工补齐。 + ## 0. 发布对象 | 包 | 目录 | 说明 | @@ -18,20 +21,22 @@ | `@mebular/skill` | `packages/skill` | 行为层:`SKILL.md` / `MEMORY_POLICY.md` / MCP 接入片段 | | `@mebular/fleet` | `packages/fleet` | 跨设备多 Agent 协作 | | `@mebular/mcp` | `packages/mcp` | MCP server(stdio + Streamable HTTP) | -| `@mebular/dsh-plugin` | `packages/dsh-plugin` | DeepSeek Harness 插件;**独立版本线**,不参与 lockstep | +| `@mebular/dsh-plugin` | `packages/dsh-plugin` | DeepSeek Harness 插件;独立 npm job 发布,版本随发布列车统一 | -- **版本策略**:5 个 lockstep 包(`core` / `service` / `skill` / `mcp` / `fleet`)永远同版本号。 -- **独立版本包**:`@mebular/dsh-plugin` 在 v0.1.1 与五包同版本发布(自身版本 `0.1.1`), - 但仍保留独立版本线;版本号只取自 `packages/dsh-plugin/package.json`;`bump_version.sh` - 不改它,verify 的 lockstep 校验也不包含它。 +- **版本策略**:6 个可发布包(`core` / `service` / `skill` / `mcp` / `fleet` / `dsh-plugin`) + 共用同一发布版本;`scripts/bump_version.sh` 一次同步全部 manifest 与版本源。 +- **MCP 版本源**:`packages/mcp/src/version.mjs` 是唯一真源;`serve.mjs` / `server.mjs` / + `client.mjs` 从它导入,`packages/mcp/scripts/publish-manifest.mjs` 从自己的 + `package.json` 取版本。禁止再手写 `0.x.y`。 - **安装入口**:`npm install -g @mebular/core` 是根包,同时提供 `mebular` + `fleet` 两个 bin; `@mebular/mcp` 单独安装只有 `mebular`,**不能**当作双 bin 安装入口。 - **桌面版本**:`apps/desktop/package.json`、`apps/desktop/package-lock.json` 与 - `apps/desktop/src/main/daemon.mjs` 的 `APP_VERSION` 必须等于 5 包 lockstep 版本。 + `apps/desktop/src/main/daemon.mjs` 的 `APP_VERSION` 必须等于发布版本。 - **触发方式**:push 到 `release/v*` 分支(例如 `release/v0.2.0`),**不使用 tag 触发**。 手动 `workflow_dispatch` 是 packaging dry-run。 -- **发布分支即真相**:`release/vX.Y.Z` 的 `X.Y.Z` 必须等于 5 个 `package.json` 的版本、 - 桌面三处版本,以及 `CHANGELOG.md` 中 `## [vX.Y.Z]` 章节。 +- **发布分支即真相**:`release/vX.Y.Z` 的 `X.Y.Z` 必须等于 6 个 `package.json` 的版本、 + 根 `package-lock.json` 的 workspace 条目、桌面三处版本、MCP 版本源,以及 + `CHANGELOG.md` 中 `## [vX.Y.Z]` 章节。`npm run verify:release-version` 一次校验全部。 ### 0.1 发布产物 @@ -96,7 +101,7 @@ ## 2. 发布流水线 -`.github/workflows/publish.yml` 的 8 个 job: +`.github/workflows/publish.yml` 的 9 个 job: ```text verify(SSOT / build / lint / test / 6 tarballs) @@ -109,7 +114,10 @@ verify(SSOT / build / lint / test / 6 tarballs) publish(5 个 lockstep tarball) │ ▼ - publish-dsh(独立版本 tarball) + publish-dsh(dsh-plugin tarball) + │ + ▼ + visibility(npm view 六包 + dist-tags.latest;失败不创建 Release) │ ▼ release(4 个安装包 + SHA256SUMS.txt) @@ -119,18 +127,48 @@ verify(SSOT / build / lint / test / 6 tarballs) 1. **verify** 是唯一一次 pack:6 个 tarball 上传为 artifact `release-tarballs`,后续 npm publish 与三平台 runtime 都消费这同一批文件,保证「测什么发什么」。 -2. **preflight** 只在 push 时运行,用 `npm whoami` 验证 token 能认证;失败则后续 job 不启动。 -3. **desktop matrix** 在 macOS / Windows / Linux runner 上各自下载 `release-tarballs`, +2. **verify 版本 SSOT** 调用 `node scripts/verify-release-version.mjs`,校验 6 个 manifest、 + 根 lock、桌面三处、MCP 版本源与 CHANGELOG;dry-run 与正式发布走同一段代码。 +3. **preflight** 只在 push 时运行,用 `npm whoami` 验证 token 能认证;失败则后续 job 不启动。 +4. **desktop matrix** 在 macOS / Windows / Linux runner 上各自下载 `release-tarballs`, 执行 `prepare-runtime` → `smoke:runtime` → `dist:*` → packaged runtime smoke,并上传 `desktop-macos` / `desktop-windows` / `desktop-linux`。 -4. **publish** 依赖 `preflight` + `verify` + 三个 desktop job,因此**安装包全部通过后才发 npm**。 -5. **publish-dsh** 依赖 `publish`,版本只读 `packages/dsh-plugin/package.json`。 -6. **release** 依赖全部 job,下载三个 `desktop-*` artifact,校验恰好 1 个 `.dmg` / `.exe` / +5. **publish** 依赖 `preflight` + `verify` + 三个 desktop job,因此**安装包全部通过后才发 npm**。 +6. **publish-dsh** 依赖 `publish`,发布 `packages/dsh-plugin` tarball;版本与六包 SSOT 一致。 +7. **visibility** 依赖 `publish` + `publish-dsh`,用 + `scripts/verify-release-visibility.mjs` 有界轮询六个包的 `npm view @ version` + 与 `dist-tags.latest`。只有六包可见且 latest 指向本次版本,`release` 才会启动。 +8. **release** 依赖全部 job,下载三个 `desktop-*` artifact,校验恰好 1 个 `.dmg` / `.exe` / `.AppImage` / `.deb`,生成 `SHA256SUMS.txt`,从 CHANGELOG 抽取 notes,创建 Release 并 `gh release upload --clobber`。 > 串行保证:`concurrency.group = publish-${{ github.ref }}`,`cancel-in-progress: false`; -> 同一 release 分支重复 push 不会并行发布。 +> 同一 release 分支重复 push 不会并行发布。dry-run(`workflow_dispatch`)不运行 +> preflight / publish / publish-dsh / visibility / release。 + +### 2.1 分支生命周期(固定流程) + +发布分支与 dry-run 必须分开,避免「push 即发布」: + +```text +origin/main + └─ release-prep/vX.Y.Z # bump + 文档 + 本地预检 + ├─ workflow_dispatch # packaging dry-run(不发布) + └─ release/vX.Y.Z # 从 prep 分支创建;push 即正式发布 + └─ PR 回合并 main # 保留版本提交与 CHANGELOG + └─ 删除 release/vX.Y.Z 与 release-prep/vX.Y.Z +``` + +1. 从最新 `origin/main` 创建 `release-prep/vX.Y.Z`,提交版本与 CHANGELOG。 +2. 在 `release-prep/vX.Y.Z` 上跑 `gh workflow run publish.yml --ref release-prep/vX.Y.Z`, + 确认三平台安装包与 6 个 tarball 全绿。**不要**用 push `release/v*` 代替 dry-run。 +3. dry-run 通过后,从 prep 分支创建 `release/vX.Y.Z` 并 push;这会触发正式发布。 +4. 发布成功后,用 PR 把 `release/vX.Y.Z` 回合并到 `main`(历史 v0.1.1 的 PR #251 即此步骤)。 +5. 回合并后删除 `release/vX.Y.Z` 与 `release-prep/vX.Y.Z`;远端已合并分支用第 8.1 节的 + dry-run 清单确认后再清理。 + +> 不要直接向 `main` push 版本号,也不要长期保留 release 分支;分支名是版本 SSOT,合并后 +> 以 tag / CHANGELOG / npm 版本为准。 ## 3. 本地准备与预检 @@ -138,21 +176,21 @@ verify(SSOT / build / lint / test / 6 tarballs) ```bash git fetch origin -git switch -c release/v0.2.0 origin/main # 分支名即版本号 -bash scripts/bump_version.sh v0.2.0 # 同步 5 包 + 桌面版本 + CHANGELOG 章节 +git switch -c release-prep/v0.2.0 origin/main # 先 prep,dry-run 后再建 release 分支 +bash scripts/bump_version.sh v0.2.0 # 同步 6 包 + 版本源 + CHANGELOG 章节 +npm run verify:release-version # 只读校验;也可带 --version/--tag ``` `bump_version.sh` 会: -1. 将 5 个 `package.json` 的 `version` 改为 `0.2.0`; -2. 同步桌面 `package.json` / `package-lock.json` / `APP_VERSION`; -3. 同步任何「已是版本号区间」的内部 `@mebular/*` 依赖为 `^0.2.0`; -4. 在 `CHANGELOG.md` 顶部插入 `## [v0.2.0] - ` 章节(已存在则跳过,幂等)。 +1. 将 6 个 `package.json`(core / fleet / mcp / service / skill / dsh-plugin)改为 `0.2.0`; +2. 同步根 `package-lock.json` 的根版本与 6 个 workspace 条目; +3. 同步桌面 `package.json` / `package-lock.json` / `APP_VERSION`; +4. 同步任何「已是版本号区间」的内部 `@mebular/*` 依赖为 `^0.2.0`; +5. 改写 `packages/mcp/src/version.mjs` 的 `APP_VERSION`(MCP 唯一版本源); +6. 在 `CHANGELOG.md` 顶部插入 `## [v0.2.0] - ` 章节(已存在则跳过,幂等)。 -`bump_version.sh` 只处理 lockstep 包与桌面版本;`@mebular/dsh-plugin` 的版本单独在 -`packages/dsh-plugin/package.json` 维护。 - -v0.1.1 的版本号已就位、CHANGELOG 章节已存在,**不需要**再跑 `bump_version.sh`。 +> v0.1.1 的版本号与 CHANGELOG 章节已存在,**不需要**再跑 `bump_version.sh`。 ### 3.2 本地预检(推荐) @@ -163,11 +201,18 @@ npm run lint npm run test:coverage # 覆盖率门槛在 package.json#jest.coverageThreshold npm run verify:mcp:publish # 5 包 npm pack + tarball 干净安装冒烟 npm run verify:dsh-plugin # 插件契约 + tarball +npm run verify:release-version # 六包 / lock / 桌面 / MCP / CHANGELOG SSOT npm run check:docs # 文档一致性 7/7 npm run check:i18n npm run check:deps ``` +发布后可见性 gate 也可以独立预演(对已发布版本只读): + +```bash +npm run verify:release-visibility -- --version 0.2.0 --timeout-seconds 60 --interval-seconds 5 +``` + 桌面链路(与 CI 同路径): ```bash @@ -194,16 +239,17 @@ node apps/desktop/scripts/smoke-runtime.mjs --runtime "apps/desktop/dist/ `workflow_dispatch` 需要该 workflow 已在默认分支注册。R2 的 `publish.yml` 合入 `main` -> 之前,GitHub 不会提供手动触发入口;此时不要用 `release/v*` 分支 push 代替 dry-run, -> 那会进入正式发布。先完成合并,再在候选分支上触发。 +> `workflow_dispatch` 需要该 workflow 已在默认分支注册。若 `publish.yml` 尚未合入 `main`, +> GitHub 不会提供手动触发入口;此时不要用 `release/v*` 分支 push 代替 dry-run,那会进入 +> 正式发布。先完成 workflow 合并,再在 prep 分支上触发。 ```bash -# 在待验证分支上触发 -gh workflow run publish.yml --ref +# 在 release-prep 分支上触发(workflow 已在 main 注册) +gh workflow run publish.yml --ref release-prep/v0.2.0 # 观察 gh run list --workflow=publish.yml --limit 5 @@ -216,6 +262,9 @@ gh run download -n desktop-windows -D release-assets gh run download -n desktop-linux -D release-assets ``` +dry-run 必须全绿后才创建 `release/vX.Y.Z`;不要在 `release/vX.Y.Z` 上重跑 dry-run, +push 该分支即正式发布。 + dry-run 的 artifacts 保留 **7 天**;没有官方 `SHA256SUMS.txt`(它由 `release` job 生成)。 需要在本地核对时,对 `release-assets` 自行计算: @@ -228,46 +277,57 @@ sha256sum release-assets/* # Linux ### 5.1 触发 +dry-run 通过后,从 prep 分支创建正式发布分支并 push: + ```bash -git add -A -git commit -m "release: v0.1.1" -git push -u origin release/v0.1.1 +git switch release-prep/v0.2.0 +git switch -c release/v0.2.0 +git push -u origin release/v0.2.0 ``` -push 后自动执行第 2 节的完整流水线。任一 job 失败即停;安装包未全绿时不会发 npm。 +push 后自动执行第 2 节的完整流水线。任一 job 失败即停;安装包未全绿时不会发 npm; +六包未全部可见(visibility gate)时不会创建 GitHub Release。 ### 5.2 观察 ```bash -gh run list --workflow=publish.yml --branch release/v0.1.1 --limit 5 +gh run list --workflow=publish.yml --branch release/v0.2.0 --limit 5 gh run watch gh run view --log-failed # 失败时 + +# 单独查看 visibility gate +gh run view --json jobs --jq '.jobs[] | select(.name | contains("visibility"))' ``` ### 5.3 发布后核验 ```bash -# npm 六包 -for p in core service skill mcp fleet; do npm view "@mebular/$p@0.1.1" version; done -npm view @mebular/dsh-plugin@0.1.1 version +# npm 六包:version 与 latest 都必须指向本次版本 +for p in core service skill mcp fleet dsh-plugin; do + npm view "@mebular/$p@0.2.0" version + npm view "@mebular/$p" dist-tags.latest +done + +# 或复用流水线同一个 gate(只读轮询;已可见时立即通过) +npm run verify:release-visibility -- --version 0.2.0 --dsh-version 0.2.0 # GitHub Release -gh release view v0.1.1 -gh release view v0.1.1 --json assets --jq '.assets[] | "\(.name) \(.size) bytes"' +gh release view v0.2.0 +gh release view v0.2.0 --json assets --jq '.assets[] | "\(.name) \(.size) bytes"' # 根包双 bin -npm install -g @mebular/core@0.1.1 +npm install -g @mebular/core@0.2.0 command -v mebular && command -v fleet mebular tools >/dev/null && fleet --version ``` -> 不要用 `npm install -g @mebular/mcp@0.1.1` 冒充双 bin 安装:`@mebular/mcp` 只提供 +> 不要用 `npm install -g @mebular/mcp@0.2.0` 冒充双 bin 安装:`@mebular/mcp` 只提供 > `mebular`,`fleet` 必须来自根包(或单独安装 `@mebular/fleet`)。 插件独立验证(先让 App / 守护运行): ```bash -dsh plugin --profile web add @mebular/dsh-plugin@0.1.1 +dsh plugin --profile web add @mebular/dsh-plugin@0.2.0 dsh --profile web --dump-config | grep -A3 mebular # host row;transport 由官方 MCP client 挂载 # DSH 会话中确认 mcp__mebular__* 可调用;侧边栏面板加载 /console/?embed=dsh ``` @@ -289,7 +349,7 @@ MEBULAR_HOME="$(mktemp -d)" mebular serve --port 7399 从 Release 下载并校验: ```bash -gh release download v0.1.1 -D release-assets +gh release download v0.2.0 -D release-assets cd release-assets # Linux @@ -325,8 +385,10 @@ Get-Content SHA256SUMS.txt | ForEach-Object { - 重跑流水线(重推同名分支或 Rerun failed jobs)安全: - 版本 SSOT 校验只读; - `publish` / `publish-dsh` 先 `npm view`,已发布版本自动 skip; + - `visibility` 只读轮询,已可见时立即通过; - `release` 已存在则跳过创建;资产用 `--clobber` 覆盖。 - 若某包发布失败:修复后重新 push 同一 `release/v*` 分支即可,已成功的包会 skip。 +- 若只是 registry 传播慢:重跑 `visibility` job 即可,不会重复发布。 ### 7.2 失败重跑 @@ -337,6 +399,7 @@ Get-Content SHA256SUMS.txt | ForEach-Object { | 单个 desktop job | 先看 packaged runtime smoke 日志;修 runtime / builder 配置后重跑。其余平台成功产物保留。 | | publish 部分包已发 | 直接重跑;已发布包 skip,未发布包继续。 | | publish-dsh | 独立 job,重跑即可;不会重复发布同版本。 | +| visibility 超时 | 先看逐包状态:E404 = 版本未可见,latest 不符 = 传播未完成。等待后重跑 visibility;若某包确实没发成功,重跑 publish / publish-dsh。 | | release | 重跑 `release` job;`gh release upload --clobber` 重传资产与 checksum。 | ### 7.3 回滚 @@ -353,8 +416,9 @@ Get-Content SHA256SUMS.txt | ForEach-Object { | 现象 | 原因 / 处理 | | --- | --- | -| verify 报 `version=... != ...` | 分支名、6 个 manifest、桌面版本或 CHANGELOG 不一致。核对后重推。 | +| verify 报 `version=... != ...` | 分支名、6 个 manifest、lock、桌面版本、MCP 版本源或 CHANGELOG 不一致。先跑 `npm run verify:release-version` 定位。 | | verify 报 `CHANGELOG.md 缺少 [vX.Y.Z]` | 未生成/未提交 CHANGELOG 章节;补内容并提交。 | +| verify 报 MCP `version.mjs` / 手写版本 | 用 `bash scripts/bump_version.sh` 重跑,或把新增版本使用点改为从 `packages/mcp/src/version.mjs` 导入。 | | preflight 报 `NPM_TOKEN secret 未配置` | GitHub Secret 缺失或 job 无权限;补 secret。 | | preflight 报 `E401` / `npm whoami` 失败 | token 过期、被 revoke 或不属于有权限的账号;重新生成 granular token。 | | publish 报 `EOTP` / 需要一次性密码 | token 未勾选 **Bypass 2FA**,重新生成。 | @@ -372,8 +436,39 @@ Get-Content SHA256SUMS.txt | ForEach-Object { | release 报 `contents: write` 被拒 | 仓库 Actions 权限未开读写;见第 1 节。 | | 本地 pack 后残留 `package.json.packbak` | 某次 pack 异常中断;删除该文件并检查 prepack/postpack 是否成对。 | -## 9. v0.1.0 首版发布清单 +### 8.1 已合并远端分支清理 + +仓库**不开启** GitHub 的「合并后自动删除分支」(见 `.github/GITHUB_MAINTENANCE.md`),因此 +清理必须手动、可审计。先 dry-run 列清单,确认后再删除: + +```bash +git fetch --prune origin +node scripts/prune-merged-branches.mjs # 只列清单(默认) +node scripts/prune-merged-branches.mjs --json # 机器可读清单 +node scripts/prune-merged-branches.mjs --apply --yes # 确认后删除 +``` + +脚本的保护范围: + +- 只列出已合并进 `--base`(默认 `origin/main`)的远端分支; +- 保护 `main` / `master` / `trunk` / `release/*` / `release-prep/*` / `site*` / `gh-pages`; +- 保护当前分支、所有 worktree 已检出的分支、仍有 open PR 的 head 分支; +- `gh` 不可用或未登录时,dry-run 会提示无法排除 open PR,`--apply` 会拒绝执行 + (除非显式 `--skip-open-pr-check`)。 + +发布分支本身在回合并 main 后,再用同一脚本或显式命令删除: + +```bash +git push origin --delete release/v0.2.0 release-prep/v0.2.0 +``` + +> 不要未经确认批量删除;不要删除 `site-*`(Pages 部署源)或仍有 open PR 的分支。 + +## 9. v0.1.0 首版发布清单(历史记录) +> 以下保留 v0.1.0 当时的操作记录,不再作为当前流程;新版本以第 2 节的 +> `release-prep → dry-run → release → back-merge` 为准。 +> > 版本号已是 `0.1.0`、`CHANGELOG.md` 已含 `## [v0.1.0]` 章节,因此**不需要**再跑 > `bump_version.sh`。 diff --git a/package.json b/package.json index a43e7675..236e9d33 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,9 @@ "verify:mcp:skill": "node scripts/verify-mcp-skill.mjs", "verify:mcp:publish": "node scripts/verify-mcp-publish.mjs", "verify:dsh-plugin": "node scripts/verify-dsh-plugin.mjs", + "verify:release-version": "node scripts/verify-release-version.mjs", + "verify:release-visibility": "node scripts/verify-release-visibility.mjs", + "branches:merged": "node scripts/prune-merged-branches.mjs", "verify:fleet:local": "node scripts/verify-fleet-local.mjs", "verify:fleet:remote": "node scripts/verify-fleet-remote.mjs", "verify:fleet:agents": "node scripts/verify-fleet-agents.mjs", diff --git a/packages/dsh-plugin/README.md b/packages/dsh-plugin/README.md index 4cb756ce..447de209 100644 --- a/packages/dsh-plugin/README.md +++ b/packages/dsh-plugin/README.md @@ -47,8 +47,8 @@ dsh plugin --profile web add ./packages/dsh-plugin 安装后 `$DSH_HOME/profiles/web/package.json` 的 `dsh.profile.bundles` 会包含 `@mebular/dsh-plugin`。重启 DSH Web 后,侧边栏出现 Mebular 图标;点击后进入全局面板。 -> `@mebular/dsh-plugin` 是**独立版本线**:v0.1.1 与 Mebular v0.1.1 同一 release train, -> 但版本号只由 `packages/dsh-plugin/package.json` 决定,不进入五包 lockstep。已收录于 +> `@mebular/dsh-plugin` 由独立 npm job 发布;从 v0.1.1 起版本随 Mebular release train +> 统一 bump,并由 `scripts/verify-release-version.mjs` 校验,不再单独手工维护。已收录于 > [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 的 `memory` > 分类(PR [#9167](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9167))。 @@ -335,11 +335,11 @@ npm run verify:dsh-plugin ## 发布状态 - 版本:`0.1.1` -- npm:`@mebular/dsh-plugin@0.1.1`(随 Mebular v0.1.1 release train,独立版本线) +- npm:`@mebular/dsh-plugin@0.1.1`(随 Mebular v0.1.1 release train) - 官方插件列表:已收录([awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 的 `memory` 分类,PR [#9167](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9167)) - 正式安装:`dsh plugin --profile web add @mebular/dsh-plugin@0.1.1` - 开发安装:`dsh plugin --profile web add ./packages/dsh-plugin` -- 版本线:不进入 `core` / `service` / `skill` / `mcp` / `fleet` 五包 lockstep +- 版本线:由独立 npm job 发布,但 manifest 随 `core` / `service` / `skill` / `mcp` / `fleet` 一起 bump 与校验 ## License diff --git a/packages/mcp/scripts/publish-manifest.mjs b/packages/mcp/scripts/publish-manifest.mjs index 2a8db415..c0a7f66a 100644 --- a/packages/mcp/scripts/publish-manifest.mjs +++ b/packages/mcp/scripts/publish-manifest.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node -// G6.5 / D41:發布期改寫 @mebular/core 依賴。 +// 發布期改寫 @mebular/core / @mebular/fleet 依賴。 // // 開發期以 `file:../..` 本地連結(根包非 workspace 成員、本環境 npm 不支援 `workspace:`); -// `npm pack`/`npm publish` 的 prepack 階段改為 `^0.1.1`,postpack 還原。 +// `npm pack`/`npm publish` 的 prepack 階段改為 `^<本包 package.json version>`,postpack 還原。 +// 版本區間從 package.json 讀取,不在腳本內硬編碼;升級版本只需跑 scripts/bump_version.sh。 // // 用法:node scripts/publish-manifest.mjs set|restore @@ -14,7 +15,6 @@ import { fileURLToPath } from 'node:url'; const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..'); const pkgFile = join(pkgDir, 'package.json'); const backupFile = `${pkgFile}.packbak`; -const PUBLISH_RANGE = '^0.1.1'; const action = process.argv[2]; @@ -24,15 +24,16 @@ async function writeJson(file, data) { if (action === 'set') { const original = await readFile(pkgFile, 'utf-8'); - if (!existsSync(backupFile)) await writeFile(backupFile, original, 'utf-8'); const pkg = JSON.parse(original); + const publishRange = `^${pkg.version}`; + if (!existsSync(backupFile)) await writeFile(backupFile, original, 'utf-8'); pkg.dependencies = pkg.dependencies ?? {}; // F-UNI:統一入口复用 fleet 的任務 handler/令牌原語 → 發布時一併宣告 for (const dep of ['@mebular/core', '@mebular/fleet']) { - if (pkg.dependencies[dep] !== undefined) pkg.dependencies[dep] = PUBLISH_RANGE; + if (pkg.dependencies[dep] !== undefined) pkg.dependencies[dep] = publishRange; } await writeJson(pkgFile, pkg); - console.error(`[publish-manifest] @mebular/{core,fleet} -> ${PUBLISH_RANGE}(備份 ${backupFile})`); + console.error(`[publish-manifest] @mebular/{core,fleet} -> ${publishRange}(備份 ${backupFile})`); } else if (action === 'restore') { if (existsSync(backupFile)) { await rename(backupFile, pkgFile); diff --git a/packages/mcp/src/client.mjs b/packages/mcp/src/client.mjs index 10341194..8b2361e4 100644 --- a/packages/mcp/src/client.mjs +++ b/packages/mcp/src/client.mjs @@ -3,6 +3,8 @@ // 仅覆盖 CLI 所需:initialize → notifications/initialized → tools/call。 // 复用既有 bearer token 体系(`mebular token`)。 +import { APP_VERSION } from './version.mjs'; + async function rpc(endpoint, token, message, sessionId, timeoutMs) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -39,7 +41,7 @@ export async function callToolViaHttp({ endpoint, token, name, args, timeoutMs = jsonrpc: '2.0', id: 1, method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mebular-cli', version: '0.1.1' } }, + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mebular-cli', version: APP_VERSION } }, }, null, timeoutMs); const sessionId = init.sessionId; await rpc(url, token, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId, timeoutMs); diff --git a/packages/mcp/src/serve.mjs b/packages/mcp/src/serve.mjs index 5c6531de..2630b4f9 100644 --- a/packages/mcp/src/serve.mjs +++ b/packages/mcp/src/serve.mjs @@ -19,6 +19,10 @@ import { attachFleetEvents, resolveFleetNamespace } from './fleet-events.mjs'; import { READ_ROUTES, buildHandoffPlan } from './admin.mjs'; import { configPath } from './config.mjs'; import { createServiceFacade } from './service-facade.mjs'; +import { APP_VERSION } from './version.mjs'; + +// 兼容既有导入方:APP_VERSION 的真源是 ./version.mjs。 +export { APP_VERSION } from './version.mjs'; // ---------- 控制台配置写入(schema 驱动:editable 可写 / status-only 只读 / internal 默认不可写) ---------- // 单一真源:packages/mcp/src/config-schema.mjs。写入策略(原子写 + .bak 备份;全部需重启生效): @@ -220,8 +224,7 @@ function settleWithin(promise, ms) { } // /healthz、/app/embed/info 与 /runtime.json 共用的对外版本号与 embed 协议版本。 -// 单一真源:不要在 buildEmbedInfo / runtime 写入处再写字面量。 -export const APP_VERSION = '0.1.1'; +// APP_VERSION 的唯一真源是 ./version.mjs;不要在 buildEmbedInfo / runtime 写入处再写字面量。 export const EMBED_PROTOCOL = 1; // ---------- 控制台静态托管(D1) ---------- diff --git a/packages/mcp/src/server.mjs b/packages/mcp/src/server.mjs index 914199c3..535926a4 100644 --- a/packages/mcp/src/server.mjs +++ b/packages/mcp/src/server.mjs @@ -10,6 +10,7 @@ import { MemoryService } from '@mebular/core'; import { createMebular, homeDir, loadConfigFile } from './config.mjs'; import { registerTools } from './tools.mjs'; import { startHttpServer, acquireLock, pidAlive } from './serve.mjs'; +import { APP_VERSION } from './version.mjs'; import { createJoinServer } from './jointoken.mjs'; import { clearApplyPending, readApplyPending, writeApplyResult, writeServeReady } from './config-apply.mjs'; import { buildSettings } from './admin.mjs'; @@ -26,7 +27,7 @@ const MEMORY_POLICY = `# Mebular 记忆使用规约(memory_policy) 6. 无结果别编:召回为空就如实说明,不要臆造记忆。`; export function buildServer(service, options = {}) { - const server = new McpServer({ name: 'mebular', version: '0.1.1' }); + const server = new McpServer({ name: 'mebular', version: APP_VERSION }); registerTools(server, service, options); server.registerPrompt( 'memory_policy', diff --git a/packages/mcp/src/version.mjs b/packages/mcp/src/version.mjs new file mode 100644 index 00000000..6f88a9ef --- /dev/null +++ b/packages/mcp/src/version.mjs @@ -0,0 +1,8 @@ +// MCP 包对外报告的版本号唯一真源。 +// +// serve.mjs 的 /healthz、/app/embed/info、runtime.json,server.mjs 的 MCP server +// 元数据,client.mjs 的 clientInfo 都从这里读取。发布脚本 +// scripts/bump-version.mjs 只改这一处,scripts/verify-release-version.mjs +// 校验它等于发布版本;不要在其它文件再写版本字面量。 + +export const APP_VERSION = '0.1.1'; diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs new file mode 100644 index 00000000..33a46b33 --- /dev/null +++ b/scripts/bump-version.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node +// 发布版本号同步(lockstep:6 个可发布包共用同一版本)。 +// +// 通常通过 scripts/bump_version.sh 调用;也可以直接运行: +// node scripts/bump-version.mjs --root . --version 0.2.0 --tag v0.2.0 +// +// 同步内容: +// 1. package.json × 6(root @mebular/core + fleet / mcp / service / skill / dsh-plugin); +// 2. 这些 manifest 中任何“已是版本号区间”的内部 @mebular/* 依赖 -> ^ +// (file: 本地链接留给 pack 期由 publish-manifest 改写); +// 3. package-lock.json 根 version / packages[""] / 6 个 workspace 条目; +// 4. apps/desktop/package.json、apps/desktop/package-lock.json、main 进程 APP_VERSION; +// 5. packages/mcp/src/version.mjs(MCP 对外版本唯一真源); +// 6. CHANGELOG.md 插入 `## [vX.Y.Z] - ` 章节占位(幂等)。 +// +// 发布分支命名(版本 SSOT):release/vX.Y.Z,工作流据此校验版本一致。 + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +export const RELEASE_PACKAGES = [ + { rel: '.', name: '@mebular/core' }, + { rel: 'packages/fleet', name: '@mebular/fleet' }, + { rel: 'packages/mcp', name: '@mebular/mcp' }, + { rel: 'packages/service', name: '@mebular/service' }, + { rel: 'packages/skill', name: '@mebular/skill' }, + { rel: 'packages/dsh-plugin', name: '@mebular/dsh-plugin' }, +]; + +const INTERNAL = new Set(RELEASE_PACKAGES.map((pkg) => pkg.name)); +const DEP_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies', 'devDependencies']; +const IS_VERSION_RANGE = /^\^?\d+\.\d+\.\d+/; + +const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')); +const writeJson = (file, data) => writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + +function replaceOnce(text, pattern, replacement, label) { + if (!pattern.test(text)) { + throw new Error(`${label} 缺少可替换的版本号`); + } + return text.replace(pattern, replacement); +} + +export function bumpVersion({ + root, + version, + tag = `v${version}`, + date = new Date().toISOString().slice(0, 10), + log = () => {}, +}) { + if (!SEMVER_RE.test(String(version))) { + throw new Error(`版本号格式非法:${version}`); + } + + const changes = []; + let changedDeps = 0; + + // ---- 6 个可发布包 manifest + 内部依赖区间 ---- + for (const target of RELEASE_PACKAGES) { + const file = join(root, target.rel, 'package.json'); + const pkg = readJson(file); + if (pkg.name !== target.name) { + throw new Error(`${target.rel}/package.json name=${pkg.name},期望 ${target.name}`); + } + pkg.version = version; + for (const field of DEP_FIELDS) { + const deps = pkg[field]; + if (!deps) continue; + for (const [name, spec] of Object.entries(deps)) { + if (!INTERNAL.has(name)) continue; + // 仅同步已成为版本号区间的内部依赖;file:/workspace: 留给本地 workspace / pack 期改写。 + if (IS_VERSION_RANGE.test(String(spec))) { + deps[name] = `^${version}`; + changedDeps += 1; + } + } + } + writeJson(file, pkg); + changes.push(`${target.rel}/package.json -> ${version}`); + log(` version -> ${version} ${target.name}`); + } + + // ---- 根 package-lock(workspace 成员版本也是 lock 的一部分) ---- + const rootLockFile = join(root, 'package-lock.json'); + if (existsSync(rootLockFile)) { + const lock = readJson(rootLockFile); + lock.version = version; + if (lock.packages?.['']) lock.packages[''].version = version; + for (const target of RELEASE_PACKAGES) { + if (target.rel === '.') continue; + const entry = lock.packages?.[target.rel]; + if (entry && entry.name === target.name) entry.version = version; + } + writeJson(rootLockFile, lock); + changes.push(`package-lock.json -> ${version}`); + log(` version -> ${version} package-lock.json`); + } else { + log(' WARN: package-lock.json 不存在,跳过(发布前请确认 npm ci 可用)'); + } + + // ---- 桌面 App:package.json + package-lock 根 version + main 进程 APP_VERSION ---- + const desktopPackageFile = join(root, 'apps/desktop/package.json'); + if (!existsSync(desktopPackageFile)) { + throw new Error('apps/desktop/package.json 不存在'); + } + const desktopPackage = readJson(desktopPackageFile); + if (desktopPackage.name !== '@mebular/desktop') { + throw new Error(`apps/desktop/package.json name=${desktopPackage.name},期望 @mebular/desktop`); + } + desktopPackage.version = version; + writeJson(desktopPackageFile, desktopPackage); + changes.push(`apps/desktop/package.json -> ${version}`); + log(` version -> ${version} apps/desktop/package.json`); + + const desktopLockFile = join(root, 'apps/desktop/package-lock.json'); + if (!existsSync(desktopLockFile)) { + throw new Error('apps/desktop/package-lock.json 不存在'); + } + const desktopLock = readJson(desktopLockFile); + desktopLock.version = version; + if (desktopLock.packages?.['']) desktopLock.packages[''].version = version; + writeJson(desktopLockFile, desktopLock); + changes.push(`apps/desktop/package-lock.json -> ${version}`); + log(` version -> ${version} apps/desktop/package-lock.json`); + + const desktopDaemon = join(root, 'apps', 'desktop', 'src', 'main', 'daemon.mjs'); + if (!existsSync(desktopDaemon)) { + throw new Error('apps/desktop/src/main/daemon.mjs 不存在'); + } + const daemonBefore = readFileSync(desktopDaemon, 'utf8'); + const daemonAfter = replaceOnce( + daemonBefore, + /(export const APP_VERSION = ')[^']+(';)/, + `$1${version}$2`, + 'apps/desktop/src/main/daemon.mjs', + ); + if (daemonAfter === daemonBefore) { + log(` APP_VERSION 已是 ${version},跳过`); + } else { + writeFileSync(desktopDaemon, daemonAfter, 'utf8'); + changes.push(`apps/desktop/src/main/daemon.mjs APP_VERSION -> ${version}`); + log(` APP_VERSION -> ${version} apps/desktop/src/main/daemon.mjs`); + } + + // ---- MCP 对外版本唯一真源 ---- + const mcpVersionFile = join(root, 'packages/mcp/src/version.mjs'); + if (!existsSync(mcpVersionFile)) { + throw new Error('packages/mcp/src/version.mjs 不存在;MCP 版本必须走单一真源'); + } + const mcpBefore = readFileSync(mcpVersionFile, 'utf8'); + const mcpAfter = replaceOnce( + mcpBefore, + /(export const APP_VERSION = ')[^']+(';)/, + `$1${version}$2`, + 'packages/mcp/src/version.mjs', + ); + if (mcpAfter === mcpBefore) { + log(` MCP APP_VERSION 已是 ${version},跳过`); + } else { + writeFileSync(mcpVersionFile, mcpAfter, 'utf8'); + changes.push(`packages/mcp/src/version.mjs APP_VERSION -> ${version}`); + log(` MCP APP_VERSION -> ${version} packages/mcp/src/version.mjs`); + } + + // ---- CHANGELOG 章节占位 ---- + const clFile = join(root, 'CHANGELOG.md'); + const HEADER = + '# Changelog\n\nAll notable changes to Mebular are documented here.\nAll published packages share a single release version.\n'; + let text = existsSync(clFile) ? readFileSync(clFile, 'utf8') : HEADER; + const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (new RegExp(`^## \\[${escapedTag}\\]`, 'm').test(text)) { + log(` CHANGELOG 已存在 [${tag}] 章节,跳过`); + } else { + const block = + `## [${tag}] - ${date}\n\n` + + '### Added\n\n- _TODO_\n\n' + + '### Changed\n\n- _TODO_\n\n' + + '### Fixed\n\n- _TODO_\n'; + const lines = text.split('\n'); + const idx = lines.findIndex((line) => line.startsWith('## [')); + if (idx === -1) { + text = `${text.replace(/\s*$/, '')}\n\n${block}\n`; + } else { + // 在首个 `## [` 之前插入新章节,并保留与旧章节之间的空行。 + lines.splice(idx, 0, block.replace(/\n$/, ''), ''); + text = lines.join('\n'); + } + writeFileSync(clFile, text, 'utf8'); + changes.push(`CHANGELOG.md + [${tag}]`); + log(` CHANGELOG 新增 [${tag}] 章节占位`); + } + + log(` 内部依赖版本号同步:${changedDeps} 处(file: 本地依赖由 pack 期改写)`); + return { version, tag, changes, changedDeps }; +} + +function parseArgs(argv) { + const options = { + root: resolve(dirname(fileURLToPath(import.meta.url)), '..'), + version: undefined, + tag: undefined, + date: undefined, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + options.help = true; + } else if (arg === '--root') { + options.root = resolve(argv[++i] ?? ''); + } else if (arg === '--version') { + options.version = argv[++i]; + } else if (arg === '--tag') { + options.tag = argv[++i]; + } else if (arg === '--date') { + options.date = argv[++i]; + } else { + throw new Error(`未知参数:${arg}`); + } + } + return options; +} + +function usage() { + return '用法:node scripts/bump-version.mjs --root --version [--tag vx.y.z] [--date YYYY-MM-DD]'; +} + +async function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`错误:${error.message}`); + console.error(usage()); + return 2; + } + if (options.help) { + console.log(usage()); + return 0; + } + if (!options.version) { + console.error('错误:缺少 --version'); + console.error(usage()); + return 2; + } + const version = String(options.version).replace(/^v/, ''); + try { + const result = bumpVersion({ + root: options.root, + version, + tag: options.tag ?? `v${version}`, + date: options.date, + log: (line) => console.log(line), + }); + console.log( + `✓ 已同步为 ${result.tag}(6 个 npm 包 + 根 lock + 桌面 App + MCP 版本源 + CHANGELOG)`, + ); + return 0; + } catch (error) { + console.error(`错误:${error.message}`); + return 1; + } +} + +const invokedAsScript = process.argv[1] + ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; +if (invokedAsScript) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh index 6d9a2254..d3a8aecd 100755 --- a/scripts/bump_version.sh +++ b/scripts/bump_version.sh @@ -1,16 +1,15 @@ #!/usr/bin/env bash -# 一键同步版本号(lockstep:5 个可发布包共用同一版本)。 +# 一键同步版本号(lockstep:6 个可发布包共用同一版本)。 # # bash scripts/bump_version.sh v0.2.0 # -# 同步内容: -# 1. package.json × 5(root @mebular/core + fleet / mcp / service / skill)的 version; -# 2. 这些 manifest 中任何“已是版本号区间”的内部 @mebular/* 依赖 -> ^ -# (源码开发期使用 file: 本地链接,pack 期由 scripts/publish-manifest.mjs 统一改写为 -# ^,故 file: 依赖保持不动、无需在此改写); -# 3. apps/desktop/package.json(桌面 App 与 npm 包 lockstep)+ package-lock 根 version -# + src/main/daemon.mjs 的 APP_VERSION(打包态 / 诊断共用); -# 4. CHANGELOG.md 插入 `## [vX.Y.Z] - ` 章节占位(幂等,已存在则跳过)。 +# 具体改写逻辑在 scripts/bump-version.mjs(可被测试直接 import): +# 1. package.json × 6(root @mebular/core + fleet / mcp / service / skill / dsh-plugin); +# 2. 这些 manifest 中“已是版本号区间”的内部 @mebular/* 依赖 -> ^; +# 3. 根 package-lock.json 与 apps/desktop/package-lock.json; +# 4. apps/desktop/src/main/daemon.mjs 的 APP_VERSION; +# 5. packages/mcp/src/version.mjs(MCP 对外版本唯一真源); +# 6. CHANGELOG.md 插入 `## [vX.Y.Z] - ` 章节占位(幂等)。 # # 发布分支命名(版本 SSOT):release/vX.Y.Z,工作流据此校验版本一致。 @@ -38,117 +37,11 @@ fi REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" DATE="$(date -u +%Y-%m-%d)" -export REPO_ROOT VERSION TAG DATE -node <<'NODE' -const fs = require('node:fs'); -const path = require('node:path'); +node "$REPO_ROOT/scripts/bump-version.mjs" \ + --root "$REPO_ROOT" \ + --version "$VERSION" \ + --tag "$TAG" \ + --date "$DATE" -const root = process.env.REPO_ROOT; -const version = process.env.VERSION; -const tag = process.env.TAG; -const date = process.env.DATE; - -const TARGETS = [ - '.', - 'packages/fleet', - 'packages/mcp', - 'packages/service', - 'packages/skill', - 'apps/desktop', -]; -const INTERNAL = new Set([ - '@mebular/core', - '@mebular/fleet', - '@mebular/mcp', - '@mebular/service', - '@mebular/skill', -]); -const DEP_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies', 'devDependencies']; -const IS_VERSION_RANGE = /^\^?\d+\.\d+\.\d+/; - -let changedDeps = 0; -for (const rel of TARGETS) { - const file = path.join(root, rel, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); - pkg.version = version; - for (const field of DEP_FIELDS) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, spec] of Object.entries(deps)) { - if (!INTERNAL.has(name)) continue; - // 仅同步已成为版本号区间的内部依赖;file:/workspace: 留给本地 workspace / pack 期改写。 - if (IS_VERSION_RANGE.test(String(spec))) { - deps[name] = `^${version}`; - changedDeps += 1; - } - } - } - fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); - console.log(` version -> ${version} ${rel === '.' ? '@mebular/core' : rel}`); -} - -// ---- 桌面 App:package-lock 根 version + main 进程 APP_VERSION ---- -const desktopLockFile = path.join(root, 'apps/desktop/package-lock.json'); -if (fs.existsSync(desktopLockFile)) { - const lock = JSON.parse(fs.readFileSync(desktopLockFile, 'utf8')); - const rootEntry = lock.packages?.[''] ?? null; - const lockChanged = lock.version !== version || (rootEntry !== null && rootEntry.version !== version); - lock.version = version; - if (rootEntry !== null) rootEntry.version = version; - if (lockChanged) { - fs.writeFileSync(desktopLockFile, `${JSON.stringify(lock, null, 2)}\n`, 'utf8'); - console.log(` version -> ${version} apps/desktop/package-lock.json`); - } -} - -const desktopDaemon = path.join(root, 'apps', 'desktop', 'src', 'main', 'daemon.mjs'); -if (fs.existsSync(desktopDaemon)) { - const before = fs.readFileSync(desktopDaemon, 'utf8'); - const after = before.replace( - /(export const APP_VERSION = ')[^']+(';)/, - `$1${version}$2`, - ); - if (after === before) { - if (!/export const APP_VERSION = '/.test(before)) { - console.error('ERROR: apps/desktop/src/main/daemon.mjs 缺少 APP_VERSION 导出'); - process.exit(1); - } - console.log(` APP_VERSION 已是 ${version},跳过`); - } else { - fs.writeFileSync(desktopDaemon, after, 'utf8'); - console.log(` APP_VERSION -> ${version} apps/desktop/src/main/daemon.mjs`); - } -} - -// ---- CHANGELOG 章节占位 ---- -const clFile = path.join(root, 'CHANGELOG.md'); -const HEADER = - '# Changelog\n\nAll notable changes to Mebular are documented here.\nAll published packages share a single lockstep version.\n'; -let text = fs.existsSync(clFile) ? fs.readFileSync(clFile, 'utf8') : HEADER; - -if (new RegExp(`^## \\[${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm').test(text)) { - console.log(` CHANGELOG 已存在 [${tag}] 章节,跳过`); -} else { - const block = - `## [${tag}] - ${date}\n\n` + - '### Added\n\n- _TODO_\n\n' + - '### Changed\n\n- _TODO_\n\n' + - '### Fixed\n\n- _TODO_\n'; - const lines = text.split('\n'); - const idx = lines.findIndex((line) => line.startsWith('## [')); - if (idx === -1) { - text = `${text.replace(/\s*$/, '')}\n\n${block}\n`; - } else { - // 在首个 `## [` 之前插入新章节,并保留与旧章节之间的空行。 - lines.splice(idx, 0, block.replace(/\n$/, ''), ''); - text = lines.join('\n'); - } - fs.writeFileSync(clFile, text, 'utf8'); - console.log(` CHANGELOG 新增 [${tag}] 章节占位`); -} - -console.log(` 内部依赖版本号同步:${changedDeps} 处(file: 本地依赖由 pack 期改写)`); -NODE - -echo "✓ 已同步为 ${TAG}(5 个 npm 包 + 桌面 App + CHANGELOG;请填写章节内容后提交,并将分支命名为 release/${TAG})" +echo "请填写 CHANGELOG 章节内容后提交,并将分支命名为 release-prep/${TAG}(dry-run 通过后再创建 release/${TAG})" diff --git a/scripts/prune-merged-branches.mjs b/scripts/prune-merged-branches.mjs new file mode 100644 index 00000000..87582158 --- /dev/null +++ b/scripts/prune-merged-branches.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +// 已合并远端分支清理清单(默认只列清单,绝不删除)。 +// +// node scripts/prune-merged-branches.mjs +// node scripts/prune-merged-branches.mjs --fetch +// node scripts/prune-merged-branches.mjs --apply --yes # 明确确认后才删除 +// +// 安全约束: +// - 默认 dry-run;--apply 必须同时给 --yes; +// - 保护 main / master / release/* / release-prep/* / site* / gh-pages; +// - 保护当前分支、所有 worktree 已检出的分支、仍有 open PR 的 head 分支; +// - open PR 检查不可用时,--apply 中止(除非显式 --skip-open-pr-check); +// - 只删除确认已合并进 --base(默认 origin/main)的远端分支。 + +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const BUILTIN_PROTECTED = [/^main$/, /^master$/, /^trunk$/, /^release\//, /^release-prep\//, /^site$/, /^site-/, /^gh-pages$/, /^pages$/]; + +export function isProtectedBranch(name, { current, worktrees = [], openPrs = [] } = {}) { + if (!name || name === 'HEAD') return true; + if (name === current) return true; + if (worktrees.includes(name)) return true; + if (openPrs.includes(name)) return true; + return BUILTIN_PROTECTED.some((pattern) => pattern.test(name)); +} + +export function selectMergedBranches(branches, options = {}) { + return branches.filter((branch) => !isProtectedBranch(branch.name, options)); +} + +function git(args, options = {}) { + return execFileSync('git', args, { cwd: SCRIPT_ROOT, encoding: 'utf8', ...options }); +} + +function currentBranch() { + try { + return git(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + } catch { + return ''; + } +} + +function worktreeBranches() { + try { + return git(['worktree', 'list', '--porcelain']) + .split('\n') + .filter((line) => line.startsWith('branch refs/heads/')) + .map((line) => line.slice('branch refs/heads/'.length).trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function openPullRequestHeads() { + try { + const out = execFileSync( + 'gh', + ['pr', 'list', '--state', 'open', '--limit', '1000', '--json', 'headRefName', '--jq', '.[].headRefName'], + { encoding: 'utf8' }, + ); + return out.split('\n').map((line) => line.trim()).filter(Boolean); + } catch { + return null; + } +} + +function listMergedRemoteBranches({ remote, baseRef }) { + const format = '%(refname:short)%09%(objectname:short)%09%(committerdate:short)%09%(subject)'; + const out = git(['for-each-ref', `--merged=${baseRef}`, `--format=${format}`, `refs/remotes/${remote}`]); + const prefix = `${remote}/`; + return out + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [ref, sha, date, ...subject] = line.split('\t'); + return { + name: ref.startsWith(prefix) ? ref.slice(prefix.length) : ref, + sha, + date, + subject: subject.join('\t'), + }; + }) + .filter((branch) => branch.name && branch.name !== 'HEAD'); +} + +function parseArgs(argv) { + const options = { + remote: 'origin', + base: 'origin/main', + apply: false, + yes: false, + json: false, + fetch: false, + skipOpenPrCheck: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + options.help = true; + } else if (arg === '--remote') { + options.remote = argv[++i]; + } else if (arg === '--base') { + options.base = argv[++i]; + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--yes') { + options.yes = true; + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--fetch') { + options.fetch = true; + } else if (arg === '--skip-open-pr-check') { + options.skipOpenPrCheck = true; + } else { + throw new Error(`未知参数:${arg}`); + } + } + return options; +} + +function usage() { + return [ + '用法:node scripts/prune-merged-branches.mjs [选项]', + ' --remote 远端,默认 origin', + ' --base 合并基准,默认 origin/main', + ' --fetch 先执行 git fetch --prune ', + ' --json 输出机器可读清单', + ' --apply --yes 删除清单中的分支(必须同时给 --yes)', + ' --skip-open-pr-check 跳过 open PR 保护(仅在你确认无 open PR 时使用)', + ].join('\n'); +} + +async function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`错误:${error.message}`); + console.error(usage()); + return 2; + } + if (options.help) { + console.log(usage()); + return 0; + } + if (!options.remote || !options.base) { + console.error('错误:--remote / --base 不能为空'); + return 2; + } + if (options.apply && !options.yes) { + console.error('错误:--apply 必须同时给 --yes(默认只做 dry-run 清单)。'); + return 2; + } + + if (options.fetch) { + console.log(`$ git fetch --prune ${options.remote}`); + try { + git(['fetch', '--prune', options.remote], { stdio: 'inherit' }); + } catch { + console.error('错误:git fetch 失败,未做任何删除。'); + return 1; + } + } + + let branches; + try { + branches = listMergedRemoteBranches({ remote: options.remote, baseRef: options.base }); + } catch (error) { + console.error(`错误:无法列出已合并分支(base=${options.base}):${error.message}`); + return 1; + } + + const openPrs = options.skipOpenPrCheck ? [] : openPullRequestHeads(); + const openPrCheckUnavailable = openPrs === null; + const selected = selectMergedBranches(branches, { + current: currentBranch(), + worktrees: worktreeBranches(), + openPrs: openPrs ?? [], + }); + + if (options.json) { + console.log( + JSON.stringify( + { + remote: options.remote, + base: options.base, + apply: options.apply, + openPrCheck: openPrCheckUnavailable ? 'unavailable' : 'ok', + candidates: selected, + }, + null, + 2, + ), + ); + } else { + if (openPrCheckUnavailable) { + console.warn('WARN: gh 不可用或未登录,无法检查 open PR;dry-run 清单未排除 open PR。'); + } + console.log(`已合并进 ${options.base} 且可安全删除的远端分支(${selected.length}):`); + if (selected.length === 0) { + console.log(' (无)'); + } + for (const branch of selected) { + console.log(` ${branch.name} ${branch.sha} ${branch.date} ${branch.subject}`); + } + if (!options.apply) { + console.log('\n这是 dry-run 清单,未删除任何分支。确认后运行:'); + console.log(' node scripts/prune-merged-branches.mjs --apply --yes'); + } + } + + if (!options.apply) return 0; + + if (openPrCheckUnavailable) { + console.error('错误:无法检查 open PR,拒绝批量删除。确认无 open PR 后可加 --skip-open-pr-check。'); + return 1; + } + + let failures = 0; + for (const branch of selected) { + const ref = `${options.remote}/${branch.name}`; + console.log(`删除 ${ref}(${branch.sha})`); + try { + git(['push', options.remote, '--delete', branch.name], { stdio: 'inherit' }); + } catch { + failures += 1; + console.error(`失败:${ref}`); + } + } + if (failures > 0) { + console.error(`完成,但有 ${failures} 个分支删除失败。`); + return 1; + } + console.log(`完成:已删除 ${selected.length} 个已合并远端分支。`); + return 0; +} + +const invokedAsScript = process.argv[1] + ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; +if (invokedAsScript) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/scripts/verify-dsh-plugin.mjs b/scripts/verify-dsh-plugin.mjs index 18615c37..2437650f 100644 --- a/scripts/verify-dsh-plugin.mjs +++ b/scripts/verify-dsh-plugin.mjs @@ -67,7 +67,7 @@ if (existsSync(pkgJsonPath)) { } check('name is @mebular/dsh-plugin', pkg.name === '@mebular/dsh-plugin', String(pkg.name)); -check('version is 0.1.1', pkg.version === '0.1.1', String(pkg.version)); +check('version is a semver string', /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(pkg.version)), String(pkg.version)); check( 'description is present', typeof pkg.description === 'string' && pkg.description.length > 0, @@ -299,7 +299,7 @@ check( check('README documents the npm name', readme.includes('@mebular/dsh-plugin')); check( 'README documents npm install', - readme.includes('dsh plugin --profile web add @mebular/dsh-plugin@0.1.1'), + /dsh plugin --profile web add @mebular\/dsh-plugin@\d+\.\d+\.\d+/.test(readme), ); check( 'README does not claim the npm package is unpublished', diff --git a/scripts/verify-mcp-publish.mjs b/scripts/verify-mcp-publish.mjs index f34b5afe..002fd67c 100644 --- a/scripts/verify-mcp-publish.mjs +++ b/scripts/verify-mcp-publish.mjs @@ -3,7 +3,7 @@ // // 1) 對五個可發布套件(@mebular/core / service / fleet / mcp / skill)npm pack; // 檢驗 tarball 內容,且 mcp/fleet 內的 `@mebular/*` 已於 prepack 由 `file:` 改寫為 -// `^0.1.1`,pack 後本地依賴已還原、備份已清除。 +// `^<根 package.json version>`,pack 後本地依賴已還原、備份已清除。 // 2) 用產出的 tarball 在乾淨目錄安裝;以安裝後的 `mebular status`、`mebular mcp` // (真实 MCP client)、`fleet --version` 與 `@mebular/skill/scripts/install.mjs` 做冒煙。 // `qrcode-generator@2.0.4` 是 fleet/core 的普通 npm 依賴,安裝時由 registry 解析。 @@ -20,6 +20,8 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); +const version = JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf-8')).version; +const range = `^${version}`; // F-UNI:统一 MCP 入口让 mcp 依赖 fleet → 发布面 = core + service + fleet + mcp + skill const serviceDir = join(rootDir, 'packages', 'service'); const fleetDir = join(rootDir, 'packages', 'fleet'); @@ -76,10 +78,10 @@ try { const mcpManifest = manifestOf(mcp.file); const fleetManifest = manifestOf(fleet.file); const coreManifestForDeps = manifestOf(core.file); - check('mcp tarball 依賴 @mebular/core=^0.1.1', mcpManifest.dependencies?.['@mebular/core'] === '^0.1.1', mcpManifest.dependencies?.['@mebular/core']); - check('mcp tarball 依賴 @mebular/fleet=^0.1.1', mcpManifest.dependencies?.['@mebular/fleet'] === '^0.1.1', mcpManifest.dependencies?.['@mebular/fleet']); - check('fleet tarball 依賴 @mebular/core=^0.1.1', fleetManifest.dependencies?.['@mebular/core'] === '^0.1.1', fleetManifest.dependencies?.['@mebular/core']); - check('fleet tarball 依賴 @mebular/service=^0.1.1', fleetManifest.dependencies?.['@mebular/service'] === '^0.1.1', fleetManifest.dependencies?.['@mebular/service']); + check(`mcp tarball 依賴 @mebular/core=${range}`, mcpManifest.dependencies?.['@mebular/core'] === range, mcpManifest.dependencies?.['@mebular/core']); + check(`mcp tarball 依賴 @mebular/fleet=${range}`, mcpManifest.dependencies?.['@mebular/fleet'] === range, mcpManifest.dependencies?.['@mebular/fleet']); + check(`fleet tarball 依賴 @mebular/core=${range}`, fleetManifest.dependencies?.['@mebular/core'] === range, fleetManifest.dependencies?.['@mebular/core']); + check(`fleet tarball 依賴 @mebular/service=${range}`, fleetManifest.dependencies?.['@mebular/service'] === range, fleetManifest.dependencies?.['@mebular/service']); check('fleet tarball 依賴 qrcode-generator=2.0.4', fleetManifest.dependencies?.['qrcode-generator'] === '2.0.4', fleetManifest.dependencies?.['qrcode-generator']); check('core tarball 依賴 qrcode-generator=2.0.4', coreManifestForDeps.dependencies?.['qrcode-generator'] === '2.0.4', coreManifestForDeps.dependencies?.['qrcode-generator']); const leaks = [ @@ -138,7 +140,7 @@ try { { cwd: work, stdio: ['ignore', 'ignore', 'inherit'], env: process.env }, ); const installedCore = JSON.parse(await readFile(join(work, 'node_modules', '@mebular', 'core', 'package.json'), 'utf-8')); - check('tarball 安裝:@mebular/core@0.1.1', installedCore.version === '0.1.1', installedCore.version); + check(`tarball 安裝:@mebular/core@${version}`, installedCore.version === version, installedCore.version); check( 'tarball 安裝:五包齊備', ['core', 'service', 'fleet', 'mcp', 'skill'].every((name) => existsSync(join(work, 'node_modules', '@mebular', name))), @@ -156,7 +158,7 @@ try { check('安裝後 mebular status', /^[0-9a-f]{64}$/.test(statusOut.stateHash ?? ''), `deviceId=${statusOut.deviceId}`); // 安裝後的 stdio MCP 冒煙(真实 MCP client) - client = new Client({ name: 'mebular-publish-smoke', version: '0.1.1' }); + client = new Client({ name: 'mebular-publish-smoke', version }); const transport = new StdioClientTransport({ command: process.execPath, args: [bin, 'mcp'], env, stderr: 'pipe' }); await client.connect(transport); const { tools } = await client.listTools(); @@ -183,7 +185,7 @@ try { // 安裝後的 fleet CLI(來自 fleet tarball) const fleetBin = join(work, 'node_modules', '.bin', 'fleet'); const fleetOut = execFileSync(process.execPath, [fleetBin, '--version'], { cwd: work, encoding: 'utf-8', env }); - check('安裝後 fleet --version', /0\.1\.1/.test(fleetOut), fleetOut.trim().split('\n').pop()); + check('安裝後 fleet --version', new RegExp(version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(fleetOut), fleetOut.trim().split('\n').pop()); // ---------- F-INST-1:根包自足(`npm i -g github:…#` 一條命令裝出可用 CLI) ---------- const coreManifest = manifestOf(core.file); diff --git a/scripts/verify-release-version.mjs b/scripts/verify-release-version.mjs new file mode 100644 index 00000000..29923aa8 --- /dev/null +++ b/scripts/verify-release-version.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +// 发布版本 SSOT 校验(只读,可独立运行,也可在 dry-run / release-prep 分支上跑)。 +// +// node scripts/verify-release-version.mjs +// node scripts/verify-release-version.mjs --version 0.2.0 --tag v0.2.0 +// +// 校验: +// - 6 个 package manifest(core / fleet / mcp / service / skill / dsh-plugin); +// - 根 package-lock 与 6 个 workspace 条目; +// - apps/desktop package.json / package-lock / APP_VERSION; +// - packages/mcp/src/version.mjs,以及 MCP 其它版本使用点不再手写版本号; +// - CHANGELOG.md 含 `## [vX.Y.Z]` 章节。 + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RELEASE_PACKAGES, SEMVER_RE } from './bump-version.mjs'; + +const SCRIPT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const readText = (file) => readFileSync(file, 'utf8'); +const readJson = (file) => JSON.parse(readText(file)); + +export function verifyReleaseVersion({ root = SCRIPT_ROOT, version, tag = `v${version}` } = {}) { + const checks = []; + const add = (label, ok, detail = '') => { + checks.push({ label, ok: Boolean(ok), detail: ok ? '' : String(detail ?? '') }); + }; + const safe = (label, fn) => { + try { + fn(); + } catch (error) { + add(label, false, error.message); + } + }; + const releaseTag = String(tag).startsWith('v') ? String(tag) : `v${tag}`; + + if (!version || !SEMVER_RE.test(String(version))) { + add('version 参数是合法 semver', false, String(version)); + return { ok: false, version, tag: releaseTag, checks }; + } + + // ---- 6 个可发布包 manifest ---- + for (const target of RELEASE_PACKAGES) { + safe(`${target.name} manifest`, () => { + const file = join(root, target.rel, 'package.json'); + if (!existsSync(file)) { + add(`${target.name} manifest`, false, `${target.rel}/package.json 不存在`); + return; + } + const pkg = readJson(file); + add(`${target.name} name`, pkg.name === target.name, pkg.name); + add(`${target.name} version`, pkg.version === version, `${pkg.version} != ${version}`); + }); + } + + // ---- 根 lockfile 与 workspace 条目 ---- + safe('package-lock.json', () => { + const file = join(root, 'package-lock.json'); + if (!existsSync(file)) { + add('package-lock.json', false, '不存在'); + return; + } + const lock = readJson(file); + add('package-lock.json version', lock.version === version, `${lock.version} != ${version}`); + add( + 'package-lock.json packages[""].version', + lock.packages?.['']?.version === version, + `${lock.packages?.['']?.version} != ${version}`, + ); + for (const target of RELEASE_PACKAGES) { + if (target.rel === '.') continue; + const entry = lock.packages?.[target.rel]; + add( + `package-lock.json ${target.rel} version`, + entry?.version === version, + entry ? `${entry.version} != ${version}` : '缺少 workspace 条目', + ); + } + }); + + // ---- 桌面 App ---- + safe('apps/desktop/package.json', () => { + const pkg = readJson(join(root, 'apps/desktop/package.json')); + add('apps/desktop/package.json version', pkg.version === version, `${pkg.version} != ${version}`); + }); + safe('apps/desktop/package-lock.json', () => { + const file = join(root, 'apps/desktop/package-lock.json'); + if (!existsSync(file)) { + add('apps/desktop/package-lock.json', false, '不存在'); + return; + } + const lock = readJson(file); + add('apps/desktop/package-lock.json version', lock.version === version, `${lock.version} != ${version}`); + add( + 'apps/desktop/package-lock.json packages[""].version', + lock.packages?.['']?.version === version, + `${lock.packages?.['']?.version} != ${version}`, + ); + }); + safe('apps/desktop/src/main/daemon.mjs APP_VERSION', () => { + const source = readText(join(root, 'apps/desktop/src/main/daemon.mjs')); + const value = /export const APP_VERSION = '([^']+)'/.exec(source)?.[1]; + add('apps/desktop/src/main/daemon.mjs APP_VERSION', value === version, `${value} != ${version}`); + }); + + // ---- MCP 版本真源 ---- + safe('packages/mcp/src/version.mjs', () => { + const file = join(root, 'packages/mcp/src/version.mjs'); + if (!existsSync(file)) { + add('packages/mcp/src/version.mjs', false, '不存在'); + return; + } + const source = readText(file); + const value = /export const APP_VERSION = '([^']+)'/.exec(source)?.[1]; + add('packages/mcp/src/version.mjs APP_VERSION', value === version, `${value} != ${version}`); + }); + + safe('packages/mcp/src/serve.mjs 版本来源', () => { + const source = readText(join(root, 'packages/mcp/src/serve.mjs')); + add( + 'packages/mcp/src/serve.mjs 从 version.mjs 导入 APP_VERSION', + /import\s*\{[^}]*\bAPP_VERSION\b[^}]*\}\s*from\s*'\.\/version\.mjs'/.test(source), + ); + add( + 'packages/mcp/src/serve.mjs 重新导出 APP_VERSION', + /export\s*\{[^}]*\bAPP_VERSION\b[^}]*\}\s*from\s*'\.\/version\.mjs'/.test(source), + ); + add( + 'packages/mcp/src/serve.mjs 不再手写 APP_VERSION', + !/export const APP_VERSION = '\d+\.\d+\.\d+/.test(source), + ); + }); + + for (const rel of ['packages/mcp/src/server.mjs', 'packages/mcp/src/client.mjs']) { + safe(`${rel} 版本来源`, () => { + const source = readText(join(root, rel)); + add( + `${rel} 从 version.mjs 导入 APP_VERSION`, + /import\s*\{[^}]*\bAPP_VERSION\b[^}]*\}\s*from\s*'\.\/version\.mjs'/.test(source), + ); + add( + `${rel} 不再手写 semver version`, + !/(?:^|[^A-Za-z])version:\s*'\d+\.\d+\.\d+/.test(source), + ); + }); + } + + safe('packages/mcp/scripts/publish-manifest.mjs 版本来源', () => { + const source = readText(join(root, 'packages/mcp/scripts/publish-manifest.mjs')); + add( + 'packages/mcp/scripts/publish-manifest.mjs 从 package.json 读取版本', + /pkg\.version/.test(source), + ); + add( + 'packages/mcp/scripts/publish-manifest.mjs 不再硬编码发布区间', + !/PUBLISH_RANGE\s*=\s*'\^\d+\.\d+\.\d+/.test(source), + ); + }); + + // ---- CHANGELOG ---- + safe('CHANGELOG.md', () => { + const file = join(root, 'CHANGELOG.md'); + if (!existsSync(file)) { + add('CHANGELOG.md', false, '不存在'); + return; + } + const text = readText(file); + add( + `CHANGELOG.md 含 [${releaseTag}] 章节`, + text.split('\n').some((line) => line.startsWith(`## [${releaseTag}]`)), + ); + }); + + return { + ok: checks.every((check) => check.ok), + version, + tag: releaseTag, + checks, + }; +} + +function parseArgs(argv) { + const options = { root: SCRIPT_ROOT, version: undefined, tag: undefined, json: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + options.help = true; + } else if (arg === '--root') { + options.root = resolve(argv[++i] ?? ''); + } else if (arg === '--version') { + options.version = argv[++i]; + } else if (arg === '--tag') { + options.tag = argv[++i]; + } else if (arg === '--json') { + options.json = true; + } else { + throw new Error(`未知参数:${arg}`); + } + } + return options; +} + +function usage() { + return '用法:node scripts/verify-release-version.mjs [--root ] [--version ] [--tag vx.y.z] [--json]'; +} + +async function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`错误:${error.message}`); + console.error(usage()); + return 2; + } + if (options.help) { + console.log(usage()); + return 0; + } + if (!options.version) { + try { + options.version = readJson(join(options.root, 'package.json')).version; + } catch (error) { + console.error(`错误:无法读取根 package.json:${error.message}`); + return 2; + } + } + const result = verifyReleaseVersion({ + root: options.root, + version: options.version, + tag: options.tag ?? `v${options.version}`, + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + for (const check of result.checks) { + console.log(`${check.ok ? 'ok ' : 'FAIL'} ${check.label}${check.detail ? `(${check.detail})` : ''}`); + } + console.log('== verify:release-version =='); + console.log(result.ok ? `✓ ${result.tag} 版本 SSOT 一致` : `✗ ${result.tag} 版本 SSOT 不一致`); + } + return result.ok ? 0 : 1; +} + +const invokedAsScript = process.argv[1] + ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; +if (invokedAsScript) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/scripts/verify-release-visibility.mjs b/scripts/verify-release-visibility.mjs new file mode 100644 index 00000000..a33afc42 --- /dev/null +++ b/scripts/verify-release-visibility.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +// 发布后 npm 可见性 gate:六个包都必须在 registry 可见,且 dist-tags.latest 指向本次发布版本。 +// +// 背景:v0.1.1 的 npm publish job 成功时,@mebular/mcp 的 latest 仍短暂指向旧版本 / +// `npm view` 返回 E404;流水线没有 gate,GitHub Release 仍会创建。本脚本用有界轮询替代 +// “sleep 一下就过”,失败时逐包输出最后状态。 +// +// 独立运行: +// node scripts/verify-release-visibility.mjs +// node scripts/verify-release-visibility.mjs --version 0.2.0 --dsh-version 0.2.0 +// node scripts/verify-release-visibility.mjs --timeout-seconds 600 --interval-seconds 10 +// +// 退出码:0 = 六包可见且 latest 正确;1 = 超时/状态不符;2 = 参数或本地 manifest 错误。 + +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { SEMVER_RE } from './bump-version.mjs'; + +const execFileAsync = promisify(execFile); +const SCRIPT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +export const LOCKSTEP_PACKAGES = [ + '@mebular/core', + '@mebular/service', + '@mebular/skill', + '@mebular/mcp', + '@mebular/fleet', +]; +export const DSH_PACKAGE = '@mebular/dsh-plugin'; + +const DEFAULT_TIMEOUT_SECONDS = 600; +const DEFAULT_INTERVAL_SECONDS = 10; +const VIEW_TIMEOUT_MS = 60_000; + +export function buildReleasePackages({ version, dshVersion = version }) { + return [ + ...LOCKSTEP_PACKAGES.map((name) => ({ name, version })), + { name: DSH_PACKAGE, version: dshVersion }, + ]; +} + +export function npmViewArgs(name, version, { registry } = {}) { + const args = [ + 'view', + `${name}@${version}`, + 'version', + 'dist-tags.latest', + '--json', + '--prefer-online', + ]; + if (registry) args.push('--registry', registry); + return args; +} + +export function parseViewOutput(stdout) { + const parsed = JSON.parse(String(stdout)); + if (typeof parsed === 'string') { + // 单字段输出(例如只返回 version)也应能被解析;latest 视为未知。 + return { version: parsed, latest: null }; + } + return { + version: parsed?.version ?? null, + latest: parsed?.['dist-tags.latest'] ?? parsed?.['dist-tags']?.latest ?? null, + }; +} + +export async function viewPackageWithNpm( + name, + version, + { npm = 'npm', registry, timeoutMs = VIEW_TIMEOUT_MS } = {}, +) { + const { stdout } = await execFileAsync(npm, npmViewArgs(name, version, { registry }), { + timeout: timeoutMs, + maxBuffer: 1024 * 1024, + env: process.env, + }); + return parseViewOutput(stdout); +} + +export function formatViewError(error) { + const stderr = String(error?.stderr ?? '').trim(); + const message = stderr || String(error?.message ?? error); + const firstMeaningful = message + .split('\n') + .map((line) => line.trim()) + .find((line) => /E404|404|not in this registry|ETARGET|ERR!/i.test(line)); + return firstMeaningful ?? message.split('\n')[0] ?? 'unknown error'; +} + +/** + * 有界轮询:每一轮并行检查所有未通过的包;全部 ok 才返回成功。 + * 依赖注入 viewPackage / sleep / now,便于用确定性 fixture 测试。 + */ +export async function verifyVisibility({ + packages, + timeoutMs = DEFAULT_TIMEOUT_SECONDS * 1000, + intervalMs = DEFAULT_INTERVAL_SECONDS * 1000, + viewPackage, + sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), + now = () => Date.now(), + log = () => {}, +}) { + const viewer = viewPackage ?? viewPackageWithNpm; + const states = packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + attempts: 0, + versionVisible: false, + latest: null, + lastError: null, + ok: false, + loggedError: undefined, + })); + + const deadline = now() + Math.max(0, timeoutMs); + let round = 0; + let firstRound = true; + + const checkOne = async (state) => { + state.attempts += 1; + try { + const result = await viewer(state.name, state.version); + state.versionVisible = result.version === state.version; + state.latest = result.latest ?? null; + if (!state.versionVisible) { + state.lastError = `npm view 返回 ${String(result.version)},期望 ${state.version}`; + } else if (state.latest !== state.version) { + state.lastError = `dist-tags.latest=${String(state.latest)},期望 ${state.version}`; + } else { + state.ok = true; + state.lastError = null; + } + } catch (error) { + state.versionVisible = false; + state.latest = null; + state.lastError = formatViewError(error); + } + + if (state.ok) { + log(` ok ${state.name}@${state.version} latest=${state.latest} (attempt ${state.attempts})`); + } else if (state.lastError !== state.loggedError) { + log(` wait ${state.name}@${state.version} ${state.lastError} (attempt ${state.attempts})`); + } + state.loggedError = state.lastError; + }; + + while (firstRound || now() < deadline) { + firstRound = false; + round += 1; + const pending = states.filter((state) => !state.ok); + if (pending.length === 0) break; + await Promise.all(pending.map(checkOne)); + if (states.every((state) => state.ok)) break; + const remaining = deadline - now(); + if (remaining <= 0) break; + await sleep(Math.min(intervalMs, remaining)); + } + + return { + ok: states.every((state) => state.ok), + timeoutMs, + intervalMs, + rounds: round, + packages: states.map(({ loggedError: _loggedError, ...state }) => state), + }; +} + +function readManifestVersion(root, rel) { + return JSON.parse(readFileSync(join(root, rel, 'package.json'), 'utf8')).version; +} + +function parseArgs(argv) { + const options = { + root: SCRIPT_ROOT, + version: undefined, + dshVersion: undefined, + timeoutSeconds: DEFAULT_TIMEOUT_SECONDS, + intervalSeconds: DEFAULT_INTERVAL_SECONDS, + registry: undefined, + npm: 'npm', + json: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + options.help = true; + } else if (arg === '--root') { + options.root = resolve(argv[++i] ?? ''); + } else if (arg === '--version') { + options.version = argv[++i]; + } else if (arg === '--dsh-version') { + options.dshVersion = argv[++i]; + } else if (arg === '--timeout-seconds') { + options.timeoutSeconds = Number(argv[++i]); + } else if (arg === '--interval-seconds') { + options.intervalSeconds = Number(argv[++i]); + } else if (arg === '--registry') { + options.registry = argv[++i]; + } else if (arg === '--npm') { + options.npm = argv[++i]; + } else if (arg === '--json') { + options.json = true; + } else { + throw new Error(`未知参数:${arg}`); + } + } + return options; +} + +function usage() { + return [ + '用法:node scripts/verify-release-visibility.mjs [选项]', + ' --version 5 个 lockstep 包版本(默认根 package.json)', + ' --dsh-version @mebular/dsh-plugin 版本(默认其 package.json)', + ' --timeout-seconds 总超时,默认 600', + ' --interval-seconds 轮询间隔,默认 10', + ' --registry 覆盖 npm registry', + ' --npm 覆盖 npm 可执行文件(测试用)', + ' --json 输出机器可读结果', + ].join('\n'); +} + +export function formatVisibilityResult(result) { + const lines = []; + const status = result.ok ? 'PASS' : 'FAIL'; + lines.push( + `Release visibility ${status}: ${result.packages.filter((pkg) => pkg.ok).length}/${result.packages.length} packages ok` + + ` (rounds=${result.rounds}, timeout=${result.timeoutMs / 1000}s, interval=${result.intervalMs / 1000}s)`, + ); + for (const pkg of result.packages) { + const detail = pkg.ok + ? `version=${pkg.version} latest=${pkg.latest}` + : `versionVisible=${pkg.versionVisible} latest=${String(pkg.latest)} lastError=${pkg.lastError}`; + lines.push(` ${pkg.ok ? '✓' : '✗'} ${pkg.name}@${pkg.version} ${detail} (attempts=${pkg.attempts})`); + } + if (!result.ok) { + lines.push('修复:确认 publish / publish-dsh 已成功,并等待 registry 传播后重跑该 job;不要跳过本 gate。'); + } + return lines.join('\n'); +} + +async function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`错误:${error.message}`); + console.error(usage()); + return 2; + } + if (options.help) { + console.log(usage()); + return 0; + } + + try { + options.version ??= readManifestVersion(options.root, '.'); + options.dshVersion ??= readManifestVersion(options.root, 'packages/dsh-plugin'); + } catch (error) { + console.error(`错误:无法读取本地 manifest 版本:${error.message}`); + return 2; + } + + for (const [label, value] of [ + ['--version', options.version], + ['--dsh-version', options.dshVersion], + ]) { + if (!SEMVER_RE.test(String(value))) { + console.error(`错误:${label} 不是合法 semver:${value}`); + return 2; + } + } + for (const [label, value] of [ + ['--timeout-seconds', options.timeoutSeconds], + ['--interval-seconds', options.intervalSeconds], + ]) { + if (!Number.isFinite(value) || value < 0) { + console.error(`错误:${label} 需要非负数字:${value}`); + return 2; + } + } + + const packages = buildReleasePackages({ + version: options.version, + dshVersion: options.dshVersion, + }); + const log = options.json + ? () => {} + : (line) => console.log(line); + + log( + `[visibility] ${packages.length} packages · timeout=${options.timeoutSeconds}s · interval=${options.intervalSeconds}s` + + `${options.registry ? ` · registry=${options.registry}` : ''}`, + ); + + const result = await verifyVisibility({ + packages, + timeoutMs: options.timeoutSeconds * 1000, + intervalMs: options.intervalSeconds * 1000, + viewPackage: (name, version) => + viewPackageWithNpm(name, version, { + npm: options.npm, + registry: options.registry, + }), + log, + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatVisibilityResult(result)); + } + return result.ok ? 0 : 1; +} + +const invokedAsScript = process.argv[1] + ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; +if (invokedAsScript) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/tests/dsh/manifest.test.mjs b/tests/dsh/manifest.test.mjs index 8394cf09..178d1bb0 100644 --- a/tests/dsh/manifest.test.mjs +++ b/tests/dsh/manifest.test.mjs @@ -19,7 +19,7 @@ const client = read('client.js'); describe('P2 @mebular/dsh-plugin manifest:DSH bundle 形状', () => { it('package.json 标识、入口与 exports 完整', () => { expect(pkg.name).toBe('@mebular/dsh-plugin'); - expect(pkg.version).toBe('0.1.1'); + expect(pkg.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); expect(pkg.license).toBe('MIT'); expect(pkg.type).toBe('module'); expect(pkg.main).toBe('index.js'); diff --git a/tests/mcp/console-embed.test.mjs b/tests/mcp/console-embed.test.mjs index 73f0d477..8c4651d6 100644 --- a/tests/mcp/console-embed.test.mjs +++ b/tests/mcp/console-embed.test.mjs @@ -2,9 +2,11 @@ // /app/embed/info 匿名只读且不含敏感信息,并且在 bearer/OAuth 鉴权之前。 import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { readFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { APP_VERSION, buildEmbedInfo, @@ -13,6 +15,11 @@ import { } from '../../packages/mcp/src/serve.mjs'; import { readRuntimeFile } from '../../packages/mcp/src/runtime-file.mjs'; +const here = dirname(fileURLToPath(import.meta.url)); +const rootVersion = JSON.parse( + readFileSync(join(here, '..', '..', 'package.json'), 'utf8'), +).version; + function headerRecorder() { const headers = {}; return { @@ -115,7 +122,7 @@ describe('P1 console embed:真实 HTTP server', () => { loopback: true, capabilities: { embedConsole: true }, }); - expect(APP_VERSION).toBe('0.1.1'); + expect(APP_VERSION).toBe(rootVersion); const serialized = JSON.stringify(info).toLowerCase(); for (const word of ['token', 'secret', 'key', 'config', 'home']) { expect(serialized).not.toContain(word); diff --git a/tests/release/branches.test.mjs b/tests/release/branches.test.mjs new file mode 100644 index 00000000..475155f2 --- /dev/null +++ b/tests/release/branches.test.mjs @@ -0,0 +1,36 @@ +// 已合并分支清理:纯选择逻辑,确保受保护分支不会进入删除清单。 + +import { describe, it, expect } from '@jest/globals'; +import { isProtectedBranch, selectMergedBranches } from '../../scripts/prune-merged-branches.mjs'; + +const branches = [ + 'main', + 'release/v0.1.1', + 'release-prep/v0.1.1', + 'site', + 'site-v1.0', + 'docs/merged', + 'fix/merged', + 'feat/open-pr', + 'chore/current', + 'chore/worktree', +].map((name) => ({ name, sha: 'abc1234', date: '2026-10-25', subject: `subject ${name}` })); + +describe('prune-merged-branches:保护规则', () => { + it('保护 main / release / release-prep / site / 当前分支 / worktree / open PR', () => { + const selected = selectMergedBranches(branches, { + current: 'chore/current', + worktrees: ['chore/worktree'], + openPrs: ['feat/open-pr'], + }); + expect(selected.map((branch) => branch.name)).toEqual(['docs/merged', 'fix/merged']); + }); + + it('没有保护上下文时仍保护内置分支模式', () => { + expect(isProtectedBranch('main')).toBe(true); + expect(isProtectedBranch('release/v0.2.0')).toBe(true); + expect(isProtectedBranch('release-prep/v0.2.0')).toBe(true); + expect(isProtectedBranch('site-v1.3')).toBe(true); + expect(isProtectedBranch('docs/merged')).toBe(false); + }); +}); diff --git a/tests/release/version-ssot.test.mjs b/tests/release/version-ssot.test.mjs new file mode 100644 index 00000000..bb0cf80b --- /dev/null +++ b/tests/release/version-ssot.test.mjs @@ -0,0 +1,208 @@ +// Release SSOT:bump-version 与 verify-release-version 的 fixture 测试。 +// 不触碰真实仓库文件:每个用例在临时目录搭一个最小仓库。 + +import { describe, it, expect, afterEach } from '@jest/globals'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bumpVersion } from '../../scripts/bump-version.mjs'; +import { verifyReleaseVersion } from '../../scripts/verify-release-version.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '..', '..'); +const tempDirs = []; + +function write(root, rel, content) { + const file = join(root, rel); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content, 'utf8'); +} + +function writeJson(root, rel, value) { + write(root, rel, `${JSON.stringify(value, null, 2)}\n`); +} + +function buildFixture() { + const root = mkdtempSync(join(tmpdir(), 'mebular-release-ssot-')); + tempDirs.push(root); + + const packages = { + 'packages/fleet': { name: '@mebular/fleet', deps: { '@mebular/core': 'file:../..', '@mebular/service': '^0.1.1' } }, + 'packages/mcp': { name: '@mebular/mcp', deps: { '@mebular/core': 'file:../..' } }, + 'packages/service': { name: '@mebular/service' }, + 'packages/skill': { name: '@mebular/skill' }, + 'packages/dsh-plugin': { name: '@mebular/dsh-plugin' }, + }; + + writeJson(root, 'package.json', { + name: '@mebular/core', + version: '0.1.1', + workspaces: ['packages/*'], + }); + + const lockPackages = { + '': { name: '@mebular/core', version: '0.1.1' }, + }; + for (const [rel, pkg] of Object.entries(packages)) { + writeJson(root, `${rel}/package.json`, { + name: pkg.name, + version: '0.1.1', + ...(pkg.deps ? { dependencies: pkg.deps } : {}), + }); + lockPackages[rel] = { name: pkg.name, version: '0.1.1' }; + } + writeJson(root, 'package-lock.json', { + name: '@mebular/core', + version: '0.1.1', + lockfileVersion: 3, + packages: lockPackages, + }); + + writeJson(root, 'apps/desktop/package.json', { name: '@mebular/desktop', version: '0.1.1' }); + writeJson(root, 'apps/desktop/package-lock.json', { + name: '@mebular/desktop', + version: '0.1.1', + lockfileVersion: 3, + packages: { '': { name: '@mebular/desktop', version: '0.1.1' } }, + }); + write(root, 'apps/desktop/src/main/daemon.mjs', "export const APP_VERSION = '0.1.1';\n"); + + write(root, 'packages/mcp/src/version.mjs', "export const APP_VERSION = '0.1.1';\n"); + write( + root, + 'packages/mcp/src/serve.mjs', + [ + "import { APP_VERSION } from './version.mjs';", + "export { APP_VERSION } from './version.mjs';", + 'export const EMBED_PROTOCOL = 1;', + 'export function info() { return { version: APP_VERSION }; }', + '', + ].join('\n'), + ); + write( + root, + 'packages/mcp/src/server.mjs', + [ + "import { APP_VERSION } from './version.mjs';", + "export function build() { return { name: 'mebular', version: APP_VERSION }; }", + '', + ].join('\n'), + ); + write( + root, + 'packages/mcp/src/client.mjs', + [ + "import { APP_VERSION } from './version.mjs';", + 'export const init = { clientInfo: { name: \'mebular-cli\', version: APP_VERSION } };', + '', + ].join('\n'), + ); + write( + root, + 'packages/mcp/scripts/publish-manifest.mjs', + [ + "const pkg = JSON.parse('{\"version\":\"0.1.1\"}');", + 'const publishRange = `^${pkg.version}`;', + 'export { publishRange };', + '', + ].join('\n'), + ); + + write( + root, + 'CHANGELOG.md', + '# Changelog\n\n## [v0.1.1] - 2026-10-25\n\n- fixture\n', + ); + return root; +} + +function readVersion(root, rel) { + return JSON.parse(readFileSync(join(root, rel, 'package.json'), 'utf8')).version; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('bump-version:六包 + MCP 版本源', () => { + it('同步 6 个 manifest、两个 lock、桌面 APP_VERSION、MCP version.mjs 与 CHANGELOG', () => { + const root = buildFixture(); + const result = bumpVersion({ root, version: '0.2.0', tag: 'v0.2.0', date: '2026-10-26' }); + + for (const rel of ['', 'packages/fleet', 'packages/mcp', 'packages/service', 'packages/skill', 'packages/dsh-plugin']) { + expect(readVersion(root, rel)).toBe('0.2.0'); + } + const rootLock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')); + expect(rootLock.version).toBe('0.2.0'); + expect(rootLock.packages[''].version).toBe('0.2.0'); + expect(rootLock.packages['packages/dsh-plugin'].version).toBe('0.2.0'); + expect(rootLock.packages['packages/fleet'].version).toBe('0.2.0'); + + const desktopLock = JSON.parse(readFileSync(join(root, 'apps/desktop/package-lock.json'), 'utf8')); + expect(desktopLock.version).toBe('0.2.0'); + expect(desktopLock.packages[''].version).toBe('0.2.0'); + expect(readFileSync(join(root, 'apps/desktop/src/main/daemon.mjs'), 'utf8')).toContain( + "APP_VERSION = '0.2.0'", + ); + expect(readFileSync(join(root, 'packages/mcp/src/version.mjs'), 'utf8')).toContain( + "APP_VERSION = '0.2.0'", + ); + + const fleet = JSON.parse(readFileSync(join(root, 'packages/fleet/package.json'), 'utf8')); + expect(fleet.dependencies['@mebular/core']).toBe('file:../..'); + expect(fleet.dependencies['@mebular/service']).toBe('^0.2.0'); + expect(result.changedDeps).toBe(1); + + const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8'); + expect(changelog).toContain('## [v0.2.0] - 2026-10-26'); + expect(changelog).toContain('## [v0.1.1]'); + }); + + it('重复执行同一版本不会重复插入 CHANGELOG 章节', () => { + const root = buildFixture(); + bumpVersion({ root, version: '0.2.0', tag: 'v0.2.0', date: '2026-10-26' }); + bumpVersion({ root, version: '0.2.0', tag: 'v0.2.0', date: '2026-10-26' }); + const matches = readFileSync(join(root, 'CHANGELOG.md'), 'utf8').match(/^## \[v0\.2\.0\]/gm) ?? []; + expect(matches).toHaveLength(1); + }); +}); + +describe('verify-release-version:通过/失败定位', () => { + it('bump 后 fixture 通过完整 SSOT 校验', () => { + const root = buildFixture(); + bumpVersion({ root, version: '0.2.0', tag: 'v0.2.0', date: '2026-10-26' }); + const result = verifyReleaseVersion({ root, version: '0.2.0', tag: 'v0.2.0' }); + expect(result.ok).toBe(true); + expect(result.checks.filter((check) => !check.ok)).toEqual([]); + }); + + it('MCP version.mjs 落后时报错并指出文件', () => { + const root = buildFixture(); + bumpVersion({ root, version: '0.2.0', tag: 'v0.2.0', date: '2026-10-26' }); + write(root, 'packages/mcp/src/version.mjs', "export const APP_VERSION = '0.1.1';\n"); + const result = verifyReleaseVersion({ root, version: '0.2.0', tag: 'v0.2.0' }); + expect(result.ok).toBe(false); + expect(result.checks.find((check) => check.label.includes('version.mjs APP_VERSION'))?.ok).toBe(false); + }); + + it('dsh-plugin manifest 落后时报错', () => { + const root = buildFixture(); + bumpVersion({ root, version: '0.2.0', tag: 'v0.2.0', date: '2026-10-26' }); + writeJson(root, 'packages/dsh-plugin/package.json', { + name: '@mebular/dsh-plugin', + version: '0.1.1', + }); + const result = verifyReleaseVersion({ root, version: '0.2.0', tag: 'v0.2.0' }); + expect(result.ok).toBe(false); + expect(result.checks.find((check) => check.label === '@mebular/dsh-plugin version')?.ok).toBe(false); + }); + + it('真实仓库当前版本通过 SSOT 校验', () => { + const version = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')).version; + const result = verifyReleaseVersion({ root: repoRoot, version, tag: `v${version}` }); + const failed = result.checks.filter((check) => !check.ok); + if (failed.length > 0) console.error(failed); + expect(result.ok).toBe(true); + }); +}); diff --git a/tests/release/visibility.test.mjs b/tests/release/visibility.test.mjs new file mode 100644 index 00000000..e54ce1c0 --- /dev/null +++ b/tests/release/visibility.test.mjs @@ -0,0 +1,143 @@ +// 发布后 npm 可见性 gate:确定性 fixture(fake viewPackage + fake clock)。 + +import { describe, it, expect } from '@jest/globals'; +import { + buildReleasePackages, + formatVisibilityResult, + npmViewArgs, + parseViewOutput, + verifyVisibility, +} from '../../scripts/verify-release-visibility.mjs'; + +const VERSION = '0.2.0'; + +function e404() { + const error = new Error('npm error code E404'); + error.stderr = 'npm error code E404\nnpm error 404 Not Found'; + return error; +} + +function fakeClock() { + let now = 0; + return { + now: () => now, + sleep: async (ms) => { + now += ms; + }, + }; +} + +describe('verify-release-visibility:六包与 latest gate', () => { + it('逐轮重试直到六包可见且 latest 全部指向发布版本', async () => { + const attempts = new Map(); + const viewPackage = async (name, version) => { + const attempt = (attempts.get(name) ?? 0) + 1; + attempts.set(name, attempt); + if (name === '@mebular/mcp') { + if (attempt <= 2) throw e404(); + if (attempt === 3) return { version, latest: '0.1.1' }; + } + return { version, latest: version }; + }; + const clock = fakeClock(); + const result = await verifyVisibility({ + packages: buildReleasePackages({ version: VERSION }), + timeoutMs: 100_000, + intervalMs: 1_000, + viewPackage, + sleep: clock.sleep, + now: clock.now, + }); + + expect(result.ok).toBe(true); + expect(result.packages).toHaveLength(6); + expect(result.rounds).toBe(4); + const mcp = result.packages.find((pkg) => pkg.name === '@mebular/mcp'); + expect(mcp).toMatchObject({ ok: true, attempts: 4, versionVisible: true, latest: VERSION }); + expect(clock.now()).toBe(3_000); + }); + + it('超时后失败,并逐包保留最后状态(E404 / latest 落后)', async () => { + const viewPackage = async (name, version) => { + if (name === '@mebular/mcp') throw e404(); + if (name === '@mebular/dsh-plugin') return { version, latest: '0.1.1' }; + return { version, latest: version }; + }; + const clock = fakeClock(); + const result = await verifyVisibility({ + packages: buildReleasePackages({ version: VERSION }), + timeoutMs: 3_000, + intervalMs: 1_000, + viewPackage, + sleep: clock.sleep, + now: clock.now, + }); + + expect(result.ok).toBe(false); + expect(result.rounds).toBe(3); + const mcp = result.packages.find((pkg) => pkg.name === '@mebular/mcp'); + expect(mcp.ok).toBe(false); + expect(mcp.versionVisible).toBe(false); + expect(mcp.lastError).toMatch(/E404/); + const dsh = result.packages.find((pkg) => pkg.name === '@mebular/dsh-plugin'); + expect(dsh.ok).toBe(false); + expect(dsh.lastError).toContain('dist-tags.latest=0.1.1'); + + const text = formatVisibilityResult(result); + expect(text).toContain('Release visibility FAIL'); + expect(text).toContain('@mebular/mcp@0.2.0'); + expect(text).toContain('@mebular/dsh-plugin@0.2.0'); + }); + + it('timeout=0 也至少检查一轮,不会只 sleep', async () => { + let calls = 0; + const result = await verifyVisibility({ + packages: [{ name: '@mebular/core', version: VERSION }], + timeoutMs: 0, + intervalMs: 1_000, + viewPackage: async () => { + calls += 1; + throw e404(); + }, + sleep: async () => { + throw new Error('timeout=0 不应 sleep'); + }, + now: () => 0, + }); + expect(calls).toBe(1); + expect(result.ok).toBe(false); + expect(result.rounds).toBe(1); + }); +}); + +describe('verify-release-visibility:参数与输出解析', () => { + it('buildReleasePackages 为 5 个 lockstep + dsh-plugin 使用独立版本', () => { + const packages = buildReleasePackages({ version: '0.2.0', dshVersion: '0.2.1' }); + expect(packages).toHaveLength(6); + expect(packages.filter((pkg) => pkg.version === '0.2.0')).toHaveLength(5); + expect(packages.find((pkg) => pkg.name === '@mebular/dsh-plugin')).toEqual({ + name: '@mebular/dsh-plugin', + version: '0.2.1', + }); + }); + + it('npm view 同时查询 version 与 dist-tags.latest,并强制在线校验', () => { + const args = npmViewArgs('@mebular/core', '0.2.0'); + expect(args).toEqual([ + 'view', + '@mebular/core@0.2.0', + 'version', + 'dist-tags.latest', + '--json', + '--prefer-online', + ]); + }); + + it('parseViewOutput 兼容对象与单字段输出', () => { + expect(parseViewOutput('{"version":"0.2.0","dist-tags.latest":"0.2.0"}')).toEqual({ + version: '0.2.0', + latest: '0.2.0', + }); + expect(parseViewOutput('"0.2.0"')).toEqual({ version: '0.2.0', latest: null }); + }); +}); From 035a681a8968f3c93957332655d99fdd69222528 Mon Sep 17 00:00:00 2001 From: Windsander Date: Sun, 25 Oct 2026 21:11:43 +0800 Subject: [PATCH 2/3] docs(release): add post-publish verification checklist --- docs/operations/RELEASE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/operations/RELEASE.md b/docs/operations/RELEASE.md index 4122e455..9bf29685 100644 --- a/docs/operations/RELEASE.md +++ b/docs/operations/RELEASE.md @@ -321,6 +321,14 @@ command -v mebular && command -v fleet mebular tools >/dev/null && fleet --version ``` +发布后核验清单: + +- [ ] 六个包的 `version` 与 `dist-tags.latest` 都等于本次发布版本(或直接看 visibility job); +- [ ] GitHub Release 资产 = 4 个安装包(`.dmg` / `.exe` / `.AppImage` / `.deb`)+ + `SHA256SUMS.txt`,release notes 来自 CHANGELOG 的 `## [vX.Y.Z]` 章节; +- [ ] 下载资产后按第 6 节校验 checksum;`SHA256SUMS.txt` 恰好 4 行; +- [ ] `release/vX.Y.Z` 已通过 PR 回合并 `main`,随后删除 release / prep 分支。 + > 不要用 `npm install -g @mebular/mcp@0.2.0` 冒充双 bin 安装:`@mebular/mcp` 只提供 > `mebular`,`fleet` 必须来自根包(或单独安装 `@mebular/fleet`)。 From f309a6d7ce08356e91b873d0f2ea584c0aea0e42 Mon Sep 17 00:00:00 2001 From: Windsander Date: Sun, 25 Oct 2026 21:27:28 +0800 Subject: [PATCH 3/3] docs(release): align DSH with single-version umbrella --- CHANGELOG.md | 8 ++++++-- README.md | 2 +- README_CN.md | 2 +- RELEASE.md | 6 ++++-- docs/integrations/DSH.md | 16 ++++++++-------- docs/integrations/DSH_CN.md | 12 ++++++------ docs/operations/RELEASE.md | 25 ++++++++++++------------- packages/dsh-plugin/README.md | 8 +++++--- 8 files changed, 43 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 120c391f..c43471b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog All notable changes to Mebular are documented here. -The six release packages share one release version; `@mebular/dsh-plugin` is still published -by its own npm job, but its manifest is bumped and verified with the rest of the train. +Mebular uses a single-version umbrella: the six npm packages (`core`, `service`, `skill`, `mcp`, +`fleet`, `dsh-plugin`) share one release version. `@mebular/dsh-plugin` is an isolated adapter +published by its own npm job, and it follows the same unified release train. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). @@ -26,6 +27,9 @@ next `## [` heading. Keep that section self-contained. `npm view @ version` and `dist-tags.latest` for all six packages before creating the GitHub Release. The previous v0.1.1 run could create the Release while a package was still propagating. +- **Single-version umbrella** — all six npm packages share one release version. The DSH + adapter keeps its code isolated in `packages/dsh-plugin` and is published by its own npm + job, but it follows the unified release train; no release ships the DSH plugin alone. - **Release SOP** — the fixed flow is now `release-prep/vX.Y.Z` → packaging dry-run → `release/vX.Y.Z` → back-merge `main` → delete release/prep branches. A dry-run-only branch cleanup helper is documented for merged remote branches. diff --git a/README.md b/README.md index e272c8b5..f8b6778a 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ mebular print-config --client opencode # MCP config

Use with DSH
Keep the Mebular app running, then install the plugin from npm into DSH's web profile:
dsh plugin --profile web add @mebular/dsh-plugin@0.1.1
-A Mebular sidebar panel embeds the same Console through ?embed=dsh (loopback only), and the official MCP client reaches the same graph. The plugin discovers the daemon by daemonUrl > MEBULAR_HTTP_PORT > runtime.json > 7331, so custom ports follow automatically. It has its own version line and is not part of the five-package lockstep. For plugin development, install from packages/dsh-plugin instead.
+A Mebular sidebar panel embeds the same Console through ?embed=dsh (loopback only), and the official MCP client reaches the same graph. The plugin discovers the daemon by daemonUrl > MEBULAR_HTTP_PORT > runtime.json > 7331, so custom ports follow automatically. The plugin is an independent adapter with its code isolated in packages/dsh-plugin; it is published by its own npm job, while its version follows the unified release train, so all six npm packages share one version. For plugin development, install from packages/dsh-plugin instead.
Listed on awesome-dsh-plugin under memory.
DSH plugin guide → · Integration notes →

diff --git a/README_CN.md b/README_CN.md index e52260f6..e62e7961 100644 --- a/README_CN.md +++ b/README_CN.md @@ -136,7 +136,7 @@ mebular print-config --client opencode # MCP 配置

与 DSH 一起使用
保持 Mebular App 运行,然后从 npm 把插件装入 DSH 的 web profile:
dsh plugin --profile web add @mebular/dsh-plugin@0.1.1
-侧边栏 Mebular 面板通过 ?embed=dsh(仅 loopback)嵌入同一套 Console,官方 MCP client 连接同一张图。插件按 daemonUrl > MEBULAR_HTTP_PORT > runtime.json > 7331 发现 daemon,自定义端口会自动跟随。插件有独立版本线,不进入五包 lockstep;开发时仍可从 packages/dsh-plugin 本地安装。
+侧边栏 Mebular 面板通过 ?embed=dsh(仅 loopback)嵌入同一套 Console,官方 MCP client 连接同一张图。插件按 daemonUrl > MEBULAR_HTTP_PORT > runtime.json > 7331 发现 daemon,自定义端口会自动跟随。插件是独立适配器,代码隔离在 packages/dsh-plugin;它由独立 npm job 发布,版本随统一 release train,六个 npm 包同版本;开发时仍可从 packages/dsh-plugin 本地安装。
已收录于 awesome-dsh-plugin 的 memory 分类。
DSH 插件指南 → · 集成说明 →

diff --git a/RELEASE.md b/RELEASE.md index fe61bdee..324e50e0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -10,8 +10,10 @@ The release operations for this repository live under `docs/operations/`: - [Changelog](CHANGELOG.md) — release notes; the `## [vX.Y.Z]` section is the source of the GitHub Release body. -The current release line is **v0.1.1**: six npm packages at `0.1.1` (five lockstep packages -plus `@mebular/dsh-plugin`), and unsigned desktop installers for macOS, Windows and Linux. +The current release line is **v0.1.1**: a single-version umbrella with six npm packages at +`0.1.1` (`core`, `service`, `skill`, `mcp`, `fleet`, `dsh-plugin`), and unsigned desktop +installers for macOS, Windows and Linux. The DSH plugin is an isolated adapter published by +its own npm job, but it follows the unified release train. From the next release on, `bump_version.sh` updates all six packages and `verify:release-version` / the publish workflow enforce the same version. Signing, notarization, and auto-update are not part of this release. diff --git a/docs/integrations/DSH.md b/docs/integrations/DSH.md index 34a908f6..a6a14259 100644 --- a/docs/integrations/DSH.md +++ b/docs/integrations/DSH.md @@ -49,9 +49,9 @@ For plugin development, install from the repository checkout instead: dsh plugin --profile web add ./packages/dsh-plugin ``` -`@mebular/dsh-plugin` has its own version line: it ships with the Mebular v0.1.1 release -train, but its version comes only from `packages/dsh-plugin/package.json` and it is not part -of the five-package lockstep. The plugin is listed in +`@mebular/dsh-plugin` is an independent adapter: its code stays isolated in +`packages/dsh-plugin`, and it is published by its own npm job. Its version follows the +unified Mebular release train, so all six npm packages share one version. The plugin is listed in [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) under **memory** (PR [#9167](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9167)). @@ -168,11 +168,11 @@ whole, not a separate package repo, so discovery points back here. See the ## Release and versioning -- The plugin has its own version, `0.1.1`, and its own npm lifecycle. -- It ships on the **same release train** as Mebular v0.1.1; both packages are at `0.1.1` - in this release. -- It is **not** part of the five-package lockstep (`core`, `service`, `skill`, `mcp`, - `fleet`). Those five always share one version; the plugin can move independently. +- The plugin is an independent adapter: its code is isolated in `packages/dsh-plugin`, and its + npm publish runs in its own `publish-dsh` job. +- All six npm packages (`core`, `service`, `skill`, `mcp`, `fleet`, `dsh-plugin`) share one + version from the unified Mebular release train. The plugin is included in every release; + no release ships the DSH plugin alone. - Publish is idempotent: the `publish-dsh` workflow job reads the version from `packages/dsh-plugin/package.json`, skips it if `npm view` already sees that version, and otherwise publishes the package. See [the release SOP](../operations/RELEASE.md). diff --git a/docs/integrations/DSH_CN.md b/docs/integrations/DSH_CN.md index 53b7bc18..1eef2d92 100644 --- a/docs/integrations/DSH_CN.md +++ b/docs/integrations/DSH_CN.md @@ -47,8 +47,8 @@ dsh plugin --profile web add @mebular/dsh-plugin@0.1.1 dsh plugin --profile web add ./packages/dsh-plugin ``` -`@mebular/dsh-plugin` 是独立版本线:随 Mebular v0.1.1 release train 发布,但版本号只取自 -`packages/dsh-plugin/package.json`,不进入五包 lockstep。插件已收录于 +`@mebular/dsh-plugin` 是独立适配器:代码隔离在 `packages/dsh-plugin`,由独立 npm job +发布;版本随统一 Mebular release train,六个 npm 包同版本。插件已收录于 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 的 **memory** 分类(PR [#9167](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9167))。 @@ -155,10 +155,10 @@ Mebular 仓库,而不是一个独立包仓库,所以搜索会回到这里。 ## 发布与版本 -- 插件独立版本 `0.1.1`,有独立的 npm 生命周期。 -- 它和 Mebular v0.1.1 走**同一条 release train**;本版两者都是 `0.1.1`。 -- 它**不进入**五包 lockstep(`core`、`service`、`skill`、`mcp`、`fleet`):那五个包永远同 - 版本,插件可以单独前进。 +- 插件是独立适配器:代码隔离在 `packages/dsh-plugin`,npm 发布由独立的 `publish-dsh` + job 执行。 +- 六个 npm 包(`core`、`service`、`skill`、`mcp`、`fleet`、`dsh-plugin`)共用统一 + Mebular release train 的同一版本。每次发布都包含该插件,不单独为 DSH 插件发版。 - 发布是幂等的:`publish-dsh` job 从 `packages/dsh-plugin/package.json` 读取版本, `npm view` 已存在则跳过,否则执行发布。详见[发布 SOP](../operations/RELEASE.md)。 diff --git a/docs/operations/RELEASE.md b/docs/operations/RELEASE.md index 9bf29685..8dd46512 100644 --- a/docs/operations/RELEASE.md +++ b/docs/operations/RELEASE.md @@ -5,12 +5,12 @@ 当前发布线 v0.1.1(历史记录): -- 6 个 npm 包统一为 `0.1.1`(5 个 lockstep 包 + `@mebular/dsh-plugin`); +- 6 个 npm 包统一为 `0.1.1`(单版本 umbrella:`core` / `service` / `skill` / `mcp` / `fleet` / `dsh-plugin`); - macOS Universal DMG、Windows NSIS x64、Linux AppImage + deb x64(unsigned); - `SHA256SUMS.txt`(仅 GitHub Release 资产生成)。 -从下一版本起,`@mebular/dsh-plugin` 仍由独立 npm job 发布,但版本号随发布列车统一 bump -并由发布 gate 校验;不再需要手工补齐。 +从下一版本起,`@mebular/dsh-plugin` 仍由独立 npm job 发布,但版本号随统一 release train bump, +六个 npm 包同版本,并由发布 gate 校验;不存在只发布 DSH 插件的批次。 ## 0. 发布对象 @@ -21,7 +21,7 @@ | `@mebular/skill` | `packages/skill` | 行为层:`SKILL.md` / `MEMORY_POLICY.md` / MCP 接入片段 | | `@mebular/fleet` | `packages/fleet` | 跨设备多 Agent 协作 | | `@mebular/mcp` | `packages/mcp` | MCP server(stdio + Streamable HTTP) | -| `@mebular/dsh-plugin` | `packages/dsh-plugin` | DeepSeek Harness 插件;独立 npm job 发布,版本随发布列车统一 | +| `@mebular/dsh-plugin` | `packages/dsh-plugin` | DeepSeek Harness 插件;独立适配器,独立 npm job 发布,版本随统一 release train | - **版本策略**:6 个可发布包(`core` / `service` / `skill` / `mcp` / `fleet` / `dsh-plugin`) 共用同一发布版本;`scripts/bump_version.sh` 一次同步全部 manifest 与版本源。 @@ -42,7 +42,7 @@ | 产物 | 来源 | 说明 | | --- | --- | --- | -| 6 个 `.tgz` | verify 的 `release-pack` | 5 个 lockstep + `mebular-dsh-plugin-.tgz` | +| 6 个 `.tgz` | verify 的 `release-pack` | 六个同版本包:`core` / `service` / `skill` / `mcp` / `fleet` / `dsh-plugin` | | `Mebular--universal.dmg` | `desktop-macos` | macOS Universal(arm64 + x64),unsigned | | `Mebular-Setup-.exe` | `desktop-windows` | Windows NSIS x64,unsigned | | `Mebular--x86_64.AppImage` | `desktop-linux` | Linux x64,unsigned | @@ -63,8 +63,8 @@ - [ ] **GitHub Actions 权限**:仓库 Settings → Actions → General,Workflow permissions 至少 勾选 **Read and write permissions**;`release` job 另有 job-level `contents: write`。 - [ ] **首次发布注意**:`@mebular/core` 虽为仓库根包,仍以普通包发布;其 tarball 携带 - `packages/fleet/dist`(`files` 已声明)以支撑 `fleet` bin。首次发布会占用 5 个包名, - 同时占用 `@mebular/dsh-plugin`。 + `packages/fleet/dist`(`files` 已声明)以支撑 `fleet` bin。首次发布会占用六个包名 + (含 `@mebular/dsh-plugin`)。 - [ ] **分支保护**:`main` 保持受保护;发布只走 `release/v*` 分支,勿直接向 `main` 提交版本号。 - [ ] **2027 迁移计划**:npm 已公布自 **2027-01** 起收紧长期 token 的直接发布路径。R6 应切换到 **trusted publishing(OIDC)**,让 GitHub Actions 以短期身份发布,不再依赖长期 @@ -75,17 +75,16 @@ 发布是不可逆的,仅在版本号尚未被下游依赖时使用「删除」,否则一律「标记废弃」: - **npm unpublish(72 小时窗口)**:仅能删除**发布后 72 小时内**、且**无人依赖**的版本; - 超时或被依赖一律 400。按包执行,lockstep 需 5 个包各跑一次,插件单独一次: + 超时或被依赖一律 400。六个包各执行一次(同一版本): ```bash - npm unpublish @mebular/core@0.2.0 # 重复 core → service → skill → mcp → fleet - npm unpublish @mebular/dsh-plugin@0.2.0 + npm unpublish @mebular/core@0.2.0 # 重复 core → service → skill → mcp → fleet → dsh-plugin ``` 同一版本被删除后**不可再以相同版本号重新发布**,后续必须升号。 - **npm deprecate(常规手段)**:标记问题版本,安装时会打印警告但不阻断: ```bash npm deprecate @mebular/mcp@0.2.0 "broken: use 0.2.1" ``` - lockstep 建议 5 个包同一条消息一并废弃;插件单独废弃,再发布更高修复版本。 + 建议六个包用同一条消息一并废弃,再发布更高修复版本。 - **回退 GitHub Release**: ```bash gh release delete v0.2.0 --yes # 仅删 Release @@ -111,10 +110,10 @@ verify(SSOT / build / lint / test / 6 tarballs) └─ desktop-linux ─┘ │ ▼(三平台全部成功 + preflight 成功) - publish(5 个 lockstep tarball) + publish(core/service/skill/mcp/fleet tarball) │ ▼ - publish-dsh(dsh-plugin tarball) + publish-dsh(dsh-plugin tarball,同一版本) │ ▼ visibility(npm view 六包 + dist-tags.latest;失败不创建 Release) diff --git a/packages/dsh-plugin/README.md b/packages/dsh-plugin/README.md index 447de209..7e76f62b 100644 --- a/packages/dsh-plugin/README.md +++ b/packages/dsh-plugin/README.md @@ -47,8 +47,9 @@ dsh plugin --profile web add ./packages/dsh-plugin 安装后 `$DSH_HOME/profiles/web/package.json` 的 `dsh.profile.bundles` 会包含 `@mebular/dsh-plugin`。重启 DSH Web 后,侧边栏出现 Mebular 图标;点击后进入全局面板。 -> `@mebular/dsh-plugin` 由独立 npm job 发布;从 v0.1.1 起版本随 Mebular release train -> 统一 bump,并由 `scripts/verify-release-version.mjs` 校验,不再单独手工维护。已收录于 +> `@mebular/dsh-plugin` 是独立适配器:代码隔离在 `packages/dsh-plugin`,由独立 npm job +> 发布;版本随统一 Mebular release train,六个 npm 包同版本,并由 +> `scripts/verify-release-version.mjs` 校验,不再单独手工维护。已收录于 > [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 的 `memory` > 分类(PR [#9167](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9167))。 @@ -339,7 +340,8 @@ npm run verify:dsh-plugin - 官方插件列表:已收录([awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 的 `memory` 分类,PR [#9167](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9167)) - 正式安装:`dsh plugin --profile web add @mebular/dsh-plugin@0.1.1` - 开发安装:`dsh plugin --profile web add ./packages/dsh-plugin` -- 版本线:由独立 npm job 发布,但 manifest 随 `core` / `service` / `skill` / `mcp` / `fleet` 一起 bump 与校验 +- 版本:随统一 Mebular release train,六个 npm 包同版本 +- 发布:独立 npm job(`publish-dsh`);代码:独立适配器,隔离在 `packages/dsh-plugin` ## License