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(