refactor(client): unify web and desktop release workflow
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 20:25:07 +08:00
parent d1a6c957f7
commit 7c721d4e5c
21 changed files with 1016 additions and 14 deletions
@@ -0,0 +1,85 @@
name: Client Quality Gates
on:
pull_request:
paths:
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
- "docs/branch-governance.md"
- "docs/guides/client-release.md"
push:
branches:
- dev
- main
- release
paths:
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
jobs:
web:
name: Shared client and Web
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
env:
VITE_BUILD_CHANNEL: ${{ github.ref_name }}
VITE_BUILD_COMMIT: ${{ github.sha }}
desktop:
name: macOS Desktop
runs-on: macos-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Build macOS application
run: npm run desktop:build -- --bundles app
env:
VITE_BUILD_CHANNEL: ${{ github.ref_name }}
VITE_BUILD_COMMIT: ${{ github.sha }}
+21
View File
@@ -3,3 +3,24 @@
处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。 处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。
桌面端仅限该计划书中的第一阶段和第二阶段。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务或离线同步。 桌面端仅限该计划书中的第一阶段和第二阶段。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务或离线同步。
## 分支与发布治理
处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读:
- `docs/guides/branch-maintenance-sop-zh.md`
- `docs/branch-governance.md`
- 涉及网页端或桌面端客户端发布时,还需阅读 `docs/guides/client-release.md`
必须遵守以下规则:
- CTMS 网页端和桌面端属于同一个产品,共用 `dev``main``release` 分支,不创建 `web-dev``desktop-dev``web-release``desktop-release` 等长期平行分支。
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
- `codex/ctms-desktop` 仅是当前桌面端临时集成分支。Tauri 基线合并到 `dev` 后,不得继续将其作为长期桌面主线。
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main``dev`
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set``version:check` 命令。
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`
+3
View File
@@ -46,9 +46,12 @@
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`DMG 构建执行 `npm run desktop:bundle:dmg` - 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`DMG 构建执行 `npm run desktop:bundle:dmg`
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health` - 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`
- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost``http://127.0.0.1` - 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost``http://127.0.0.1`
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`
## 仓库治理文档 ## 仓库治理文档
- 分支治理规范:`docs/branch-governance.md` - 分支治理规范:`docs/branch-governance.md`
- 分支维护中文 SOP`docs/guides/branch-maintenance-sop-zh.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md` - 发布检查清单:`docs/guides/release-checklist.md`
+2
View File
@@ -8,6 +8,8 @@ CTMS 文档入口只展示当前仍会影响开发、发布和运维决策的内
- [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案 - [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案
- [`branch-governance.md`](branch-governance.md): 长期分支治理规则 - [`branch-governance.md`](branch-governance.md): 长期分支治理规则
- [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁 - [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁
- [`guides/client-release.md`](guides/client-release.md): Web/桌面端统一版本、构建与发布流程
- [`guides/branch-maintenance-sop-zh.md`](guides/branch-maintenance-sop-zh.md): 分支维护、版本晋级、发布和热修复中文标准操作规程
- [`audits/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 重要数据落库治理基线 - [`audits/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 重要数据落库治理基线
- [`audits/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束 - [`audits/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束
+28
View File
@@ -78,6 +78,27 @@ Meaning:
Direct promotion that skips stages is discouraged and must be justified in writing. Direct promotion that skips stages is discouraged and must be justified in writing.
## 3.1 Unified Web and Desktop Mainline
CTMS Web and Desktop are two delivery targets of the same product version. They
share the Vue application, API contract, and product branches.
Rules:
- Do not create long-lived `web-dev`, `desktop-dev`, `web-release`, or
`desktop-release` branches.
- Web and Desktop changes both follow `feature/*` -> `dev` -> `main` ->
`release`.
- A platform-specific feature branch is allowed while work is in progress, for
example `feature/desktop-file-picker`, but it must merge back into `dev`.
- `codex/ctms-desktop` is a temporary desktop integration branch. After the
Tauri baseline is accepted into `dev`, new desktop work must use short-lived
feature branches from the current `dev`.
- Platform differences belong behind `frontend/src/runtime/`. Shared business
modules must not import Tauri APIs directly.
- A release tag identifies one product source state. Web and Desktop artifacts
for that release must be built from the same tag and Git commit.
## 4. Branch Entry Rules ## 4. Branch Entry Rules
### Changes allowed into `dev` ### Changes allowed into `dev`
@@ -214,6 +235,13 @@ Rules:
- tags are created on `release`, not on `dev` - tags are created on `release`, not on `dev`
- a tag must point to the exact production release commit - a tag must point to the exact production release commit
- patch hotfixes on `release` should increment the patch version - patch hotfixes on `release` should increment the patch version
- Web and Desktop use the same semantic version. Do not add a separate Desktop
product version.
- Desktop packaging-only rebuilds may add build metadata to the artifact name,
but must retain the product version and record the source commit.
- Before creating a tag, run `cd frontend && npm run version:check`.
- Change the shared client version with
`cd frontend && npm run version:set -- <version>`.
## 7. Hotfix Back-Merge Rules ## 7. Hotfix Back-Merge Rules
+500
View File
@@ -0,0 +1,500 @@
# CTMS 分支维护与版本更新标准操作规程
## 一、目的
本规程用于统一 CTMS 网页端和桌面端的代码提交、分支维护、版本晋级、正式发布及生产热修复流程。
CTMS 网页端和桌面端属于同一个产品,必须共用:
- 同一个代码仓库
- 同一套业务核心代码
- 同一条版本晋级链路
- 同一个语义化版本号
- 同一个正式发布标签
- 同一个源代码提交
不得为网页端和桌面端分别建立长期开发、测试或发布分支。
## 二、长期分支职责
| 分支 | 职责 | 允许进入的内容 | 稳定性要求 |
| --- | --- | --- | --- |
| `dev` | 日常开发与集成 | 已评审的功能、修复和重构 | 可持续集成 |
| `main` | 下一正式版本候选 | 从 `dev` 晋级的完整版本范围、候选版本修复 | 原则上可部署 |
| `release` | 当前生产稳定版本 | 从 `main` 验收通过的正式版本、生产热修复 | 最高 |
默认晋级方向:
```text
功能分支 -> dev -> main -> release -> 正式版本标签
```
禁止以下长期分支:
```text
web-dev
desktop-dev
web-release
desktop-release
macos-main
windows-main
```
桌面端差异必须放在 `frontend/src/runtime/` 适配层之后,不通过长期分支保存平台差异。
## 三、临时分支命名
| 类型 | 命名格式 | 示例 |
| --- | --- | --- |
| 新功能 | `feature/<功能名称>` | `feature/desktop-file-picker` |
| 缺陷修复 | `fix/<问题名称>` | `fix/session-timeout` |
| 生产热修复 | `hotfix/<问题名称>` | `hotfix/login-loop` |
| 文档调整 | `docs/<文档名称>` | `docs/release-sop` |
| 发布准备 | `release-prep/<版本号>` | `release-prep/v1.2.0` |
| 临时集成 | `codex/<任务名称>` | `codex/ctms-desktop` |
分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。
## 四、日常功能开发流程
### 1. 从最新 `dev` 创建分支
```bash
git fetch origin
git switch dev
git pull --ff-only origin dev
git switch -c feature/<功能名称>
```
不得从旧功能分支、`main``release` 创建普通功能分支。
### 2. 开发过程中同步 `dev`
短期分支优先使用变基保持提交清晰:
```bash
git fetch origin
git rebase origin/dev
```
已经由多人共同使用的分支,不得擅自强制推送。此时可使用合并:
```bash
git fetch origin
git merge origin/dev
```
### 3. 提交前检查
前端或桌面端改动至少执行:
```bash
cd frontend
npm run version:check
npm run runtime:check
npm run type-check
npm run test:unit
npm run build
```
涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行:
```bash
npm run desktop:build -- --bundles app
```
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
### 4. 创建提交
只暂存本次任务相关文件:
```bash
git status
git add <本次任务相关文件>
git diff --cached
git diff --cached --check
git commit -m "<类型>(<范围>): <变更说明>"
```
推荐提交类型:
| 类型 | 用途 |
| --- | --- |
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `refactor` | 不改变业务行为的重构 |
| `test` | 测试调整 |
| `docs` | 文档调整 |
| `build` | 构建和依赖调整 |
| `ci` | 持续集成调整 |
示例:
```text
feat(desktop): 增加原生文件选择适配器
fix(auth): 修复会话超时后的重复跳转
refactor(client): 统一网页端和桌面端运行时入口
```
一次提交只处理一个明确目的。不得将无关格式化、个人配置或临时产物混入提交。
### 5. 推送并创建合并请求
```bash
git push -u origin feature/<功能名称>
```
创建:
```text
feature/<功能名称> -> dev
```
合并要求:
- 代码评审通过
- 必要测试通过
- 客户端质量门禁通过
- 没有误提交密钥、环境文件或构建产物
- 桌面能力符合第一阶段或第二阶段边界
功能分支进入 `dev` 可使用合并请求合并或变基合并。提交过于零散时应先整理。
### 6. 合并后清理
确认改动已经进入远程 `dev` 后删除临时分支:
```bash
git switch dev
git pull --ff-only origin dev
git branch -d feature/<功能名称>
git push origin --delete feature/<功能名称>
```
工作树正在使用的分支不能直接删除,应先切换分支或移除对应工作树。
## 五、当前桌面集成分支处理流程
`codex/ctms-desktop` 是临时桌面集成分支,不作为长期桌面主线。
处理步骤:
```bash
git status
git add <本次桌面端和治理优化相关文件>
git diff --cached --check
git commit -m "refactor(client): 统一网页端和桌面端发布流程"
git fetch origin
git rebase origin/dev
git push origin codex/ctms-desktop
```
创建:
```text
codex/ctms-desktop -> dev
```
合并并验证 `dev` 后:
- 删除远程 `codex/ctms-desktop`
- 不再从该分支继续开发
- 后续桌面功能从最新 `dev` 创建短期 `feature/*` 分支
## 六、从 `dev` 晋级到 `main`
当一个版本范围在 `dev` 完成集成后,创建:
```text
dev -> main
```
进入 `main` 前必须确认:
- 本版本范围已经冻结
- 未完成功能已经排除或关闭入口
- 前后端测试通过
- 网页端构建通过
- macOS 桌面端构建通过
- 数据库迁移经过验证
- 已知风险和回滚方式已记录
正式创建晋级合并请求前,应按照第七节完成统一版本号更新,并确保版本提交已经进入 `dev`
按照当前仓库治理规则,`dev` 进入 `main` 使用压缩合并,并使用版本候选级提交说明:
```text
release(main): 准备 v1.2.0 候选版本
```
不得从功能分支直接跳过 `dev` 合并到 `main`
## 七、统一更新客户端版本
网页端和桌面端只能使用同一个产品版本号。
从最新 `dev` 创建发布准备分支:
```bash
git fetch origin
git switch dev
git pull --ff-only origin dev
git switch -c release-prep/v1.2.0
```
统一更新版本并提交:
```bash
cd frontend
npm run version:set -- 1.2.0
npm run version:check
cd ..
git add frontend/package.json frontend/package-lock.json frontend/src-tauri/tauri.conf.json
git add frontend/src-tauri/Cargo.toml frontend/src-tauri/Cargo.lock
git commit -m "build(release): 更新客户端版本至 v1.2.0"
git push -u origin release-prep/v1.2.0
```
创建 `release-prep/v1.2.0 -> dev` 合并请求。合并后再执行 `dev -> main` 的版本晋级。
该命令同步更新:
- `frontend/package.json`
- `frontend/package-lock.json`
- `frontend/src-tauri/tauri.conf.json`
- `frontend/src-tauri/Cargo.toml`
- `frontend/src-tauri/Cargo.lock`
版本号遵循:
| 类型 | 示例 | 使用场景 |
| --- | --- | --- |
| 主版本 | `2.0.0` | 不兼容变更或重大架构调整 |
| 次版本 | `1.3.0` | 向后兼容的新功能 |
| 修订版本 | `1.2.1` | 向后兼容的缺陷修复 |
禁止单独设置桌面端版本号。
## 八、从 `main` 发布到 `release`
候选版本验收通过后,创建:
```text
main -> release
```
按照当前仓库治理规则,使用普通合并提交,保留候选版本与生产版本之间的关系。
合并前必须确认:
- 回归测试通过
- 数据库迁移和回滚方案确认
- 网页端生产构建通过
- 桌面端生产构建通过
- 发布说明完成
- 生产配置和密钥不在仓库中
- 正式版本号已经统一
合并后立即在 `release` 的准确提交上创建标签:
```bash
git switch release
git pull --ff-only origin release
git tag -a v1.2.0 -m "CTMS v1.2.0"
git push origin v1.2.0
```
网页端和桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。
发布记录至少包含:
- 产品版本号
- Git 标签
- 完整提交编号
- 网页端制品编号
- 桌面端制品编号
- 数据库迁移版本
- 发布日期和负责人
## 九、生产热修复流程
### 1. 从 `release` 创建热修复分支
```bash
git fetch origin
git switch release
git pull --ff-only origin release
git switch -c hotfix/<问题名称>
```
热修复只能包含解决生产问题所需的最小改动,不得顺带加入新功能或大规模重构。
### 2. 更新修订版本
例如从 `1.2.0` 更新到 `1.2.1`
```bash
cd frontend
npm run version:set -- 1.2.1
npm run version:check
cd ..
```
### 3. 验证并提交
```bash
git add <热修复相关文件和版本文件>
git diff --cached --check
git commit -m "fix(<范围>): <生产问题说明>"
git push -u origin hotfix/<问题名称>
```
创建:
```text
hotfix/<问题名称> -> release
```
### 4. 合并并创建标签
热修复合并到 `release` 并验证后创建 `v1.2.1` 标签。
### 5. 强制回合并
生产热修复必须立即回合并:
```text
release -> main -> dev
```
不得假设 `main``dev` 已经包含相同修复。发生冲突时必须立即解决并在合并请求中记录原因。
## 十、冲突处理规则
发生冲突时:
1. 先确认冲突两侧的业务意图。
2. 不使用整文件覆盖方式跳过判断。
3. 保留双方仍然有效的修改。
4. 重新执行受影响测试。
5. 在合并请求中记录冲突文件和处理结果。
禁止使用以下方式处理普通同步冲突:
```bash
git reset --hard
git checkout -- <文件>
```
除非已经明确确认可以丢弃本地修改,否则不得执行破坏性命令。
## 十一、分支保护建议
### `release`
- 禁止直接推送
- 必须通过合并请求
- 至少一名评审人批准
- 必须通过状态检查
- 正式标签只由发布负责人创建
### `main`
- 禁止直接推送
- 必须通过合并请求
- 必须完成回归和构建检查
- 只接收版本候选内容
### `dev`
- 优先通过合并请求
- 必须通过相关测试
- 禁止提交密钥和本地配置
- 禁止合入明确不可构建的代码
## 十二、每周分支维护
每周至少执行一次:
```bash
git fetch --prune origin
git branch -vv
git log --oneline --decorate --graph --all -30
```
检查事项:
- 已合并临时分支是否删除
- 是否出现未经批准的长期平台分支
- `dev``main``release` 是否符合各自职责
- 生产热修复是否已回合并到 `main``dev`
- 版本文件是否一致
- 正式标签是否准确指向 `release`
- 持续集成门禁是否持续通过
## 十三、禁止事项
- 禁止长期维护网页端和桌面端平行分支
- 禁止通过复制代码维护桌面端业务页面
- 禁止在业务模块中直接使用 Tauri API
- 禁止在不同提交上构建同一版本的网页端和桌面端
- 禁止未经 `dev``main` 直接向 `release` 发布普通功能
- 禁止生产热修复只进入 `release` 而不回合并
- 禁止在正式构建中包含未提交文件
- 禁止提交 `.env`、密钥、证书、令牌和个人配置
- 禁止提交 `node_modules``dist`、Tauri `target` 等构建产物
## 十四、发布前最终检查清单
- [ ] 本次发布范围已经冻结
- [ ] `dev` 集成测试通过
- [ ] `main` 候选版本验收通过
- [ ] 网页端与桌面端版本一致
- [ ] `npm run version:check` 通过
- [ ] `npm run runtime:check` 通过
- [ ] `npm run type-check` 通过
- [ ] `npm run test:unit` 通过
- [ ] `npm run build` 通过
- [ ] macOS 桌面端构建通过
- [ ] 数据库迁移与回滚方案确认
- [ ] 发布说明完成
- [ ] `main -> release` 合并完成
- [ ] 正式标签创建在准确的 `release` 提交上
- [ ] 网页端和桌面端从同一标签构建
- [ ] 发布记录包含版本、标签和完整提交编号
## 十五、流程速查
普通功能:
```text
最新 dev
-> feature/*
-> 开发、测试、评审
-> dev
-> 删除临时分支
```
正式发布:
```text
dev
-> main
-> 统一版本号
-> 回归与验收
-> release
-> 创建 vX.Y.Z 标签
-> 同一标签构建网页端和桌面端
```
生产热修复:
```text
release
-> hotfix/*
-> release
-> 创建修订版本标签
-> main
-> dev
```
+89
View File
@@ -0,0 +1,89 @@
# CTMS Web and Desktop Release Guide
## Release Unit
CTMS uses one repository, one promotion path, and one product version. Web and
Desktop are build targets from the same source commit, not separately versioned
products.
The release identity consists of:
- semantic version, for example `1.8.0`
- Git tag, for example `v1.8.0`
- full Git commit SHA
- build channel: `dev`, `main`, or `release`
- client type: `web` or `desktop`
## Runtime Boundary
Shared Vue business code lives under `frontend/src/`. Platform decisions are
exposed through `frontend/src/runtime/index.ts`.
The runtime contract currently provides:
- API base URL resolution
- Web, macOS, Windows, and Linux runtime identification
- app version, source commit, build channel, and client type metadata
- explicit capability flags
- Desktop server address configuration
Business modules must use this public runtime entry point. They must not inspect
Tauri globals or import Tauri packages directly. Native files, notifications,
secure session storage, and automatic updates remain disabled until their
second-phase adapters are implemented and reviewed.
## Version Change
Update every client manifest with one command:
```bash
cd frontend
npm run version:set -- 1.8.0
npm run version:check
```
This synchronizes:
- `frontend/package.json`
- `frontend/package-lock.json`
- `frontend/src-tauri/tauri.conf.json`
- `frontend/src-tauri/Cargo.toml`
- `frontend/src-tauri/Cargo.lock`
Manual edits that leave these files inconsistent fail CI.
## Promotion
1. Merge feature branches into `dev`.
2. Require the shared client/Web and macOS Desktop CI jobs to pass.
3. Promote the accepted scope from `dev` to `main`.
4. Set the release version and complete regression testing on `main`.
5. Promote `main` to `release`.
6. Create the matching `vX.Y.Z` tag on the accepted `release` commit.
7. Build both Web and Desktop artifacts from that exact tag.
Build metadata is injected by CI:
```bash
VITE_BUILD_CHANNEL=release
VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
```
These values support diagnostics but do not replace the semantic version.
## Required Checks
```bash
cd frontend
npm ci
npm run version:check
npm run runtime:check
npm run type-check
npm run test:unit
npm run build
npm run desktop:build -- --bundles app
```
The Desktop build must run on macOS for the current first-phase target. A signed
or notarized public release additionally requires the Apple credentials defined
by the release owner; an unsigned internal build is not a formal distribution.
+4 -1
View File
@@ -10,7 +10,10 @@
"tauri": "tauri", "tauri": "tauri",
"desktop:dev": "tauri dev", "desktop:dev": "tauri dev",
"desktop:build": "tauri build", "desktop:build": "tauri build",
"desktop:bundle:dmg": "tauri build -- --bundles dmg", "desktop:bundle:dmg": "tauri build --bundles dmg",
"version:check": "node scripts/client-version.mjs --check",
"version:set": "node scripts/client-version.mjs --set",
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
"test:unit": "vitest run --environment jsdom", "test:unit": "vitest run --environment jsdom",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"ui:contract": "node scripts/verify-ui-contract.mjs" "ui:contract": "node scripts/verify-ui-contract.mjs"
+122
View File
@@ -0,0 +1,122 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const paths = {
packageJson: new URL("../package.json", import.meta.url),
packageLock: new URL("../package-lock.json", import.meta.url),
tauriConfig: new URL("../src-tauri/tauri.conf.json", import.meta.url),
cargoToml: new URL("../src-tauri/Cargo.toml", import.meta.url),
cargoLock: new URL("../src-tauri/Cargo.lock", import.meta.url),
};
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
const readCargoPackageVersion = async () => {
const cargo = await readFile(paths.cargoToml, "utf8");
const packageSection = cargo.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) throw new Error("Cannot find [package].version in src-tauri/Cargo.toml");
return version;
};
const readCargoLockPackageVersion = async () => {
const cargoLock = await readFile(paths.cargoLock, "utf8");
const packageSection = cargoLock
.match(/\[\[package\]\]([\s\S]*?)(?=\n\[\[package\]\]|$)/g)
?.find((section) => /^name\s*=\s*"ctms-desktop"$/m.test(section));
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) throw new Error("Cannot find ctms-desktop version in src-tauri/Cargo.lock");
return version;
};
const readVersions = async () => {
const [packageJson, packageLock, tauriConfig, cargoVersion, cargoLockVersion] = await Promise.all([
readJson(paths.packageJson),
readJson(paths.packageLock),
readJson(paths.tauriConfig),
readCargoPackageVersion(),
readCargoLockPackageVersion(),
]);
return {
"package.json": packageJson.version,
"package-lock.json": packageLock.version,
"package-lock.json root": packageLock.packages?.[""]?.version,
"tauri.conf.json": tauriConfig.version,
"Cargo.toml": cargoVersion,
"Cargo.lock": cargoLockVersion,
};
};
const assertVersionsMatch = async () => {
const versions = await readVersions();
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1 || uniqueVersions.has(undefined)) {
const details = Object.entries(versions)
.map(([file, version]) => ` ${file}: ${version ?? "<missing>"}`)
.join("\n");
throw new Error(`Client versions are not synchronized:\n${details}`);
}
const [version] = uniqueVersions;
console.log(`Client version ${version} is synchronized.`);
};
const setVersion = async (version) => {
if (!SEMVER_PATTERN.test(version)) {
throw new Error(`Invalid semantic version: ${version}`);
}
const [packageJson, packageLock, tauriConfig, tauriConfigSource, cargo, cargoLock] = await Promise.all([
readJson(paths.packageJson),
readJson(paths.packageLock),
readJson(paths.tauriConfig),
readFile(paths.tauriConfig, "utf8"),
readFile(paths.cargoToml, "utf8"),
readFile(paths.cargoLock, "utf8"),
]);
packageJson.version = version;
packageLock.version = version;
packageLock.packages[""].version = version;
const tauriVersionPattern = /("version"\s*:\s*")[^"]+(")/;
const packageSectionPattern = /(\[package\][\s\S]*?^version\s*=\s*")[^"]+(")/m;
const lockPackagePattern =
/(\[\[package\]\]\nname\s*=\s*"ctms-desktop"\nversion\s*=\s*")[^"]+(")/m;
if (typeof tauriConfig.version !== "string" || !tauriVersionPattern.test(tauriConfigSource)) {
throw new Error("Cannot update version in src-tauri/tauri.conf.json");
}
if (!packageSectionPattern.test(cargo)) {
throw new Error("Cannot update [package].version in src-tauri/Cargo.toml");
}
if (!lockPackagePattern.test(cargoLock)) {
throw new Error("Cannot update ctms-desktop version in src-tauri/Cargo.lock");
}
await Promise.all([
writeJson(paths.packageJson, packageJson),
writeJson(paths.packageLock, packageLock),
writeFile(paths.tauriConfig, tauriConfigSource.replace(tauriVersionPattern, `$1${version}$2`)),
writeFile(paths.cargoToml, cargo.replace(packageSectionPattern, `$1${version}$2`)),
writeFile(paths.cargoLock, cargoLock.replace(lockPackagePattern, `$1${version}$2`)),
]);
console.log(`Updated CTMS Web and Desktop client version to ${version} in ${frontendDir}`);
await assertVersionsMatch();
};
const [, , command = "--check", value] = process.argv;
try {
if (command === "--check") {
await assertVersionsMatch();
} else if (command === "--set" && value) {
await setVersion(value);
} else {
throw new Error("Usage: node scripts/client-version.mjs [--check | --set <semver>]");
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
@@ -0,0 +1,41 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const sourceDir = resolve(frontendDir, "src");
const runtimeDir = resolve(sourceDir, "runtime");
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
return (
await Promise.all(
entries.map(async (entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? walk(path) : path;
}),
)
).flat();
};
for (const path of await walk(sourceDir)) {
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
const source = await readFile(path, "utf8");
const file = relative(frontendDir, path);
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
}
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
}
}
if (violations.length > 0) {
console.error(`Runtime boundary violations:\n${violations.map((item) => ` ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Runtime boundary is respected.");
}
+3 -4
View File
@@ -1,15 +1,14 @@
import axios from "axios"; import axios from "axios";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl"; import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
const authClient = axios.create({ const authClient = axios.create({
baseURL: resolveApiBaseUrl(), baseURL: clientRuntime.apiBaseUrl(),
timeout: 15000, timeout: 15000,
}); });
export const refreshAuthClientBaseUrl = (): void => { export const refreshAuthClientBaseUrl = (): void => {
authClient.defaults.baseURL = resolveApiBaseUrl(); authClient.defaults.baseURL = clientRuntime.apiBaseUrl();
}; };
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
+3 -4
View File
@@ -3,16 +3,15 @@ import { ElMessage } from "element-plus";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
import type { ApiError } from "../types/api"; import type { ApiError } from "../types/api";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl"; import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: resolveApiBaseUrl(), baseURL: clientRuntime.apiBaseUrl(),
timeout: 15000, timeout: 15000,
}); });
export const refreshApiBaseUrl = (): void => { export const refreshApiBaseUrl = (): void => {
instance.defaults.baseURL = resolveApiBaseUrl(); instance.defaults.baseURL = clientRuntime.apiBaseUrl();
}; };
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
readonly VITE_BUILD_COMMIT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "*.vue" { declare module "*.vue" {
import type { DefineComponent } from "vue"; import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>; const component: DefineComponent<{}, {}, any>;
+1 -1
View File
@@ -12,7 +12,7 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import { getToken } from "./utils/auth"; import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study"; import { useStudyStore } from "./store/study";
import { shouldRequireDesktopServerUrl } from "./runtime/desktopServerConfig"; import { shouldRequireDesktopServerUrl } from "./runtime";
const bootstrap = async () => { const bootstrap = async () => {
const app = createApp(App); const app = createApp(App);
+1 -2
View File
@@ -1,6 +1,5 @@
import type { RouteLocationNormalized } from "vue-router"; import type { RouteLocationNormalized } from "vue-router";
import { hasDesktopServerUrl, shouldRequireDesktopServerUrl } from "../runtime/desktopServerConfig"; import { hasDesktopServerUrl, isTauriRuntime, shouldRequireDesktopServerUrl } from "../runtime";
import { isTauriRuntime } from "../runtime/platform";
const DESKTOP_SETTINGS_PATH = "/desktop/server-settings"; const DESKTOP_SETTINGS_PATH = "/desktop/server-settings";
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from "vitest";
import packageInfo from "../../package.json";
import { clientRuntime } from "./clientRuntime";
import { getAppMetadata } from "./appMetadata";
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
describe("client runtime", () => {
it("reports a web runtime with desktop capabilities disabled", () => {
expect(getAppMetadata()).toMatchObject({
version: packageInfo.version,
commit: "local",
channel: "local",
clientType: "web",
platform: "web",
});
expect(clientRuntime.capabilities()).toEqual({
serverConfiguration: false,
nativeFiles: false,
systemNotifications: false,
secureSessionStorage: false,
automaticUpdates: false,
});
});
it("exposes only the implemented desktop capability", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
expect(getAppMetadata().clientType).toBe("desktop");
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import packageInfo from "../../package.json";
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export type ClientType = "web" | "desktop";
export type BuildChannel = "dev" | "main" | "release" | "local";
export interface AppMetadata {
version: string;
commit: string;
channel: BuildChannel;
clientType: ClientType;
platform: RuntimePlatform;
}
const BUILD_CHANNELS = new Set<BuildChannel>(["dev", "main", "release", "local"]);
const resolveBuildChannel = (value: string | undefined): BuildChannel =>
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
export const getAppMetadata = (): AppMetadata => ({
version: packageInfo.version,
commit: import.meta.env.VITE_BUILD_COMMIT || "local",
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
clientType: isTauriRuntime() ? "desktop" : "web",
platform: getRuntimePlatform(),
});
+29
View File
@@ -0,0 +1,29 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
import { getAppMetadata } from "./appMetadata";
import { isTauriRuntime } from "./platform";
export interface RuntimeCapabilities {
serverConfiguration: boolean;
nativeFiles: boolean;
systemNotifications: boolean;
secureSessionStorage: boolean;
automaticUpdates: boolean;
}
export interface ClientRuntime {
apiBaseUrl(): string;
metadata: typeof getAppMetadata;
capabilities(): RuntimeCapabilities;
}
export const clientRuntime: ClientRuntime = {
apiBaseUrl: resolveApiBaseUrl,
metadata: getAppMetadata,
capabilities: () => ({
serverConfiguration: isTauriRuntime(),
nativeFiles: false,
systemNotifications: false,
secureSessionStorage: false,
automaticUpdates: false,
}),
};
+12
View File
@@ -0,0 +1,12 @@
export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./clientRuntime";
export {
clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDesktopServerUrl,
hasDesktopServerUrl,
normalizeDesktopServerUrl,
setDesktopServerUrl,
shouldRequireDesktopServerUrl,
} from "./desktopServerConfig";
export { getAppMetadata, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
+1 -1
View File
@@ -37,7 +37,7 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime/desktopServerConfig"; import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
+1 -1
View File
@@ -260,7 +260,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth"; import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales"; import { TEXT, requiredMessage } from "../locales";
import { isTauriRuntime } from "../runtime/platform"; import { isTauriRuntime } from "../runtime";
import { import {
consumeLogoutReason, consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED, LOGOUT_REASON_AUTH_EXPIRED,