5 Commits

Author SHA1 Message Date
Cheng Zhou 9c533b8c37 release(main): align v0.1.0 asset policy
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-17 12:41:00 +08:00
Cheng Zhou 795e0a75ca build(release): simplify v0.1.0 visible assets
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 12:40:16 +08:00
Cheng Zhou 72b907947b release(main): promote v0.1.0 candidate
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Desktop Release Candidate / Resolve desktop release signing policy (push) Has been cancelled
Desktop Release Candidate / macOS release candidate (push) Has been cancelled
Desktop Release Candidate / Windows release candidate (push) Has been cancelled
Desktop Release Candidate / Verify combined desktop release directory (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 11:34:26 +08:00
Cheng Zhou e3489f3b06 build(release): stage local macOS v0.1.0 distribution
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 11:31:11 +08:00
chengchengzhou7 403776cc1c release(main): 同步 v0.1.0 updater 信任根 (#9)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
* docs: add desktop project plan

* feat(desktop): implement phase 1 tauri client

* refactor(client): unify web and desktop release workflow

* feat(desktop): implement phase 2 native capabilities

* 完善桌面端交互体验与发布检查

* 完善桌面端界面、发布检查与邮箱域名同步

* fix(deploy): 修复数据库初始化复用旧镜像

* fix(deploy): 增加部署更新实时进度

* fix(auth): 支持无邮箱后缀时手动输入

* feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试

* feat(desktop): 支持桌面端三十天免登录

* 完善桌面端发布稳定化门禁

* 完善桌面端端到端回归收口

* 补齐桌面端附件文件流回归

* 完善桌面端回归与安全边界复审

* 完善桌面体验与系统通知收口

* feat(desktop): 收口桌面工作台视觉与活动反馈

* 优化桌面端界面布局

* style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标

* 功能(桌面端):增加在线辅助本地缓存

* 优化桌面端标签导航与后台交互

* ci: 新增 Windows 桌面端内测构建

* fix: 修复桌面检查脚本的 Windows 路径判断

* test: 兼容 Windows 换行的桌面布局断言

* test: 兼容 Windows 换行的路由断言

* ci: 修复 Windows 内测构建配置传参

* ci: 避免 Windows 安装器构建交互等待

* 修复桌面端界面显示与稳定性问题

* feat(网页端): 完善登录后工作台与项目管理体验

* docs(desktop): 精简桌面端约束入口

* feat(admin): 完善审计访问上下文与后台布局

* fix(git): 跟踪原生图标资源

* fix(web): 修正工作台端侧标识

* feat(监控): 完善系统监控、登录状态与访问审计能力

* 修复(权限管理):统一 PM 系统导航与权限校验

* feat(工作台): 优化入口布局与连接安全状态

* feat(桌面与监控): 完善工作台导航和登录活动定位

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖

* 功能(文档与桌面):完善文件预览下载与客户端构建基线

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档

* 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。

* feat(collaboration): 完善在线文档协作与通知闭环

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。

* refactor(frontend): 按需加载页面并清理未使用代码

- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。

* fix(审计): 移除共享库审计与预览噪声

* 功能(提醒):统一项目提醒中心与桌面通知链路

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。

* feat(deploy): 默认安装 ONLYOFFICE 标准组件

* build(release): 加固 v0.1.0 桌面发布链路 (#3)

* build(release): 轮换 v0.1.0 updater 公钥 (#7)
2026-07-17 10:58:22 +08:00
21 changed files with 260 additions and 13 deletions
@@ -40,6 +40,8 @@ jobs:
web: web:
name: Shared client and Web name: Shared client and Web
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults: defaults:
run: run:
working-directory: frontend working-directory: frontend
@@ -104,6 +106,8 @@ jobs:
desktop: desktop:
name: macOS Desktop name: macOS Desktop
runs-on: macos-latest runs-on: macos-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults: defaults:
run: run:
working-directory: frontend working-directory: frontend
@@ -55,6 +55,7 @@ jobs:
env: env:
VITE_BUILD_CHANNEL: release VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }} VITE_BUILD_COMMIT: ${{ github.sha }}
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
RELEASE_BUILD: "true" RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: macos DESKTOP_RELEASE_PLATFORM: macos
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
@@ -194,6 +195,7 @@ jobs:
env: env:
VITE_BUILD_CHANNEL: release VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }} VITE_BUILD_COMMIT: ${{ github.sha }}
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
RELEASE_BUILD: "true" RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: windows DESKTOP_RELEASE_PLATFORM: windows
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
@@ -14,6 +14,8 @@ jobs:
windows-internal: windows-internal:
name: Windows NSIS internal validation name: Windows NSIS internal validation
runs-on: windows-latest runs-on: windows-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults: defaults:
run: run:
working-directory: frontend working-directory: frontend
+3 -1
View File
@@ -14,7 +14,9 @@
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。 - 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。 - 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。 - token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用。
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。 - Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。面向安装用户的 GitHub Release 只保留 DMG 与只校验该安装包的 `SHA256SUMS.txt`,平台未签名风险和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 证据必须保存在被 Git 忽略的私有发布目录,待 Windows `NotSigned` 制品和联合 feed 均验证通过后再发布到可匿名读取的独立 HTTPS updater 源;此前不得发布或替换生产 `latest.json`。该本地/分阶段例外不适用于后续版本。
## 分支与发布治理 ## 分支与发布治理
@@ -57,4 +59,4 @@ npm run desktop:build:app
本地缓存相关变更至少执行 `runtime:check``desktop:release:check``ui:contract``type-check``test:unit``build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。 本地缓存相关变更至少执行 `runtime:check``desktop:release:check``ui:contract``type-check``test:unit``build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、发布说明、校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。该例外不自动适用于后续版本。 正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、Release Notes、私有发布证据、完整 updater 校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。面向安装用户的 GitHub Release 可按精确版本策略精简为安装包与对应 checksum,但不得因此删除私有 updater 签名制品或验证证据。该例外不自动适用于后续版本。
@@ -88,7 +88,8 @@ npm run desktop:build:app
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 | | 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 | | 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 |
| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | | 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 | | 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
@@ -288,7 +289,10 @@ npm run desktop:build:app
- macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。 - macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。
- Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。 - Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。
- 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。 - 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。
- 制品与 Actions artifact 名称必须含 `_UNSIGNED``UNSIGNED-PLATFORM`,发布目录必须携带 `UNSIGNED-PLATFORM-RELEASE.txt``DESKTOP-RELEASE-PROVENANCE.json` - GitHub Actions 额度不可用期间,发布负责人额外批准从最终 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS 制品;Windows 必须在恢复后从同一 tag/SHA 后补
- macOS 先行的用户可见 GitHub Release 只包含 DMG、仅覆盖该 DMG 的 checksum 和 GitHub 自动源码归档;Release Notes 必须明确 macOS ad-hoc/未公证风险和 Windows pending,且不得包含或替换生产 `latest.json`。updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 和 Windows pending 证据必须先复制到权限受限且被 Git 忽略的私有发布目录。
- Windows `NotSigned` 实物和联合 feed 全部验证通过后,才允许激活生产 updater feedtag 不得为补充 Windows 制品而移动。
- 制品与 Actions artifact 名称必须含 `_UNSIGNED``UNSIGNED-PLATFORM`,私有证据/完整 updater 发布目录必须携带 `UNSIGNED-PLATFORM-RELEASE.txt``DESKTOP-RELEASE-PROVENANCE.json`installer-only GitHub Release 通过 `_UNSIGNED` 安装包名和 Release Notes 呈现风险,不要求公开展示内部证据文件。
- 受控分发说明必须明确提示 macOS Gatekeeper 与 Windows SmartScreen 警告;平台未签名制品不得描述为 Apple/Microsoft 信任或已公证/已 Authenticode 签名。 - 受控分发说明必须明确提示 macOS Gatekeeper 与 Windows SmartScreen 警告;平台未签名制品不得描述为 Apple/Microsoft 信任或已公证/已 Authenticode 签名。
本地实物验证已完成:使用当前 updater 私钥成功构建 Universal `x86_64 arm64` macOS app、DMG、`.app.tar.gz``.sig``codesign --verify --deep --strict` 通过,签名详情为 `Signature=adhoc``TeamIdentifier=not set`Gatekeeper 拒绝符合预期。Windows `NotSigned`、NSIS 和 updater `.sig` 仍须由 `windows-latest` 原生 job 在正式 tag 上验证。 本地实物验证已完成:使用当前 updater 私钥成功构建 Universal `x86_64 arm64` macOS app、DMG、`.app.tar.gz``.sig``codesign --verify --deep --strict` 通过,签名详情为 `Signature=adhoc``TeamIdentifier=not set`Gatekeeper 拒绝符合预期。Windows `NotSigned`、NSIS 和 updater `.sig` 仍须由 `windows-latest` 原生 job 在正式 tag 上验证。
+15
View File
@@ -317,6 +317,21 @@ Platform signing remains the default for versions without an exact approved
exception. An exception never permits disabling Tauri updater signatures or exception. An exception never permits disabling Tauri updater signatures or
building Web, macOS, and Windows from different tags or commits. building Web, macOS, and Windows from different tags or commits.
For v0.1.0 only, the release owner also approved a staged contingency while
hosted Actions capacity is unavailable: macOS may be built on a controlled
local macOS host from the immutable final `v0.1.0` tag and published first.
Windows remains pending and must later be built from that same tag and SHA.
The user-facing macOS-only GitHub Release may contain only the DMG and a
checksum manifest that covers that installer; platform-trust and
Windows-pending status must remain explicit in the Release Notes. The updater
package and signature, full checksum manifest, provenance, warning, and
Windows-pending evidence must be retained in a Git-ignored private release
directory. After Windows is built from the same tag and SHA, those updater
artifacts may be published only to a separately configured anonymous HTTPS
update origin. The staged release must not publish or replace production
`latest.json`; updater feed activation waits for combined macOS and Windows
verification.
Reference: Reference:
- [docs/guides/release-checklist.md](guides/release-checklist.md) - [docs/guides/release-checklist.md](guides/release-checklist.md)
+6 -2
View File
@@ -108,6 +108,8 @@ npm run desktop:build:app
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。 正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。
v0.1.0 额外批准 GitHub Actions 额度不可用时的分阶段应急路径:先在受控 macOS 主机从最终、不可移动的 `v0.1.0` tag/SHA 本地构建 macOS ad-hoc 制品,面向安装用户的 GitHub Release 只保留 DMG 与仅覆盖该安装包的 checksum,平台未签名警告和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、风险说明和 Windows pending 证据必须保存在被 Git 忽略的私有发布目录;Windows 恢复后必须从同一 tag/SHA 后补,并在联合验证通过后把 updater 制品发布到可匿名读取的独立 HTTPS 更新源。macOS 先行阶段不得发布或替换生产 `latest.json`,联合 feed 必须等待 Windows `NotSigned` 实物验证完成。
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
### 4. 创建提交 ### 4. 创建提交
@@ -295,7 +297,9 @@ git tag -a v1.2.0 -m "CTMS v1.2.0"
git push origin v1.2.0 git push origin v1.2.0
``` ```
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并随安装包分发 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum,之后才能最后原子替换生产 `latest.json` 网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并在 Release Notes、私有发布证据、完整 updater checksum 和 provenance 中记录 `UNSIGNED-PLATFORM` 风险;面向安装用户的 Release 可按精确版本策略只显示安装包与安装包 checksum,但 updater 制品及验证证据必须保留,之后才能最后原子替换生产 `latest.json`
若执行 v0.1.0 的已批准分阶段应急路径,首次 macOS Release 可先于 Windows 发布,但 tag/SHA 不得移动;GitHub Release 只保留 DMG、仅覆盖该 DMG 的 checksum 和 GitHub 自动提供的源码归档,并在 Release Notes 中明确标记未签名风险与 Windows pending。updater 包、`.sig`、完整 checksum、provenance 和说明文件必须在私有发布目录留存。Windows 后补且联合 feed 验证通过后,才允许把这些 updater 制品发布到独立匿名 HTTPS 更新源并按上述顺序激活生产 updater feed。
发布记录至少包含: 发布记录至少包含:
@@ -450,7 +454,7 @@ git log --oneline --decorate --graph --all -30
- [ ] `npm run build` 通过 - [ ] `npm run build` 通过
- [ ] `npm run desktop:build:app` 通过 - [ ] `npm run desktop:build:app` 通过
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行 - [ ] 正式桌面发布构建已使用 updater 签名私钥执行
- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum 齐备 - [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 Release Notes、私有 `UNSIGNED-PLATFORM` 证据、provenance 和完整 updater checksum 齐备;若 GitHub Release 采用 installer-only profile,公开 checksum 只覆盖公开安装包
- [ ] 数据库迁移与回滚方案确认 - [ ] 数据库迁移与回滚方案确认
- [ ] 发布说明完成 - [ ] 发布说明完成
- [ ] `main -> release` 合并完成 - [ ] `main -> release` 合并完成
+44 -5
View File
@@ -163,11 +163,39 @@ Platform signing defaults to `signed`. An exception is allowed only when
`frontend/desktop-release-policy.json` names the exact product version and `frontend/desktop-release-policy.json` names the exact product version and
records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc
signing and unsigned Windows application/installer binaries. Its artifact names signing and unsigned Windows application/installer binaries. Its artifact names
and release directory contain `UNSIGNED-PLATFORM`; the directory must include and verified updater release directory contain `UNSIGNED-PLATFORM`; the private
`UNSIGNED-PLATFORM-RELEASE.txt`, `DESKTOP-RELEASE-PROVENANCE.json`, and evidence/update directory must include `UNSIGNED-PLATFORM-RELEASE.txt`,
`SHA256SUMS.txt`. This does not establish Apple or Microsoft publisher trust, `DESKTOP-RELEASE-PROVENANCE.json`, and the full `SHA256SUMS.txt`. The
and Gatekeeper or SmartScreen warnings are expected. Later versions return to user-facing GitHub Release may use the exact-version
the signed default unless separately approved. `installer-and-checksum-only` profile, with platform warnings in Release Notes
and a separate checksum manifest covering only the displayed installer. This
does not establish Apple or Microsoft publisher trust, and Gatekeeper or
SmartScreen warnings are expected. Later versions return to the signed default
unless separately approved.
### v0.1.0 staged macOS contingency
The release owner approved one additional v0.1.0 contingency for unavailable
hosted Actions capacity. A controlled local macOS host may build the macOS
ad-hoc artifacts from the immutable final `v0.1.0` tag and publish them first.
That initial user-facing GitHub Release contains only the DMG and a
`SHA256SUMS.txt` that covers the DMG, in addition to GitHub's automatic source
archives. The Release Notes must state that macOS is ad-hoc/not notarized and
that Windows remains pending. The macOS updater package and `.sig`, full
checksum manifest, provenance, unsigned-platform warning, and Windows-pending
notice must be copied to a permission-restricted, Git-ignored private release
directory before any visible asset is removed. Windows must later be built from
the same tag and SHA.
The staged macOS release is an installer distribution, not an activated
cross-platform updater release. Do not upload or replace production
`latest.json` until the Windows `NotSigned` installer/updater has been built and
the combined macOS/Windows feed passes full verification. The combined updater
artifacts, signatures, provenance, full checksum manifest, and `latest.json`
must use a separately configured HTTPS update origin that anonymous production
clients can read; a private GitHub Release is not a valid production updater
origin. This fallback is limited to v0.1.0 and does not authorize local formal
builds for later versions.
## Windows Release and Internal Validation ## Windows Release and Internal Validation
@@ -216,9 +244,19 @@ The formal workflow always requires these organization settings:
- secrets: `TAURI_SIGNING_PRIVATE_KEY` and - secrets: `TAURI_SIGNING_PRIVATE_KEY` and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`; `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`;
- client default CTMS origin: repository variable `VITE_DESKTOP_SERVER_URL`;
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the - shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
manual workflow input. manual workflow input.
`VITE_DESKTOP_SERVER_URL` must be an HTTPS origin without credentials, path,
query, or fragment. Vite embeds it as the Desktop client's first-run default;
the runtime source must not contain the production hostname. A user can still
override this default in Desktop server settings, where the persisted manual
value takes precedence and switching origins clears the existing session and
server-scoped client state. Do not reuse this value as
`DESKTOP_UPDATE_BASE_URL`: the latter points to immutable updater artifacts,
not the CTMS business API.
The default signed path additionally requires `WINDOWS_CERTIFICATE`, The default signed path additionally requires `WINDOWS_CERTIFICATE`,
`WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple `WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple
credentials documented by the release owner. The v0.1.0 exception does not credentials documented by the release owner. The v0.1.0 exception does not
@@ -239,6 +277,7 @@ npm ci
npm run version:check npm run version:check
export VITE_BUILD_CHANNEL=release export VITE_BUILD_CHANNEL=release
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)" export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
export VITE_DESKTOP_SERVER_URL="https://ctms.example.com"
npm run release:env:check npm run release:env:check
npm run runtime:check npm run runtime:check
npm run desktop:release:check npm run desktop:release:check
+2
View File
@@ -2,6 +2,8 @@
VITE_RUNTIME_ENV=production VITE_RUNTIME_ENV=production
VITE_ALLOW_INSECURE_DEV_LOGIN=false VITE_ALLOW_INSECURE_DEV_LOGIN=false
VITE_DEV_API_PROXY_TARGET=http://backend:8000 VITE_DEV_API_PROXY_TARGET=http://backend:8000
# Default CTMS origin embedded in Desktop builds; users can override it in Desktop settings.
VITE_DESKTOP_SERVER_URL=https://ctms.example.com
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry. # Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
VITE_HMR_CLIENT_PORT= VITE_HMR_CLIENT_PORT=
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
+7 -1
View File
@@ -9,8 +9,14 @@
"macos": "ad-hoc", "macos": "ad-hoc",
"windows": "unsigned", "windows": "unsigned",
"distribution": "controlled", "distribution": "controlled",
"macosLocalExactTagFallback": true,
"windowsDelivery": "deferred-same-tag",
"updaterFeedActivation": "after-combined-platform-verification",
"githubReleaseAssetProfile": "installer-and-checksum-only",
"stagedEvidenceRetention": "private-until-updater-publish",
"updaterArtifactPublishTarget": "anonymous-https-origin",
"approvedOn": "2026-07-17", "approvedOn": "2026-07-17",
"reason": "The v0.1.0 release owner cannot currently provide Apple Developer signing/notarization credentials or an organization Windows code-signing certificate." "reason": "The v0.1.0 release owner cannot currently provide Apple Developer signing/notarization credentials or an organization Windows code-signing certificate, and hosted Windows build capacity is temporarily unavailable."
} }
] ]
} }
@@ -6,6 +6,7 @@ This release is distributed under the version-scoped v0.1.0 platform-signing exc
- The Windows application and installer have no Authenticode signature or RFC 3161 timestamp. - The Windows application and installer have no Authenticode signature or RFC 3161 timestamp.
- macOS Gatekeeper and Windows SmartScreen warnings are expected. - macOS Gatekeeper and Windows SmartScreen warnings are expected.
- Tauri updater artifacts remain cryptographically signed and must pass updater feed verification. - Tauri updater artifacts remain cryptographically signed and must pass updater feed verification.
- Platform availability may be staged; production latest.json remains withheld until macOS and Windows artifacts from the same tag pass combined verification.
- Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel. - Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel.
This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json. This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json.
@@ -0,0 +1,9 @@
CTMS v0.1.0 WINDOWS RELEASE PENDING
This GitHub Release is the approved first stage of the v0.1.0 Desktop distribution.
- macOS Universal artifacts are available and use an ad-hoc platform signature.
- Windows x64 NSIS artifacts are not yet available.
- Windows must later be built from the same immutable v0.1.0 tag and Git commit.
- Production latest.json is intentionally withheld until Windows NotSigned artifacts and the combined updater feed pass verification.
- Do not interpret this staged release as a complete cross-platform automatic-update release.
@@ -17,6 +17,7 @@ const tag = value("--tag") || process.env.GITHUB_REF_NAME;
const commit = value("--commit") || process.env.GITHUB_SHA; const commit = value("--commit") || process.env.GITHUB_SHA;
const macosSigning = value("--macos-signing"); const macosSigning = value("--macos-signing");
const windowsSigning = value("--windows-signing"); const windowsSigning = value("--windows-signing");
const releaseStage = value("--stage") || "complete";
const outputPath = resolve( const outputPath = resolve(
frontendDir, frontendDir,
value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json", value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json",
@@ -31,6 +32,13 @@ assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${pac
assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA."); assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA.");
assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`); assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`);
assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`); assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`);
assert(["complete", "macos-first"].includes(releaseStage), "Release provenance stage must be complete or macos-first.");
if (releaseStage === "macos-first") {
assert(
resolution.macosLocalExactTagFallback === true && resolution.windowsDelivery === "deferred-same-tag",
`v${packageInfo.version} does not approve a staged local macOS-first release.`,
);
}
if (failures.length > 0) { if (failures.length > 0) {
throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
@@ -50,6 +58,13 @@ const provenance = {
updaterSigning: "required-and-verified", updaterSigning: "required-and-verified",
artifactLabel: resolution.artifactLabel, artifactLabel: resolution.artifactLabel,
distribution: resolution.distribution, distribution: resolution.distribution,
stage: releaseStage,
platformAvailability:
releaseStage === "macos-first"
? { macos: "available", windows: "pending-same-tag" }
: { macos: "available", windows: "available" },
updaterFeedActivation:
releaseStage === "macos-first" ? "withheld-pending-combined-verification" : "combined-platform-verification-required",
warnings: resolution.warningRequired warnings: resolution.warningRequired
? [ ? [
"macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.", "macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.",
@@ -34,6 +34,26 @@ const validatePolicy = (policy) => {
assert(exception?.macos === "ad-hoc", `${prefix} must constrain macOS to ad-hoc signing.`); assert(exception?.macos === "ad-hoc", `${prefix} must constrain macOS to ad-hoc signing.`);
assert(exception?.windows === "unsigned", `${prefix} must constrain Windows to unsigned.`); assert(exception?.windows === "unsigned", `${prefix} must constrain Windows to unsigned.`);
assert(exception?.distribution === "controlled", `${prefix} must constrain distribution to controlled.`); assert(exception?.distribution === "controlled", `${prefix} must constrain distribution to controlled.`);
if (exception?.version === "0.1.0") {
assert(exception?.macosLocalExactTagFallback === true, `${prefix} must explicitly approve the local exact-tag macOS fallback.`);
assert(exception?.windowsDelivery === "deferred-same-tag", `${prefix} must defer Windows only from the same tag.`);
assert(
exception?.updaterFeedActivation === "after-combined-platform-verification",
`${prefix} must withhold the production updater feed until both platforms are verified.`,
);
assert(
exception?.githubReleaseAssetProfile === "installer-and-checksum-only",
`${prefix} must keep the user-facing GitHub Release limited to installers and their checksum manifest.`,
);
assert(
exception?.stagedEvidenceRetention === "private-until-updater-publish",
`${prefix} must retain updater artifacts and release evidence privately until updater publication.`,
);
assert(
exception?.updaterArtifactPublishTarget === "anonymous-https-origin",
`${prefix} must publish updater artifacts to an anonymous HTTPS origin.`,
);
}
assert(/^\d{4}-\d{2}-\d{2}$/.test(exception?.approvedOn || ""), `${prefix} must record an approval date.`); assert(/^\d{4}-\d{2}-\d{2}$/.test(exception?.approvedOn || ""), `${prefix} must record an approval date.`);
assert( assert(
typeof exception?.reason === "string" && exception.reason.trim().length >= 30, typeof exception?.reason === "string" && exception.reason.trim().length >= 30,
@@ -81,6 +101,12 @@ export const resolveDesktopReleasePolicy = (version, policy) => {
artifactLabel: "UNSIGNED-PLATFORM", artifactLabel: "UNSIGNED-PLATFORM",
distribution: exception.distribution, distribution: exception.distribution,
warningRequired: true, warningRequired: true,
macosLocalExactTagFallback: exception.macosLocalExactTagFallback === true,
windowsDelivery: exception.windowsDelivery,
updaterFeedActivation: exception.updaterFeedActivation,
githubReleaseAssetProfile: exception.githubReleaseAssetProfile,
stagedEvidenceRetention: exception.stagedEvidenceRetention,
updaterArtifactPublishTarget: exception.updaterArtifactPublishTarget,
exception, exception,
}; };
}; };
@@ -70,6 +70,25 @@ const validateBaseUrl = () => {
); );
}; };
const validateClientServerUrl = () => {
const raw = env.VITE_DESKTOP_SERVER_URL;
requireEnv("VITE_DESKTOP_SERVER_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
};
const validateWindowsTimestampUrl = () => { const validateWindowsTimestampUrl = () => {
const raw = env.WINDOWS_TIMESTAMP_URL; const raw = env.WINDOWS_TIMESTAMP_URL;
requireEnv("WINDOWS_TIMESTAMP_URL"); requireEnv("WINDOWS_TIMESTAMP_URL");
@@ -173,6 +192,7 @@ if (requiresWindowsSigning) {
} }
validateBaseUrl(); validateBaseUrl();
validateClientServerUrl();
if (failures.length > 0) { if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
@@ -387,6 +387,19 @@ const verifyUpdaterBoundary = async () => {
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials."); assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
}; };
const verifyDesktopServerConfigurationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/desktopServerConfig.ts"), "utf8");
assert(
source.includes("import.meta.env.VITE_DESKTOP_SERVER_URL"),
"Desktop server defaults must come from VITE_DESKTOP_SERVER_URL.",
);
assert(!source.includes("ctms.huapont.cn"), "The production CTMS origin must not be hard-coded in Desktop runtime source.");
assert(
source.includes("getStoredDesktopServerUrl() || getDefaultDesktopServerUrl()"),
"A manually stored Desktop server URL must override the build-time default.",
);
};
const verifyWorkflowGates = async () => { const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json")); const packageInfo = await readJson(resolve(frontendDir, "package.json"));
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0."); assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
@@ -406,6 +419,18 @@ const verifyWorkflowGates = async () => {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`); assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
} }
const provenanceSource = await readFile(resolve(frontendDir, "scripts/create-desktop-release-provenance.mjs"), "utf8");
assert(provenanceSource.includes('"macos-first"'), "Release provenance must support the approved v0.1.0 macOS-first stage.");
assert(
provenanceSource.includes("withheld-pending-combined-verification"),
"Staged macOS provenance must record that updater feed activation is withheld.",
);
const windowsPendingNotice = await readFile(resolve(frontendDir, "desktop-release-windows-pending.txt"), "utf8");
assert(
windowsPendingNotice.includes("same immutable v0.1.0 tag") && windowsPendingNotice.includes("latest.json is intentionally withheld"),
"The staged v0.1.0 private release evidence must include an explicit Windows-pending and updater-feed notice.",
);
const releasePolicy = await readJson(resolve(frontendDir, "desktop-release-policy.json")); const releasePolicy = await readJson(resolve(frontendDir, "desktop-release-policy.json"));
assert(releasePolicy.schemaVersion === 1, "Desktop release policy schemaVersion must be 1."); assert(releasePolicy.schemaVersion === 1, "Desktop release policy schemaVersion must be 1.");
assert( assert(
@@ -418,6 +443,15 @@ const verifyWorkflowGates = async () => {
); );
if (packageInfo.version === "0.1.0") { if (packageInfo.version === "0.1.0") {
assert(Boolean(currentUnsignedException), "Desktop release policy must record the approved v0.1.0 exception."); assert(Boolean(currentUnsignedException), "Desktop release policy must record the approved v0.1.0 exception.");
assert(
currentUnsignedException?.macosLocalExactTagFallback === true &&
currentUnsignedException?.windowsDelivery === "deferred-same-tag" &&
currentUnsignedException?.updaterFeedActivation === "after-combined-platform-verification" &&
currentUnsignedException?.githubReleaseAssetProfile === "installer-and-checksum-only" &&
currentUnsignedException?.stagedEvidenceRetention === "private-until-updater-publish" &&
currentUnsignedException?.updaterArtifactPublishTarget === "anonymous-https-origin",
"The v0.1.0 exception must constrain local macOS staging, deferred Windows delivery, visible GitHub assets, private evidence retention, and updater publication.",
);
} }
if (currentUnsignedException) { if (currentUnsignedException) {
assert( assert(
@@ -444,6 +478,7 @@ const verifyWorkflowGates = async () => {
} }
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL."); assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT."); assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
assert(workflow.includes("VITE_DESKTOP_SERVER_URL"), "Client quality gates workflow must inject VITE_DESKTOP_SERVER_URL.");
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs."); assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8"); const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
@@ -463,6 +498,7 @@ const verifyWorkflowGates = async () => {
"DESKTOP-RELEASE-PROVENANCE.json", "DESKTOP-RELEASE-PROVENANCE.json",
"--require-provenance", "--require-provenance",
"TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY",
"VITE_DESKTOP_SERVER_URL",
"APPLE_ID", "APPLE_ID",
"APPLE_PASSWORD", "APPLE_PASSWORD",
"APPLE_TEAM_ID", "APPLE_TEAM_ID",
@@ -496,6 +532,7 @@ const verifyWorkflowGates = async () => {
"runs-on: windows-latest", "runs-on: windows-latest",
"VITE_BUILD_CHANNEL", "VITE_BUILD_CHANNEL",
"VITE_BUILD_COMMIT", "VITE_BUILD_COMMIT",
"VITE_DESKTOP_SERVER_URL",
"npm run release:env:check", "npm run release:env:check",
"npm run version:check", "npm run version:check",
"npm run runtime:check", "npm run runtime:check",
@@ -554,6 +591,7 @@ await verifySourceSafety();
await verifyNotificationBoundary(); await verifyNotificationBoundary();
await verifySessionBoundary(); await verifySessionBoundary();
await verifyUpdaterBoundary(); await verifyUpdaterBoundary();
await verifyDesktopServerConfigurationBoundary();
await verifyOnlyOfficeBoundary(); await verifyOnlyOfficeBoundary();
await verifyWorkflowGates(); await verifyWorkflowGates();
@@ -35,6 +35,25 @@ const isNativeDesktopRelease = Boolean(
requiresUpdaterSigning, requiresUpdaterSigning,
); );
const validateDesktopServerUrl = () => {
const raw = env.VITE_DESKTOP_SERVER_URL;
requireEnv("VITE_DESKTOP_SERVER_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
};
const fail = (message) => failures.push(message); const fail = (message) => failures.push(message);
const assert = (condition, message) => { const assert = (condition, message) => {
if (!condition) fail(message); if (!condition) fail(message);
@@ -61,6 +80,7 @@ assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...al
if (isCi || isReleaseBuild) { if (isCi || isReleaseBuild) {
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL."); assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT."); assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
validateDesktopServerUrl();
} }
if (env.GITHUB_SHA) { if (env.GITHUB_SHA) {
+1
View File
@@ -3,6 +3,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local"; readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
readonly VITE_BUILD_COMMIT?: string; readonly VITE_BUILD_COMMIT?: string;
readonly VITE_DESKTOP_SERVER_URL?: string;
} }
interface ImportMeta { interface ImportMeta {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
DESKTOP_SERVER_URL_KEY, DESKTOP_SERVER_URL_KEY,
getDefaultDesktopServerUrl,
getDesktopServerUrl, getDesktopServerUrl,
normalizeDesktopServerUrl, normalizeDesktopServerUrl,
setDesktopServerUrl, setDesktopServerUrl,
@@ -31,6 +32,7 @@ const createMemoryStorage = (): Storage => {
}; };
beforeEach(() => { beforeEach(() => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
Object.defineProperty(window, "localStorage", { Object.defineProperty(window, "localStorage", {
value: createMemoryStorage(), value: createMemoryStorage(),
configurable: true, configurable: true,
@@ -40,6 +42,7 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
window.localStorage.clear(); window.localStorage.clear();
setTauriRuntime(false); setTauriRuntime(false);
vi.unstubAllEnvs();
}); });
describe("desktop server config", () => { describe("desktop server config", () => {
@@ -70,6 +73,23 @@ describe("desktop server config", () => {
expect(listener).toHaveBeenCalledOnce(); expect(listener).toHaveBeenCalledOnce();
}); });
it("uses the build-time default while allowing a persisted manual override", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
expect(getDefaultDesktopServerUrl()).toBe("https://default.ctms.example.com/");
expect(getDesktopServerUrl()).toBe("https://default.ctms.example.com/");
setDesktopServerUrl("https://manual.ctms.example.com");
expect(getDesktopServerUrl()).toBe("https://manual.ctms.example.com/");
});
it("ignores an invalid build-time default", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "http://public.ctms.example.com");
expect(getDefaultDesktopServerUrl()).toBeNull();
expect(getDesktopServerUrl()).toBeNull();
});
it("requires a server URL only inside the Tauri runtime", () => { it("requires a server URL only inside the Tauri runtime", () => {
expect(shouldRequireDesktopServerUrl()).toBe(false); expect(shouldRequireDesktopServerUrl()).toBe(false);
setTauriRuntime(true); setTauriRuntime(true);
@@ -77,4 +97,11 @@ describe("desktop server config", () => {
setDesktopServerUrl("https://ctms.example.com"); setDesktopServerUrl("https://ctms.example.com");
expect(shouldRequireDesktopServerUrl()).toBe(false); expect(shouldRequireDesktopServerUrl()).toBe(false);
}); });
it("does not require first-run configuration when a valid build-time default exists", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
setTauriRuntime(true);
expect(shouldRequireDesktopServerUrl()).toBe(false);
});
}); });
+11 -2
View File
@@ -51,13 +51,22 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
return { ok: true, url: `${parsed.origin}/` }; return { ok: true, url: `${parsed.origin}/` };
}; };
export const getDesktopServerUrl = (): string | null => { export const getDefaultDesktopServerUrl = (): string | null => {
const configured = import.meta.env.VITE_DESKTOP_SERVER_URL?.trim();
if (!configured) return null;
const result = normalizeDesktopServerUrl(configured);
return result.ok ? result.url : null;
};
const getStoredDesktopServerUrl = (): string | null => {
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY); const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
if (!stored) return null; if (!stored) return null;
const result = normalizeDesktopServerUrl(stored); const result = normalizeDesktopServerUrl(stored);
return result.ok ? result.url : null; return result.ok ? result.url : null;
}; };
export const getDesktopServerUrl = (): string | null => getStoredDesktopServerUrl() || getDefaultDesktopServerUrl();
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl()); export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => { export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
@@ -74,7 +83,7 @@ export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationRe
export const clearDesktopServerUrl = (): void => { export const clearDesktopServerUrl = (): void => {
const previous = getDesktopServerUrl(); const previous = getDesktopServerUrl();
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY); getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
emitServerUrlChanged(previous, null); emitServerUrlChanged(previous, getDesktopServerUrl());
}; };
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl(); export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
+1
View File
@@ -2,6 +2,7 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
export { export {
clearDesktopServerUrl, clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
getDefaultDesktopServerUrl,
getDesktopServerUrl, getDesktopServerUrl,
hasDesktopServerUrl, hasDesktopServerUrl,
normalizeDesktopServerUrl, normalizeDesktopServerUrl,