diff --git a/.github/workflows/client-quality-gates.yml b/.github/workflows/client-quality-gates.yml new file mode 100644 index 00000000..3cd40474 --- /dev/null +++ b/.github/workflows/client-quality-gates.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index 3629f5ff..4f2e0baf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,24 @@ 处理 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`。 diff --git a/README.md b/README.md index cc85eb41..3b3310d6 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,12 @@ - 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`;DMG 构建执行 `npm run desktop:bundle:dmg`。 - 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`。 - 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost` 或 `http://127.0.0.1`。 +- Web 与桌面端共用产品版本;执行 `npm run version:set -- ` 统一升级,执行 `npm run version:check` 检查漂移。 +- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。 ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` +- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md` - 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 发布检查清单:`docs/guides/release-checklist.md` diff --git a/docs/README.md b/docs/README.md index 89550d4a..b5282827 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,8 @@ CTMS 文档入口只展示当前仍会影响开发、发布和运维决策的内 - [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案 - [`branch-governance.md`](branch-governance.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/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束 diff --git a/docs/branch-governance.md b/docs/branch-governance.md index 78b2a386..f4e4bf44 100644 --- a/docs/branch-governance.md +++ b/docs/branch-governance.md @@ -78,6 +78,27 @@ Meaning: 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 ### Changes allowed into `dev` @@ -214,6 +235,13 @@ Rules: - tags are created on `release`, not on `dev` - a tag must point to the exact production release commit - 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 -- `. ## 7. Hotfix Back-Merge Rules diff --git a/docs/guides/branch-maintenance-sop-zh.md b/docs/guides/branch-maintenance-sop-zh.md new file mode 100644 index 00000000..94bc1c52 --- /dev/null +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -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 +``` diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md new file mode 100644 index 00000000..88e90e30 --- /dev/null +++ b/docs/guides/client-release.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index 4847e4da..307f89d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,10 @@ "tauri": "tauri", "desktop:dev": "tauri dev", "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", "type-check": "vue-tsc --noEmit", "ui:contract": "node scripts/verify-ui-contract.mjs" diff --git a/frontend/scripts/client-version.mjs b/frontend/scripts/client-version.mjs new file mode 100644 index 00000000..0ae95371 --- /dev/null +++ b/frontend/scripts/client-version.mjs @@ -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 ?? ""}`) + .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 ]"); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/frontend/scripts/verify-runtime-boundary.mjs b/frontend/scripts/verify-runtime-boundary.mjs new file mode 100644 index 00000000..80dd99e8 --- /dev/null +++ b/frontend/scripts/verify-runtime-boundary.mjs @@ -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."); +} diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts index bb67e6be..993648cd 100644 --- a/frontend/src/api/authClient.ts +++ b/frontend/src/api/authClient.ts @@ -1,15 +1,14 @@ import axios from "axios"; import type { AxiosResponse } from "axios"; -import { resolveApiBaseUrl } from "../runtime/apiBaseUrl"; -import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig"; +import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime"; const authClient = axios.create({ - baseURL: resolveApiBaseUrl(), + baseURL: clientRuntime.apiBaseUrl(), timeout: 15000, }); export const refreshAuthClientBaseUrl = (): void => { - authClient.defaults.baseURL = resolveApiBaseUrl(); + authClient.defaults.baseURL = clientRuntime.apiBaseUrl(); }; if (typeof window !== "undefined") { diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 9b001336..26d24db5 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -3,16 +3,15 @@ import { ElMessage } from "element-plus"; import { getToken } from "../utils/auth"; import type { ApiError } from "../types/api"; import { TEXT } from "../locales"; -import { resolveApiBaseUrl } from "../runtime/apiBaseUrl"; -import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig"; +import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime"; const instance: AxiosInstance = axios.create({ - baseURL: resolveApiBaseUrl(), + baseURL: clientRuntime.apiBaseUrl(), timeout: 15000, }); export const refreshApiBaseUrl = (): void => { - instance.defaults.baseURL = resolveApiBaseUrl(); + instance.defaults.baseURL = clientRuntime.apiBaseUrl(); }; if (typeof window !== "undefined") { diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index fc812394..6b14a58a 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -1,5 +1,14 @@ /// +interface ImportMetaEnv { + readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local"; + readonly VITE_BUILD_COMMIT?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare module "*.vue" { import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, any>; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c24674d3..6d225389 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -12,7 +12,7 @@ import App from "./App.vue"; import router from "./router"; import { getToken } from "./utils/auth"; import { useStudyStore } from "./store/study"; -import { shouldRequireDesktopServerUrl } from "./runtime/desktopServerConfig"; +import { shouldRequireDesktopServerUrl } from "./runtime"; const bootstrap = async () => { const app = createApp(App); diff --git a/frontend/src/router/desktopGuard.ts b/frontend/src/router/desktopGuard.ts index 4bc624fc..7dae6761 100644 --- a/frontend/src/router/desktopGuard.ts +++ b/frontend/src/router/desktopGuard.ts @@ -1,6 +1,5 @@ import type { RouteLocationNormalized } from "vue-router"; -import { hasDesktopServerUrl, shouldRequireDesktopServerUrl } from "../runtime/desktopServerConfig"; -import { isTauriRuntime } from "../runtime/platform"; +import { hasDesktopServerUrl, isTauriRuntime, shouldRequireDesktopServerUrl } from "../runtime"; const DESKTOP_SETTINGS_PATH = "/desktop/server-settings"; diff --git a/frontend/src/runtime/appMetadata.test.ts b/frontend/src/runtime/appMetadata.test.ts new file mode 100644 index 00000000..30f66b59 --- /dev/null +++ b/frontend/src/runtime/appMetadata.test.ts @@ -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); + }); +}); diff --git a/frontend/src/runtime/appMetadata.ts b/frontend/src/runtime/appMetadata.ts new file mode 100644 index 00000000..aa95ff0e --- /dev/null +++ b/frontend/src/runtime/appMetadata.ts @@ -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(["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(), +}); diff --git a/frontend/src/runtime/clientRuntime.ts b/frontend/src/runtime/clientRuntime.ts new file mode 100644 index 00000000..f3ee8d80 --- /dev/null +++ b/frontend/src/runtime/clientRuntime.ts @@ -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, + }), +}; diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts new file mode 100644 index 00000000..030bd72f --- /dev/null +++ b/frontend/src/runtime/index.ts @@ -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"; diff --git a/frontend/src/views/DesktopServerSettings.vue b/frontend/src/views/DesktopServerSettings.vue index 7b4cc5bd..72d8864d 100644 --- a/frontend/src/views/DesktopServerSettings.vue +++ b/frontend/src/views/DesktopServerSettings.vue @@ -37,7 +37,7 @@ import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; -import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime/desktopServerConfig"; +import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime"; const router = useRouter(); const auth = useAuthStore(); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 73d76ced..b6850a93 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -260,7 +260,7 @@ import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; import { fetchEmailDomains } from "../api/auth"; import { TEXT, requiredMessage } from "../locales"; -import { isTauriRuntime } from "../runtime/platform"; +import { isTauriRuntime } from "../runtime"; import { consumeLogoutReason, LOGOUT_REASON_AUTH_EXPIRED,