diff --git a/.github/release-notes/MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md b/.github/release-notes/MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md
new file mode 100644
index 000000000..6dbf4b33c
--- /dev/null
+++ b/.github/release-notes/MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md
@@ -0,0 +1,423 @@
+# MemOS Release 与本地插件自动发布链路说明
+
+## 1. 先看结论
+
+这个 PR 与 `MemOS Release — Prepare` 配合后,发布入口分为“准备”和“发布”两段:
+
+```text
+发布人员从 main 运行 MemOS Release — Prepare,填写 X.Y.Z
+-> 自动校验 dev-vX.Y.Z,并生成只包含主仓版本更新的 release/vX.Y.Z
+-> 维护者创建并审核 release/vX.Y.Z -> main PR
+-> PR 合并后自动启动 MemOS Release — Publish
+-> 自动生成检查结果
+-> 创建 MemOS tag 和 Draft Release
+-> 如果本地插件确实有用户可见变化,再创建本地插件 tag 和 Draft Release
+-> 此时不发布 npm,不写官网,不进入灰度
+-> 发布人员只审核并 Publish MemOS Draft
+-> 配对流程校验通过后,才发布 npm 和本地插件 Release
+-> 本地插件 release.published webhook 触发 106 Doc Agent
+-> 自动生成并合并 docs PR,进入 pre / gray
+-> 产品人工验收灰度,最后人工发布线上
+```
+
+最重要的人工边界是:
+
+1. `release/vX.Y.Z` 合并到 `main` 后,系统会自动创建 tag 和 Draft。
+2. 发布人员只手动 Publish `MemOS vX.Y.Z` Draft。
+3. 不要手动 Publish 配对的 `MemOS Local Plugin vX.Y.Z` Draft。
+4. npm、官网 Plugin tab、pre、gray 都不会在人工 Publish MemOS Draft 前发生。
+5. 生产环境仍然必须人工确认和发布。
+
+本次功能 PR 的分支是 `ci/memos-local-plugin-auto-release`。它不符合发版分支命名规则,所以合并这个 PR 本身不会触发 MemOS 发版。
+
+## 2. 这个 PR 具体改了什么
+
+| 改动 | 合并后的效果 |
+|---|---|
+| 兼容两段式发版入口 | 推荐 `release/vX.Y.Z` 合并到 `main` 后自动生成 tag 和 Draft;历史 `dev-vX.Y.Z` / `dev-X.Y.Z` 直合仍受控兼容 |
+| 增加触发分类 | 普通分支、fork、未合并 PR 只记录跳过,不进入发布链路 |
+| 增加 `.github/release-notes/vX.Y.Z.md` | 发版分支可为 MemOS 整体 Release 添加简短 Highlights |
+| 增加 `release:validate` | 发布前统一检查 Hermes 版本、lint 和测试 |
+| 强化本地插件 evidence | 只看插件路径,并按 commit 实际改动、用户影响和 revert 结果筛选 |
+| 增加 `auto / skip / manual` | 周发布可自动判断、明确跳过或人工确认下一 patch |
+| 自动推导下一稳定 patch | 不需要先提交一个只改 `package.json` 版本的 PR |
+| 拆分 stage 与 publish | 合并后先创建插件 tag/Draft,人工 Publish MemOS 后才发 npm |
+| 增加 paired Release 契约 | MemOS Release 与插件 Release 的 tag、SHA、digest、URL 必须完全对应 |
+| 强化 npm 校验与恢复 | 防止 registry 延迟导致重复发布,并支持已发 npm、未完成 Release 的显式恢复 |
+| 强化 artifact 与质量闸门 | 发布前可以检查 Release Notes、证据、双语预览、覆盖率和 repair 结果 |
+| 收紧权限与脚本来源 | 默认只读;写权限仅给需要的 job;发布自动化始终使用受信任版本 |
+
+## 3. 合并后完整流程
+
+```mermaid
+flowchart TD
+ A["从 main 运行 MemOS Release — Prepare"] --> A1["校验 dev-vX.Y.Z
生成 release/vX.Y.Z"]
+ A1 --> A2["维护者审核并合并
release/vX.Y.Z -> main"]
+ A2 --> B{"是否为受支持的同仓库
release/vX.Y.Z / dev-vX.Y.Z / dev-X.Y.Z"}
+ B -- "否" --> B1["记录跳过原因
不创建 tag、Release、npm 或 docs"]
+ B -- "是" --> C["锁定 merge commit 与 MemOS 版本"]
+ C --> D["生成 MemOS 整体 Release Notes"]
+ C --> E["从上一稳定本地插件版本
到当前 merge commit 收集证据"]
+ E --> F{"存在真实的用户可见变化吗"}
+ F -- "否" --> G["本地插件正常跳过"]
+ F -- "是" --> H["按上一稳定版本 patch + 1"]
+ H --> I["生成双语文案并执行质量校验和 repair"]
+ I --> J["创建本地插件 tag 与 Draft Release
只暂存,不发布 npm"]
+ G --> K["创建 MemOS tag 与 Draft Release"]
+ J --> K
+ K --> L["发布人员下载 artifact 并审核"]
+ L --> M{"审核是否通过"}
+ M -- "否" --> N["停止,不 Publish
修正文案或由 release owner 处理错误 tag/Draft"]
+ M -- "是" --> O["人工 Publish MemOS Draft"]
+ O --> P["配对流程复核隐藏意图、tag、SHA、digest 与 Draft 绑定"]
+ P --> Q["发布并验证 npm latest"]
+ Q --> R["自动 Publish 本地插件 Draft"]
+ R --> S["本地插件 release.published webhook 到 106"]
+ S --> T["106 再校验证据、source_refs、双语质量"]
+ T --> U["生成并合并 MemOS-Docs PR"]
+ U --> V["自动部署 pre / gray"]
+ V --> W["产品人工验收灰度"]
+ W --> X["人工发布线上"]
+```
+
+## 4. 哪些合并会自动触发
+
+自动入口只接受以下全部条件:
+
+| 条件 | 要求 |
+|---|---|
+| PR 状态 | 已合并,不是只关闭 |
+| 目标分支 | `main` |
+| PR 来源 | `MemTensor/MemOS` 同仓库分支,不接受 fork |
+| 分支名 | 推荐 `release/vX.Y.Z`;兼容 `dev-vX.Y.Z` 或 `dev-X.Y.Z` |
+| 版本格式 | 三段稳定 SemVer,例如 `release/v2.0.30`、`dev-v2.1.0`、`dev-3.0.0` |
+| 代码版本 | merge commit 中 `pyproject.toml` 与 `src/memos/__init__.py` 的版本必须彼此一致,并与分支版本完全一致 |
+
+以下分支不会触发发布:
+
+```text
+feature/xxx
+fix/xxx
+docs-sync/xxx
+dev-v2.0.30-beta.1
+dev-v2.0
+release/v2.0
+```
+
+自动入口固定使用:
+
+```text
+target_ref = PR 的 merge commit
+local_plugin_release_mode = auto
+dry_run = false
+create_draft_release = true
+```
+
+因此它会创建 tag 和 Draft,但不会越过人工 Publish 边界。
+
+## 5. 本地插件是否发版的严格判定
+
+本地插件不是只要目录里有文件变化就发布。系统会依次做以下判断:
+
+```mermaid
+flowchart TD
+ A["以上一条已发布的稳定本地插件 tag 为基线"] --> B["比较到当前 MemOS release merge commit"]
+ B --> C["只保留 apps/memos-local-plugin/**"]
+ C --> D{"是否只有测试、文档、锁文件、版本元数据"}
+ D -- "是" --> X["跳过本地插件发版"]
+ D -- "否" --> E["按每个 commit 的实际改动路径筛选"]
+ E --> F["过滤 ci / chore / docs / test / build / release 噪音"]
+ F --> G["过滤已被 revert 的原始改动及 revert 条目"]
+ G --> H["只保留 feat / fix / perf
或有明确用户影响的 refactor"]
+ H --> I{"是否仍有重要用户可见证据"}
+ I -- "否" --> X
+ I -- "是" --> J["发布本地插件稳定版"]
+```
+
+### 5.1 基线不是上一个 MemOS 周版本
+
+本地插件的比较基线是上一条已经发布并完成 npm 校验的稳定本地插件 tag,而不是上一个 MemOS `vX.Y.Z` tag。
+
+这样即使连续几周没有发布本地插件,未发布的有效变化也不会丢失;下一次真正需要发版时会一起被检测到。
+
+### 5.2 没有用户可见变化时
+
+以下情况会被判定为正常跳过,不是 Action 失败:
+
+- `apps/memos-local-plugin/**` 完全没有变化。
+- 只有测试、文档、README、锁文件或版本元数据变化。
+- 有源码变化,但 commit 只有维护、构建或发布噪音,没有可证明的 feature、fix、performance 影响。
+- 一个功能先加入,之后又被 revert,最终版本中不再生效。
+
+正常跳过时:
+
+```text
+MemOS tag / Draft Release:仍然创建
+本地插件 npm:不发布
+本地插件 tag:不创建
+本地插件 Release:不创建
+Plugin tab / pre / gray:不触发
+artifact:记录明确的 skip_reason
+```
+
+### 5.3 有用户可见变化时
+
+系统会从上一条稳定本地插件版本自动执行 patch + 1:
+
+```text
+上一稳定版:v2.0.13
+本次有有效变化
+解析版本:v2.0.14
+tag:memos-local-plugin-v2.0.14
+```
+
+周发布不能借此跳 major 或 minor。需要主动发布 `v2.1.0`、`v3.0.0`、beta、alpha 或 next 时,应使用独立入口 `MemOS Local Plugin (V2) — Legacy Standalone Publisher`。
+
+### 5.4 三种手动模式
+
+手动运行 `MemOS Release — Publish` 时可以选择:
+
+| 模式 | 行为 |
+|---|---|
+| `auto` | 推荐。自动判断用户可见变化;有变化时自动使用下一 patch。`local_plugin_version` 通常留空。 |
+| `skip` | 即使有变化也不发本地插件,并将变化留到下一次继续累计。`local_plugin_version` 必须留空。 |
+| `manual` | 明确要求本次发本地插件。必须填写 `local_plugin_version`,但仍必须有用户可见变化,且版本必须是下一 patch。 |
+
+`manual` 不是绕过质量闸门的开关。没有有效变化时仍会失败,不能创建一个只有版本号变化的空发布。
+
+### 5.5 独立本地插件入口仍然保留
+
+`MemOS Local Plugin (V2) — Legacy Standalone Publisher` 仍用于不等待 MemOS 周发布的本地插件发版:
+
+```mermaid
+flowchart TD
+ A["人工运行独立本地插件 workflow"] --> B{"版本和 npm dist-tag"}
+ B -- "beta / alpha / next" --> C["构建并校验 package"]
+ C --> D["发布 npm prerelease"]
+ D --> E["创建本地插件 tag 和 GitHub Prerelease"]
+ E --> F["到此结束
106 跳过,不写官网,不进 pre / gray"]
+ B -- "稳定 SemVer + latest" --> G["构建、文案和质量校验"]
+ G --> H["发布 npm latest"]
+ H --> I["创建本地插件 tag 和稳定 GitHub Release"]
+ I --> J["release.published webhook 触发 106"]
+ J --> K["Plugin tab / pre / gray"]
+```
+
+| 使用场景 | 应选入口 |
+|---|---|
+| 随 MemOS 周版本自动判断是否发布下一 patch | `MemOS Release — Publish` |
+| 随时发布 beta、alpha、next | `MemOS Local Plugin (V2) — Legacy Standalone Publisher` |
+| 主动发布本地插件 major 或 minor | 独立本地插件入口,并由 release owner 确认版本 |
+| 本地插件紧急稳定版,不等待 MemOS 周版本 | 独立本地插件入口的稳定版 + `latest` |
+
+两条稳定版入口共享同一套 npm/tag/Release 完成性校验。某个版本已经由独立入口完整发布后,MemOS 周发布不会重复发布同一版本;它会以上一条已完成的稳定版本为基线,只在之后又积累了新的用户可见变化时推进下一 patch。
+
+## 6. 合并后会生成什么
+
+### 6.1 GitHub 中可见的对象
+
+有本地插件有效变化时,会先创建:
+
+1. MemOS tag,例如 `v2.0.30`。
+2. MemOS Draft Release,例如 `Release v2.0.30`。
+3. 本地插件 tag,例如 `memos-local-plugin-v2.0.14`。
+4. 本地插件 Draft Release,例如 `MemOS Local Plugin v2.0.14`。
+
+此时 npm 尚未发布,本地插件 Draft 也不会产生 `release.published` webhook。
+
+没有本地插件有效变化时,只创建前两项。
+
+### 6.2 检查 artifact
+
+Action 会上传 `memos-release-inspection`,发布人员重点查看:
+
+| 文件 | 用途 |
+|---|---|
+| `release-notes.md` | MemOS 整体 GitHub Release 页面预览 |
+| `local-plugin-evidence.json` / `evidence.json` | 本地插件真实 commit、PR、文件和版本证据,已脱敏 |
+| `local-plugin-docs-preview.md` / `docs-preview.md` | 官网 Plugin tab 的中英文可读预览 |
+| `local-plugin-docs-preview.json` / `docs-preview.json` | 结构化文案预览 |
+| `quality-report.json` | 是否发布、skip 原因、覆盖率、source_refs、repair 次数等 |
+| `local-plugin-release-intent.json` | MemOS Release 与配对插件 Release 的预期绑定 |
+| `local-plugin-docs-draft.json` | Doc Agent 原始结构化草稿 |
+
+发布人员至少确认:
+
+- `current_tag` 和目标 commit 正确。
+- MemOS `What's Changed` 范围正确。
+- `local_plugin_release_requested` 是否符合预期。
+- 如果发布插件,版本是否为上一稳定版的下一 patch。
+- 每条中英文文案是否准确、易懂且没有夸大。
+- 每条文案是否有真实 `source_refs`。
+- `quality-report.json` 中 `ok=true` 且没有未解释的警告。
+
+## 7. Draft 审核时怎么操作
+
+```mermaid
+flowchart LR
+ A["检查 memos-release-inspection"] --> B["检查 MemOS Draft"]
+ B --> C["如有插件变化,再检查插件 Draft"]
+ C --> D{"内容是否正确"}
+ D -- "否" --> E["停止,不 Publish"]
+ D -- "是" --> F["只 Publish MemOS Draft"]
+ F --> G["系统自动发布并验证 npm
再 Publish 本地插件 Draft"]
+```
+
+允许修改 Draft 中面向用户的可见文案,但不要删除或修改 Release body 里的隐藏绑定注释。该注释用于绑定:
+
+- MemOS tag。
+- 本地插件 tag 和版本。
+- 源码 SHA。
+- evidence digest。
+- 配对本地插件 Draft URL。
+
+如果隐藏绑定被破坏,后续配对流程会停止,不会发布 npm,也不会发布本地插件 Draft。
+
+如果发现 tag、版本或目标 commit 错误,不要继续 Publish,也不要自行移动已有 tag。由 release owner 审核错误 Draft/tag 后再决定恢复方式。
+
+## 8. 主要防护措施
+
+| 防护层 | 具体措施 | 防止的问题 |
+|---|---|---|
+| 触发范围 | 只接受同仓库规范发版分支合并到 `main` | 普通 PR、fork PR 或误关闭 PR 触发发版 |
+| 可信脚本 | 工作流和发布脚本始终来自受信任的默认分支版本 | 旧 SHA 或待发布源码带回旧发布逻辑 |
+| 目标锁定 | 自动入口锁定 PR merge commit | 发布到漂移的 `main` 或错误 SHA |
+| 主版本一致性 | 分支版本、Release 版本以及目标提交中的 `pyproject.toml`、`src/memos/__init__.py` 必须一致 | 只改分支名或只改一个版本文件却发布了错误代码版本 |
+| 路径隔离 | 只分析 `apps/memos-local-plugin/**` | MemOS 其他模块噪音进入 Plugin tab |
+| 文件过滤 | 排除测试、文档、锁文件和版本元数据 | 微小维护变化误发稳定版 |
+| 语义过滤 | 只保留有用户影响证据的 feature/fix/performance 等 | chore、merge、release commit 被写成新功能 |
+| revert 处理 | 原始改动和对应 revert 一起过滤;之后真正重新实现的 commit 可重新进入 | 已撤销功能仍出现在更新日志 |
+| 稳定版版本 | 只能在上一稳定插件版本上 patch + 1 | 版本跳跃、复用旧版本或周发布误升 major/minor |
+| 基线完成性 | 上一稳定 tag 必须有已发布 Release、已验证 npm 和正确源码关系 | 使用半成品 tag 作为下一次基线 |
+| tag 不可移动 | 已有 tag 指向不同 SHA 时立即失败 | 静默覆盖正式 tag |
+| Draft 隔离 | 本地插件始终先暂存为 Draft | MemOS Release 尚未确认时提前触发 106 |
+| 配对契约 | MemOS intent 和插件 binding 必须各恰好一个并完全匹配 | 两个 Release 串错版本、源码或证据 |
+| 发布顺序 | 先 Publish MemOS,再验证 npm,最后 Publish 插件 Draft | docs 早于 npm 或主版本发布 |
+| npm 防重复 | 发布前鉴权;发布后有限等待并校验 tarball integrity 和内容;不盲目二次 publish | npm 延迟导致重复发布或错误包被当作成功 |
+| 文案质量 | source_refs 覆盖、双语、条数、长度、重要 commit 覆盖;最多 repair 3 次 | 空中文、英文夹中文、漏 commit、虚构或过长文案 |
+| 最小权限 | 默认只读,只有创建 tag/Release 的 job 临时使用写权限 | 工作流拥有不必要的仓库写权限 |
+| 并发锁 | 同一发布链串行,`cancel-in-progress=false` | 两个发布同时争抢 tag、npm 或 Release |
+| 敏感信息 | token、106 地址等只从 Secrets 读取;artifact 使用脱敏证据 | 凭据进入代码、日志、Release 或 artifact |
+| 生产边界 | pre/gray 后仍需人工验收并发布线上 | 自动化直接影响生产环境 |
+
+## 9. 出错时会发生什么
+
+整个链路采用 fail closed:校验不确定时停止,而不是猜测后继续发布。
+
+| 出错位置 | 自动行为 | 已产生的影响 | 正确处理 |
+|---|---|---|---|
+| 分支名不符合规则 | 后续 job 跳过 | 无 tag、Release、npm、docs | 如果确实要发版,使用规范 release 分支或手动入口 |
+| 分支版本与两个主版本文件不一致 | prepare 阶段失败 | 不创建 tag、Draft、npm 或 docs | 通过 `MemOS Release — Prepare` 重新生成一致的 release 分支,不能只修改分支名或单个版本文件 |
+| 版本、目标 SHA 或 tag 冲突 | prepare 或创建 Release 阶段失败 | npm 和 docs 不会发布 | 核对目标 commit;不要移动正式 tag,由 release owner 处理 |
+| 没有用户可见插件变化 | 正常跳过插件发布 | 只继续创建 MemOS Draft | 查看 `skip_reason`,无需修复 |
+| Doc Agent 草稿质量不合格 | 最多 repair 3 次,仍不合格则失败 | 不创建 tag/Draft/npm/docs | 查看 `quality-report.json` 和 evidence,修复证据或文案规则后重跑 |
+| 本地插件暂存失败 | MemOS Release job 不继续 | 不创建 MemOS Draft,npm 不发布 | 修复构建、包审计或证据问题后重跑;如果 tag/Draft 已创建,系统只会在同一 MemOS 版本、同一源码和完整隐藏绑定全部匹配时复用 |
+| Draft 审核发现错误 | 等待人工,不会自动 Publish | tag 和 Draft 已存在,但 npm/docs 未发生 | 不要 Publish;由 release owner 处理错误 Draft/tag |
+| 隐藏 intent/binding 被改坏 | 配对校验失败 | MemOS 可能已发布,但 npm、插件 Release、docs 被拦截 | 恢复正确绑定后使用配对恢复入口,不能手工绕过 |
+| npm 返回不确定或可见性延迟 | 有限轮询和内容校验,不盲目重发 | 插件 Draft 保持 Draft,docs 不触发 | 先确认 registry 中的实际版本;再走显式 recovery |
+| npm 已成功但插件 Draft 发布失败 | 流程停止 | npm 已有版本,插件 Draft 未发布,docs 未触发 | 配对恢复会先验证现有 npm 内容,再发布 Draft,不会二次 npm publish |
+| 本地插件 Draft 被人提前 Publish | 契约或 106 校验应拒绝继续 | 可能产生失败通知,不应写 docs | 立即停止,由 release owner 核对 Release 状态和恢复方案 |
+| 106 webhook 或队列失败 | 106 停止写 docs并保留失败信息 | npm/Release 已发布,docs/pre/gray 未继续 | 修复 106 后再重放 webhook,不手工绕过 source_refs 和双语校验 |
+| docs PR 或 pre/gray 失败 | 后续部署停止 | 不进入生产 | 修复 PR 或部署问题;灰度通过后仍由人发布线上 |
+
+### 9.1 配对发布恢复入口
+
+只有在 MemOS Release 已发布、但 npm 或配对本地插件 Release 因部分失败未完成时,才使用:
+
+```text
+Workflow: MemOS Release — Publish Paired Local Plugin
+memos_release_tag: vX.Y.Z
+recovery_confirmation: PUBLISH PAIRED LOCAL PLUGIN FOR vX.Y.Z
+```
+
+这个入口会重新校验绑定和现有 npm 包,不是强行跳过校验。正常发布不要手动运行它。
+
+## 10. 手动入口怎么用
+
+建议先做无副作用预览:
+
+| 字段 | 建议填写 |
+|---|---|
+| Use workflow from | `main` |
+| `version` | MemOS 版本,不带 `v`,例如 `2.0.30` |
+| `target_ref` | `main` |
+| `local_plugin_release_mode` | `auto` |
+| `local_plugin_version` | 留空 |
+| `dry_run` | `true` |
+| `create_draft_release` | `true` |
+| `publish_confirmation` | 留空 |
+| `recover_existing_local_plugin_publish` | `false` |
+
+确认 artifact 后,再运行 Draft 模式:
+
+```text
+dry_run = false
+create_draft_release = true
+publish_confirmation = PUBLISH v2.0.30
+```
+
+如果在 `auto` 模式填写了本地插件版本 guard,例如 `2.0.14`,确认词必须是:
+
+```text
+PUBLISH v2.0.30 WITH LOCAL PLUGIN v2.0.14
+```
+
+不要用手动入口重复运行已经由发版分支合并自动创建的同一版本。先查看原 Action run、tag 和 Draft 状态,部分失败时走专门 recovery。
+
+## 11. `.github/release-notes` 的作用
+
+发版分支可以选择增加:
+
+```text
+.github/release-notes/vX.Y.Z.md
+```
+
+例如:
+
+```text
+.github/release-notes/v2.0.30.md
+```
+
+它只用于给 MemOS 整体 GitHub Release 增加简短的产品重点,并放在 GitHub 自动生成的 `What's Changed` 前面。
+
+它不能:
+
+- 决定是否发布本地插件。
+- 替代 `apps/memos-local-plugin/**` 的 git evidence。
+- 手工指定 Plugin tab 条目。
+- 存放 Doc Agent 隐藏 payload、token、106 地址或其他敏感信息。
+
+本地插件版本和 Hermes manifest 的同步由发布脚本在受控的发布元数据步骤中完成,不要求开发人员为了周发布提前提交一个只改版本号的 PR。
+
+## 12. 这个 PR 不会自动完成什么
+
+这个 PR 负责 MemOS 仓库内的工作流、校验、tag/Release 配对和 npm 发布顺序。它不会单独完成以下外部操作:
+
+- 不会部署或重启 106 Doc Agent。
+- 不会修复历史上已经错误发布的 tag、Release 或 docs 条目。
+- 不会替发布人员审核 Draft。
+- 不会自动发布生产环境。
+- 不会替代 GitHub Secrets、npm 权限、webhook 和 106 持久化队列的部署验收。
+
+正式启用前应确认:
+
+1. `NPM_TOKEN`、Doc Agent draft URL/token 等 Secrets 已配置且有效。
+2. 106 已部署支持本地插件 binding/intent 契约的版本。
+3. GitHub `release.published` webhook 可到达 106,持久化队列 timer 处于 active。
+4. MemOS-Docs 自动 PR、pre、gray 仍可用,生产发布保持人工。
+5. 本 PR 的脚本测试、workflow 语法检查和本地插件测试全部通过。
+
+## 13. 发布人员最短操作清单
+
+```text
+1. 从 main 运行 MemOS Release — Prepare,填写本次稳定版本 X.Y.Z。
+2. 按 Action 给出的链接创建并审核 release/vX.Y.Z -> main PR。
+3. 合并 PR,等待 MemOS Release — Publish 自动完成。
+4. 下载 memos-release-inspection,检查 Release Notes、evidence、docs preview、quality report。
+5. 检查 MemOS Draft;如果有插件版本,再检查本地插件 Draft。
+6. 有问题就停止,不要 Publish,也不要移动 tag。
+7. 没问题时只 Publish MemOS Draft。
+8. 检查 MemOS Release — Publish Paired Local Plugin:npm 和插件 Release 均成功。
+9. 检查 106 通知、MemOS-Docs PR 和 pre / gray。
+10. 产品验收灰度后,人工发布线上。
+```
diff --git a/.github/release-notes/README.md b/.github/release-notes/README.md
new file mode 100644
index 000000000..e1438407e
--- /dev/null
+++ b/.github/release-notes/README.md
@@ -0,0 +1,15 @@
+# Repository release notes
+
+For the complete MemOS and embedded local-plugin release flow, safeguards, and recovery guide, see [MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md](./MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md).
+
+Release branches may include an optional MemOS release overview at:
+
+```text
+.github/release-notes/vX.Y.Z.md
+```
+
+For example, `dev-v2.0.30` may add `.github/release-notes/v2.0.30.md`.
+
+The `MemOS Release — Publish` workflow reads the file from the exact release target commit and places it before GitHub's generated `What's Changed` section. Keep it short and product-facing. Start with a section such as `## Highlights`; do not copy commit lists that GitHub already generates.
+
+This file is optional. It does not decide whether the embedded MemOS local plugin is released, does not replace path-filtered git evidence, and must not contain Doc Agent payloads, binding markers, tokens, internal service URLs, or other credentials. Local-plugin Plugin tab copy still comes from `apps/memos-local-plugin/**` evidence and must pass source-ref, bilingual, coverage, and repair validation.
diff --git a/.github/scripts/memos-version.mjs b/.github/scripts/memos-version.mjs
new file mode 100644
index 000000000..4e48a05c8
--- /dev/null
+++ b/.github/scripts/memos-version.mjs
@@ -0,0 +1,312 @@
+#!/usr/bin/env node
+import { readFileSync, realpathSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const MEMOS_PYPROJECT_PATH = "pyproject.toml";
+export const MEMOS_PACKAGE_INIT_PATH = "src/memos/__init__.py";
+
+const STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
+const PYPROJECT_SECTION_RE = /^\[project\][ \t]*$/gm;
+const TOML_SECTION_RE = /^\[\[?[^\]\r\n]+\]\]?[ \t]*$/gm;
+const PYPROJECT_VERSION_RE = /^version = "([^"]+)"$/gm;
+const PACKAGE_VERSION_RE = /^__version__ = "([^"]+)"$/gm;
+const PYPROJECT_VERSION_ASSIGNMENT_RE = /^[ \t]*version[ \t]*=/gm;
+const PACKAGE_VERSION_ASSIGNMENT_RE =
+ /(?:^|;)[ \t]*__version__(?:[ \t]*:[^=;\n]+)?[ \t]*=/gm;
+const PACKAGE_VERSION_IDENTIFIER_RE = /\b__version__\b/g;
+
+function fail(message) {
+ throw new Error(String(message));
+}
+
+export function normalizeStableMemOSVersion(raw) {
+ const version = String(raw || "").trim();
+ if (!version) fail("MemOS version is required.");
+ if (version.startsWith("v"))
+ fail("MemOS version must not include a leading v.");
+ if (!STABLE_VERSION_RE.test(version)) {
+ fail("MemOS version must be a stable X.Y.Z version without metadata.");
+ }
+ return version;
+}
+
+function compareNumericIdentifier(left, right) {
+ if (left.length !== right.length) return left.length - right.length;
+ return left.localeCompare(right);
+}
+
+export function compareStableMemOSVersions(leftRaw, rightRaw) {
+ const left = normalizeStableMemOSVersion(leftRaw).split(".");
+ const right = normalizeStableMemOSVersion(rightRaw).split(".");
+ for (let index = 0; index < left.length; index += 1) {
+ const comparison = compareNumericIdentifier(left[index], right[index]);
+ if (comparison !== 0) return comparison;
+ }
+ return 0;
+}
+
+export function expectedReleaseBranches(rawVersion) {
+ const version = normalizeStableMemOSVersion(rawVersion);
+ return {
+ sourceBranch: `dev-v${version}`,
+ releaseBranch: `release/v${version}`,
+ };
+}
+
+function extractSingleVersion(text, pattern, assignmentPattern, path) {
+ const assignments = [...String(text).matchAll(assignmentPattern)];
+ if (assignments.length !== 1) {
+ fail(
+ `${path} must contain exactly one version assignment; found ${assignments.length}.`,
+ );
+ }
+ const matches = [...String(text).matchAll(pattern)];
+ if (matches.length !== 1) {
+ fail(
+ `${path} must contain exactly one canonical version declaration; found ${matches.length}.`,
+ );
+ }
+ return normalizeStableMemOSVersion(matches[0][1]);
+}
+
+function projectSectionBounds(pyprojectText) {
+ const text = String(pyprojectText);
+ const sections = [...text.matchAll(PYPROJECT_SECTION_RE)];
+ if (sections.length !== 1) {
+ fail(
+ `${MEMOS_PYPROJECT_PATH} must contain exactly one [project] section; found ${sections.length}.`,
+ );
+ }
+ const start = sections[0].index + sections[0][0].length;
+ TOML_SECTION_RE.lastIndex = start;
+ const nextSection = TOML_SECTION_RE.exec(text);
+ return { start, end: nextSection?.index ?? text.length };
+}
+
+function inspectPyprojectVersion(pyprojectText) {
+ const bounds = projectSectionBounds(pyprojectText);
+ return extractSingleVersion(
+ String(pyprojectText).slice(bounds.start, bounds.end),
+ PYPROJECT_VERSION_RE,
+ PYPROJECT_VERSION_ASSIGNMENT_RE,
+ `${MEMOS_PYPROJECT_PATH} [project]`,
+ );
+}
+
+function inspectPackageVersion(packageInitText) {
+ const identifiers = [
+ ...String(packageInitText).matchAll(PACKAGE_VERSION_IDENTIFIER_RE),
+ ];
+ if (identifiers.length !== 1) {
+ fail(
+ `${MEMOS_PACKAGE_INIT_PATH} must contain exactly one __version__ identifier; found ${identifiers.length}.`,
+ );
+ }
+ return extractSingleVersion(
+ packageInitText,
+ PACKAGE_VERSION_RE,
+ PACKAGE_VERSION_ASSIGNMENT_RE,
+ MEMOS_PACKAGE_INIT_PATH,
+ );
+}
+
+function replacePyprojectVersion(pyprojectText, expectedVersion) {
+ const text = String(pyprojectText);
+ const bounds = projectSectionBounds(text);
+ const section = text.slice(bounds.start, bounds.end);
+ const nextSection = section.replace(
+ /^version = "[^"]+"$/m,
+ `version = "${expectedVersion}"`,
+ );
+ return `${text.slice(0, bounds.start)}${nextSection}${text.slice(bounds.end)}`;
+}
+
+export function inspectMemOSVersionTexts({ pyprojectText, packageInitText }) {
+ const pyprojectVersion = inspectPyprojectVersion(pyprojectText);
+ const packageVersion = inspectPackageVersion(packageInitText);
+ if (pyprojectVersion !== packageVersion) {
+ fail(
+ `MemOS package versions do not match: ${MEMOS_PYPROJECT_PATH}=${pyprojectVersion}, ` +
+ `${MEMOS_PACKAGE_INIT_PATH}=${packageVersion}.`,
+ );
+ }
+ return { pyprojectVersion, packageVersion, version: pyprojectVersion };
+}
+
+export function assertMemOSVersionTexts({
+ expectedVersion,
+ pyprojectText,
+ packageInitText,
+}) {
+ const expected = normalizeStableMemOSVersion(expectedVersion);
+ const inspected = inspectMemOSVersionTexts({
+ pyprojectText,
+ packageInitText,
+ });
+ if (inspected.version !== expected) {
+ fail(
+ `MemOS package version mismatch: expected ${expected}, but both package files contain ` +
+ `${inspected.version}. Run MemOS Release — Prepare and merge its PR before publishing.`,
+ );
+ }
+ return inspected;
+}
+
+export function updateMemOSVersionTexts({
+ expectedVersion,
+ pyprojectText,
+ packageInitText,
+}) {
+ const expected = normalizeStableMemOSVersion(expectedVersion);
+ const inspected = inspectMemOSVersionTexts({
+ pyprojectText,
+ packageInitText,
+ });
+ const comparison = compareStableMemOSVersions(expected, inspected.version);
+ if (comparison < 0) {
+ fail(
+ `Requested MemOS version ${expected} must be newer than the current version ${inspected.version}.`,
+ );
+ }
+ if (comparison === 0) {
+ return {
+ ...inspected,
+ previousVersion: inspected.version,
+ changed: false,
+ pyprojectText,
+ packageInitText,
+ };
+ }
+
+ const nextPyprojectText = replacePyprojectVersion(pyprojectText, expected);
+ const nextPackageInitText = String(packageInitText).replace(
+ /^__version__ = "[^"]+"$/m,
+ `__version__ = "${expected}"`,
+ );
+ assertMemOSVersionTexts({
+ expectedVersion: expected,
+ pyprojectText: nextPyprojectText,
+ packageInitText: nextPackageInitText,
+ });
+
+ return {
+ pyprojectVersion: expected,
+ packageVersion: expected,
+ version: expected,
+ previousVersion: inspected.version,
+ changed: true,
+ pyprojectText: nextPyprojectText,
+ packageInitText: nextPackageInitText,
+ };
+}
+
+export function updateMemOSVersionFiles({
+ root = process.cwd(),
+ expectedVersion,
+}) {
+ const pyprojectPath = resolve(root, MEMOS_PYPROJECT_PATH);
+ const packageInitPath = resolve(root, MEMOS_PACKAGE_INIT_PATH);
+ const result = updateMemOSVersionTexts({
+ expectedVersion,
+ pyprojectText: readFileSync(pyprojectPath, "utf8"),
+ packageInitText: readFileSync(packageInitPath, "utf8"),
+ });
+ if (result.changed) {
+ writeFileSync(pyprojectPath, result.pyprojectText, "utf8");
+ writeFileSync(packageInitPath, result.packageInitText, "utf8");
+ }
+ return result;
+}
+
+function appendOutput(name, value) {
+ if (!process.env.GITHUB_OUTPUT) return;
+ writeFileSync(process.env.GITHUB_OUTPUT, `${name}=${String(value)}\n`, {
+ flag: "a",
+ });
+}
+
+function readMemOSVersionFiles(root) {
+ return {
+ pyprojectText: readFileSync(resolve(root, MEMOS_PYPROJECT_PATH), "utf8"),
+ packageInitText: readFileSync(
+ resolve(root, MEMOS_PACKAGE_INIT_PATH),
+ "utf8",
+ ),
+ };
+}
+
+function escapeWorkflowCommand(value) {
+ return String(value)
+ .replaceAll("%", "%25")
+ .replaceAll("\r", "%0D")
+ .replaceAll("\n", "%0A");
+}
+
+export function run(mode = process.argv[2] || "update") {
+ const root = process.env.MEMOS_VERSION_ROOT || process.cwd();
+ if (mode === "inspect") {
+ const inspected = inspectMemOSVersionTexts(readMemOSVersionFiles(root));
+ appendOutput("version", inspected.version);
+ console.log(`MemOS package version is ${inspected.version}.`);
+ return inspected;
+ }
+
+ const version = normalizeStableMemOSVersion(process.env.RELEASE_VERSION);
+ const branches = expectedReleaseBranches(version);
+ if (mode === "plan") {
+ appendOutput("version", version);
+ appendOutput("source_branch", branches.sourceBranch);
+ appendOutput("release_branch", branches.releaseBranch);
+ console.log(`Prepared release request for MemOS v${version}.`);
+ return { version, ...branches };
+ }
+ if (mode === "require-newer") {
+ const currentVersion = normalizeStableMemOSVersion(
+ process.env.CURRENT_MEMOS_VERSION,
+ );
+ if (compareStableMemOSVersions(version, currentVersion) <= 0) {
+ fail(
+ `Requested MemOS version ${version} must be newer than main package version ${currentVersion}.`,
+ );
+ }
+ console.log(
+ `Requested MemOS version ${version} is newer than main package version ${currentVersion}.`,
+ );
+ return { version, currentVersion, ...branches };
+ }
+ if (mode === "assert") {
+ const inspected = assertMemOSVersionTexts({
+ expectedVersion: version,
+ ...readMemOSVersionFiles(root),
+ });
+ console.log(`MemOS package version matches ${version}.`);
+ return inspected;
+ }
+ if (mode !== "update") fail(`Unsupported memos-version mode: ${mode}`);
+
+ const result = updateMemOSVersionFiles({ root, expectedVersion: version });
+ appendOutput("version", version);
+ appendOutput("source_branch", branches.sourceBranch);
+ appendOutput("release_branch", branches.releaseBranch);
+ appendOutput("previous_version", result.previousVersion);
+ appendOutput("changed", result.changed);
+ console.log(
+ result.changed
+ ? `Updated MemOS package version ${result.previousVersion} -> ${version}.`
+ : `MemOS package version is already ${version}.`,
+ );
+ return result;
+}
+
+if (
+ process.argv[1] &&
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
+) {
+ try {
+ run();
+ } catch (error) {
+ console.error(`::error::${escapeWorkflowCommand(error?.message || error)}`);
+ process.exit(1);
+ }
+}
diff --git a/.github/scripts/memos-version.test.mjs b/.github/scripts/memos-version.test.mjs
new file mode 100644
index 000000000..a006c4e86
--- /dev/null
+++ b/.github/scripts/memos-version.test.mjs
@@ -0,0 +1,327 @@
+import assert from "node:assert/strict";
+import {
+ copyFileSync,
+ existsSync,
+ mkdtempSync,
+ mkdirSync,
+ readFileSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { spawnSync } from "node:child_process";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ MEMOS_PACKAGE_INIT_PATH,
+ MEMOS_PYPROJECT_PATH,
+ assertMemOSVersionTexts,
+ compareStableMemOSVersions,
+ expectedReleaseBranches,
+ inspectMemOSVersionTexts,
+ normalizeStableMemOSVersion,
+ updateMemOSVersionFiles,
+ updateMemOSVersionTexts,
+} from "./memos-version.mjs";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const workflowsDir = join(__dirname, "../workflows");
+
+function versionTexts(version = "2.0.29") {
+ return {
+ pyprojectText: `[project]\nname = "MemoryOS"\nversion = "${version}"\ndescription = "test"\n`,
+ packageInitText: `__version__ = "${version}"\n\nfrom memos.example import Example\n`,
+ };
+}
+
+test("accepts only stable MemOS versions without a leading v", () => {
+ assert.equal(normalizeStableMemOSVersion("2.0.30"), "2.0.30");
+ assert.deepEqual(expectedReleaseBranches("2.0.30"), {
+ sourceBranch: "dev-v2.0.30",
+ releaseBranch: "release/v2.0.30",
+ });
+
+ for (const invalid of [
+ "",
+ "v2.0.30",
+ "2.0",
+ "2.0.30-beta.1",
+ "2.0.30+build.1",
+ "02.0.30",
+ "2.0.30; echo unsafe",
+ ]) {
+ assert.throws(
+ () => normalizeStableMemOSVersion(invalid),
+ /stable X\.Y\.Z|leading v|required/,
+ );
+ }
+});
+
+test("compares stable MemOS versions without numeric precision loss", () => {
+ assert.ok(compareStableMemOSVersions("2.0.30", "2.0.29") > 0);
+ assert.ok(compareStableMemOSVersions("10.0.0", "2.99.99") > 0);
+ assert.ok(
+ compareStableMemOSVersions(
+ "2.0.10000000000000000000",
+ "2.0.9999999999999999999",
+ ) > 0,
+ );
+ assert.equal(compareStableMemOSVersions("2.0.30", "2.0.30"), 0);
+});
+
+test("updates both MemOS version declarations and preserves surrounding content", () => {
+ const current = versionTexts();
+ const result = updateMemOSVersionTexts({
+ expectedVersion: "2.0.30",
+ ...current,
+ });
+
+ assert.equal(result.previousVersion, "2.0.29");
+ assert.equal(result.version, "2.0.30");
+ assert.equal(result.changed, true);
+ assert.equal(
+ result.pyprojectText,
+ `[project]\nname = "MemoryOS"\nversion = "2.0.30"\ndescription = "test"\n`,
+ );
+ assert.equal(
+ result.packageInitText,
+ `__version__ = "2.0.30"\n\nfrom memos.example import Example\n`,
+ );
+});
+
+test("updates only the version in the pyproject project section", () => {
+ const result = updateMemOSVersionTexts({
+ expectedVersion: "2.0.30",
+ pyprojectText: `[project]\nversion = "2.0.29"\n\n[example]\nversion = "9.9.9"\n\n[[example.index]]\nversion = "8.8.8"\n`,
+ packageInitText: `__version__ = "2.0.29"\n`,
+ });
+
+ assert.equal(
+ result.pyprojectText,
+ `[project]\nversion = "2.0.30"\n\n[example]\nversion = "9.9.9"\n\n[[example.index]]\nversion = "8.8.8"\n`,
+ );
+});
+
+test("is idempotent when both declarations already match", () => {
+ const current = versionTexts("2.0.30");
+ const result = updateMemOSVersionTexts({
+ expectedVersion: "2.0.30",
+ ...current,
+ });
+
+ assert.equal(result.changed, false);
+ assert.equal(result.previousVersion, "2.0.30");
+ assert.deepEqual(inspectMemOSVersionTexts(current), {
+ pyprojectVersion: "2.0.30",
+ packageVersion: "2.0.30",
+ version: "2.0.30",
+ });
+});
+
+test("fails closed for mismatched, malformed, duplicate, or downgraded versions", () => {
+ assert.throws(
+ () =>
+ inspectMemOSVersionTexts({
+ ...versionTexts(),
+ packageInitText: `__version__ = "2.0.28"\n`,
+ }),
+ /do not match/,
+ );
+ assert.throws(
+ () =>
+ inspectMemOSVersionTexts({
+ ...versionTexts(),
+ pyprojectText: `[project]\nversion = "2.0.29"\nversion = "2.0.30"\n`,
+ }),
+ /exactly one/,
+ );
+ assert.throws(
+ () =>
+ inspectMemOSVersionTexts({
+ ...versionTexts(),
+ pyprojectText: `[project]\nversion = "2.0.29"\nversion='2.0.30'\n`,
+ }),
+ /exactly one version assignment/,
+ );
+ assert.throws(
+ () =>
+ inspectMemOSVersionTexts({
+ ...versionTexts(),
+ packageInitText: `__version__ = "2.0.29"\n__version__='1.0.0'\n`,
+ }),
+ /exactly one __version__ identifier/,
+ );
+ assert.throws(
+ () =>
+ inspectMemOSVersionTexts({
+ ...versionTexts(),
+ packageInitText: `__version__ = "2.0.29"; __version__ = "1.0.0"\n`,
+ }),
+ /exactly one __version__ identifier/,
+ );
+ assert.throws(
+ () =>
+ inspectMemOSVersionTexts({
+ ...versionTexts(),
+ packageInitText: `__version__ = "2.0.29"\nif True: __version__ = "1.0.0"\n`,
+ }),
+ /exactly one __version__ identifier/,
+ );
+ assert.throws(
+ () =>
+ updateMemOSVersionTexts({ expectedVersion: "2.0.28", ...versionTexts() }),
+ /must be newer/,
+ );
+ assert.throws(
+ () =>
+ assertMemOSVersionTexts({ expectedVersion: "2.0.30", ...versionTexts() }),
+ /expected 2\.0\.30/,
+ );
+});
+
+test("updates the repository files as one version pair", () => {
+ const root = mkdtempSync(join(tmpdir(), "memos-version-test-"));
+ const current = versionTexts();
+ const pyprojectPath = join(root, MEMOS_PYPROJECT_PATH);
+ const packageInitPath = join(root, MEMOS_PACKAGE_INIT_PATH);
+ mkdirSync(dirname(packageInitPath), { recursive: true });
+ writeFileSync(pyprojectPath, current.pyprojectText);
+ writeFileSync(packageInitPath, current.packageInitText);
+
+ const result = updateMemOSVersionFiles({ root, expectedVersion: "2.0.30" });
+
+ assert.equal(result.changed, true);
+ assert.match(readFileSync(pyprojectPath, "utf8"), /^version = "2\.0\.30"$/m);
+ assert.match(
+ readFileSync(packageInitPath, "utf8"),
+ /^__version__ = "2\.0\.30"$/m,
+ );
+});
+
+test("runs from a trusted copy reached through a symbolic path", () => {
+ const root = mkdtempSync(join(tmpdir(), "memos-version-cli-test-"));
+ const realDirectory = join(root, "real");
+ const linkedDirectory = join(root, "linked");
+ const outputPath = join(root, "output.txt");
+ mkdirSync(realDirectory);
+ symlinkSync(realDirectory, linkedDirectory, "dir");
+ copyFileSync(
+ join(__dirname, "memos-version.mjs"),
+ join(realDirectory, "memos-version.mjs"),
+ );
+
+ const result = spawnSync(
+ process.execPath,
+ [join(linkedDirectory, "memos-version.mjs"), "plan"],
+ {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ GITHUB_OUTPUT: outputPath,
+ RELEASE_VERSION: "2.0.30",
+ },
+ },
+ );
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(readFileSync(outputPath, "utf8"), /^version=2\.0\.30$/m);
+ assert.match(
+ readFileSync(outputPath, "utf8"),
+ /^source_branch=dev-v2\.0\.30$/m,
+ );
+ assert.match(
+ readFileSync(outputPath, "utf8"),
+ /^release_branch=release\/v2\.0\.30$/m,
+ );
+});
+
+test("prepare workflow creates a guarded release branch and manual PR handoff without touching dev", () => {
+ const workflow = readFileSync(
+ join(workflowsDir, "memos-release-prepare.yml"),
+ "utf8",
+ );
+
+ assert.match(workflow, /name: MemOS Release — Prepare/);
+ assert.match(workflow, /workflow_dispatch:/);
+ assert.match(
+ workflow,
+ /permissions:\n\s+contents: write\n\s+pull-requests: read/,
+ );
+ assert.match(workflow, /concurrency:/);
+ assert.match(workflow, /SELECTED_BRANCH: \$\{\{ github\.ref_name \}\}/);
+ assert.match(workflow, /SELECTED_REF: \$\{\{ github\.ref \}\}/);
+ assert.match(workflow, /SELECTED_REF_TYPE: \$\{\{ github\.ref_type \}\}/);
+ assert.match(workflow, /expected_ref="refs\/heads\/\$\{DEFAULT_BRANCH\}"/);
+ assert.match(
+ workflow,
+ /DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/,
+ );
+ assert.match(workflow, /RELEASE_VERSION: \$\{\{ inputs\.version \}\}/);
+ assert.match(workflow, /node "\$\{RUNNER_TEMP\}\/memos-version\.mjs"/);
+ assert.match(workflow, /refs\/heads\/\$\{SOURCE_BRANCH\}/);
+ assert.match(workflow, /refs\/heads\/\$\{RELEASE_BRANCH\}/);
+ assert.match(workflow, /merge-base --is-ancestor/);
+ assert.match(workflow, /merged_branch_deleted/);
+ assert.match(workflow, /require-newer/);
+ assert.match(workflow, /assert_release_matches_trusted_update/);
+ assert.match(workflow, /cmp -s/);
+ assert.match(workflow, /git diff --name-only/);
+ assert.match(workflow, /pyproject\.toml/);
+ assert.match(workflow, /src\/memos\/__init__\.py/);
+ assert.match(
+ workflow,
+ /chore: change version number to v\$\{RELEASE_VERSION\}/,
+ );
+ assert.match(workflow, /gh pr list/);
+ assert.match(workflow, /The PR is intentionally opened by a maintainer/);
+ assert.doesNotMatch(workflow, /gh pr create/);
+ assert.match(
+ workflow,
+ /compare\/\$\{DEFAULT_BRANCH\}\.\.\.\$\{RELEASE_BRANCH\}/,
+ );
+ assert.doesNotMatch(
+ workflow,
+ /git push[^\n]*refs\/heads\/\$\{SOURCE_BRANCH\}/,
+ );
+ assert.doesNotMatch(workflow, /--force/);
+ assert.doesNotMatch(workflow, /run:[^\n]*\$\{\{ inputs\./);
+});
+
+test("publish inspection fails closed unless the target ref contains the requested package version", () => {
+ const publishScript = readFileSync(
+ join(__dirname, "prepare-memos-release.mjs"),
+ "utf8",
+ );
+ const publishWorkflow = readFileSync(
+ join(workflowsDir, "memos-release-publish-main.yml"),
+ "utf8",
+ );
+
+ assert.match(publishScript, /assertMemOSVersionTexts/);
+ assert.match(publishScript, /target\.sha/);
+ assert.match(publishScript, /MEMOS_PYPROJECT_PATH/);
+ assert.match(publishScript, /MEMOS_PACKAGE_INIT_PATH/);
+ assert.match(publishWorkflow, /memos-version\.test\.mjs/);
+ assert.match(publishScript, /"refs\/remotes\/origin\/main"/);
+ assert.equal(
+ existsSync(join(workflowsDir, "memos-release-publish.yml")),
+ false,
+ "the legacy path must remain absent so old dev workflow revisions cannot be dispatched",
+ );
+ assert.match(
+ publishWorkflow,
+ /SELECTED_BRANCH: \$\{\{ github\.ref_name \}\}/,
+ );
+ assert.match(publishWorkflow, /SELECTED_REF: \$\{\{ github\.ref \}\}/);
+ assert.match(
+ publishWorkflow,
+ /SELECTED_REF_TYPE: \$\{\{ github\.ref_type \}\}/,
+ );
+ assert.match(
+ publishWorkflow,
+ /DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/,
+ );
+});
diff --git a/.github/scripts/prepare-memos-release.mjs b/.github/scripts/prepare-memos-release.mjs
index 610832130..d6fe680cf 100644
--- a/.github/scripts/prepare-memos-release.mjs
+++ b/.github/scripts/prepare-memos-release.mjs
@@ -6,6 +6,12 @@ import { tmpdir } from "node:os";
import { pathToFileURL } from "node:url";
import { createHash } from "node:crypto";
import { buildLocalPluginReleaseIntent } from "./append-local-plugin-release-intent.mjs";
+import {
+ MEMOS_PACKAGE_INIT_PATH,
+ MEMOS_PYPROJECT_PATH,
+ assertMemOSVersionTexts,
+} from "./memos-version.mjs";
+import { parseLocalPluginReleaseBinding } from "./local-plugin-release-contract.mjs";
export const PRODUCT_ID = "openclaw-local-plugin";
export const PRODUCT_PATH = "apps/memos-local-plugin";
@@ -27,6 +33,11 @@ const MAX_TEXT_CN_CHARS = 180;
const MAX_TEXT_EN_CHARS = 220;
const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
const CJK_GLOBAL_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/g;
+const LOCAL_PLUGIN_RELEASE_METADATA_PATHS = new Set([
+ `${PRODUCT_PATH}/package.json`,
+ `${PRODUCT_PATH}/package-lock.json`,
+ `${PRODUCT_PATH}/adapters/hermes/plugin.yaml`,
+]);
const TOKEN_RE =
/(github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|npm_[A-Za-z0-9_]+|xox[baprs]-[A-Za-z0-9-]+|Bearer\s+[A-Za-z0-9._~+/=-]+)/g;
const INTERNAL_URL_RE =
@@ -109,6 +120,46 @@ export function displayVersion(raw) {
return value ? `v${value}` : "";
}
+export function repositoryReleaseNotesPath(rawVersion) {
+ const version = cleanVersion(rawVersion);
+ if (!parseSemver(version) || version.includes("+")) {
+ fail(`Cannot resolve repository release notes for invalid version ${rawVersion || ""}.`);
+ }
+ return `.github/release-notes/v${version}.md`;
+}
+
+export function validateRepositoryReleaseNotes(notes, { path = "repository release notes" } = {}) {
+ const text = String(notes || "").trim();
+ if (!text) return "";
+ if (text.length > 24000) {
+ fail(`${path} exceeds the 24,000-character release-note limit.`);
+ }
+ if (/"),
+ /must not contain Doc Agent/,
+ );
+ assert.throws(
+ () => validateRepositoryReleaseNotes(`token: ${["github", "pat", "test_only"].join("_")}`),
+ /credential-like value/,
+ );
+ assert.throws(() => repositoryReleaseNotesPath("v2.0.30"), /must not include a leading v/);
+});
+
test("selects the previous MemOS stable tag for release evidence", () => {
assert.equal(
findPreviousMemOSTag("2.0.25", "v2.0.25", ["v2.0.24", "v2.0.25", "v2.0.25-beta.1", "memos-local-plugin-v2.0.10"]),
@@ -192,18 +226,73 @@ test("rejects leading v in manual version input", () => {
assert.throws(() => incrementPatchVersion("2.0.12-beta.1"), /Cannot auto-increment prerelease/);
});
-test("leaves local-plugin publishing disabled when local_plugin_version is blank", () => {
+test("derives automatic weekly release versions from supported merged release branches", () => {
+ assert.equal(deriveReleaseVersionFromMergedPrHead("release/v2.0.29"), "2.0.29");
+ assert.equal(deriveReleaseVersionFromMergedPrHead("dev-v2.0.29"), "2.0.29");
+ assert.equal(deriveReleaseVersionFromMergedPrHead("dev-2.0.29"), "2.0.29");
+ assert.equal(deriveReleaseVersionFromMergedPrHead("dev-v12.34.56"), "12.34.56");
+ assert.equal(deriveReleaseVersionFromMergedPrHead("dev-12.34.56"), "12.34.56");
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("feature/foo"), /release\/vX\.Y\.Z/);
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("v2.0.29"), /release\/vX\.Y\.Z/);
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("dev-feature"), /release\/vX\.Y\.Z/);
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("dev-v2.0"), /release\/vX\.Y\.Z/);
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("release/v2.0"), /release\/vX\.Y\.Z/);
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("dev-v2.0.29-hotfix"), /release\/vX\.Y\.Z/);
+ assert.throws(() => deriveReleaseVersionFromMergedPrHead("dev-v02.0.29"), /release\/vX\.Y\.Z/);
+});
+
+test("quality report exposes the validated MemOS project version", () => {
+ const source = readFileSync(join(scriptsDir, "prepare-memos-release.mjs"), "utf8");
+ assert.match(source, /memos_project_version:\s*evidence\.memos_project_version/);
+});
+
+test("auto mode publishes the next local-plugin patch when the version guard is blank", () => {
const plan = validateLocalPluginVersionPlan(evidence, "");
+ assert.equal(plan.release_requested, true);
+ assert.equal(plan.release_mode, "auto");
+ assert.equal(plan.pending_local_plugin_changes, false);
+ assert.equal(plan.version, "v2.0.11");
+ assert.equal(plan.next_patch_version, "v2.0.11");
+ assert.equal(plan.local_plugin_tag, "memos-local-plugin-v2.0.11");
+ assert.equal(plan.version_source, "auto_next_patch_from_latest_stable_local_plugin_tag");
+ assert.equal(plan.auto_incremented, true);
+});
+
+test("auto mode treats local_plugin_version as a strict guard", () => {
+ const guarded = validateLocalPluginVersionPlan(evidence, "2.0.11");
+ assert.equal(guarded.release_requested, true);
+ assert.equal(guarded.version, "v2.0.11");
+ assert.equal(guarded.version_source, "auto_detected_with_manual_guard");
+ assert.equal(guarded.auto_incremented, false);
+ assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.12"), /next stable patch/);
+ assert.throws(
+ () =>
+ validateLocalPluginVersionPlan(
+ { ...evidence, has_product_changes: false, has_user_facing_product_changes: false },
+ "2.0.11",
+ ),
+ /auto-mode guard.*no unpublished apps\/memos-local-plugin/,
+ );
+ assert.throws(
+ () => validateLocalPluginVersionPlan({ ...evidence, has_user_facing_product_changes: false }, "2.0.11"),
+ /auto-mode guard.*no unpublished user-facing/,
+ );
+});
+
+test("skip mode leaves local-plugin publishing disabled even when changes exist", () => {
+ const plan = validateLocalPluginVersionPlan(evidence, "", { releaseMode: "skip" });
assert.equal(plan.release_requested, false);
+ assert.equal(plan.release_mode, "skip");
assert.equal(plan.pending_local_plugin_changes, true);
assert.equal(plan.version, "v2.0.10");
assert.equal(plan.next_patch_version, "v2.0.11");
assert.equal(plan.local_plugin_tag, "");
- assert.match(plan.input_ignored_reason, /left blank/);
+ assert.match(plan.input_ignored_reason, /local_plugin_release_mode=skip/);
+ assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.11", { releaseMode: "skip" }), /requires local_plugin_version to be blank/);
});
test("accepts only the next unused stable patch for a weekly local-plugin release", () => {
- const plan = validateLocalPluginVersionPlan(evidence, "2.0.11");
+ const plan = validateLocalPluginVersionPlan(evidence, "2.0.11", { releaseMode: "manual" });
assert.equal(plan.release_requested, true);
assert.equal(plan.input_raw, "2.0.11");
assert.equal(plan.expected_version, "v2.0.11");
@@ -212,18 +301,19 @@ test("accepts only the next unused stable patch for a weekly local-plugin releas
assert.equal(plan.version_source, "manual_weekly_release_opt_in");
assert.equal(plan.local_plugin_tag, "memos-local-plugin-v2.0.11");
assert.equal(plan.package_version, "v2.0.11");
- assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.12"), /next stable patch/);
- assert.throws(() => validateLocalPluginVersionPlan(evidence, "3.0.0"), /next stable patch/);
- assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.11-beta.1"), /stable SemVer/);
+ assert.throws(() => validateLocalPluginVersionPlan(evidence, "", { releaseMode: "manual" }), /requires local_plugin_version/);
+ assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.12", { releaseMode: "manual" }), /next stable patch/);
+ assert.throws(() => validateLocalPluginVersionPlan(evidence, "3.0.0", { releaseMode: "manual" }), /next stable patch/);
+ assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.11-beta.1", { releaseMode: "manual" }), /stable SemVer/);
});
test("fails when a weekly local-plugin version is supplied without publishable evidence", () => {
assert.throws(
- () => validateLocalPluginVersionPlan({ ...evidence, has_product_changes: false, has_user_facing_product_changes: false }, "2.0.11"),
+ () => validateLocalPluginVersionPlan({ ...evidence, has_product_changes: false, has_user_facing_product_changes: false }, "2.0.11", { releaseMode: "manual" }),
/no unpublished apps\/memos-local-plugin/,
);
assert.throws(
- () => validateLocalPluginVersionPlan({ ...evidence, has_user_facing_product_changes: false }, "2.0.11"),
+ () => validateLocalPluginVersionPlan({ ...evidence, has_user_facing_product_changes: false }, "2.0.11", { releaseMode: "manual" }),
/no unpublished user-facing/,
);
const skipped = validateLocalPluginVersionPlan(
@@ -274,6 +364,93 @@ test("used npm/tag versions fail closed unless npm-backed recovery is explicit",
});
assert.equal(recovered.recovery_enabled, true);
assert.equal(recovered.release_requested, true);
+
+ const stagedRetry = validateLocalPluginVersionPlan(evidence, "2.0.11", {
+ requestedTagExists: true,
+ npmVersionExists: false,
+ stagedReleaseRetryVerified: true,
+ });
+ assert.equal(stagedRetry.staged_release_retry_verified, true);
+ assert.equal(stagedRetry.release_requested, true);
+ assert.throws(
+ () => validateLocalPluginVersionPlan(evidence, "2.0.11", {
+ requestedTagExists: false,
+ npmVersionExists: false,
+ stagedReleaseRetryVerified: true,
+ }),
+ /requires an existing release tag/,
+ );
+ assert.throws(
+ () => validateLocalPluginVersionPlan(evidence, "2.0.11", {
+ requestedTagExists: true,
+ npmVersionExists: true,
+ stagedReleaseRetryVerified: true,
+ }),
+ /npm version that is not published yet/,
+ );
+});
+
+test("only reuses an exact unpublished weekly local-plugin Draft on automatic retry", () => {
+ const sourceSha = "a".repeat(40);
+ const binding = buildLocalPluginReleaseBinding({
+ version: "2.0.11",
+ tag: "memos-local-plugin-v2.0.11",
+ sourceSha,
+ evidenceDigest: "b".repeat(64),
+ originMode: "memos_weekly",
+ memosReleaseTag: "v2.0.25",
+ });
+ const release = {
+ exists: true,
+ tag_name: "memos-local-plugin-v2.0.11",
+ name: "MemOS Local Plugin v2.0.11",
+ body: appendLocalPluginReleaseBinding("## Changelog\n\n### Fixed\n- Retry-safe staging.", binding),
+ draft: true,
+ prerelease: false,
+ published_at: "",
+ html_url: "https://github.com/MemTensor/MemOS/releases/tag/memos-local-plugin-v2.0.11",
+ };
+ const source = {
+ accepted: true,
+ relationship: "release_metadata_child",
+ tag_commit: sourceSha,
+ };
+ const result = validateWeeklyStagedLocalPluginRetry({
+ candidate: { tag: "memos-local-plugin-v2.0.11", version: "2.0.11" },
+ memosReleaseTag: "v2.0.25",
+ release,
+ source,
+ });
+ assert.equal(result.verified, true);
+ assert.equal(result.source_sha, sourceSha);
+
+ assert.throws(
+ () => validateWeeklyStagedLocalPluginRetry({
+ candidate: { tag: "memos-local-plugin-v2.0.11", version: "2.0.11" },
+ memosReleaseTag: "v2.0.26",
+ release,
+ source,
+ }),
+ /memos_release_tag/,
+ );
+ assert.throws(
+ () => validateWeeklyStagedLocalPluginRetry({
+ candidate: { tag: "memos-local-plugin-v2.0.11", version: "2.0.11" },
+ memosReleaseTag: "v2.0.25",
+ release: { ...release, draft: false, published_at: "2026-08-13T00:00:00Z" },
+ source,
+ }),
+ /not an unpublished stable Draft/,
+ );
+ assert.throws(
+ () => validateWeeklyStagedLocalPluginRetry({
+ candidate: { tag: "memos-local-plugin-v2.0.11", version: "2.0.11" },
+ memosReleaseTag: "v2.0.25",
+ release: { ...release, body: "## Changelog" },
+ source,
+ }),
+ /exactly one binding marker/,
+ );
});
test("resolves stable local-plugin tag baselines independently from MemOS tags", () => {
@@ -286,6 +463,8 @@ test("resolves stable local-plugin tag baselines independently from MemOS tags",
const previous = findPreviousStableLocalPluginTag(tags);
assert.equal(previous.tag, "memos-local-plugin-v2.0.11");
assert.equal(previous.version, "2.0.11");
+ const previousBeforeStagedRetry = findPreviousStableLocalPluginTag(tags, { requestedVersion: "v2.0.11" });
+ assert.equal(previousBeforeStagedRetry.tag, "memos-local-plugin-v2.0.10");
assert.equal(localPluginTagForVersion("2.0.12"), "memos-local-plugin-v2.0.12");
assert.equal(npmVersionLookupResult({ status: 0, output: '"2.0.12"' }), true);
assert.equal(npmVersionLookupResult({ status: 1, output: "E404 Not Found" }), false);
@@ -295,6 +474,180 @@ test("resolves stable local-plugin tag baselines independently from MemOS tags",
);
});
+test("accepts only a fully published stable local-plugin baseline", () => {
+ const candidate = {
+ tag: "memos-local-plugin-v2.0.13",
+ version: "2.0.13",
+ parsed: { major: 2, minor: 0, patch: 13, prerelease: [] },
+ };
+ const baseline = validatePublishedStableLocalPluginBaseline({
+ candidate,
+ npmExists: true,
+ sourceIsAncestor: true,
+ release: {
+ exists: true,
+ tag_name: candidate.tag,
+ draft: false,
+ prerelease: false,
+ published_at: "2026-08-07T03:24:54Z",
+ html_url: `https://github.com/MemTensor/MemOS/releases/tag/${candidate.tag}`,
+ },
+ });
+ assert.equal(baseline.tag, candidate.tag);
+ assert.equal(baseline.npm_verified, true);
+ assert.equal(baseline.release_verified, true);
+});
+
+test("accepts stable local-plugin tags on target history or a direct metadata-only release child", () => {
+ const candidate = { tag: "memos-local-plugin-v2.0.13", version: "2.0.13" };
+ const common = {
+ candidate,
+ tagCommit: "a".repeat(40),
+ packageVersion: "2.0.13",
+ manifestVersion: "2.0.13",
+ };
+ assert.deepEqual(
+ validateStableLocalPluginSourceLineage({ ...common, tagIsTargetAncestor: true }),
+ { accepted: true, relationship: "target_history" },
+ );
+ assert.deepEqual(
+ validateStableLocalPluginSourceLineage({
+ ...common,
+ tagIsTargetAncestor: false,
+ parentCommits: ["b".repeat(40)],
+ parentIsTargetAncestor: true,
+ changedFiles: [
+ "apps/memos-local-plugin/package.json",
+ "apps/memos-local-plugin/package-lock.json",
+ "apps/memos-local-plugin/adapters/hermes/plugin.yaml",
+ ],
+ }),
+ { accepted: true, relationship: "release_metadata_child" },
+ );
+});
+
+test("rejects detached or mismatched stable local-plugin release tags", () => {
+ const common = {
+ candidate: { tag: "memos-local-plugin-v2.0.13", version: "2.0.13" },
+ tagCommit: "a".repeat(40),
+ tagIsTargetAncestor: false,
+ parentCommits: ["b".repeat(40)],
+ parentIsTargetAncestor: true,
+ packageVersion: "2.0.13",
+ manifestVersion: "2.0.13",
+ };
+ assert.throws(
+ () => validateStableLocalPluginSourceLineage({
+ ...common,
+ changedFiles: [
+ "apps/memos-local-plugin/package.json",
+ "apps/memos-local-plugin/adapters/hermes/plugin.yaml",
+ "apps/memos-local-plugin/src/index.ts",
+ ],
+ }),
+ /changes non-metadata file.*src\/index\.ts/,
+ );
+ assert.throws(
+ () => validateStableLocalPluginSourceLineage({
+ ...common,
+ parentIsTargetAncestor: false,
+ changedFiles: [
+ "apps/memos-local-plugin/package.json",
+ "apps/memos-local-plugin/adapters/hermes/plugin.yaml",
+ ],
+ }),
+ /parent is not in the current MemOS release target history/,
+ );
+ assert.throws(
+ () => validateStableLocalPluginSourceLineage({
+ ...common,
+ packageVersion: "2.0.12",
+ changedFiles: [
+ "apps/memos-local-plugin/package.json",
+ "apps/memos-local-plugin/adapters/hermes/plugin.yaml",
+ ],
+ }),
+ /contains package version 2\.0\.12, expected 2\.0\.13/,
+ );
+});
+
+test("rejects incomplete or inconsistent stable local-plugin baselines", () => {
+ const candidate = {
+ tag: "memos-local-plugin-v2.0.14",
+ version: "2.0.14",
+ parsed: { major: 2, minor: 0, patch: 14, prerelease: [] },
+ };
+ assert.throws(
+ () => validatePublishedStableLocalPluginBaseline({
+ candidate,
+ npmExists: false,
+ sourceIsAncestor: true,
+ release: {
+ exists: true,
+ tag_name: candidate.tag,
+ draft: true,
+ prerelease: false,
+ published_at: "",
+ },
+ }),
+ /npm package.*is missing.*still Draft.*no published_at.*Finish or explicitly recover/,
+ );
+ assert.throws(
+ () => validatePublishedStableLocalPluginBaseline({
+ candidate,
+ npmExists: true,
+ sourceIsAncestor: true,
+ release: { exists: false },
+ }),
+ /GitHub Release memos-local-plugin-v2\.0\.14 is missing/,
+ );
+ assert.throws(
+ () => validatePublishedStableLocalPluginBaseline({
+ candidate,
+ npmExists: true,
+ sourceIsAncestor: true,
+ release: {
+ exists: true,
+ tag_name: "memos-local-plugin-v2.0.15",
+ draft: false,
+ prerelease: false,
+ published_at: "2026-08-07T03:24:54Z",
+ },
+ }),
+ /bound to memos-local-plugin-v2\.0\.15 instead of memos-local-plugin-v2\.0\.14/,
+ );
+ assert.throws(
+ () => validatePublishedStableLocalPluginBaseline({
+ candidate,
+ npmExists: true,
+ sourceIsAncestor: true,
+ release: {
+ exists: true,
+ tag_name: candidate.tag,
+ draft: false,
+ prerelease: true,
+ published_at: "2026-08-07T03:24:54Z",
+ },
+ }),
+ /marked as a prerelease/,
+ );
+ assert.throws(
+ () => validatePublishedStableLocalPluginBaseline({
+ candidate,
+ npmExists: true,
+ sourceIsAncestor: false,
+ release: {
+ exists: true,
+ tag_name: candidate.tag,
+ draft: false,
+ prerelease: false,
+ published_at: "2026-08-07T03:24:54Z",
+ },
+ }),
+ /is not an ancestor of the current MemOS release target/,
+ );
+});
+
test("requires an exact publish confirmation for non-dry-run releases", () => {
assert.doesNotThrow(() => validatePublishConfirmation({ dryRun: "true", version: "2.0.25", confirmation: "" }));
assert.throws(
@@ -319,16 +672,43 @@ test("requires an exact publish confirmation for non-dry-run releases", () => {
localPluginVersion: "2.0.11",
confirmation: "PUBLISH v2.0.25 WITH LOCAL PLUGIN v2.0.11",
}));
+ assert.doesNotThrow(() =>
+ validatePublishConfirmation({
+ dryRun: "false",
+ version: "2.0.25",
+ localPluginVersion: "2.0.11",
+ confirmation: "",
+ autoPostMergeRelease: "true",
+ }),
+ );
});
test("publish workflow defaults real releases to draft before release.published", () => {
- const workflow = readFileSync(join(workflowsDir, "memos-release-publish.yml"), "utf8");
+ const workflow = readFileSync(join(workflowsDir, "memos-release-publish-main.yml"), "utf8");
+ assert.match(workflow, /permissions:\n contents: read/);
+ assert.match(workflow, /publish-local-plugin:[\s\S]*?permissions:\n contents: write/);
+ assert.match(workflow, /release:[\s\S]*?permissions:\n contents: write/);
assert.match(workflow, /create_draft_release:/);
assert.match(workflow, /default:\s+true/);
assert.match(workflow, /CREATE_DRAFT_RELEASE/);
assert.match(workflow, /timeout-minutes:\s+30/);
- assert.match(workflow, /Validate publish confirmation/);
- assert.match(workflow, /publish_confirmation must exactly equal/);
+ assert.doesNotMatch(workflow, /Validate publish confirmation/);
+ assert.match(workflow, /pull_request:\n\s+types: \[closed\]\n\s+branches:\n\s+- main/);
+ assert.match(workflow, /classify_release_trigger:/);
+ assert.match(workflow, /\^\(release\/v\|dev-v\?\)/);
+ assert.match(workflow, /dev-v\?/);
+ assert.match(workflow, /needs\.classify_release_trigger\.outputs\.is_release_trigger == 'true'/);
+ assert.match(workflow, /PR_HEAD_REPO: \$\{\{ github\.event\.pull_request\.head\.repo\.full_name \|\| '' \}\}/);
+ assert.match(workflow, /\[\[ "\$PR_HEAD_REPO" != "\$REPOSITORY" \]\]/);
+ assert.match(workflow, /\[\[ "\$WORKFLOW_REF_NAME" != "\$DEFAULT_BRANCH" \]\]/);
+ assert.match(
+ workflow,
+ /name: Require the default-branch workflow\n\s+if: \$\{\{ github\.event_name == 'workflow_dispatch' \}\}/,
+ );
+ assert.match(workflow, /Run MemOS Release — Publish from \$\{DEFAULT_BRANCH\}/);
+ assert.doesNotMatch(workflow, /startsWith\(github\.event\.pull_request\.head\.ref, 'dev-'/);
+ assert.match(workflow, /AUTO_POST_MERGE_RELEASE/);
+ assert.match(workflow, /MERGED_PR_HEAD_REF/);
assert.match(workflow, /flags\+=\(--draft\)/);
assert.match(workflow, /wait_for_remote_tag\(\)/);
assert.match(workflow, /wait_for_release_visibility\(\)/);
@@ -340,11 +720,15 @@ test("publish workflow defaults real releases to draft before release.published"
assert.match(workflow, /exists after a failed create response; treating it as success/);
assert.match(workflow, /did not become visible in time/);
assert.match(workflow, /local-plugin Release is always staged as a Draft/);
+ assert.match(workflow, /local_plugin_release_mode:/);
+ assert.match(workflow, /options:\n\s+- auto\n\s+- skip\n\s+- manual/);
assert.match(workflow, /local_plugin_version:/);
- assert.match(workflow, /Leave blank to skip local-plugin npm\/tag\/docs/);
+ assert.match(workflow, /Optional stable local-plugin version guard/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/memos-local-plugin-publish\.yml/);
assert.match(workflow, /docs_sync_mode: paired_with_memos_release/);
- assert.match(workflow, /memos_release_tag: v\$\{\{ inputs\.version \}\}/);
+ assert.match(workflow, /memos_release_tag: \$\{\{ needs\.prepare\.outputs\.current_tag \}\}/);
+ assert.match(workflow, /publish_phase: stage_release/);
+ assert.match(workflow, /publish_phase: publish_npm_only/);
assert.match(workflow, /create_draft_release: true/);
assert.doesNotMatch(workflow, /create_draft_release: \$\{\{ inputs\.create_draft_release \}\}/);
assert.match(workflow, /needs\.prepare\.outputs\.local_plugin_release_requested == 'true'/);
@@ -360,7 +744,7 @@ test("publish workflow defaults real releases to draft before release.published"
assert.match(workflow, /version: \$\{\{ needs\.prepare\.outputs\.local_plugin_publish_version \}\}/);
assert.match(
workflow,
- /LOCAL_PLUGIN_VERSION: \$\{\{ needs\.prepare\.outputs\.local_plugin_publish_version \}\}/,
+ /local_plugin_publish_version: \$\{\{ steps\.prepare\.outputs\.local_plugin_publish_version \}\}/,
);
assert.doesNotMatch(workflow, /version: \$\{\{ needs\.prepare\.outputs\.local_plugin_version \}\}/);
assert.doesNotMatch(workflow, /version: \$\{\{ needs\.prepare\.outputs\.local_plugin_expected_version \}\}/);
@@ -369,11 +753,13 @@ test("publish workflow defaults real releases to draft before release.published"
assert.match(workflow, /append-local-plugin-release-intent\.mjs/);
assert.match(workflow, /LOCAL_PLUGIN_RELEASE_URL/);
assert.match(workflow, /local_plugin_evidence_digest/);
- assert.match(workflow, /WITH LOCAL PLUGIN v\$\{LOCAL_PLUGIN_VERSION\}/);
- assert.match(workflow, /Publish paired local-plugin Release after immediate MemOS publish/);
+ assert.match(workflow, /WITH LOCAL PLUGIN v/);
+ assert.match(workflow, /caller_publish_confirmation/);
+ assert.match(workflow, /publish-local-plugin-npm-after-immediate-release:/);
+ assert.match(workflow, /publish-paired-local-plugin-after-immediate-release:/);
assert.match(workflow, /id: memos_release/);
assert.match(workflow, /release_is_draft=\$\{is_draft\}/);
- assert.match(workflow, /steps\.memos_release\.outputs\.release_is_draft == 'false'/);
+ assert.match(workflow, /needs\.release\.outputs\.release_is_draft == 'false'/);
assert.match(workflow, /MEMOS_RELEASE_TAG_OVERRIDE: \$\{\{ needs\.prepare\.outputs\.current_tag \}\}/);
assert.match(workflow, /run: node \.github\/scripts\/publish-paired-local-plugin-release\.mjs/);
});
@@ -388,7 +774,19 @@ test("paired local-plugin publisher is release-triggered, idempotent, and has ex
assert.match(workflow, /PUBLISH PAIRED LOCAL PLUGIN FOR/);
assert.match(workflow, /github\.repository == 'MemTensor\/MemOS'/);
assert.match(workflow, /startsWith\(github\.event\.release\.tag_name, 'v'\)/);
- assert.match(workflow, /permissions:\n\s+contents: write/);
+ assert.match(workflow, /permissions:\n contents: read/);
+ assert.match(workflow, /resolve-paired-local-plugin:/);
+ assert.match(
+ workflow,
+ /publish-local-plugin-npm:[\s\S]*?permissions:\n contents: write/,
+ );
+ assert.match(
+ workflow,
+ /publish-paired-local-plugin:[\s\S]*?permissions:\n contents: write/,
+ );
+ assert.match(workflow, /publish-local-plugin-npm:/);
+ assert.match(workflow, /publish_phase: publish_npm_only/);
+ assert.match(workflow, /VALIDATE_ONLY: "true"/);
assert.match(workflow, /publish-paired-local-plugin-release\.mjs/);
assert.doesNotMatch(workflow, /NPM_TOKEN|DOC_AGENT_RELEASE_SYNC|pull-requests:\s*write/);
});
@@ -401,6 +799,9 @@ test("legacy standalone local-plugin publisher requires an extra non-dry-run con
assert.match(workflow, /guard-legacy-publish:/);
assert.match(workflow, /guard-legacy-publish:\n\s+runs-on: ubuntu-latest\n\s+timeout-minutes: 5/);
assert.match(workflow, /expected="LEGACY PUBLISH memos-local-plugin-v\$\{RELEASE_VERSION\}"/);
+ assert.match(workflow, /npm run release:validate/);
+ assert.doesNotMatch(workflow, /--label "npm run lint" -- npm run lint/);
+ assert.doesNotMatch(workflow, /--label "npm test" -- npm test/);
assert.match(workflow, /standalone local-plugin npm publisher for beta or latest package releases/);
assert.match(workflow, /MemOS Release — Publish remains the weekly whole-repo release path/);
assert.match(workflow, /needs: guard-legacy-publish/);
@@ -416,8 +817,16 @@ test("legacy standalone local-plugin publisher requires an extra non-dry-run con
assert.match(workflow, /Formal publish source .* is not in .* history/);
assert.match(workflow, /Formal publishing must use the latest release automation from/);
assert.match(workflow, /Select \$\{DEFAULT_BRANCH\} in Run workflow and retry/);
+ assert.match(workflow, /PUBLISH_PHASE: \$\{\{ inputs\.publish_phase \|\| 'full' \}\}/);
+ assert.match(workflow, /MEMOS_RELEASE_TAG: \$\{\{ inputs\.memos_release_tag \}\}/);
+ assert.match(workflow, /paired weekly release may stage the local-plugin tag on a direct/);
+ assert.match(workflow, /must equal \$\{MEMOS_RELEASE_TAG\} .* or be its direct single-parent metadata child/);
+ assert.match(workflow, /apps\/memos-local-plugin\/package\.json\|/);
+ assert.match(workflow, /apps\/memos-local-plugin\/package-lock\.json\|/);
+ assert.match(workflow, /apps\/memos-local-plugin\/adapters\/hermes\/plugin\.yaml\)/);
+ assert.match(workflow, /Paired local-plugin metadata commit changes forbidden file/);
assert.match(workflow, /Validate npm authentication before platform builds/);
- assert.match(workflow, /if: \$\{\{ inputs\.dry_run != true \}\}/);
+ assert.match(workflow, /inputs\.dry_run != true && \(inputs\.publish_phase \|\| 'full'\) != 'stage_release'/);
assert.match(workflow, /npm whoami/);
assert.match(workflow, /NPM_TOKEN authentication failed; stopping before platform builds/);
assert.match(workflow, /cp -R \.release-workflow\/\.github\/scripts \.github\/scripts/);
@@ -427,6 +836,8 @@ test("legacy standalone local-plugin publisher requires an extra non-dry-run con
assert.match(workflow, /inspect-local-plugin-release-state\.mjs/);
assert.match(workflow, /EXPECTED_PACKAGE_SOURCE_SHA/);
assert.match(workflow, /RELEASE_METADATA_STATE/);
+ assert.match(workflow, /Require staged local-plugin metadata before npm-only publish/);
+ assert.match(workflow, /ALLOW_STAGED_TAG_BEFORE_NPM/);
assert.match(workflow, /audit-local-plugin-package\.mjs/);
assert.match(workflow, /wait-for-local-plugin-npm-release\.test\.mjs/);
assert.match(workflow, /NPM_VISIBILITY_TIMEOUT_SECONDS: "150"/);
@@ -494,6 +905,9 @@ test("read-only dry-run workflows declare bounded fallback behavior", () => {
assert.match(workflow, /ALLOW_OFFLINE_DOCS_PREVIEW: true/);
assert.match(workflow, /offline_docs_preview: true/);
assert.match(workflow, /production publish does not set ALLOW_OFFLINE_DOCS_PREVIEW/);
+ assert.match(workflow, /TARGET_REF: \$\{\{ github\.sha \}\}/);
+ assert.match(workflow, /\.github\/workflows\/memos-release-publish\.yml/);
+ assert.doesNotMatch(workflow, /TARGET_REF: origin\/main|TARGET_REF: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
}
});
@@ -524,6 +938,48 @@ test("allows flexible target refs only for dry runs", () => {
assert.throws(() => validateReleaseTarget({ dryRun: "false", targetRef: "feature/test" }), /exactly main/);
});
+test("validates both MemOS package versions at the exact release target", () => {
+ withFixtureRepo(() => {
+ writeRepoFile("pyproject.toml", `[project]\nname = "MemoryOS"\nversion = "9.9.1"\n`);
+ writeRepoFile("src/memos/__init__.py", `__version__ = "9.9.1"\n`);
+ commitAll("chore: prepare package version");
+ const target = git(["rev-parse", "HEAD"]).trim();
+
+ assert.equal(assertMemOSVersionAtRef("9.9.1", target).version, "9.9.1");
+ assert.throws(() => assertMemOSVersionAtRef("9.9.2", target), /expected 9\.9\.2/);
+
+ writeRepoFile("src/memos/__init__.py", `__version__ = "9.9.0"\n`);
+ commitAll("test: create mismatched package version");
+ const mismatchedTarget = git(["rev-parse", "HEAD"]).trim();
+ assert.throws(() => assertMemOSVersionAtRef("9.9.1", mismatchedTarget), /do not match/);
+ });
+});
+
+test("local dry runs prefer the fetched origin branch over a stale local branch", () => {
+ const root = mkdtempSync(join(tmpdir(), "memos-resolve-ref-"));
+ execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "ignore" });
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root });
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: root });
+ writeFileSync(join(root, "state.txt"), "old\n");
+ execFileSync("git", ["add", "state.txt"], { cwd: root });
+ execFileSync("git", ["commit", "-m", "old"], { cwd: root, stdio: "ignore" });
+ const localSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
+ writeFileSync(join(root, "state.txt"), "new\n");
+ execFileSync("git", ["commit", "-am", "new"], { cwd: root, stdio: "ignore" });
+ const remoteSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
+ execFileSync("git", ["update-ref", "refs/remotes/origin/main", remoteSha], { cwd: root });
+ execFileSync("git", ["reset", "--hard", localSha], { cwd: root, stdio: "ignore" });
+
+ const originalCwd = process.cwd();
+ try {
+ process.chdir(root);
+ assert.deepEqual(resolveRef("main"), { ref: "origin/main", sha: remoteSha });
+ assert.deepEqual(resolveRef(localSha), { ref: localSha, sha: localSha });
+ } finally {
+ process.chdir(originalCwd);
+ }
+});
+
test("reports absent, matching, and conflicting manual release tags", () => {
withFixtureRepo(() => {
const firstTarget = git(["rev-parse", "HEAD"]).trim();
@@ -623,7 +1079,7 @@ test("filters mixed MemOS release evidence down to local-plugin paths", () => {
test("filters standalone local-plugin release metadata from docs evidence", () => {
withFixtureRepo(() => {
- writeRepoFile("apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts", "export const v7Defaults = true;\n");
+ writeRepoFile("apps/memos-local-plugin/src/session-defaults.ts", "export const v7Defaults = true;\n");
commitAll("fix(plugin): preserve V7 session defaults (#11)");
writeRepoFile(
@@ -1064,7 +1520,7 @@ test("allows the draft service one initial response plus three repair attempts",
}
});
-test("real weekly release skips Doc Agent drafting when local_plugin_version is blank", async () => {
+test("real weekly release skips Doc Agent drafting when local-plugin publishing is not requested", async () => {
const originalFetch = globalThis.fetch;
let callCount = 0;
try {
@@ -1082,7 +1538,7 @@ test("real weekly release skips Doc Agent drafting when local_plugin_version is
assert.equal(callCount, 0);
assert.equal(draft.ok, true);
assert.deepEqual(draft.release_items, []);
- assert.match(draft.warnings[0], /left local_plugin_version blank/);
+ assert.match(draft.warnings[0], /did not request a local-plugin release/);
const validation = validateDraft(draft, {
...evidence,
dry_run: false,
@@ -1092,6 +1548,7 @@ test("real weekly release skips Doc Agent drafting when local_plugin_version is
});
assert.equal(validation.ok, true);
assert.equal(validation.skipped_by_operator, true);
+ assert.equal(validation.local_plugin_release_not_requested, true);
assert.equal(validation.coverage.required_count, 0);
} finally {
globalThis.fetch = originalFetch;
@@ -1210,7 +1667,7 @@ test("skips Plugin tab docs when local-plugin changes are maintenance-only", ()
assert.equal(result.has_product_changes, true);
assert.equal(result.has_user_facing_product_changes, false);
- assert.match(result.skip_reason, /no user-facing/);
+ assert.match(result.skip_reason, /only tests\/docs\/package metadata\/release files changed/);
assert.deepEqual(result.important_commits, []);
const emptyDraft = { ok: true, needs_review: false, release_items: [] };
@@ -1221,6 +1678,127 @@ test("skips Plugin tab docs when local-plugin changes are maintenance-only", ()
assert.equal(preview.docs_action, "skip_plugin_tab_entry");
assert.deepEqual(preview.cn.products.plugin, {});
assert.deepEqual(preview.en.products.plugin, {});
- assert.match(docsPreviewMarkdown(preview, emptyDraft, result), /no user-facing/);
+ assert.match(docsPreviewMarkdown(preview, emptyDraft, result), /only tests\/docs\/package metadata\/release files changed/);
+ });
+});
+
+test("skips feat-labeled commits when the final diff only changes local-plugin tests", () => {
+ withFixtureRepo(() => {
+ writeRepoFile("apps/memos-local-plugin/tests/unit/new-capability.test.ts", "export const exercisesNewCapability = true;\n");
+ commitAll("feat(plugin): cover the new capability contract (#10)");
+
+ const result = collectLocalPluginEvidence({
+ previousTag: "v9.9.0",
+ currentTag: "v9.9.1",
+ currentRef: "HEAD",
+ targetVersion: "9.9.1",
+ repo: "MemTensor/MemOS",
+ });
+
+ assert.equal(result.has_product_changes, true);
+ assert.equal(result.has_user_facing_product_file_changes, false);
+ assert.equal(result.has_user_facing_product_changes, false);
+ assert.match(result.skip_reason, /only tests\/docs\/package metadata\/release files changed/);
+ assert.deepEqual(result.important_commits, []);
+ });
+});
+
+test("does not combine a test-only feat with unrelated runtime maintenance into a release", () => {
+ withFixtureRepo(() => {
+ writeRepoFile(
+ "apps/memos-local-plugin/tests/unit/provider-routing.test.ts",
+ "export const coversProviderRouting = true;\n",
+ );
+ commitAll("feat(plugin): cover provider routing behavior (#10)");
+ writeRepoFile("apps/memos-local-plugin/src/internal-layout.js", "export const layout = 'reorganized';\n");
+ commitAll("chore(plugin): reorganize internal runtime modules (#11)");
+
+ const result = collectLocalPluginEvidence({
+ previousTag: "v9.9.0",
+ currentTag: "v9.9.1",
+ currentRef: "HEAD",
+ targetVersion: "9.9.1",
+ repo: "MemTensor/MemOS",
+ });
+
+ assert.equal(result.has_product_changes, true);
+ assert.equal(result.has_user_facing_product_file_changes, true);
+ assert.equal(result.has_user_facing_product_changes, false);
+ assert.match(result.skip_reason, /no user-facing/);
+ assert.deepEqual(result.important_commits, []);
+ assert.equal(
+ result.commits.find((commit) => commit.subject.includes("provider routing"))?.has_user_facing_file_changes,
+ false,
+ );
+ });
+});
+
+test("skips local-plugin package metadata only changes even when package version changes", () => {
+ withFixtureRepo(() => {
+ writeRepoFile(
+ "apps/memos-local-plugin/package.json",
+ `${JSON.stringify({ name: "@memtensor/memos-local-plugin", version: "9.9.1" }, null, 2)}\n`,
+ );
+ commitAll("chore(plugin): bump package metadata (#10)");
+
+ const result = collectLocalPluginEvidence({
+ previousTag: "v9.9.0",
+ currentTag: "v9.9.1",
+ currentRef: "HEAD",
+ targetVersion: "9.9.1",
+ repo: "MemTensor/MemOS",
+ });
+
+ assert.equal(result.has_product_changes, true);
+ assert.equal(result.has_user_facing_product_file_changes, false);
+ assert.equal(result.has_user_facing_product_changes, false);
+ assert.match(result.skip_reason, /only tests\/docs\/package metadata\/release files changed/);
+ assert.deepEqual(result.important_commits, []);
+ });
+});
+
+test("skips broad build and refactor noise inside local-plugin runtime paths", () => {
+ withFixtureRepo(() => {
+ writeRepoFile("apps/memos-local-plugin/src/build-info.js", "export const buildInfo = 'metadata';\n");
+ commitAll("build(plugin): update local plugin packaging helpers (#10)");
+ writeRepoFile("apps/memos-local-plugin/src/internal-layout.js", "export const layout = 'reorganized';\n");
+ commitAll("refactor(plugin): reorganize local plugin internals (#11)");
+
+ const result = collectLocalPluginEvidence({
+ previousTag: "v9.9.0",
+ currentTag: "v9.9.1",
+ currentRef: "HEAD",
+ targetVersion: "9.9.1",
+ repo: "MemTensor/MemOS",
+ });
+
+ assert.equal(result.has_product_changes, true);
+ assert.equal(result.has_user_facing_product_file_changes, true);
+ assert.equal(result.has_user_facing_product_changes, false);
+ assert.match(result.skip_reason, /no user-facing/);
+ assert.deepEqual(result.important_commits, []);
+ });
+});
+
+test("keeps refactors that state a concrete local-plugin user impact", () => {
+ withFixtureRepo(() => {
+ writeRepoFile("apps/memos-local-plugin/src/hermes-bridge.js", "export const bridge = 'shared runtime';\n");
+ commitAll("refactor(plugin): reduce Hermes bridge restarts during provider switching (#10)");
+
+ const result = collectLocalPluginEvidence({
+ previousTag: "v9.9.0",
+ currentTag: "v9.9.1",
+ currentRef: "HEAD",
+ targetVersion: "9.9.1",
+ repo: "MemTensor/MemOS",
+ });
+
+ assert.equal(result.has_product_changes, true);
+ assert.equal(result.has_user_facing_product_file_changes, true);
+ assert.equal(result.has_user_facing_product_changes, true);
+ assert.deepEqual(
+ result.important_commits.map((commit) => commit.subject),
+ ["refactor(plugin): reduce Hermes bridge restarts during provider switching (#10)"],
+ );
});
});
diff --git a/.github/scripts/publish-local-plugin.sh b/.github/scripts/publish-local-plugin.sh
index 13ca15bdb..034a00d8c 100755
--- a/.github/scripts/publish-local-plugin.sh
+++ b/.github/scripts/publish-local-plugin.sh
@@ -18,6 +18,7 @@ npm_visibility_interval_seconds="${NPM_VISIBILITY_INTERVAL_SECONDS:-10}"
npm_visibility_request_timeout_seconds="${NPM_VISIBILITY_REQUEST_TIMEOUT_SECONDS:-8}"
npm_registry_url="https://registry.npmjs.org"
release_metadata_state="${RELEASE_METADATA_STATE:-fresh}"
+allow_staged_tag_before_npm="${ALLOW_STAGED_TAG_BEFORE_NPM:-false}"
validate_positive_integer() {
local name="$1"
@@ -40,6 +41,14 @@ case "${release_metadata_state}" in
;;
esac
+case "${allow_staged_tag_before_npm}" in
+ true|false) ;;
+ *)
+ echo "::error::ALLOW_STAGED_TAG_BEFORE_NPM must be true or false; received ${allow_staged_tag_before_npm}."
+ exit 2
+ ;;
+esac
+
script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
npm_view_log="${RUNNER_TEMP}/memos-local-plugin-npm-view.log"
@@ -109,6 +118,123 @@ npm_dist_tag_matches() {
' "${output_file}" "${NPM_DIST_TAG}" "${RELEASE_VERSION}"
}
+read_current_npm_dist_tag() {
+ local output_file="${RUNNER_TEMP}/memos-local-plugin-npm-dist-tags-preflight.json"
+ local attempt
+ local status
+
+ for attempt in 1 2 3; do
+ set +e
+ npm view "${PACKAGE_NAME}" dist-tags \
+ --json \
+ --prefer-online \
+ --fetch-retries=0 \
+ --fetch-timeout=8000 \
+ --registry="${npm_registry_url}" \
+ >"${output_file}" 2>&1
+ status=$?
+ set -e
+ if [ "${status}" = 0 ]; then
+ node -e '
+ const fs = require("node:fs");
+ const tags = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
+ const value = tags[process.argv[2]];
+ if (value !== undefined && typeof value !== "string") {
+ throw new Error(`npm dist-tag ${process.argv[2]} is not a string`);
+ }
+ process.stdout.write(value || "");
+ ' "${output_file}" "${NPM_DIST_TAG}"
+ return 0
+ fi
+ if grep -Eiq "E404|404 Not Found|is not in this registry" "${output_file}"; then
+ return 0
+ fi
+ sed -n '1,120p' "${output_file}" >&2
+ if [ "${attempt}" = 3 ]; then
+ echo "::error::Failed to inspect npm dist-tag ${NPM_DIST_TAG} after three attempts; refusing to publish without a channel monotonicity check." >&2
+ exit "${status}"
+ fi
+ sleep "$((attempt * 5))"
+ done
+}
+
+ensure_npm_dist_tag_will_not_regress() {
+ local current_version
+ local comparison
+ local comparison_status
+
+ current_version="$(read_current_npm_dist_tag)"
+ if [ -z "${current_version}" ]; then
+ echo "npm dist-tag ${NPM_DIST_TAG} is not set; the new release may initialize it."
+ return 0
+ fi
+
+ set +e
+ comparison="$(node -e '
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
+ function parse(value) {
+ const match = SEMVER.exec(value);
+ if (!match) throw new Error(`invalid SemVer: ${value}`);
+ return {
+ core: match.slice(1, 4).map(Number),
+ pre: match[4] === undefined ? null : match[4].split("."),
+ };
+ }
+ function compareIdentifier(left, right) {
+ const leftNumeric = /^\d+$/.test(left);
+ const rightNumeric = /^\d+$/.test(right);
+ if (leftNumeric && rightNumeric) return Number(left) - Number(right);
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
+ return left === right ? 0 : left < right ? -1 : 1;
+ }
+ function compare(leftValue, rightValue) {
+ const left = parse(leftValue);
+ const right = parse(rightValue);
+ for (let index = 0; index < 3; index += 1) {
+ if (left.core[index] !== right.core[index]) return left.core[index] - right.core[index];
+ }
+ if (left.pre === null || right.pre === null) {
+ if (left.pre === right.pre) return 0;
+ return left.pre === null ? 1 : -1;
+ }
+ const length = Math.max(left.pre.length, right.pre.length);
+ for (let index = 0; index < length; index += 1) {
+ if (left.pre[index] === undefined) return -1;
+ if (right.pre[index] === undefined) return 1;
+ const result = compareIdentifier(left.pre[index], right.pre[index]);
+ if (result !== 0) return result;
+ }
+ return 0;
+ }
+ process.stdout.write(String(Math.sign(compare(process.argv[1], process.argv[2]))));
+ ' "${current_version}" "${RELEASE_VERSION}" 2>&1)"
+ comparison_status=$?
+ set -e
+ if [ "${comparison_status}" != 0 ]; then
+ echo "${comparison}" >&2
+ echo "::error::Could not compare npm dist-tag ${NPM_DIST_TAG} value ${current_version} with ${RELEASE_VERSION}; refusing to publish."
+ exit 1
+ fi
+
+ case "${comparison}" in
+ 1)
+ echo "::error::Refusing to move npm dist-tag ${NPM_DIST_TAG} backwards from ${current_version} to ${RELEASE_VERSION}. Another release has already advanced this channel."
+ exit 1
+ ;;
+ 0)
+ echo "::error::npm dist-tag ${NPM_DIST_TAG} already points to ${RELEASE_VERSION}, but npm reports that version as absent. Refusing to publish against inconsistent registry metadata."
+ exit 1
+ ;;
+ -1)
+ echo "npm dist-tag ${NPM_DIST_TAG} currently points to ${current_version}; advancing it to ${RELEASE_VERSION} is allowed."
+ ;;
+ *)
+ echo "::error::Unexpected SemVer comparison result: ${comparison}."
+ exit 1
+ ;;
+ esac
+}
+
remote_tag_exists() {
local release_tag="$1"
local attempt
@@ -220,10 +346,15 @@ if npm_version_exists; then
fi
else
if [ "${release_metadata_state}" != "fresh" ]; then
- echo "::error::Tag state is ${release_metadata_state}, but ${PACKAGE_NAME}@${RELEASE_VERSION} is absent from npm. Refusing to publish after tag metadata already exists."
- exit 1
+ if [ "${allow_staged_tag_before_npm}" != "true" ]; then
+ echo "::error::Tag state is ${release_metadata_state}, but ${PACKAGE_NAME}@${RELEASE_VERSION} is absent from npm. Refusing to publish after tag metadata already exists."
+ exit 1
+ fi
+ echo "::notice::Tag state is ${release_metadata_state}; publishing npm after a staged paired local-plugin Draft Release."
fi
+ ensure_npm_dist_tag_will_not_regress
+
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "::error::NPM_TOKEN is missing; refusing a real npm publish."
exit 1
diff --git a/.github/scripts/publish-local-plugin.test.mjs b/.github/scripts/publish-local-plugin.test.mjs
index d6c79ad58..2e7864545 100644
--- a/.github/scripts/publish-local-plugin.test.mjs
+++ b/.github/scripts/publish-local-plugin.test.mjs
@@ -28,7 +28,19 @@ case "\${1:-}" in
view)
if [ "\${3:-}" = "dist-tags" ]; then
increment_counter dist_tag >/dev/null
- printf '{"%s":"%s"}\n' "\${NPM_DIST_TAG}" "\${NPM_MOCK_DIST_TAG_VERSION:-\${RELEASE_VERSION}}"
+ if [ "\${NPM_MOCK_SCENARIO}" = "dist-tag-lookup-fails" ]; then
+ echo "npm error code E500" >&2
+ exit 1
+ fi
+ if [ "\${NPM_MOCK_DIST_TAGS_EMPTY:-false}" = "true" ]; then
+ printf '{}\n'
+ exit 0
+ elif [ "\${NPM_MOCK_SCENARIO}" = "already-visible" ]; then
+ dist_tag_version="\${NPM_MOCK_DIST_TAG_VERSION:-\${RELEASE_VERSION}}"
+ else
+ dist_tag_version="\${NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION:-2.0.11}"
+ fi
+ printf '{"%s":"%s"}\n' "\${NPM_DIST_TAG}" "\${dist_tag_version}"
exit 0
fi
view_count="$(increment_counter view)"
@@ -138,6 +150,10 @@ echo "Unexpected git command: $*" >&2
exit 2
`;
+const mockSleep = `#!/usr/bin/env bash
+exit 0
+`;
+
function readCounter(stateDirectory, name) {
try {
return Number(readFileSync(join(stateDirectory, name), "utf8"));
@@ -156,6 +172,7 @@ function runScenario(scenario, overrides = {}) {
const npmPath = join(binDirectory, "npm");
const nodePath = join(binDirectory, "node");
const gitPath = join(binDirectory, "git");
+ const sleepPath = join(binDirectory, "sleep");
const releaseTarball = join(fixtureDirectory, "release.tgz");
writeFileSync(npmPath, mockNpm, "utf8");
chmodSync(npmPath, 0o755);
@@ -163,6 +180,8 @@ function runScenario(scenario, overrides = {}) {
chmodSync(nodePath, 0o755);
writeFileSync(gitPath, mockGit, "utf8");
chmodSync(gitPath, 0o755);
+ writeFileSync(sleepPath, mockSleep, "utf8");
+ chmodSync(sleepPath, 0o755);
const localPackRoot = join(fixtureDirectory, "local-pack-root");
mkdirSync(join(localPackRoot, "package", "adapters", "hermes"), { recursive: true });
writeFileSync(
@@ -211,6 +230,7 @@ function runScenario(scenario, overrides = {}) {
whoamiCount: readCounter(stateDirectory, "whoami"),
packCount: readCounter(stateDirectory, "pack"),
metadataWaitCount: readCounter(stateDirectory, "metadata_wait"),
+ distTagCount: readCounter(stateDirectory, "dist_tag"),
publishedArgument: (() => {
try {
return readFileSync(join(stateDirectory, "published-argument"), "utf8");
@@ -244,6 +264,64 @@ test("publishes once and continues only after bounded registry verification", ()
assert.match(result.stdout, /bounded registry visibility check both succeeded/);
});
+test("fails before authentication when a newer release already owns the npm channel", () => {
+ const result = runScenario("eventually-visible", {
+ NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION: "2.0.13",
+ });
+
+ assert.notEqual(result.status, 0);
+ assert.equal(result.distTagCount, 1);
+ assert.equal(result.whoamiCount, 0);
+ assert.equal(result.publishCount, 0);
+ assert.equal(result.metadataWaitCount, 0);
+ assert.match(result.stdout + result.stderr, /Refusing to move npm dist-tag latest backwards from 2\.0\.13 to 2\.0\.12/);
+});
+
+test("uses SemVer precedence instead of lexical order for prerelease channels", () => {
+ const result = runScenario("eventually-visible", {
+ RELEASE_VERSION: "2.0.12-beta.9",
+ RELEASE_TAG: "memos-local-plugin-v2.0.12-beta.9",
+ NPM_DIST_TAG: "beta",
+ NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION: "2.0.12-beta.10",
+ });
+
+ assert.notEqual(result.status, 0);
+ assert.equal(result.whoamiCount, 0);
+ assert.equal(result.publishCount, 0);
+ assert.match(result.stdout + result.stderr, /Refusing to move npm dist-tag beta backwards from 2\.0\.12-beta\.10 to 2\.0\.12-beta\.9/);
+});
+
+test("fails closed when npm version and dist-tag metadata contradict each other", () => {
+ const result = runScenario("eventually-visible", {
+ NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION: "2.0.12",
+ });
+
+ assert.notEqual(result.status, 0);
+ assert.equal(result.whoamiCount, 0);
+ assert.equal(result.publishCount, 0);
+ assert.match(result.stdout + result.stderr, /reports that version as absent/);
+});
+
+test("fails before authentication when npm channel state cannot be inspected", () => {
+ const result = runScenario("dist-tag-lookup-fails");
+
+ assert.notEqual(result.status, 0);
+ assert.equal(result.distTagCount, 3);
+ assert.equal(result.whoamiCount, 0);
+ assert.equal(result.publishCount, 0);
+ assert.match(result.stdout + result.stderr, /refusing to publish without a channel monotonicity check/);
+});
+
+test("allows initializing an npm channel that does not exist yet", () => {
+ const result = runScenario("eventually-visible", {
+ NPM_MOCK_DIST_TAGS_EMPTY: "true",
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.publishCount, 1);
+ assert.match(result.stdout, /dist-tag latest is not set/);
+});
+
test("stops before tag creation when publish succeeds but visibility remains delayed", () => {
const result = runScenario("always-missing");
@@ -254,6 +332,27 @@ test("stops before tag creation when publish succeeds but visibility remains del
assert.match(result.stdout + result.stderr, /Refusing to issue a second publish request/);
});
+test("allows npm publish after a staged paired Draft Release only in npm-only phase", () => {
+ const blocked = runScenario("eventually-visible", {
+ RELEASE_METADATA_STATE: "complete",
+ });
+
+ assert.notEqual(blocked.status, 0);
+ assert.equal(blocked.publishCount, 0);
+ assert.match(blocked.stdout + blocked.stderr, /Refusing to publish after tag metadata already exists/);
+
+ const allowed = runScenario("eventually-visible", {
+ RELEASE_METADATA_STATE: "complete",
+ ALLOW_STAGED_TAG_BEFORE_NPM: "true",
+ });
+
+ assert.equal(allowed.status, 0, allowed.stderr);
+ assert.equal(allowed.publishCount, 1);
+ assert.equal(allowed.metadataWaitCount, 1);
+ assert.equal(allowed.packCount, 1);
+ assert.match(allowed.stdout, /publishing npm after a staged paired local-plugin Draft Release/);
+});
+
test("fails when publish fails and the requested version remains absent", () => {
const result = runScenario("publish-fails");
diff --git a/.github/scripts/publish-paired-local-plugin-release.mjs b/.github/scripts/publish-paired-local-plugin-release.mjs
index c6e06cf35..2a516ff79 100644
--- a/.github/scripts/publish-paired-local-plugin-release.mjs
+++ b/.github/scripts/publish-paired-local-plugin-release.mjs
@@ -39,6 +39,17 @@ function output(values) {
appendFileSync(outputFile, `${lines}\n`, "utf8");
}
+function outputForIntent(status, memosRelease, intent) {
+ output({
+ status,
+ memos_release_tag: memosRelease.tag,
+ memos_release_version: memosRelease.tag.replace(/^v/, ""),
+ local_plugin_tag: intent?.tag || "",
+ local_plugin_version: intent?.version?.replace(/^v/, "") || "",
+ local_plugin_source_sha: intent?.source_sha || "",
+ });
+}
+
function normalizeRelease(raw) {
return {
id: Number(raw?.id || 0),
@@ -155,11 +166,12 @@ function loadMemOSRelease(repo) {
export function main() {
const repo = String(process.env.GITHUB_REPOSITORY || "").trim();
if (repo !== "MemTensor/MemOS") fail(`paired publisher is restricted to MemTensor/MemOS; received ${repo || ""}`);
+ const validateOnly = String(process.env.VALIDATE_ONLY || "").trim() === "true";
const memosRelease = loadMemOSRelease(repo);
const intent = parseLocalPluginReleaseIntent(memosRelease.body);
if (!intent.enabled) {
validatePair({ memosRelease, pluginRelease: null, pluginTagSha: "" });
- output({ status: "skipped", memos_release_tag: memosRelease.tag, local_plugin_tag: "" });
+ outputForIntent("skipped", memosRelease, intent);
console.log(`MemOS Release ${memosRelease.tag} has no paired local-plugin publish; nothing to do.`);
return;
}
@@ -178,10 +190,15 @@ export function main() {
memosTagSha: String(memosTagCommit.sha || ""),
});
if (validated.alreadyPublished) {
- output({ status: "already_published", memos_release_tag: memosRelease.tag, local_plugin_tag: intent.tag });
+ outputForIntent("already_published", memosRelease, intent);
console.log(`Paired local-plugin GitHub Release ${intent.tag} is already published and matches the MemOS intent.`);
return;
}
+ if (validateOnly) {
+ outputForIntent("staged", memosRelease, intent);
+ console.log(`Paired local-plugin GitHub Release ${intent.tag} is staged and ready for npm publish.`);
+ return;
+ }
ghJson([
"api",
@@ -204,7 +221,7 @@ export function main() {
memosTagSha: String(memosTagCommit.sha || ""),
});
if (!after.alreadyPublished) fail(`paired local-plugin GitHub Release ${intent.tag} remained a Draft after publish`);
- output({ status: "published", memos_release_tag: memosRelease.tag, local_plugin_tag: intent.tag });
+ outputForIntent("published", memosRelease, intent);
console.log(`Published paired local-plugin GitHub Release ${intent.tag}; its release.published webhook is the docs trigger.`);
}
diff --git a/.github/workflows/memos-local-plugin-publish.yml b/.github/workflows/memos-local-plugin-publish.yml
index b740f894d..52d634384 100644
--- a/.github/workflows/memos-local-plugin-publish.yml
+++ b/.github/workflows/memos-local-plugin-publish.yml
@@ -79,6 +79,14 @@ on:
required: false
type: string
default: ""
+ publish_phase:
+ required: false
+ type: string
+ default: "full"
+ auto_post_merge_release:
+ required: false
+ type: boolean
+ default: false
outputs:
local_plugin_tag:
value: ${{ jobs.publish.outputs.local_plugin_tag }}
@@ -122,6 +130,8 @@ jobs:
MEMOS_RELEASE_VERSION: ${{ inputs.memos_release_version }}
CALLER_PUBLISH_CONFIRMATION: ${{ inputs.caller_publish_confirmation }}
MEMOS_RELEASE_TAG: ${{ inputs.memos_release_tag }}
+ PUBLISH_PHASE: ${{ inputs.publish_phase || 'full' }}
+ AUTO_POST_MERGE_RELEASE: ${{ inputs.auto_post_merge_release }}
run: |
set -euo pipefail
node -e '
@@ -148,6 +158,22 @@ jobs:
}
' "${RELEASE_VERSION}" "${NPM_DIST_TAG}"
+ case "${PUBLISH_PHASE}" in
+ full|stage_release|publish_npm_only) ;;
+ *)
+ echo "::error::publish_phase must be full, stage_release, or publish_npm_only; received ${PUBLISH_PHASE}."
+ exit 1
+ ;;
+ esac
+ if [ "${PUBLISH_PHASE}" != "full" ] && [ "${DOCS_SYNC_MODE}" != "paired_with_memos_release" ]; then
+ echo "::error::publish_phase=${PUBLISH_PHASE} is only valid for paired MemOS weekly releases."
+ exit 1
+ fi
+ if [ "${PUBLISH_PHASE}" = "publish_npm_only" ] && [ "${DRY_RUN}" = "true" ]; then
+ echo "::error::publish_phase=publish_npm_only requires dry_run=false."
+ exit 1
+ fi
+
if [ -n "${DOCS_SYNC_MODE}" ]; then
if [ "${DOCS_SYNC_MODE}" != "paired_with_memos_release" ]; then
echo "::error::Reusable local-plugin publishing only accepts docs_sync_mode=paired_with_memos_release."
@@ -161,7 +187,7 @@ jobs:
echo "::error::memos_release_tag must exactly equal v${MEMOS_RELEASE_VERSION}."
exit 1
fi
- if [ "${DRY_RUN}" != "true" ]; then
+ if [ "${DRY_RUN}" != "true" ] && [ "${AUTO_POST_MERGE_RELEASE}" != "true" ]; then
expected="PUBLISH v${MEMOS_RELEASE_VERSION} WITH LOCAL PLUGIN v${RELEASE_VERSION}"
if [ "${CALLER_PUBLISH_CONFIRMATION}" != "${expected}" ]; then
echo "::error::The calling MemOS release confirmation must exactly equal: ${expected}"
@@ -199,6 +225,8 @@ jobs:
DRY_RUN: ${{ inputs.dry_run }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
+ PUBLISH_PHASE: ${{ inputs.publish_phase || 'full' }}
+ MEMOS_RELEASE_TAG: ${{ inputs.memos_release_tag }}
run: |
set -euo pipefail
package_source_sha="$(git rev-parse HEAD)"
@@ -215,17 +243,66 @@ jobs:
"refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" \
"${WORKFLOW_SHA}"
default_branch_sha="$(git rev-parse "refs/remotes/origin/${DEFAULT_BRANCH}")"
- if ! git merge-base --is-ancestor "${package_source_sha}" "refs/remotes/origin/${DEFAULT_BRANCH}"; then
- echo "::error::Formal publish source ${package_source_sha} is not in ${DEFAULT_BRANCH} history. Merge it before publishing."
- exit 1
- fi
if [ "${WORKFLOW_SHA}" != "${default_branch_sha}" ]; then
echo "::error::Formal publishing must use the latest release automation from ${DEFAULT_BRANCH} (${default_branch_sha}); this run uses ${WORKFLOW_SHA}. Select ${DEFAULT_BRANCH} in Run workflow and retry."
exit 1
fi
+ source_is_allowed=false
+ if git merge-base --is-ancestor "${package_source_sha}" "refs/remotes/origin/${DEFAULT_BRANCH}"; then
+ source_is_allowed=true
+ elif [ "${PUBLISH_PHASE}" = "publish_npm_only" ] && [ -n "${MEMOS_RELEASE_TAG}" ]; then
+ # A paired weekly release may stage the local-plugin tag on a direct
+ # metadata-only child of the MemOS Release commit. The automation
+ # still comes from current main; only the package source is allowed
+ # to use this narrowly validated child commit.
+ paired_source_ref="refs/memos-paired-source/${MEMOS_RELEASE_TAG}"
+ git fetch --force --no-tags origin \
+ "refs/tags/${MEMOS_RELEASE_TAG}:${paired_source_ref}"
+ memos_release_sha="$(git rev-parse "${paired_source_ref}^{commit}")"
+
+ if [ "${package_source_sha}" = "${memos_release_sha}" ]; then
+ source_is_allowed=true
+ else
+ commit_line="$(git rev-list --parents -n 1 "${package_source_sha}")"
+ read -r commit_sha parent_sha extra_parent <<< "${commit_line}"
+ if [ "${commit_sha}" != "${package_source_sha}" ] || \
+ [ -z "${parent_sha:-}" ] || \
+ [ -n "${extra_parent:-}" ] || \
+ [ "${parent_sha}" != "${memos_release_sha}" ]; then
+ echo "::error::Paired local-plugin source ${package_source_sha} must equal ${MEMOS_RELEASE_TAG} (${memos_release_sha}) or be its direct single-parent metadata child."
+ exit 1
+ fi
+
+ unexpected_file=""
+ while IFS= read -r changed_file; do
+ case "${changed_file}" in
+ apps/memos-local-plugin/package.json|\
+ apps/memos-local-plugin/package-lock.json|\
+ apps/memos-local-plugin/adapters/hermes/plugin.yaml)
+ ;;
+ *)
+ unexpected_file="${changed_file}"
+ break
+ ;;
+ esac
+ done < <(git diff-tree --no-commit-id --name-only -r "${package_source_sha}")
+
+ if [ -n "${unexpected_file}" ]; then
+ echo "::error::Paired local-plugin metadata commit changes forbidden file: ${unexpected_file}"
+ exit 1
+ fi
+ source_is_allowed=true
+ fi
+ fi
+
+ if [ "${source_is_allowed}" != "true" ]; then
+ echo "::error::Formal publish source ${package_source_sha} is not in ${DEFAULT_BRANCH} history and is not a valid paired local-plugin metadata source. Merge it before publishing."
+ exit 1
+ fi
+
- name: Configure npm authentication preflight
- if: ${{ inputs.dry_run != true }}
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') != 'stage_release' }}
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0
with:
node-version: 22
@@ -233,7 +310,7 @@ jobs:
package-manager-cache: false
- name: Validate npm authentication before platform builds
- if: ${{ inputs.dry_run != true }}
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') != 'stage_release' }}
shell: bash
working-directory: .
env:
@@ -427,7 +504,7 @@ jobs:
'
- name: Generate telemetry credentials for a real publish
- if: ${{ inputs.dry_run != true }}
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') != 'stage_release' }}
run: bash ../../.github/scripts/retry.sh --label "generate telemetry credentials" -- node scripts/generate-telemetry-credentials.cjs
env:
MEMOS_ARMS_ENDPOINT: ${{ secrets.MEMOS_ARMS_ENDPOINT }}
@@ -451,8 +528,7 @@ jobs:
RELEASE_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
- bash ../../.github/scripts/retry.sh --attempts 2 --label "npm run lint" -- npm run lint
- bash ../../.github/scripts/retry.sh --attempts 2 --label "npm test" -- npm test
+ bash ../../.github/scripts/retry.sh --attempts 2 --label "npm run release:validate" -- npm run release:validate
release_dir="${RUNNER_TEMP}/memos-local-plugin-release"
pack_json="${RUNNER_TEMP}/memos-local-plugin-pack.json"
mkdir -p "${release_dir}"
@@ -702,8 +778,19 @@ jobs:
EXPECTED_PACKAGE_SOURCE_SHA: ${{ needs.guard-legacy-publish.outputs.package_source_sha }}
run: node .github/scripts/inspect-local-plugin-release-state.mjs
+ - name: Require staged local-plugin metadata before npm-only publish
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') == 'publish_npm_only' }}
+ env:
+ RELEASE_METADATA_STATE: ${{ steps.release_state.outputs.state }}
+ run: |
+ set -euo pipefail
+ if [ "${RELEASE_METADATA_STATE}" != "complete" ]; then
+ echo "::error::publish_phase=publish_npm_only requires an existing, validated memos-local-plugin tag created by the staged paired Release step."
+ exit 1
+ fi
+
- name: Publish to npm
- if: ${{ inputs.dry_run != true }}
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') != 'stage_release' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE_NAME: "@memtensor/memos-local-plugin"
@@ -715,11 +802,12 @@ jobs:
NPM_VISIBILITY_TIMEOUT_SECONDS: "150"
NPM_VISIBILITY_INTERVAL_SECONDS: "10"
NPM_VISIBILITY_REQUEST_TIMEOUT_SECONDS: "8"
+ ALLOW_STAGED_TAG_BEFORE_NPM: ${{ (inputs.publish_phase || 'full') == 'publish_npm_only' && 'true' || 'false' }}
run: bash ../../.github/scripts/publish-local-plugin.sh
- name: Create standalone package tag
id: release_tag
- if: ${{ inputs.dry_run != true }}
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') != 'publish_npm_only' }}
working-directory: .
env:
GH_TOKEN: ${{ github.token }}
@@ -785,7 +873,7 @@ jobs:
- name: Create and verify independent local-plugin GitHub Release
id: plugin_release
- if: ${{ inputs.dry_run != true }}
+ if: ${{ inputs.dry_run != true && (inputs.publish_phase || 'full') != 'publish_npm_only' }}
working-directory: .
env:
GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/memos-release-post-merge-dry-run.yml b/.github/workflows/memos-release-post-merge-dry-run.yml
index 9042c40f7..01c8be568 100644
--- a/.github/workflows/memos-release-post-merge-dry-run.yml
+++ b/.github/workflows/memos-release-post-merge-dry-run.yml
@@ -6,11 +6,17 @@ on:
- main
paths:
- ".github/workflows/memos-release-publish.yml"
+ - ".github/workflows/memos-release-publish-main.yml"
+ - ".github/workflows/memos-release-prepare.yml"
- ".github/workflows/memos-release-pre-merge-dry-run.yml"
- ".github/workflows/memos-release-post-merge-dry-run.yml"
+ - ".github/scripts/memos-version.mjs"
+ - ".github/scripts/memos-version.test.mjs"
- ".github/scripts/prepare-memos-release.mjs"
- ".github/scripts/prepare-memos-release.test.mjs"
- ".github/scripts/retry.sh"
+ - "pyproject.toml"
+ - "src/memos/__init__.py"
concurrency:
group: memos-release-post-merge-dry-run-main
@@ -40,11 +46,19 @@ jobs:
git fetch --tags --force origin
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
- - name: Infer next stable release version for preview
+ - name: Read stable package version for preview
id: version
shell: bash
run: |
set -euo pipefail
+ version_output="${RUNNER_TEMP}/memos-package-version.out"
+ : > "${version_output}"
+ GITHUB_OUTPUT="${version_output}" node .github/scripts/memos-version.mjs inspect
+ version="$(sed -n 's/^version=//p' "${version_output}")"
+ if [ -z "${version}" ]; then
+ echo "::error::Cannot read the stable MemOS package version."
+ exit 1
+ fi
latest="$(
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --merged origin/main --sort=-v:refname |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
@@ -54,22 +68,19 @@ jobs:
echo "::error::Cannot infer the latest MemOS stable tag from origin/main."
exit 1
fi
- version="${latest#v}"
- IFS=. read -r major minor patch <<< "${version}"
- next_version="${major}.${minor}.$((patch + 1))"
- echo "version=${next_version}" >> "${GITHUB_OUTPUT}"
+ echo "version=${version}" >> "${GITHUB_OUTPUT}"
echo "latest_tag=${latest}" >> "${GITHUB_OUTPUT}"
- name: Run release workflow tests
shell: bash
- run: node --test .github/scripts/prepare-memos-release.test.mjs
+ run: node --test .github/scripts/memos-version.test.mjs .github/scripts/prepare-memos-release.test.mjs
- name: Prepare offline release inspection
id: prepare
shell: bash
env:
RELEASE_VERSION: ${{ steps.version.outputs.version }}
- TARGET_REF: origin/main
+ TARGET_REF: ${{ github.sha }}
DRY_RUN: true
GITHUB_TOKEN: ${{ github.token }}
# Post-merge smoke check fallback. Manual publish still requires GitHub/Doc Agent validation.
diff --git a/.github/workflows/memos-release-pre-merge-dry-run.yml b/.github/workflows/memos-release-pre-merge-dry-run.yml
index 57227c021..521bcb7c2 100644
--- a/.github/workflows/memos-release-pre-merge-dry-run.yml
+++ b/.github/workflows/memos-release-pre-merge-dry-run.yml
@@ -4,11 +4,17 @@ on:
pull_request:
paths:
- ".github/workflows/memos-release-publish.yml"
+ - ".github/workflows/memos-release-publish-main.yml"
+ - ".github/workflows/memos-release-prepare.yml"
- ".github/workflows/memos-release-pre-merge-dry-run.yml"
- ".github/workflows/memos-release-post-merge-dry-run.yml"
+ - ".github/scripts/memos-version.mjs"
+ - ".github/scripts/memos-version.test.mjs"
- ".github/scripts/prepare-memos-release.mjs"
- ".github/scripts/prepare-memos-release.test.mjs"
- ".github/scripts/retry.sh"
+ - "pyproject.toml"
+ - "src/memos/__init__.py"
concurrency:
group: memos-release-pre-merge-dry-run-${{ github.event.pull_request.number || github.ref }}
@@ -38,11 +44,19 @@ jobs:
git fetch --tags --force origin
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
- - name: Infer next stable release version for preview
+ - name: Read stable package version for preview
id: version
shell: bash
run: |
set -euo pipefail
+ version_output="${RUNNER_TEMP}/memos-package-version.out"
+ : > "${version_output}"
+ GITHUB_OUTPUT="${version_output}" node .github/scripts/memos-version.mjs inspect
+ version="$(sed -n 's/^version=//p' "${version_output}")"
+ if [ -z "${version}" ]; then
+ echo "::error::Cannot read the stable MemOS package version."
+ exit 1
+ fi
latest="$(
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --merged origin/main --sort=-v:refname |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
@@ -52,22 +66,19 @@ jobs:
echo "::error::Cannot infer the latest MemOS stable tag from origin/main."
exit 1
fi
- version="${latest#v}"
- IFS=. read -r major minor patch <<< "${version}"
- next_version="${major}.${minor}.$((patch + 1))"
- echo "version=${next_version}" >> "${GITHUB_OUTPUT}"
+ echo "version=${version}" >> "${GITHUB_OUTPUT}"
echo "latest_tag=${latest}" >> "${GITHUB_OUTPUT}"
- name: Run release workflow tests
shell: bash
- run: node --test .github/scripts/prepare-memos-release.test.mjs
+ run: node --test .github/scripts/memos-version.test.mjs .github/scripts/prepare-memos-release.test.mjs
- name: Prepare offline release inspection
id: prepare
shell: bash
env:
RELEASE_VERSION: ${{ steps.version.outputs.version }}
- TARGET_REF: origin/main
+ TARGET_REF: ${{ github.sha }}
DRY_RUN: true
GITHUB_TOKEN: ${{ github.token }}
# PR-only local fallback. Production publish requires GitHub/Doc Agent validation.
diff --git a/.github/workflows/memos-release-prepare.yml b/.github/workflows/memos-release-prepare.yml
new file mode 100644
index 000000000..380e90e5e
--- /dev/null
+++ b/.github/workflows/memos-release-prepare.yml
@@ -0,0 +1,366 @@
+name: MemOS Release — Prepare
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Stable MemOS version without leading v (for example 2.0.30)"
+ required: true
+ type: string
+
+concurrency:
+ group: memos-release-prepare
+ cancel-in-progress: false
+
+permissions:
+ contents: write
+ pull-requests: read
+
+jobs:
+ prepare:
+ if: ${{ github.repository == 'MemTensor/MemOS' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Require the default-branch workflow
+ shell: bash
+ env:
+ SELECTED_REF: ${{ github.ref }}
+ SELECTED_REF_TYPE: ${{ github.ref_type }}
+ SELECTED_BRANCH: ${{ github.ref_name }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ expected_ref="refs/heads/${DEFAULT_BRANCH}"
+ if [ "${SELECTED_REF_TYPE}" != branch ] || [ "${SELECTED_REF}" != "${expected_ref}" ] || [ "${SELECTED_BRANCH}" != "${DEFAULT_BRANCH}" ]; then
+ echo "::error::Use workflow from must be branch ${expected_ref}; received ${SELECTED_REF}."
+ exit 1
+ fi
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0
+ with:
+ node-version: 22
+
+ - name: Test version automation
+ run: node --test .github/scripts/memos-version.test.mjs
+
+ - name: Preserve trusted version automation
+ shell: bash
+ run: cp .github/scripts/memos-version.mjs "${RUNNER_TEMP}/memos-version.mjs"
+
+ - name: Validate release request and fetch source
+ id: request
+ shell: bash
+ env:
+ RELEASE_VERSION: ${{ inputs.version }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ output_file="${RUNNER_TEMP}/memos-version-request.out"
+ : > "${output_file}"
+ RELEASE_VERSION="${RELEASE_VERSION}" GITHUB_OUTPUT="${output_file}" \
+ node "${RUNNER_TEMP}/memos-version.mjs" plan
+
+ version="$(sed -n 's/^version=//p' "${output_file}")"
+ source_branch="$(sed -n 's/^source_branch=//p' "${output_file}")"
+ release_branch="$(sed -n 's/^release_branch=//p' "${output_file}")"
+ if [ -z "${version}" ] || [ -z "${source_branch}" ] || [ -z "${release_branch}" ]; then
+ echo "::error::Version automation did not return the expected release request."
+ exit 1
+ fi
+ {
+ echo "version=${version}"
+ echo "source_branch=${source_branch}"
+ echo "release_branch=${release_branch}"
+ } >> "${GITHUB_OUTPUT}"
+
+ git fetch --no-tags origin \
+ "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" \
+ "+refs/heads/${source_branch}:refs/remotes/origin/${source_branch}"
+ if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" >/dev/null 2>&1; then
+ git fetch --no-tags origin \
+ "+refs/heads/${release_branch}:refs/remotes/origin/${release_branch}"
+ fi
+ if git ls-remote --exit-code --tags origin "refs/tags/v${version}" >/dev/null 2>&1; then
+ echo "::error::Tag v${version} already exists; refusing to prepare an already tagged release."
+ exit 1
+ fi
+
+ - name: Create or verify release branch
+ id: branch
+ shell: bash
+ env:
+ RELEASE_VERSION: ${{ steps.request.outputs.version }}
+ SOURCE_BRANCH: ${{ steps.request.outputs.source_branch }}
+ RELEASE_BRANCH: ${{ steps.request.outputs.release_branch }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ source_ref="refs/remotes/origin/${SOURCE_BRANCH}"
+ release_ref="refs/remotes/origin/${RELEASE_BRANCH}"
+ main_ref="refs/remotes/origin/${DEFAULT_BRANCH}"
+ source_sha="$(git rev-parse --verify "${source_ref}^{commit}")"
+ main_sha="$(git rev-parse --verify "${main_ref}^{commit}")"
+ expected_files="$(printf '%s\n' pyproject.toml src/memos/__init__.py)"
+ expected_commit="chore: change version number to v${RELEASE_VERSION}"
+ already_merged=false
+ branch_status=missing
+ release_exists=false
+ release_is_ancestor_main=false
+
+ materialize_ref_version() {
+ local ref="$1"
+ local root="${2:-${RUNNER_TEMP}/memos-version-assert}"
+ mkdir -p "${root}/src/memos"
+ git show "${ref}:pyproject.toml" > "${root}/pyproject.toml"
+ git show "${ref}:src/memos/__init__.py" > "${root}/src/memos/__init__.py"
+ }
+
+ inspect_ref_version() {
+ local ref="$1"
+ local output="${RUNNER_TEMP}/memos-version-inspect.out"
+ materialize_ref_version "${ref}"
+ : > "${output}"
+ MEMOS_VERSION_ROOT="${RUNNER_TEMP}/memos-version-assert" GITHUB_OUTPUT="${output}" \
+ node "${RUNNER_TEMP}/memos-version.mjs" inspect >&2
+ sed -n 's/^version=//p' "${output}"
+ }
+
+ assert_ref_version() {
+ local ref="$1"
+ materialize_ref_version "${ref}"
+ MEMOS_VERSION_ROOT="${RUNNER_TEMP}/memos-version-assert" RELEASE_VERSION="${RELEASE_VERSION}" \
+ node "${RUNNER_TEMP}/memos-version.mjs" assert
+ }
+
+ assert_release_matches_trusted_update() {
+ local release_sha="$1"
+ local expected_root="${RUNNER_TEMP}/memos-version-expected"
+ local actual_root="${RUNNER_TEMP}/memos-version-actual"
+ local expected_output="${RUNNER_TEMP}/memos-version-expected.out"
+ materialize_ref_version "${source_sha}" "${expected_root}"
+ : > "${expected_output}"
+ MEMOS_VERSION_ROOT="${expected_root}" RELEASE_VERSION="${RELEASE_VERSION}" GITHUB_OUTPUT="${expected_output}" \
+ node "${RUNNER_TEMP}/memos-version.mjs" update
+ materialize_ref_version "${release_sha}" "${actual_root}"
+ if ! cmp -s "${expected_root}/pyproject.toml" "${actual_root}/pyproject.toml" || \
+ ! cmp -s "${expected_root}/src/memos/__init__.py" "${actual_root}/src/memos/__init__.py"; then
+ echo "::error::${RELEASE_BRANCH} version files do not exactly match the trusted update of ${SOURCE_BRANCH}."
+ exit 1
+ fi
+ }
+
+ if git rev-parse --verify "${release_ref}^{commit}" >/dev/null 2>&1; then
+ release_exists=true
+ branch_status=existing
+ release_sha="$(git rev-parse --verify "${release_ref}^{commit}")"
+ if ! git merge-base --is-ancestor "${source_sha}" "${release_sha}"; then
+ echo "::error::${RELEASE_BRANCH} does not contain the current ${SOURCE_BRANCH} tip. Refusing to rewrite it."
+ exit 1
+ fi
+ release_commit_count="$(git rev-list --count "${source_sha}..${release_sha}")"
+ changed_files="$(git diff --name-only "${source_sha}..${release_sha}")"
+ if [ "${release_commit_count}" = 0 ]; then
+ if [ -n "${changed_files}" ]; then
+ echo "::error::${RELEASE_BRANCH} has an invalid zero-commit diff."
+ exit 1
+ fi
+ elif [ "${release_commit_count}" = 1 ]; then
+ if [ "${changed_files}" != "${expected_files}" ]; then
+ echo "::error::${RELEASE_BRANCH} must change exactly the two MemOS version files."
+ printf 'Expected:\n%s\nActual:\n%s\n' "${expected_files}" "${changed_files}"
+ exit 1
+ fi
+ actual_commit="$(git show -s --format=%s "${release_sha}")"
+ if [ "${actual_commit}" != "${expected_commit}" ]; then
+ echo "::error::${RELEASE_BRANCH} has an unexpected release commit."
+ exit 1
+ fi
+ else
+ echo "::error::${RELEASE_BRANCH} contains ${release_commit_count} commits beyond ${SOURCE_BRANCH}; expected at most one version commit."
+ exit 1
+ fi
+ assert_ref_version "${release_sha}"
+ assert_release_matches_trusted_update "${release_sha}"
+ if git merge-base --is-ancestor "${release_sha}" "${main_sha}"; then
+ release_is_ancestor_main=true
+ fi
+ fi
+
+ main_version="$(inspect_ref_version "${main_sha}")"
+ if [ "${main_version}" = "${RELEASE_VERSION}" ]; then
+ if [ "${release_exists}" = true ] && [ "${release_is_ancestor_main}" = true ]; then
+ already_merged=true
+ branch_status=merged
+ elif [ "${release_exists}" = false ] && git merge-base --is-ancestor "${source_sha}" "${main_sha}"; then
+ release_sha="${main_sha}"
+ already_merged=true
+ branch_status=merged_branch_deleted
+ else
+ echo "::error::${DEFAULT_BRANCH} already declares v${RELEASE_VERSION}, but it does not contain the validated release source. Review repository history before publishing."
+ exit 1
+ fi
+ else
+ CURRENT_MEMOS_VERSION="${main_version}" RELEASE_VERSION="${RELEASE_VERSION}" \
+ node "${RUNNER_TEMP}/memos-version.mjs" require-newer
+ if [ "${release_is_ancestor_main}" = true ]; then
+ echo "::error::${RELEASE_BRANCH} is already merged, but ${DEFAULT_BRANCH} now declares ${main_version} instead of ${RELEASE_VERSION}."
+ exit 1
+ fi
+ fi
+
+ if [ "${release_exists}" = false ] && [ "${already_merged}" = false ]; then
+ git switch --detach "${source_sha}"
+ update_output="${RUNNER_TEMP}/memos-version-update.out"
+ : > "${update_output}"
+ RELEASE_VERSION="${RELEASE_VERSION}" GITHUB_OUTPUT="${update_output}" \
+ node "${RUNNER_TEMP}/memos-version.mjs" update
+ changed="$(sed -n 's/^changed=//p' "${update_output}")"
+ changed_files="$(git diff --name-only)"
+ if [ "${changed}" = true ]; then
+ if [ "${changed_files}" != "${expected_files}" ]; then
+ echo "::error::Version preparation must change exactly the two MemOS version files."
+ printf 'Expected:\n%s\nActual:\n%s\n' "${expected_files}" "${changed_files}"
+ exit 1
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add pyproject.toml src/memos/__init__.py
+ git commit -m "${expected_commit}"
+ elif [ "${changed}" = false ]; then
+ if [ -n "${changed_files}" ]; then
+ echo "::error::Version automation reported no change but left a dirty worktree."
+ exit 1
+ fi
+ else
+ echo "::error::Version automation returned an invalid changed state."
+ exit 1
+ fi
+ release_sha="$(git rev-parse HEAD)"
+
+ remote_source_sha="$(git ls-remote --heads origin "refs/heads/${SOURCE_BRANCH}" | awk '{print $1}')"
+ if [ "${remote_source_sha}" != "${source_sha}" ]; then
+ echo "::error::${SOURCE_BRANCH} moved while the release was being prepared. Rerun to include its latest commit."
+ exit 1
+ fi
+ git push origin "HEAD:refs/heads/${RELEASE_BRANCH}"
+ remote_release_sha="$(git ls-remote --heads origin "refs/heads/${RELEASE_BRANCH}" | awk '{print $1}')"
+ if [ "${remote_release_sha}" != "${release_sha}" ]; then
+ echo "::error::Remote ${RELEASE_BRANCH} does not match the validated release commit."
+ exit 1
+ fi
+ branch_status=created
+ fi
+
+ remote_source_sha="$(git ls-remote --heads origin "refs/heads/${SOURCE_BRANCH}" | awk '{print $1}')"
+ if [ "${remote_source_sha}" != "${source_sha}" ]; then
+ echo "::error::${SOURCE_BRANCH} moved during validation. Rerun to include its latest commit."
+ exit 1
+ fi
+ if [ "${release_exists}" = true ]; then
+ remote_release_sha="$(git ls-remote --heads origin "refs/heads/${RELEASE_BRANCH}" | awk '{print $1}')"
+ if [ "${remote_release_sha}" != "${release_sha}" ]; then
+ echo "::error::${RELEASE_BRANCH} moved during validation. Rerun before creating or reviewing its PR."
+ exit 1
+ fi
+ fi
+
+ {
+ echo "source_sha=${source_sha}"
+ echo "release_sha=${release_sha}"
+ echo "already_merged=${already_merged}"
+ echo "branch_status=${branch_status}"
+ } >> "${GITHUB_OUTPUT}"
+
+ - name: Locate existing release PR
+ if: ${{ steps.branch.outputs.already_merged != 'true' }}
+ id: pr
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_VERSION: ${{ steps.request.outputs.version }}
+ SOURCE_BRANCH: ${{ steps.request.outputs.source_branch }}
+ RELEASE_BRANCH: ${{ steps.request.outputs.release_branch }}
+ RELEASE_SHA: ${{ steps.branch.outputs.release_sha }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ pr_url="$(gh pr list \
+ --repo "${GITHUB_REPOSITORY}" \
+ --state open \
+ --base "${DEFAULT_BRANCH}" \
+ --head "${RELEASE_BRANCH}" \
+ --json url,headRefOid,headRepositoryOwner \
+ --jq '[.[] | select(.headRepositoryOwner.login == "MemTensor")][0].url // ""')"
+ pr_head_sha="$(gh pr list \
+ --repo "${GITHUB_REPOSITORY}" \
+ --state open \
+ --base "${DEFAULT_BRANCH}" \
+ --head "${RELEASE_BRANCH}" \
+ --json headRefOid,headRepositoryOwner \
+ --jq '[.[] | select(.headRepositoryOwner.login == "MemTensor")][0].headRefOid // ""')"
+ closed_url="$(gh pr list \
+ --repo "${GITHUB_REPOSITORY}" \
+ --state closed \
+ --base "${DEFAULT_BRANCH}" \
+ --head "${RELEASE_BRANCH}" \
+ --json url,mergedAt,headRepositoryOwner \
+ --jq '[.[] | select(.mergedAt == null and .headRepositoryOwner.login == "MemTensor")][0].url // ""')"
+ if [ -n "${pr_url}" ] && [ "${pr_head_sha}" != "${RELEASE_SHA}" ]; then
+ echo "::error::The open release PR head ${pr_head_sha} does not match validated branch ${RELEASE_SHA}."
+ exit 1
+ fi
+ if [ -z "${pr_url}" ] && [ -n "${closed_url}" ]; then
+ echo "::error::A release PR for ${RELEASE_BRANCH} was closed without merging (${closed_url}). Review it before retrying."
+ exit 1
+ fi
+ if [ -z "${pr_url}" ]; then
+ compare_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${DEFAULT_BRANCH}...${RELEASE_BRANCH}?expand=1"
+ else
+ compare_url=""
+ fi
+ {
+ echo "url=${pr_url}"
+ echo "compare_url=${compare_url}"
+ } >> "${GITHUB_OUTPUT}"
+
+ - name: Summarize release preparation
+ if: ${{ steps.branch.outcome == 'success' && (steps.branch.outputs.already_merged == 'true' || steps.pr.outcome == 'success') }}
+ shell: bash
+ env:
+ RELEASE_VERSION: ${{ steps.request.outputs.version }}
+ SOURCE_BRANCH: ${{ steps.request.outputs.source_branch }}
+ RELEASE_BRANCH: ${{ steps.request.outputs.release_branch }}
+ SOURCE_SHA: ${{ steps.branch.outputs.source_sha }}
+ RELEASE_SHA: ${{ steps.branch.outputs.release_sha }}
+ ALREADY_MERGED: ${{ steps.branch.outputs.already_merged }}
+ BRANCH_STATUS: ${{ steps.branch.outputs.branch_status }}
+ PR_URL: ${{ steps.pr.outputs.url }}
+ COMPARE_URL: ${{ steps.pr.outputs.compare_url }}
+ run: |
+ {
+ echo "## MemOS v${RELEASE_VERSION} prepared"
+ echo
+ echo "- Source: \`${SOURCE_BRANCH}\` at \`${SOURCE_SHA}\`"
+ if [ "${BRANCH_STATUS}" = merged_branch_deleted ]; then
+ echo "- Release commit: \`${RELEASE_SHA}\` (the merged release branch was automatically deleted)"
+ else
+ echo "- Release branch: \`${RELEASE_BRANCH}\` at \`${RELEASE_SHA}\`"
+ fi
+ if [ "${ALREADY_MERGED}" = true ]; then
+ echo "- Status: already merged into the default branch"
+ elif [ -n "${PR_URL}" ]; then
+ echo "- Release PR: ${PR_URL}"
+ else
+ echo "- Create release PR: ${COMPARE_URL}"
+ echo "- PR title: \`chore: prepare release v${RELEASE_VERSION}\`"
+ echo "- The PR is intentionally opened by a maintainer so GitHub runs the required pull-request checks."
+ fi
+ echo
+ echo "After review, required checks, and merge, **MemOS Release — Publish** starts automatically from the trusted main workflow. Its manual main-branch entry remains available for dry runs and recovery."
+ } >> "${GITHUB_STEP_SUMMARY}"
diff --git a/.github/workflows/memos-release-publish.yml b/.github/workflows/memos-release-publish-main.yml
similarity index 70%
rename from .github/workflows/memos-release-publish.yml
rename to .github/workflows/memos-release-publish-main.yml
index 0131849fd..8426b8981 100644
--- a/.github/workflows/memos-release-publish.yml
+++ b/.github/workflows/memos-release-publish-main.yml
@@ -13,10 +13,19 @@ on:
type: string
default: "main"
local_plugin_version:
- description: "Optional stable local-plugin version, without v. Leave blank to skip local-plugin npm/tag/docs even when changes exist."
+ description: "Optional stable local-plugin version guard, without v. In auto mode leave blank to let the workflow use the next patch."
required: false
type: string
default: ""
+ local_plugin_release_mode:
+ description: "Local-plugin handling: auto detects user-visible changes, skip disables it, manual requires local_plugin_version."
+ required: true
+ type: choice
+ options:
+ - auto
+ - skip
+ - manual
+ default: "auto"
dry_run:
description: "Preview only. Skip npm publish, all tag/Release creation, docs PR, and deployment."
required: true
@@ -37,20 +46,87 @@ on:
required: true
type: boolean
default: false
+ pull_request:
+ types: [closed]
+ branches:
+ - main
concurrency:
group: memos-release-publish
cancel-in-progress: false
permissions:
- contents: write
+ contents: read
jobs:
+ classify_release_trigger:
+ name: Classify release trigger
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ is_release_trigger: ${{ steps.classify.outputs.is_release_trigger }}
+ steps:
+ - name: Classify release trigger
+ id: classify
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ WORKFLOW_REF_NAME: ${{ github.ref_name }}
+ REPOSITORY: ${{ github.repository }}
+ PR_MERGED: ${{ github.event.pull_request.merged || false }}
+ PR_BASE_REF: ${{ github.event.pull_request.base.ref || '' }}
+ PR_HEAD_REF: ${{ github.event.pull_request.head.ref || '' }}
+ PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || '' }}
+ run: |
+ set -euo pipefail
+
+ is_release_trigger=false
+ reason="not a supported release trigger"
+
+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
+ if [[ "$WORKFLOW_REF_NAME" != "$DEFAULT_BRANCH" ]]; then
+ echo "::error::Run MemOS Release — Publish from ${DEFAULT_BRANCH}; selected ${WORKFLOW_REF_NAME:-}."
+ exit 1
+ fi
+ is_release_trigger=true
+ reason="manual workflow dispatch from trusted default branch $DEFAULT_BRANCH"
+ elif [[ "$EVENT_NAME" == "pull_request" && "$PR_MERGED" == "true" && "$PR_BASE_REF" == "main" ]]; then
+ if [[ "$PR_HEAD_REPO" != "$REPOSITORY" ]]; then
+ reason="merged branch $PR_HEAD_REF came from $PR_HEAD_REPO, not $REPOSITORY"
+ elif [[ "$PR_HEAD_REF" =~ ^(release/v|dev-v?)(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
+ is_release_trigger=true
+ reason="merged release branch $PR_HEAD_REF"
+ else
+ reason="merged branch $PR_HEAD_REF is not release/vX.Y.Z, dev-vX.Y.Z, or dev-X.Y.Z"
+ fi
+ fi
+
+ echo "is_release_trigger=$is_release_trigger" >> "$GITHUB_OUTPUT"
+ {
+ echo "### MemOS release trigger"
+ echo
+ echo "- Eligible: \`$is_release_trigger\`"
+ echo "- Reason: $reason"
+ } >> "$GITHUB_STEP_SUMMARY"
+
prepare:
- if: ${{ github.repository == 'MemTensor/MemOS' }}
+ needs: classify_release_trigger
+ if: >-
+ ${{
+ github.repository == 'MemTensor/MemOS' &&
+ needs.classify_release_trigger.outputs.is_release_trigger == 'true'
+ }}
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
+ release_version: ${{ steps.prepare.outputs.release_version }}
+ dry_run: ${{ steps.prepare.outputs.dry_run }}
+ create_draft_release: ${{ steps.prepare.outputs.create_draft_release }}
+ auto_post_merge_release: ${{ steps.prepare.outputs.auto_post_merge_release }}
+ local_plugin_release_mode: ${{ steps.prepare.outputs.local_plugin_release_mode }}
current_tag: ${{ steps.prepare.outputs.current_tag }}
target_sha: ${{ steps.prepare.outputs.target_sha }}
local_plugin_release_requested: ${{ steps.prepare.outputs.local_plugin_release_requested }}
@@ -62,9 +138,25 @@ jobs:
publish_blocked: ${{ steps.prepare.outputs.publish_blocked }}
publish_block_reason: ${{ steps.prepare.outputs.publish_block_reason }}
steps:
+ - name: Require the default-branch workflow
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ shell: bash
+ env:
+ SELECTED_REF: ${{ github.ref }}
+ SELECTED_REF_TYPE: ${{ github.ref_type }}
+ SELECTED_BRANCH: ${{ github.ref_name }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ expected_ref="refs/heads/${DEFAULT_BRANCH}"
+ if [ "${SELECTED_REF_TYPE}" != branch ] || [ "${SELECTED_REF}" != "${expected_ref}" ] || [ "${SELECTED_BRANCH}" != "${DEFAULT_BRANCH}" ]; then
+ echo "::error::Use workflow from must be branch ${expected_ref}; received ${SELECTED_REF}."
+ exit 1
+ fi
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- ref: ${{ github.ref }}
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0
@@ -82,41 +174,27 @@ jobs:
shell: bash
run: |
node --test \
+ .github/scripts/memos-version.test.mjs \
.github/scripts/prepare-memos-release.test.mjs \
.github/scripts/append-local-plugin-release-intent.test.mjs \
.github/scripts/local-plugin-release-contract.test.mjs \
.github/scripts/create-local-plugin-github-release.test.mjs \
.github/scripts/publish-paired-local-plugin-release.test.mjs
- - name: Validate publish confirmation
- if: ${{ !inputs.dry_run }}
- shell: bash
- env:
- RELEASE_VERSION: ${{ inputs.version }}
- LOCAL_PLUGIN_VERSION: ${{ inputs.local_plugin_version }}
- PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }}
- run: |
- set -euo pipefail
- if [ -n "${LOCAL_PLUGIN_VERSION}" ]; then
- expected="PUBLISH v${RELEASE_VERSION} WITH LOCAL PLUGIN v${LOCAL_PLUGIN_VERSION}"
- else
- expected="PUBLISH v${RELEASE_VERSION}"
- fi
- if [ "${PUBLISH_CONFIRMATION}" != "${expected}" ]; then
- echo "::error::publish_confirmation must exactly equal: ${expected}"
- exit 1
- fi
-
- name: Prepare MemOS release inspection
id: prepare
shell: bash
env:
- RELEASE_VERSION: ${{ inputs.version }}
- TARGET_REF: ${{ inputs.target_ref || 'main' }}
- LOCAL_PLUGIN_VERSION: ${{ inputs.local_plugin_version }}
- RECOVER_EXISTING_LOCAL_PLUGIN_PUBLISH: ${{ inputs.recover_existing_local_plugin_publish }}
- DRY_RUN: ${{ inputs.dry_run }}
- PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }}
+ RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }}
+ TARGET_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || inputs.target_ref || 'main' }}
+ LOCAL_PLUGIN_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.local_plugin_version || '' }}
+ LOCAL_PLUGIN_RELEASE_MODE: ${{ github.event_name == 'pull_request' && 'auto' || inputs.local_plugin_release_mode || 'auto' }}
+ RECOVER_EXISTING_LOCAL_PLUGIN_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.recover_existing_local_plugin_publish || false }}
+ DRY_RUN: ${{ github.event_name == 'pull_request' && 'false' || inputs.dry_run }}
+ CREATE_DRAFT_RELEASE: ${{ github.event_name == 'pull_request' && 'true' || inputs.create_draft_release }}
+ PUBLISH_CONFIRMATION: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_confirmation || '' }}
+ AUTO_POST_MERGE_RELEASE: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
+ MERGED_PR_HEAD_REF: ${{ github.event.pull_request.head.ref || '' }}
GITHUB_TOKEN: ${{ github.token }}
DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }}
DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }}
@@ -153,6 +231,8 @@ jobs:
PUBLISH_BLOCKED: ${{ steps.prepare.outputs.publish_blocked }}
PUBLISH_BLOCK_REASON: ${{ steps.prepare.outputs.publish_block_reason }}
SOURCE_ID: ${{ steps.prepare.outputs.source_id }}
+ LOCAL_PLUGIN_RELEASE_MODE: ${{ steps.prepare.outputs.local_plugin_release_mode }}
+ AUTO_POST_MERGE_RELEASE: ${{ steps.prepare.outputs.auto_post_merge_release }}
TARGET_REF: ${{ steps.prepare.outputs.target_ref }}
TARGET_SHA: ${{ steps.prepare.outputs.target_sha }}
HAS_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_product_changes }}
@@ -167,6 +247,8 @@ jobs:
echo "## MemOS release inspection"
echo ""
echo "- source_id: ${SOURCE_ID}"
+ echo "- local_plugin_release_mode: ${LOCAL_PLUGIN_RELEASE_MODE}"
+ echo "- auto_post_merge_release: ${AUTO_POST_MERGE_RELEASE}"
echo "- current_tag: ${CURRENT_TAG}"
echo "- previous_tag: ${PREVIOUS_TAG}"
echo "- local_plugin_version: ${LOCAL_PLUGIN_VERSION}"
@@ -199,7 +281,7 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"
- name: Report dry-run completion
- if: ${{ inputs.dry_run }}
+ if: ${{ steps.prepare.outputs.dry_run == 'true' }}
shell: bash
run: |
echo "::notice::dry_run=true; skipped npm publish, local-plugin tag, MemOS tag, and GitHub Release."
@@ -215,16 +297,18 @@ jobs:
tag: latest
git_ref: ${{ needs.prepare.outputs.target_sha }}
release_notes: ""
- dry_run: ${{ inputs.dry_run }}
- recover_existing_npm_release: ${{ inputs.recover_existing_local_plugin_publish }}
+ dry_run: ${{ needs.prepare.outputs.dry_run == 'true' }}
+ recover_existing_npm_release: ${{ github.event_name == 'workflow_dispatch' && inputs.recover_existing_local_plugin_publish || false }}
docs_sync_mode: paired_with_memos_release
- memos_release_version: ${{ inputs.version }}
- memos_release_tag: v${{ inputs.version }}
+ memos_release_version: ${{ needs.prepare.outputs.release_version }}
+ memos_release_tag: ${{ needs.prepare.outputs.current_tag }}
+ publish_phase: stage_release
+ auto_post_merge_release: ${{ needs.prepare.outputs.auto_post_merge_release == 'true' }}
# Always stage the paired plugin Release. This prevents its release.published
# webhook from reaching 106 before the MemOS Release exists, including when
# the caller asks to publish the MemOS Release immediately.
create_draft_release: true
- caller_publish_confirmation: ${{ inputs.publish_confirmation }}
+ caller_publish_confirmation: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_confirmation || '' }}
secrets: inherit
release:
@@ -234,7 +318,7 @@ jobs:
if: >-
${{
always() &&
- !inputs.dry_run &&
+ needs.prepare.outputs.dry_run != 'true' &&
needs.prepare.result == 'success' &&
(
(needs.prepare.outputs.local_plugin_release_requested == 'true' && needs.publish-local-plugin.result == 'success') ||
@@ -245,10 +329,13 @@ jobs:
timeout-minutes: 20
permissions:
contents: write
+ outputs:
+ release_is_draft: ${{ steps.memos_release.outputs.release_is_draft }}
+ release_url: ${{ steps.memos_release.outputs.release_url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- ref: ${{ github.sha }}
+ ref: ${{ needs.prepare.outputs.target_sha }}
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0
@@ -283,7 +370,7 @@ jobs:
CURRENT_TAG: ${{ needs.prepare.outputs.current_tag }}
TARGET_SHA: ${{ needs.prepare.outputs.target_sha }}
RELEASE_NOTES_FILE: ${{ runner.temp }}/memos-release-notes-with-intent.md
- CREATE_DRAFT_RELEASE: ${{ inputs.create_draft_release }}
+ CREATE_DRAFT_RELEASE: ${{ needs.prepare.outputs.create_draft_release }}
PUBLISH_BLOCKED: ${{ needs.prepare.outputs.publish_blocked }}
PUBLISH_BLOCK_REASON: ${{ needs.prepare.outputs.publish_block_reason }}
run: |
@@ -489,13 +576,63 @@ jobs:
create_release_if_missing
- - name: Publish paired local-plugin Release after immediate MemOS publish
- if: >-
- ${{
- steps.memos_release.outputs.release_is_draft == 'false' &&
- needs.prepare.outputs.local_plugin_release_requested == 'true'
- }}
- shell: bash
+ publish-local-plugin-npm-after-immediate-release:
+ needs:
+ - prepare
+ - publish-local-plugin
+ - release
+ if: >-
+ ${{
+ needs.prepare.outputs.local_plugin_release_requested == 'true' &&
+ needs.release.outputs.release_is_draft == 'false'
+ }}
+ permissions:
+ contents: write
+ uses: ./.github/workflows/memos-local-plugin-publish.yml
+ with:
+ version: ${{ needs.prepare.outputs.local_plugin_publish_version }}
+ tag: latest
+ git_ref: ${{ needs.publish-local-plugin.outputs.local_plugin_tag_sha }}
+ release_notes: ""
+ dry_run: false
+ recover_existing_npm_release: true
+ docs_sync_mode: paired_with_memos_release
+ memos_release_version: ${{ needs.prepare.outputs.release_version }}
+ memos_release_tag: ${{ needs.prepare.outputs.current_tag }}
+ publish_phase: publish_npm_only
+ auto_post_merge_release: ${{ needs.prepare.outputs.auto_post_merge_release == 'true' }}
+ create_draft_release: true
+ caller_publish_confirmation: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_confirmation || '' }}
+ secrets: inherit
+
+ publish-paired-local-plugin-after-immediate-release:
+ needs:
+ - prepare
+ - release
+ - publish-local-plugin-npm-after-immediate-release
+ if: >-
+ ${{
+ needs.prepare.outputs.local_plugin_release_requested == 'true' &&
+ needs.release.outputs.release_is_draft == 'false' &&
+ needs.publish-local-plugin-npm-after-immediate-release.result == 'success'
+ }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0
+ with:
+ node-version: 22
+ package-manager-cache: false
+
+ - name: Verify and publish paired local-plugin Draft
env:
GH_TOKEN: ${{ github.token }}
MEMOS_RELEASE_TAG_OVERRIDE: ${{ needs.prepare.outputs.current_tag }}
diff --git a/.github/workflows/memos-release-publish-paired-local-plugin.yml b/.github/workflows/memos-release-publish-paired-local-plugin.yml
index 681c619bf..1f702b193 100644
--- a/.github/workflows/memos-release-publish-paired-local-plugin.yml
+++ b/.github/workflows/memos-release-publish-paired-local-plugin.yml
@@ -19,10 +19,10 @@ concurrency:
cancel-in-progress: false
permissions:
- contents: write
+ contents: read
jobs:
- publish-paired-local-plugin:
+ resolve-paired-local-plugin:
if: >-
${{
github.repository == 'MemTensor/MemOS' &&
@@ -36,6 +36,13 @@ jobs:
}}
runs-on: ubuntu-latest
timeout-minutes: 10
+ outputs:
+ status: ${{ steps.resolve.outputs.status }}
+ memos_release_tag: ${{ steps.resolve.outputs.memos_release_tag }}
+ memos_release_version: ${{ steps.resolve.outputs.memos_release_version }}
+ local_plugin_tag: ${{ steps.resolve.outputs.local_plugin_tag }}
+ local_plugin_version: ${{ steps.resolve.outputs.local_plugin_version }}
+ local_plugin_source_sha: ${{ steps.resolve.outputs.local_plugin_source_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -69,11 +76,82 @@ jobs:
.github/scripts/local-plugin-release-contract.test.mjs \
.github/scripts/publish-paired-local-plugin-release.test.mjs
+ - name: Verify paired local-plugin Draft binding
+ id: resolve
+ env:
+ GH_TOKEN: ${{ github.token }}
+ MEMOS_RELEASE_TAG_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.memos_release_tag || '' }}
+ VALIDATE_ONLY: "true"
+ run: node .github/scripts/publish-paired-local-plugin-release.mjs
+
+ - name: Summarize paired resolve
+ env:
+ STATUS: ${{ steps.resolve.outputs.status }}
+ MEMOS_RELEASE_TAG: ${{ steps.resolve.outputs.memos_release_tag }}
+ LOCAL_PLUGIN_TAG: ${{ steps.resolve.outputs.local_plugin_tag }}
+ run: |
+ {
+ echo "## Paired local-plugin Release"
+ echo
+ echo "- status: ${STATUS}"
+ echo "- MemOS Release: ${MEMOS_RELEASE_TAG}"
+ echo "- local-plugin Release: ${LOCAL_PLUGIN_TAG:-not requested}"
+ echo
+ echo "A stable local-plugin Release publishes only after its binding matches the published MemOS Release and npm publish succeeds. Its release.published webhook is the only local-plugin docs trigger."
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ publish-local-plugin-npm:
+ needs: resolve-paired-local-plugin
+ if: ${{ needs.resolve-paired-local-plugin.outputs.status == 'staged' }}
+ permissions:
+ contents: write
+ uses: ./.github/workflows/memos-local-plugin-publish.yml
+ with:
+ version: ${{ needs.resolve-paired-local-plugin.outputs.local_plugin_version }}
+ tag: latest
+ git_ref: ${{ needs.resolve-paired-local-plugin.outputs.local_plugin_source_sha }}
+ release_notes: ""
+ dry_run: false
+ recover_existing_npm_release: true
+ docs_sync_mode: paired_with_memos_release
+ memos_release_version: ${{ needs.resolve-paired-local-plugin.outputs.memos_release_version }}
+ memos_release_tag: ${{ needs.resolve-paired-local-plugin.outputs.memos_release_tag }}
+ publish_phase: publish_npm_only
+ auto_post_merge_release: true
+ create_draft_release: true
+ caller_publish_confirmation: ""
+ secrets: inherit
+
+ publish-paired-local-plugin:
+ needs:
+ - resolve-paired-local-plugin
+ - publish-local-plugin-npm
+ if: >-
+ ${{
+ needs.resolve-paired-local-plugin.outputs.status == 'staged' &&
+ needs.publish-local-plugin-npm.result == 'success'
+ }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0
+ with:
+ node-version: 22
+ package-manager-cache: false
+
- name: Verify and publish paired local-plugin Draft
id: publish
env:
GH_TOKEN: ${{ github.token }}
- MEMOS_RELEASE_TAG_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.memos_release_tag || '' }}
+ MEMOS_RELEASE_TAG_OVERRIDE: ${{ needs.resolve-paired-local-plugin.outputs.memos_release_tag }}
run: node .github/scripts/publish-paired-local-plugin-release.mjs
- name: Summarize paired publish
@@ -87,7 +165,7 @@ jobs:
echo
echo "- status: ${STATUS}"
echo "- MemOS Release: ${MEMOS_RELEASE_TAG}"
- echo "- local-plugin Release: ${LOCAL_PLUGIN_TAG:-not requested}"
+ echo "- local-plugin Release: ${LOCAL_PLUGIN_TAG}"
echo
- echo "A stable local-plugin Release publishes only after its binding matches the published MemOS Release. Its release.published webhook is the only local-plugin docs trigger."
+ echo "npm was verified before the local-plugin Draft was published."
} >> "${GITHUB_STEP_SUMMARY}"
diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json
index 125b7388c..5df682959 100644
--- a/apps/memos-local-plugin/package.json
+++ b/apps/memos-local-plugin/package.json
@@ -53,6 +53,7 @@
"prepack": "npm run check:hermes-version && npm run build:package",
"sync:hermes-version": "node scripts/sync-hermes-version.cjs",
"check:hermes-version": "node scripts/sync-hermes-version.cjs --check",
+ "release:validate": "npm run check:hermes-version && npm run lint && npm test",
"dev": "tsc -p tsconfig.json --watch",
"viewer:dev": "vite --config vite.config.ts",
"bridge": "tsx bridge.cts",
diff --git a/apps/memos-local-plugin/tests/unit/install/hermes-version-sync.test.ts b/apps/memos-local-plugin/tests/unit/install/hermes-version-sync.test.ts
index 63a711c47..6f2ce6993 100644
--- a/apps/memos-local-plugin/tests/unit/install/hermes-version-sync.test.ts
+++ b/apps/memos-local-plugin/tests/unit/install/hermes-version-sync.test.ts
@@ -94,11 +94,14 @@ describe("Hermes version synchronization", () => {
);
});
- it("synchronizes before tests and publishes the validated tarball", () => {
+ it("synchronizes before release validation and publishes the validated tarball", () => {
const workflow = readFileSync(
path.resolve(repoRoot, "../../.github/workflows/memos-local-plugin-publish.yml"),
"utf8",
);
+ const packageJson = JSON.parse(
+ readFileSync(path.join(repoRoot, "package.json"), "utf8"),
+ ) as { scripts: Record };
const publishHelper = readFileSync(
path.resolve(repoRoot, "../../.github/scripts/publish-local-plugin.sh"),
"utf8",
@@ -106,13 +109,14 @@ describe("Hermes version synchronization", () => {
const bumpPosition = workflow.indexOf('npm version "${RELEASE_VERSION}"');
const syncPosition = workflow.indexOf("npm run sync:hermes-version");
- const checkPosition = workflow.indexOf("npm run check:hermes-version");
- const testPosition = workflow.indexOf('"npm test"');
+ const validationPosition = workflow.indexOf('"npm run release:validate"');
expect(bumpPosition).toBeGreaterThan(-1);
expect(syncPosition).toBeGreaterThan(bumpPosition);
- expect(checkPosition).toBeGreaterThan(syncPosition);
- expect(testPosition).toBeGreaterThan(checkPosition);
+ expect(validationPosition).toBeGreaterThan(syncPosition);
+ expect(packageJson.scripts["release:validate"]).toBe(
+ "npm run check:hermes-version && npm run lint && npm test",
+ );
expect(workflow).toContain("npm pack --json --silent --pack-destination");
expect(workflow).toContain("raw.match(/^\\[/m)");
expect(workflow).toContain(