From 6db74f6dd419d36db4ac1b4a7468f0532b8620b8 Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Tue, 4 Aug 2026 18:56:11 +0800 Subject: [PATCH] build(release): add Chinese release flow and automation package --- .github/workflows/publish.yml | 57 ++++------ CHANGELOG.md | 38 +++++++ CONTRIBUTING.md | 12 +++ README.md | 30 +++++- docs/AUTOMATION_DESIGN_NOTES.md | 6 +- docs/AUTOMATION_QUICKSTART_ZH.md | 70 ++++++++++++ docs/README_EN.md | 23 +++- docs/release-notes/TEMPLATE-zh.md | 53 +++++++++ docs/release-notes/v0.4.0-zh.md | 76 +++++++++++++ package.json | 1 + scripts/package-release-assets.ps1 | 129 ++++++++++++++++++++++ scripts/publish-gui.ps1 | 6 ++ scripts/read-release-metadata.js | 136 +++++++++++++++++++++++ test/release-metadata.test.js | 138 ++++++++++++++++++++++++ test/release-packaging-contract.test.js | 43 ++++++++ 15 files changed, 772 insertions(+), 46 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/AUTOMATION_QUICKSTART_ZH.md create mode 100644 docs/release-notes/TEMPLATE-zh.md create mode 100644 docs/release-notes/v0.4.0-zh.md create mode 100644 scripts/package-release-assets.ps1 create mode 100644 scripts/read-release-metadata.js create mode 100644 test/release-metadata.test.js create mode 100644 test/release-packaging-contract.test.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f65d2d..1e85a87 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,7 +36,9 @@ jobs: shell: pwsh env: RELEASE_TAG: ${{ github.ref_name }} - run: node scripts/verify-release-version.js --tag $env:RELEASE_TAG + run: | + node scripts/verify-release-version.js --tag $env:RELEASE_TAG + node scripts/read-release-metadata.js --tag $env:RELEASE_TAG - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -58,24 +60,11 @@ jobs: - name: Package release assets shell: pwsh + env: + RELEASE_TAG: ${{ github.ref_name }} run: | - $ErrorActionPreference = "Stop" - $version = "${{ github.ref_name }}".TrimStart("v") - $assetRoot = "artifacts/release" - New-Item -ItemType Directory -Force -Path $assetRoot | Out-Null - - $exe = "artifacts/win-x64/CodexProviderSync.exe" - $zip = Join-Path $assetRoot "codex-provider-sync-v$version-win-x64.zip" - Compress-Archive -Path "artifacts/win-x64/*" -DestinationPath $zip -Force - Copy-Item $exe (Join-Path $assetRoot "CodexProviderSync.exe") -Force - - $assets = Get-ChildItem -File $assetRoot | Sort-Object Name - $checksums = foreach ($asset in $assets) { - $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset.FullName).Hash.ToLowerInvariant() - "$hash $($asset.Name)" - Set-Content -LiteralPath "$($asset.FullName).sha256" -Value "$hash $($asset.Name)" -Encoding ASCII - } - Set-Content -LiteralPath (Join-Path $assetRoot "checksums.txt") -Value $checksums -Encoding ASCII + $version = $env:RELEASE_TAG.Substring(1) + ./scripts/package-release-assets.ps1 -Version $version -PublishOutput artifacts/win-x64 -Output artifacts/release - name: Upload packaged assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -91,6 +80,19 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.1 + with: + node-version: "24" + + - name: Resolve versioned Chinese release announcement + id: release_metadata + shell: bash + run: node scripts/read-release-metadata.js --tag "$GITHUB_REF_NAME" --github-output "$GITHUB_OUTPUT" + - name: Download packaged assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -100,23 +102,8 @@ jobs: - name: Upload release assets uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: + name: ${{ steps.release_metadata.outputs.release_title }} + body_path: ${{ steps.release_metadata.outputs.release_body_path }} files: | artifacts/release/* fail_on_unmatched_files: true - body: | - ## Windows SmartScreen 提示 - 当前 Windows GUI 未做代码签名,首次运行时可能提示“发布者未知”或“Windows 已保护你的电脑”。这是未签名 EXE 的常见提示。 - - 请确认下载来源是本项目 GitHub Releases,并可用随附的 SHA256 文件校验后运行。 - - ## 安全边界 - - 不会修改 auth.json - - 不会改写对话正文或消息历史 - - 不会修改 updated_at 来改变排序 - - 不会重新加密 encrypted_content - - ## 回滚 - 可使用 `codex-provider restore ` 恢复本工具创建的备份。 - - ## 已知事项 - 含 encrypted_content 的历史会话跨 provider/account 后,通常只能恢复列表可见性;继续对话或 compact 仍可能失败。 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5ec8c7e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# 更新日志 + +本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。 + +## [0.4.0] - 2026-08-04 + +### 新增 + +- 新增实验性 Windows 自动化接口,支持 `describe`、`status`、`plan`、`sync`、`switch`、`restore` 和 `prune`。 +- 新增独立 SQLite Home 支持,以及按 Codex Home 保存的 Windows GUI SQLite Home 配置。 +- 新增独立 Automation ZIP;单文件 GUI 和包含全部工具的 Windows ZIP 保持可用。 + +### 变更 + +- Windows GUI 与自动化接口改为共享 Application 用例,Core 继续统一负责配置、rollout、SQLite、备份、恢复、锁和 WSL 安全策略。 +- 新备份使用 metadata v2 记录 SQLite Home 和数据库文件,同时继续支持旧版托管备份。 +- GitHub Release 正文改为读取随版本 tag 入库的中文发布说明。 + +### 修复 + +- 修复多文件写入部分成功后无法可靠补偿的问题;失败和取消现在会按事务记录回滚。 +- SQLite 提交结果无法确认时改为保守恢复,不再把不确定状态报告为成功。 +- 强化锁所有权恢复、SQLite 快照恢复和 WSL UNC 路径安全诊断。 + +### 安全 + +- 写操作在目标修改前创建绑定备份,并保留崩溃恢复信息。 +- 自动化接口的写操作默认只生成计划;实际执行需要 `--apply`、匹配的计划文件和 SHA-256 摘要。 +- 自动化路径拒绝 `auth.json`、符号链接、reparse point 和非绝对路径。 + +### 升级说明 + +- v0.3.1 / v0.3.2 Windows GUI 可以通过内置更新升级,但内置更新只替换单文件 GUI。 +- 升级不要求手动迁移配置;需要自动化接口的用户应单独下载 Automation ZIP 或 Windows 完整包。 + +[中文发布说明](docs/release-notes/v0.4.0-zh.md) · [技术发布说明](docs/RELEASE_NOTES_V0.4.0.md) · [完整变更对比](https://github.com/Dailin521/codex-provider-sync/compare/v0.3.2...v0.4.0) + +更早版本见 [GitHub Releases](https://github.com/Dailin521/codex-provider-sync/releases)。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d235ed..a1b8bca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,18 @@ PR 中请特别说明: - 自动化测试、真实手测和未执行项目。 - GUI 变化的前后截图。 +## 准备发布 + +发布 tag 前需要: + +1. 将 [中文发布说明模板](docs/release-notes/TEMPLATE-zh.md) 复制为 `docs/release-notes/v<版本>-zh.md`。 +2. 填写文件顶部的 `release-title`、面向用户的升级结果、下载、升级说明、安全边界、验证结果和实际贡献者。 +3. 更新 `CHANGELOG.md`,并确认 `package.json`、`package-lock.json` 和所有发布项目版本一致。 +4. 运行 `node scripts/read-release-metadata.js --tag v<版本>` 和 `node scripts/verify-release-version.js --tag v<版本>`。 +5. 运行完整测试和发布构建,再创建指向 `main` 中已验证提交的 tag。 + +发布工作流会读取与 tag 同名的中文发布说明,并生成单文件 GUI、独立 Automation ZIP、Windows 完整包和对应 SHA-256。缺少发布说明、标题与 tag 不匹配,或遗漏固定的下载、安全和限制声明时会直接停止。 + ## English quick guide - Small fixes, tests, and documentation updates can be submitted directly as a PR. Please open an Issue before starting a large feature, behavior change, or refactor. diff --git a/README.md b/README.md index 17db0e5..d1aaf4d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,17 @@ Codex 切换 `model_provider` 后,旧会话可能从 Desktop 或 `/resume` 中 本工具不负责登录、认证或切换账号;请先用原有方式完成 Provider 切换,再执行同步。 +## 它与 Provider 切换工具的关系 + +包括 cc-switch 在内的 Provider 管理工具,主要负责账号、API Key、`auth.json` 或 `config.toml` 的切换,有些工具也提供自己的历史会话处理能力。codex-provider-sync 刻意不接管认证,它专注于切换之后的会话可见性元数据、rollout、SQLite、备份和恢复。 + +如果你正在使用的切换工具已经能让全部历史会话保持可见,就不需要重复同步。以下情况仍适合使用本工具: + +- 使用多个切换工具,或先切换后才发现旧会话已经按 Provider 分开。 +- 需要同时核对并修复 rollout、SQLite 和项目可见性,而不只修改配置文件。 +- SQLite Home 与 Codex Home 分开存放,特别是 Windows Codex Home + WSL SQLite Home。 +- 需要可恢复的批量同步、明确的备份记录和事务回滚保护。 + ## 它会处理什么 - 同步 `~/.codex/sessions` 和 `~/.codex/archived_sessions` 中的 rollout metadata。 @@ -40,7 +51,13 @@ Codex 切换 `model_provider` 后,旧会话可能从 Desktop 或 `/resume` 中 ### Windows GUI -普通 Windows 用户建议直接从 [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) 下载并解压: +普通 Windows 用户只需从 [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) 下载单文件 GUI: + +| 使用场景 | Release 资产 | 更新方式 | +| --- | --- | --- | +| 只需要 Windows GUI | `CodexProviderSync.exe` | 支持软件内自动更新 | +| 脚本、CI 或 AI Agent | `codex-provider-sync-v<版本>-automation-win-x64.zip` | 手动下载更新 | +| GUI 与自动化接口都需要 | `codex-provider-sync-v<版本>-win-x64.zip` | 手动下载更新 | 1. 打开 `CodexProviderSync.exe` 2. 点击“刷新” @@ -90,9 +107,9 @@ codex-provider sync --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you `status` 会显示 effective SQLite Home 和来源。显式路径缺少 `state_5.sqlite` 时,状态查询只报告诊断,`sync`、`switch` 和数据库恢复不会偷偷回退到其它位置。默认布局中的数据库被删除时,`restore` 可以根据备份 metadata 在原默认位置重建数据库。 -### Business Automation API(v0.4 实验性) +### 自动化接口(v0.4 实验性) -v0.4 Windows Release 构建同时包含 `CodexProviderSync.Automation.exe` 和 `automation-protocol-v0.4.schema.json`。这个一次性进程接口与 Windows GUI 共用同一套 Application 用例;每次调用只在 stdout 输出一份协议 `0.4` JSON,诊断信息写入 stderr。 +Release 提供独立的 Windows 自动化接口包,内含 `CodexProviderSync.Automation.exe`、`automation-protocol-v0.4.schema.json` 和中文快速说明;Windows 完整包也包含这些文件。这个一次性进程接口与 Windows GUI 共用同一套 Application 用例;每次调用只在 stdout 输出一份协议 `0.4` JSON,诊断信息写入 stderr。普通桌面用户不需要下载自动化接口包。 | 命令 | 用途 | | --- | --- | @@ -118,6 +135,8 @@ $planDigest = $planResponse.data.digest 计划有有效期、绑定规范化输入和目标状态,并由持久化 ledger 保证只能使用一次;默认 ledger 位于 `\tmp\provider-sync-automation-ledger`。所有路径参数必须是绝对路径,不能穿过符号链接或 reparse point,Automation 也拒绝直接指向或访问 `auth.json`。协议仍处于 pre-1.0 实验阶段,`0.4` 之外不承诺兼容。 +中文分步示例见 [自动化接口快速开始](docs/AUTOMATION_QUICKSTART_ZH.md)。 + ## 安全与限制 每次 `sync` / `switch` 前都会备份到: @@ -139,7 +158,10 @@ $planDigest = $planResponse.data.digest - [Windows GUI 说明](docs/README_GUI_ZH.md) - macOS GUI 说明:[中文](docs/README_MAC_GUI_ZH.md) · [English](docs/README_MAC_GUI_EN.md) -- [v0.4.0 Release Notes(Draft)](docs/RELEASE_NOTES_V0.4.0.md) +- [v0.4.0 中文发布说明](docs/release-notes/v0.4.0-zh.md) +- [v0.4.0 技术发布说明](docs/RELEASE_NOTES_V0.4.0.md) +- [更新日志](CHANGELOG.md) +- [自动化接口快速开始](docs/AUTOMATION_QUICKSTART_ZH.md) - [v0.4 Automation 执行计划](docs/V0.4_AUTOMATION_PLAN.md) - [English documentation](docs/README_EN.md) - [AI / Agent 操作指南](AGENTS.md) diff --git a/docs/AUTOMATION_DESIGN_NOTES.md b/docs/AUTOMATION_DESIGN_NOTES.md index c87ea2d..ecde9ce 100644 --- a/docs/AUTOMATION_DESIGN_NOTES.md +++ b/docs/AUTOMATION_DESIGN_NOTES.md @@ -5,9 +5,9 @@ > > The v0.4 source and Windows Release build contain an Automation executable, > protocol schema, GUI manifest, and isolated GUI bridge. Implementation commit -> `7545b5d` passed the visible Headful Windows release gate; v0.4 has not been -> tagged or published as a formal Release. Protocol `0.4` is a pre-1.0 -> compatibility boundary. +> `7545b5d` passed the visible Headful Windows release gate, and v0.4.0 was +> published on 2026-08-04. Protocol `0.4` remains a pre-1.0 compatibility +> boundary. ## Motivation diff --git a/docs/AUTOMATION_QUICKSTART_ZH.md b/docs/AUTOMATION_QUICKSTART_ZH.md new file mode 100644 index 0000000..15fa306 --- /dev/null +++ b/docs/AUTOMATION_QUICKSTART_ZH.md @@ -0,0 +1,70 @@ +# 自动化接口快速开始 + +`CodexProviderSync.Automation.exe` 是供脚本、持续集成(CI)和 AI Agent 调用的实验性 Windows 接口。普通桌面用户不需要它,直接使用 `CodexProviderSync.exe` 即可。 + +自动化接口包包含: + +- `CodexProviderSync.Automation.exe` +- `automation-protocol-v0.4.schema.json` +- 本说明文件 + +接口每次运行只向标准输出写入一份协议 `0.4` JSON;诊断信息写入标准错误。所有路径都必须是绝对路径。 + +## 只读命令 + +```powershell +# 查看协议能力和安全要求 +.\CodexProviderSync.Automation.exe describe + +# 查看当前 Provider、rollout 和 SQLite 状态 +.\CodexProviderSync.Automation.exe status ` + --codex-home C:\Users\you\.codex +``` + +## 先生成计划 + +写命令默认不会修改数据。以下示例生成一份同步计划: + +```powershell +$planResponse = .\CodexProviderSync.Automation.exe plan ` + --operation sync ` + --codex-home C:\Users\you\.codex ` + --provider openai | ConvertFrom-Json + +$planPath = 'C:\Temp\codex-provider-sync-plan.json' +$planJson = $planResponse.data | ConvertTo-Json -Depth 100 -Compress +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($planPath, $planJson, $utf8NoBom) +$planDigest = $planResponse.data.digest +``` + +请先检查返回的计划、警告和目标列表。计划会绑定当前输入与目标状态,具有有效期,并且只能使用一次。 + +## 明确执行计划 + +确认计划无误后,使用同一组操作参数,并同时提供 `--apply`、计划文件和计划摘要: + +```powershell +.\CodexProviderSync.Automation.exe sync ` + --codex-home C:\Users\you\.codex ` + --provider openai ` + --apply ` + --plan $planPath ` + --plan-digest $planDigest +``` + +如果目标状态在计划生成后发生变化,执行会被拒绝,需要重新生成计划。同步和切换仍遵循 Core 的备份、事务、回滚、锁和 WSL UNC 安全规则。 + +## 支持的命令 + +| 命令 | 用途 | +| --- | --- | +| `describe` | 查看协议能力和安全要求 | +| `status` | 只读检查当前状态 | +| `plan` | 为写操作生成计划 | +| `sync` | 同步历史会话元数据 | +| `switch` | 切换 Provider/model 后同步 | +| `restore` | 恢复托管备份 | +| `prune` | 清理旧的托管备份 | + +完整参数、安全限制和返回结构见项目 [README](../README.md) 与 `automation-protocol-v0.4.schema.json`。协议 `0.4` 仍处于 1.0 之前的实验阶段,未来可能发生不兼容变更。 diff --git a/docs/README_EN.md b/docs/README_EN.md index e1601ee..837491d 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -27,6 +27,12 @@ If all of your relays can reliably reuse one `model_provider` ID and history rem The tool does not sign in, manage accounts, or switch authentication. Switch Provider using your normal workflow first, then synchronize history. +## Relationship to Provider Switchers + +Provider managers, including cc-switch, primarily switch accounts, API keys, `auth.json`, or `config.toml`; some also provide their own history handling. codex-provider-sync deliberately leaves authentication alone and focuses on post-switch visibility metadata, rollout files, SQLite state, managed backups, and restoration. + +If your current switcher already keeps all history visible, you do not need to run another synchronization. This project remains useful when several switching workflows have split existing history, rollout and SQLite need to be reconciled together, SQLite Home is separate from Codex Home, or backup-backed transactional repair is required. + ## What It Updates - Rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions`. @@ -40,7 +46,13 @@ The tool does not sign in, manage accounts, or switch authentication. Switch Pro ### Windows GUI -For normal Windows use, download and extract `CodexProviderSync.exe` from [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest): +For normal Windows use, download the standalone GUI from [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest): + +| Use case | Release asset | Update method | +| --- | --- | --- | +| Windows GUI only | `CodexProviderSync.exe` | Built-in updates supported | +| Scripts, CI, or AI agents | `codex-provider-sync-v-automation-win-x64.zip` | Manual update | +| GUI and Automation together | `codex-provider-sync-v-win-x64.zip` | Manual update | 1. Open `CodexProviderSync.exe`. 2. Click `刷新` (Refresh). @@ -92,9 +104,9 @@ codex-provider sync --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you Node.js 24+ uses the built-in `node:sqlite` module. Older supported Node.js releases use the optional `better-sqlite3` dependency. -### Business Automation API (experimental v0.4) +### Automation API (experimental v0.4) -The v0.4 Windows Release build also contains `CodexProviderSync.Automation.exe` and `automation-protocol-v0.4.schema.json`. This one-shot process interface uses the same Application use cases as the Windows GUI. Each invocation emits exactly one protocol `0.4` JSON document on stdout and sends diagnostics to stderr. +Releases provide a separate Windows Automation package containing `CodexProviderSync.Automation.exe`, `automation-protocol-v0.4.schema.json`, and a Chinese quick start. The complete Windows package contains the same files. This one-shot process interface uses the same Application use cases as the Windows GUI. Each invocation emits exactly one protocol `0.4` JSON document on stdout and sends diagnostics to stderr. Normal desktop users do not need the Automation package. | Command | Purpose | | --- | --- | @@ -140,7 +152,10 @@ Before each `sync` or `switch`, the tool creates a backup under: - [Windows GUI guide](README_GUI_ZH.md) - [macOS GUI guide](README_MAC_GUI_EN.md) -- [v0.4.0 Release Notes (Draft)](RELEASE_NOTES_V0.4.0.md) +- [v0.4.0 Chinese release announcement](release-notes/v0.4.0-zh.md) +- [v0.4.0 technical release notes](RELEASE_NOTES_V0.4.0.md) +- [Changelog](../CHANGELOG.md) +- [Chinese Automation quick start](AUTOMATION_QUICKSTART_ZH.md) - [v0.4 Automation execution plan](V0.4_AUTOMATION_PLAN.md) - [中文说明](../README.md) - [AI / Agent guide](../AGENTS.md) diff --git a/docs/release-notes/TEMPLATE-zh.md b/docs/release-notes/TEMPLATE-zh.md new file mode 100644 index 0000000..1497ce1 --- /dev/null +++ b/docs/release-notes/TEMPLATE-zh.md @@ -0,0 +1,53 @@ + + + +> <用一段话说明本次升级解决什么问题、谁会受益,以及最重要的兼容性信息。> + +## 🚀 升级后,你可以 + +- **<用户结果一>**:<避免只描述内部实现。> +- **<用户结果二>**:<说明升级带来的直接变化。> +- **<用户结果三>**:<必要时说明不同入口或平台的一致性。> + +## 📦 下载 + +| 使用场景 | 下载 | 更新方式 | +| --- | --- | --- | +| 只需要 Windows GUI | [单文件 GUI](https://github.com/Dailin521/codex-provider-sync/releases/download/v<版本>/CodexProviderSync.exe) | 支持软件内自动更新 | +| 脚本、CI 或 AI Agent | [Windows x64 自动化接口包](https://github.com/Dailin521/codex-provider-sync/releases/download/v<版本>/codex-provider-sync-v<版本>-automation-win-x64.zip) | 手动下载更新 | +| GUI 与自动化接口都需要 | [Windows x64 完整包](https://github.com/Dailin521/codex-provider-sync/releases/download/v<版本>/codex-provider-sync-v<版本>-win-x64.zip) | 手动下载更新 | + +[查看全部 SHA-256 校验值](https://github.com/Dailin521/codex-provider-sync/releases/download/v<版本>/checksums.txt) + +## ⬆️ 升级说明 + +1. <说明哪些旧版本可以使用内置更新。> +2. <说明是否需要配置、数据库或备份迁移。> +3. <说明升级后的首次检查步骤。> + +## 🛡 安全保障 + +- <说明本版本保持不变的认证和消息边界。> +- <说明备份、事务和恢复边界。> +- <说明平台或路径安全限制。> + +## ⚙️ 自动化接口(如适用) + +<先说明普通用户是否需要本节,再说明协议变化和兼容性。没有变化时可省略本节。> + +## ⚠️ 重要说明 + +- <列出会直接影响使用结果、但不能由工具自动解决的限制。> +- <不要把普通实现细节放在这里。> + +## 🔍 验证结果 + +- <自动化测试摘要。> +- <真实 GUI 或平台验证摘要。> +- <发布资产和校验结果。> + +[完整变更对比](https://github.com/Dailin521/codex-provider-sync/compare/<上一版本>...v<版本>) + +## 🙏 贡献者(如适用) + +感谢 <贡献者及其具体贡献>。 diff --git a/docs/release-notes/v0.4.0-zh.md b/docs/release-notes/v0.4.0-zh.md new file mode 100644 index 0000000..5077a0c --- /dev/null +++ b/docs/release-notes/v0.4.0-zh.md @@ -0,0 +1,76 @@ + + +> v0.4.0 重点解决同步中途失败或意外退出时的数据安全问题:所有写入先备份,失败时回滚,异常退出后保留恢复信息;同时新增了供脚本、持续集成(CI)和 AI Agent 使用的实验性自动化接口。 + +## 🚀 升级后,你可以 + +- **更放心地同步历史会话**:写入前自动创建托管备份;失败或取消时回滚;无法确认操作完整性时不会报告成功。 +- **让脚本或 AI Agent 安全调用同步功能**:新的 Windows 自动化接口返回机器可读的 JSON,写操作采用“先生成计划,再明确执行”的两阶段流程。 +- **在不同入口获得一致结果**:Windows GUI 与自动化接口使用同一套状态检查、校验、备份、恢复、锁和 WSL 安全规则。 + +## 📦 下载 + +| 使用场景 | 下载 | 更新方式 | +| --- | --- | --- | +| 只需要 Windows GUI | [单文件 GUI](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.0/CodexProviderSync.exe) | 支持软件内自动更新 | +| 脚本、CI 或 AI Agent | [Windows x64 自动化接口包](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.0/codex-provider-sync-v0.4.0-automation-win-x64.zip) | 手动下载更新 | +| GUI 与自动化接口都需要 | [Windows x64 完整包](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.0/codex-provider-sync-v0.4.0-win-x64.zip) | 手动下载更新 | + +[查看全部 SHA-256 校验值](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.0/checksums.txt) + +当前 Windows 程序尚未进行代码签名,首次运行可能出现 SmartScreen 的“发布者未知”提示。请确认文件来自本项目 GitHub Release,并按需使用 SHA-256 校验下载内容。 + +## ⬆️ 升级说明 + +1. v0.3.1 / v0.3.2 Windows GUI 用户可以直接使用内置更新:每天首次启动会自动检查稳定版,也可以点击“检查更新”;确认后会自动下载、校验并重启完成升级。 +2. 内置更新只升级 GUI,不会安装 `CodexProviderSync.Automation.exe` 或协议描述文件。需要自动化接口的用户请手动下载对应 ZIP。 +3. 更新后先在 GUI 点击“刷新”;CLI 用户先运行 `codex-provider status`,确认当前 Provider、Codex Home 和 SQLite Home。 +4. 建议在同步、切换或恢复前关闭 Codex Desktop、Codex App 和 app-server,以避免 SQLite 被占用或活跃的 rollout 文件被跳过。 + +升级程序本身不要求手动迁移配置;v0.4.0 仍可识别旧版托管备份。`sync` / `switch` 会在写入前自动创建新备份,不要求另外手动备份。如果结果显示 `Skipped locked rollout files`,结束对应的活跃会话后再次同步即可。 + +## 🛡 安全保障 + +- 工具不负责登录、认证或切换账号,也不会修改 `auth.json`;自动化接口还会拒绝直接访问该文件。 +- 工具只同步会话可见性相关元数据,不改写对话正文、消息历史、会话标题、`updated_at` 或 `encrypted_content`。 +- 写操作使用与备份绑定的事务记录和原子文件替换。失败时尝试回滚;如果无法确认回滚完整,会明确提示需要恢复。 +- 恢复到不同的 SQLite Home 默认会被拒绝;Windows 进程也不会通过 WSL UNC 路径直接修改 SQLite。 + +## ⚙️ 自动化接口(实验性) + +普通桌面用户可以跳过本节,继续使用 GUI 即可。 + +`CodexProviderSync.Automation.exe` 主要供脚本、持续集成(CI)和 AI Agent 调用。它返回固定结构的 JSON 和可区分的退出码,外部程序不需要操作 GUI,也不需要另外安装 Node.js。 + +```powershell +# 查看接口能力 +.\CodexProviderSync.Automation.exe describe + +# 只读检查 Codex 状态 +.\CodexProviderSync.Automation.exe status --codex-home C:\Users\you\.codex +``` + +接口支持 `describe`、`status`、`plan`、`sync`、`switch`、`restore` 和 `prune`。所有写命令默认只生成计划,不会修改数据;真正执行时必须同时提供 `--apply`、匹配的计划文件及其 SHA-256 摘要。计划有有效期、绑定目标状态,并且只能使用一次。 + +该接口不会替代现有 Node CLI。协议 `0.4` 仍处于 1.0 之前的实验阶段,未来可能发生不兼容变更。完整命令示例见 [README](https://github.com/Dailin521/codex-provider-sync/blob/v0.4.0/README.md#business-automation-apiv04-实验性),自动化接口包内也附带中文快速说明。 + +## ⚠️ 重要说明 + +- `\\wsl.localhost\...` 和 `\\wsl$\...` 形式的 SQLite Home 在 Windows 中仅用于安全诊断。请进入对应 WSL 发行版,使用 Linux 路径运行 CLI。 +- 含 `encrypted_content` 的会话跨 Provider 或账号后,通常只能恢复列表可见性;继续对话或执行 compact 仍可能出现 `invalid_encrypted_content`。 +- Codex Desktop 首屏可能只显示最近 50 条会话。本工具不会修改 `updated_at` 来改变排序。 +- v0.4.0 的完整发布验证以 Windows 为准;macOS GUI 尚未迁移到这套共享架构。 + +## 🔍 验证结果 + +- Core、Application、自动化接口、Windows GUI 和 Node CLI 共通过 **500+ 项自动化测试**。 +- 发布版真实 EXE 通过 53/53 个必需的可见 Windows GUI 场景,0 项错误、0 项阻断。 +- 发布版本实现零警告、零错误构建,所有发布文件均提供 SHA-256 校验。 + +完整协议约束、测试计数和审查证据见 [v0.4.0 技术发布说明](https://github.com/Dailin521/codex-provider-sync/blob/v0.4.0/docs/RELEASE_NOTES_V0.4.0.md)。 + +[查看 v0.3.2 到 v0.4.0 的完整代码变更](https://github.com/Dailin521/codex-provider-sync/compare/v0.3.2...v0.4.0)。 + +## 🙏 贡献者 + +感谢 [@Hccake](https://github.com/Hccake) 通过 [#55](https://github.com/Dailin521/codex-provider-sync/pull/55) 贡献独立 SQLite Home 支持,并完善桌面端配置、恢复迁移保护、WSL 路径安全检测及相关文档。 diff --git a/package.json b/package.json index c3b0450..6b3596a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "files": [ "README.md", + "CHANGELOG.md", "AGENTS.md", "docs", "src" diff --git a/scripts/package-release-assets.ps1 b/scripts/package-release-assets.ps1 new file mode 100644 index 0000000..f436cf3 --- /dev/null +++ b/scripts/package-release-assets.ps1 @@ -0,0 +1,129 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Version, + [string]$PublishOutput = "artifacts\win-x64", + [string]$Output = "artifacts\release" +) + +$ErrorActionPreference = "Stop" + +if ($Version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$') { + throw "Version must be a semantic version without a leading v: $Version" +} + +$repoRoot = [System.IO.Path]::GetFullPath((Resolve-Path (Join-Path $PSScriptRoot "..")).Path) +$publishDir = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $PublishOutput)) +$assetRoot = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Output)) + +function Assert-WorkspacePath([string]$Candidate, [string]$Label) { + $prefix = $repoRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($Candidate.Equals($repoRoot, [System.StringComparison]::OrdinalIgnoreCase) ` + -or -not $Candidate.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$Label must stay inside the repository workspace: $Candidate" + } +} + +Assert-WorkspacePath $publishDir "Publish output" +Assert-WorkspacePath $assetRoot "Release asset output" + +function Test-IsSameOrNestedPath([string]$Candidate, [string]$Parent) { + $parentPrefix = $Parent.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + return $Candidate.Equals($Parent, [System.StringComparison]::OrdinalIgnoreCase) ` + -or $Candidate.StartsWith($parentPrefix, [System.StringComparison]::OrdinalIgnoreCase) +} + +if ((Test-IsSameOrNestedPath $assetRoot $publishDir) ` + -or (Test-IsSameOrNestedPath $publishDir $assetRoot)) { + throw "Publish output and release asset output must be separate directory trees." +} + +if (-not (Test-Path -LiteralPath $publishDir -PathType Container)) { + throw "Publish output does not exist: $publishDir" +} + +$requiredFiles = @( + "CodexProviderSync.exe", + "CodexProviderSync.Automation.exe", + "automation-protocol-v0.4.schema.json", + "README-AUTOMATION.zh-CN.md" +) +foreach ($fileName in $requiredFiles) { + $source = Join-Path $publishDir $fileName + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Release package input is missing: $source" + } +} + +if (Test-Path -LiteralPath $assetRoot) { + Remove-Item -LiteralPath $assetRoot -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $assetRoot | Out-Null + +$fullZip = Join-Path $assetRoot "codex-provider-sync-v$Version-win-x64.zip" +Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $fullZip -Force +Copy-Item ` + -LiteralPath (Join-Path $publishDir "CodexProviderSync.exe") ` + -Destination (Join-Path $assetRoot "CodexProviderSync.exe") ` + -Force + +$automationStage = Join-Path $assetRoot "automation-win-x64" +New-Item -ItemType Directory -Force -Path $automationStage | Out-Null +try { + @( + "CodexProviderSync.Automation.exe", + "automation-protocol-v0.4.schema.json", + "README-AUTOMATION.zh-CN.md" + ) | ForEach-Object { + Copy-Item ` + -LiteralPath (Join-Path $publishDir $_) ` + -Destination $automationStage ` + -Force + } + + $automationZip = Join-Path $assetRoot "codex-provider-sync-v$Version-automation-win-x64.zip" + Compress-Archive -Path (Join-Path $automationStage "*") -DestinationPath $automationZip -Force + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($automationZip) + try { + $actualEntries = @($archive.Entries ` + | Where-Object { -not [string]::IsNullOrEmpty($_.Name) } ` + | ForEach-Object { $_.FullName.Replace('\', '/') } ` + | Sort-Object) + $expectedEntries = @( + "CodexProviderSync.Automation.exe", + "README-AUTOMATION.zh-CN.md", + "automation-protocol-v0.4.schema.json" + ) | Sort-Object + if ($actualEntries.Count -ne $expectedEntries.Count ` + -or (Compare-Object -ReferenceObject $expectedEntries -DifferenceObject $actualEntries)) { + throw "Automation ZIP contents do not match the required three-file contract: $($actualEntries -join ', ')" + } + } + finally { + $archive.Dispose() + } +} +finally { + if (Test-Path -LiteralPath $automationStage) { + Remove-Item -LiteralPath $automationStage -Recurse -Force + } +} + +$primaryAssets = @( + Join-Path $assetRoot "CodexProviderSync.exe" + Join-Path $assetRoot "codex-provider-sync-v$Version-automation-win-x64.zip" + Join-Path $assetRoot "codex-provider-sync-v$Version-win-x64.zip" +) +$checksums = foreach ($asset in $primaryAssets | Sort-Object) { + if (-not (Test-Path -LiteralPath $asset -PathType Leaf)) { + throw "Expected release asset was not created: $asset" + } + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset).Hash.ToLowerInvariant() + $fileName = [System.IO.Path]::GetFileName($asset) + "$hash $fileName" + Set-Content -LiteralPath "$asset.sha256" -Value "$hash $fileName" -Encoding ASCII +} +Set-Content -LiteralPath (Join-Path $assetRoot "checksums.txt") -Value $checksums -Encoding ASCII + +Write-Host "Release assets packaged to $assetRoot" diff --git a/scripts/publish-gui.ps1 b/scripts/publish-gui.ps1 index 7486b6f..eb6ccab 100644 --- a/scripts/publish-gui.ps1 +++ b/scripts/publish-gui.ps1 @@ -9,6 +9,7 @@ $ErrorActionPreference = "Stop" $repoRoot = [System.IO.Path]::GetFullPath((Resolve-Path (Join-Path $PSScriptRoot "..")).Path) $project = Join-Path $repoRoot "desktop\CodexProviderSync.App\CodexProviderSync.App.csproj" $automationProject = Join-Path $repoRoot "desktop\CodexProviderSync.Automation\CodexProviderSync.Automation.csproj" +$automationQuickStart = Join-Path $repoRoot "docs\AUTOMATION_QUICKSTART_ZH.md" $outputDir = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Output)) $automationOutputDir = [System.IO.Path]::GetFullPath("$outputDir-automation") @@ -23,6 +24,10 @@ function Assert-WorkspaceOutputPath([string]$Candidate, [string]$Label) { Assert-WorkspaceOutputPath $outputDir "Publish output" Assert-WorkspaceOutputPath $automationOutputDir "Automation staging output" +if (-not (Test-Path -LiteralPath $automationQuickStart -PathType Leaf)) { + throw "Automation quick-start document is missing: $automationQuickStart" +} + if (Test-Path $outputDir) { try { Remove-Item -Recurse -Force $outputDir @@ -74,6 +79,7 @@ try { } Copy-Item -LiteralPath $automationExecutable -Destination $outputDir -Force Copy-Item -LiteralPath $automationSchema -Destination $outputDir -Force + Copy-Item -LiteralPath $automationQuickStart -Destination (Join-Path $outputDir "README-AUTOMATION.zh-CN.md") -Force } finally { if (Test-Path -LiteralPath $automationOutputDir) { diff --git a/scripts/read-release-metadata.js b/scripts/read-release-metadata.js new file mode 100644 index 0000000..52d28a8 --- /dev/null +++ b/scripts/read-release-metadata.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const RELEASE_TAG_PATTERN = + /^v(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-]+)*))?$/; +const TITLE_PATTERN = /^$/gm; +const REQUIRED_ANNOUNCEMENT_MARKERS = [ + "## 📦 下载", + "## ⬆️ 升级说明", + "## 🛡 安全保障", + "## ⚠️ 重要说明", + "## 🔍 验证结果", + "SmartScreen", + "SHA-256", + "`auth.json`", + "`updated_at`", + "`encrypted_content`", + "回滚", + "WSL UNC", +]; + +function displayPath(rootDir, targetPath) { + return path.relative(rootDir, targetPath).replaceAll("\\", "/"); +} + +export function readReleaseMetadata({ rootDir, tag }) { + if (typeof tag !== "string" || !RELEASE_TAG_PATTERN.test(tag)) { + throw new Error(`Release tag must use the form v; received ${JSON.stringify(tag)}.`); + } + + const relativeBodyPath = `docs/release-notes/${tag}-zh.md`; + const bodyPath = path.join(rootDir, ...relativeBodyPath.split("/")); + if (!fs.existsSync(bodyPath)) { + throw new Error(`Chinese release announcement is missing: ${relativeBodyPath}`); + } + + const body = fs.readFileSync(bodyPath, "utf8"); + const titleMatches = [...body.matchAll(TITLE_PATTERN)]; + if (titleMatches.length !== 1) { + throw new Error( + `${relativeBodyPath} must contain exactly one line.`, + ); + } + + const title = titleMatches[0][1].trim(); + if (!title.startsWith(`${tag} - `) || title.length > 120) { + throw new Error( + `${relativeBodyPath} release title must start with ${JSON.stringify(`${tag} - `)} and be at most 120 characters.`, + ); + } + if (body.length < 200) { + throw new Error(`${relativeBodyPath} is too short to be a complete release announcement.`); + } + if (/\b(?:TODO|TBD)\b/.test(body)) { + throw new Error(`${relativeBodyPath} still contains a TODO or TBD placeholder.`); + } + const missingMarkers = REQUIRED_ANNOUNCEMENT_MARKERS.filter( + (marker) => !body.includes(marker), + ); + if (missingMarkers.length > 0) { + throw new Error( + `${relativeBodyPath} is missing required release or safety content: ${missingMarkers.join(", ")}`, + ); + } + + return { + tag, + title, + body, + bodyPath, + relativeBodyPath: displayPath(rootDir, bodyPath), + }; +} + +function parseArguments(argumentsList) { + if (argumentsList.length < 2 || argumentsList[0] !== "--tag" || !argumentsList[1]) { + throw new Error( + "Usage: node scripts/read-release-metadata.js --tag v [--github-output ]", + ); + } + + const options = { tag: argumentsList[1], githubOutput: null }; + for (let index = 2; index < argumentsList.length; index += 2) { + if (argumentsList[index] !== "--github-output" || !argumentsList[index + 1]) { + throw new Error( + "Usage: node scripts/read-release-metadata.js --tag v [--github-output ]", + ); + } + options.githubOutput = argumentsList[index + 1]; + } + return options; +} + +function main() { + try { + const { tag, githubOutput } = parseArguments(process.argv.slice(2)); + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const result = readReleaseMetadata({ + rootDir: path.resolve(scriptDirectory, ".."), + tag, + }); + + if (githubOutput) { + fs.appendFileSync( + githubOutput, + `release_title=${result.title}\nrelease_body_path=${result.relativeBodyPath}\n`, + "utf8", + ); + } else { + console.log( + JSON.stringify( + { + tag: result.tag, + title: result.title, + bodyPath: result.relativeBodyPath, + }, + null, + 2, + ), + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +const isDirectInvocation = + process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; + +if (isDirectInvocation) { + main(); +} diff --git a/test/release-metadata.test.js b/test/release-metadata.test.js new file mode 100644 index 0000000..a70ad5d --- /dev/null +++ b/test/release-metadata.test.js @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { readReleaseMetadata } from "../scripts/read-release-metadata.js"; + +function withFixture(operation) { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "release-metadata-")); + try { + fs.mkdirSync(path.join(rootDir, "docs", "release-notes"), { recursive: true }); + return operation(rootDir); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } +} + +function writeAnnouncement(rootDir, tag, content) { + fs.writeFileSync( + path.join(rootDir, "docs", "release-notes", `${tag}-zh.md`), + content, + "utf8", + ); +} + +function completeAnnouncement(tag, title, extra = "") { + return ` + +${"发布内容。".repeat(50)} + +## 📦 下载 +SmartScreen 与 SHA-256。 + +## ⬆️ 升级说明 +升级说明。 + +## 🛡 安全保障 +\`auth.json\`、\`updated_at\`、\`encrypted_content\`、回滚与 WSL UNC。 + +## ⚠️ 重要说明 +重要说明。 + +## 🔍 验证结果 +验证结果。 + +${extra} +`; +} + +test("reads the current repository Chinese release metadata", () => { + const testDirectory = path.dirname(fileURLToPath(import.meta.url)); + const rootDir = path.resolve(testDirectory, ".."); + + const result = readReleaseMetadata({ rootDir, tag: "v0.4.0" }); + + assert.equal(result.title, "v0.4.0 - 更安全的事务化同步与自动化支持"); + assert.equal(result.relativeBodyPath, "docs/release-notes/v0.4.0-zh.md"); +}); + +test("reads one validated release title and body path", () => + withFixture((rootDir) => { + writeAnnouncement( + rootDir, + "v1.2.3", + completeAnnouncement("v1.2.3", "中文发布标题"), + ); + + const result = readReleaseMetadata({ rootDir, tag: "v1.2.3" }); + + assert.equal(result.title, "v1.2.3 - 中文发布标题"); + assert.equal(result.relativeBodyPath, "docs/release-notes/v1.2.3-zh.md"); + })); + +test("rejects a missing versioned Chinese announcement", () => + withFixture((rootDir) => { + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "v1.2.3" }), + /Chinese release announcement is missing/, + ); + })); + +test("rejects duplicate, mismatched, and placeholder release metadata", () => + withFixture((rootDir) => { + writeAnnouncement( + rootDir, + "v1.2.3", + `${completeAnnouncement("v1.2.3", "标题一")}\n`, + ); + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "v1.2.3" }), + /exactly one/, + ); + + writeAnnouncement( + rootDir, + "v1.2.3", + completeAnnouncement("v1.2.2", "错误版本"), + ); + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "v1.2.3" }), + /must start with/, + ); + + writeAnnouncement( + rootDir, + "v1.2.3", + completeAnnouncement("v1.2.3", "标题", "TODO"), + ); + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "v1.2.3" }), + /placeholder/, + ); + })); + +test("rejects a release announcement missing required safety content", () => + withFixture((rootDir) => { + const incomplete = completeAnnouncement("v1.2.3", "标题").replace("`auth.json`", "认证文件"); + writeAnnouncement(rootDir, "v1.2.3", incomplete); + + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "v1.2.3" }), + /missing required release or safety content: `auth\.json`/, + ); + })); + +test("rejects unsafe or malformed release tags", () => + withFixture((rootDir) => { + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "../v1.2.3" }), + /must use the form v/, + ); + assert.throws( + () => readReleaseMetadata({ rootDir, tag: "v01.2.3" }), + /must use the form v/, + ); + })); diff --git a/test/release-packaging-contract.test.js b/test/release-packaging-contract.test.js new file mode 100644 index 0000000..82d0038 --- /dev/null +++ b/test/release-packaging-contract.test.js @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(testDirectory, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(rootDir, ...relativePath.split("/")), "utf8"); +} + +test("release packaging preserves the existing GUI updater asset contract", () => { + const updateService = read("desktop/CodexProviderSync.Core/UpdateService.cs"); + const packaging = read("scripts/package-release-assets.ps1"); + + assert.match(updateService, /FindAsset\("CodexProviderSync\.exe"\)/); + assert.match(updateService, /FindAsset\("CodexProviderSync\.exe\.sha256"\)/); + assert.match(packaging, /Join-Path \$assetRoot "CodexProviderSync\.exe"/); + assert.match(packaging, /Set-Content -LiteralPath "\$asset\.sha256"/); + assert.match(packaging, /must be separate directory trees/); +}); + +test("release packaging creates a focused Automation ZIP with its protocol and guide", () => { + const publish = read("scripts/publish-gui.ps1"); + const packaging = read("scripts/package-release-assets.ps1"); + + assert.match(publish, /README-AUTOMATION\.zh-CN\.md/); + assert.match(packaging, /codex-provider-sync-v\$Version-automation-win-x64\.zip/); + assert.match(packaging, /CodexProviderSync\.Automation\.exe/); + assert.match(packaging, /automation-protocol-v0\.4\.schema\.json/); + assert.match(packaging, /README-AUTOMATION\.zh-CN\.md/); +}); + +test("publish workflow resolves a tag-bound Chinese announcement instead of hardcoding a body", () => { + const workflow = read(".github/workflows/publish.yml"); + + assert.match(workflow, /read-release-metadata\.js --tag/); + assert.match(workflow, /body_path: \$\{\{ steps\.release_metadata\.outputs\.release_body_path \}\}/); + assert.match(workflow, /name: \$\{\{ steps\.release_metadata\.outputs\.release_title \}\}/); + assert.doesNotMatch(workflow, /^\s+body:\s*\|/m); +});