diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 459c8ed..72cb31e 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -30,6 +30,11 @@ jobs: node-version: 22 cache: 'npm' + # 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。 + # tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。 + - name: Check version consistency + run: node scripts/ci/check-version.mjs + - name: Setup JDK 17 uses: https://gitea.com/actions/setup-java@v4 with: diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 818dee5..53b30d0 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -34,9 +34,11 @@ jobs: with: node-version: 22 - - name: Check version matches branch - # 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, - # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 + - name: Check version consistency & branch + # 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota + # (tauri.conf.json 的 version 就是 APK 的 versionName) + # 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, + # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 env: GITHUB_REF_NAME: ${{ github.ref_name }} run: node scripts/ci/check-version.mjs diff --git a/docs/V0.2/设计文档.md b/docs/V0.2/设计文档.md index 7ae10d0..a20b89e 100644 --- a/docs/V0.2/设计文档.md +++ b/docs/V0.2/设计文档.md @@ -375,18 +375,32 @@ ListView() | 3 | 设计 | 打卡 Tab 移到**最左边并默认选中**;打卡页移除**特效开关**,特效永远打开 | `BottomNav.jsx`、`App.jsx`、`TabIcon.jsx`、`HabitView.jsx`、`RingProgress.jsx`、`globals.css` | | 4 | 设计 | 取消「首页」Tab,其**报警 / 新闻**并入宏观,成为宏观的第 5、6 个子 Tab | `MacroPage.jsx`、新增 `macro/AlertsView.jsx`、`macro/NewsView.jsx`、删除 `HomePage.jsx` | -### 10.2 OTA 版本号为什么必须跟分支走 +### 10.2 版本号为什么必须跟分支走 `scripts/build-ota.mjs` 里 `manifest.version = package.json.version + '-ota'`,也就是**版本号的唯一来源仍是 `package.json`**。 分支按版本命名(`dev-0.2`),但版本号是手工改的,一旦忘改就会出现「分支 0.2、包版本 0.1」的错位,客户端判新逻辑全部失准。 +**同一版本号的两种产物写法**(2026-10-22 补充确认): + +``` +分支 dev-0.2 → 0.2.0 + ├─ APK / 桌面安装包 0.2.0 裸版本号,无后缀 + ├─ 前端运行时 0.2.0 裸版本号,无后缀 + └─ OTA 热更包 0.2.0-ota 多一个 -ota 标记 +``` + +即 APK 与 OTA 的版本主体完全一致,唯一差别是 OTA 多 `-ota` 后缀;`package.json` 只写裸版本号,后缀由打包脚本追加。 + | 环节 | 取值 | 现状 | | --- | --- | --- | -| `package.json` `version` | `0.2.0` | 唯一手动管理点 | +| `package.json` `version` | `0.2.0` | 唯一手动管理点,**不得带 `-ota`** | +| `src-tauri/tauri.conf.json` `version` | `0.2.0` | 决定 **APK `versionName`**(经 `tauri android init` 写入 `tauri.properties`)与桌面包版本 | +| `src-tauri/Cargo.toml` / `Cargo.lock` | `0.2.0` | Rust 包版本,与上两者保持一致 | | OTA `latest.json.version` | `0.2.0-ota` | 打包脚本自动拼 `-ota` 后缀 | | `src/version.js` `APP_VERSION` | `0.2.0` | 前端展示与版本比较的基准 | +| APK `versionCode` | `2000` | `major*1000000 + minor*1000 + patch`,必须单调递增才能覆盖安装(`0.1.0` = 1000) | | OTA `latest.json.min_binary_version` | `0.1.0` | **与前端版本解耦**:前端 bundle 要求的最低原生二进制版本。现有 APK 原生层仍是 0.1.0,跟着抬会导致旧 APK 收不到更新,CI 固定传 `--min-binary 0.1.0` | -| CI 守卫 | `scripts/ci/check-version.mjs` | 从 `GITHUB_REF_NAME` 解析 `dev-.`,要求 `version` 以该前缀开头,否则**构建直接失败**;`main`/`master` 跳过 | +| CI 守卫 | `scripts/ci/check-version.mjs` | ① 四处版本必须完全相等且不含 `-ota`(`main`/`master` 也查);② 从 `GITHUB_REF_NAME` 解析 `dev-.`,要求 `version` 以该前缀开头;任一不满足**构建直接失败**。`docker-build.yml` 与 `android-build.yml` 都在构建前执行 | ### 10.3 判新口径:版本号 vs build sequence diff --git a/docs/V0.2/需求文档.md b/docs/V0.2/需求文档.md index b8b8421..b8585a9 100644 --- a/docs/V0.2/需求文档.md +++ b/docs/V0.2/需求文档.md @@ -179,7 +179,8 @@ | 编号 | 需求描述 | 验收要点 | 优先级 | | --- | --- | --- | --- | | F-OT-01 | **OTA 包版本必须与分支对应**:`dev-0.2` 分支上 OTA 包版本应为 `0.2.0-ota` | `GET /api/ota/check/0` 返回 `version = 0.2.0-ota` | P0 | -| F-OT-02 | 版本号错位在 CI 阶段就拦住:分支名 `dev-x.y` 与 `package.json` 版本主次号不一致时构建失败 | CI 输出明确报错并中断 | P0 | +| F-OT-01a | **APK 版本与 OTA 版本同源**:APK(含桌面包)版本为裸版本号 `0.2.0`,无后缀;OTA 版本为同一版本号加 `-ota`。两者都跟代码分支保持一致 | APK 系统信息里 versionName=`0.2.0`(versionCode=2000),OTA manifest=`0.2.0-ota` | P0 | +| F-OT-02 | 版本号错位在 CI 阶段就拦住:①`package.json`/`tauri.conf.json`/`Cargo.toml`/`Cargo.lock` 四处必须完全一致且不含 `-ota`;②分支名 `dev-x.y` 与版本主次号不一致时构建失败 | CI 输出明确报错并中断 | P0 | | F-OT-03 | 「我」页 OTA 组内新增**「开发版本」开关**,默认**关闭** | 关闭=按版本号比较;打开=按 build sequence 比较 | P0 | | F-OT-04 | 开关值**持久化在设备端**,重启 App 后保持上次选择 | 杀进程重进仍为上次状态 | P0 | | F-OT-05 | 版本号比较规则:按非数字字符切成数字字段,逐段比较大小(`0.2.0 > 0.1.0`,`0.10 > 0.9`) | 单测覆盖后缀/前缀/缺位/多位段 | P0 | @@ -189,7 +190,7 @@ ## 4. 优先级汇总(本期交付范围) -- **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;F-OT-01~05;U-01、U-02、U-04、U-05;D-01~05;N-01、N-02、N-04、N-05;E-01、E-02、E-04、E-05、E-08。 +- **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;F-OT-01、F-OT-01a、F-OT-02~05;U-01、U-02、U-04、U-05;D-01~05;N-01、N-02、N-04、N-05;E-01、E-02、E-04、E-05、E-08。 - **P1(应该)**:F-CK-09、10、12~14;F-CL-09;F-LS-05~09;F-SY-06~08;F-OT-06;U-03、U-06~08;D-06;N-03;E-03、E-06、E-07。 - **P2(可选/延后)**:F-CK-15~17;F-CL-10、11;F-LS-10、11;U-09。 diff --git a/readme b/readme index 2d46c26..eefaec5 100644 --- a/readme +++ b/readme @@ -3,21 +3,57 @@ ## 版本管理 -版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`)。 -版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`(CI 有 `scripts/ci/check-version.mjs` 强校验,不一致直接失败)。 -发版时只改这一处,以下位置必须保持一致(改版本时一并核对): +### 一句话规则 + +**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。** + +``` +分支 dev-0.2 → 版本 0.2.0 + ├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀) + ├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀) + └─ OTA 热更包 version = 0.2.0-ota (多一个 -ota) +``` + +版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`), +该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。 + +版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`。 + +### 改版本号时要同步的位置 | 文件 | 字段 | 说明 | | --- | --- | --- | | `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 | -| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) | +| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 | | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 | | `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 | -说明: +`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`, +并校验 `dev-.` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。 + +### APK 版本号是怎么来的 + +`tauri android init` 会按 `src-tauri/tauri.conf.json` 的 `version` 生成 +`src-tauri/gen/android/app/tauri.properties`(该目录被 gitignore,CI 每次 `android init` 重新生成): + +``` +tauri.android.versionName = 0.2.0 # 人类可见版本,与 tauri.conf.json 一致 +tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000 + minor*1000 + patch +``` + +注意: +- `versionCode` 必须**单调递增**才能覆盖安装,所以版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。 +- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新, + 删掉该目录重新 `npx tauri android init` 即可。 + +### 其它 + - `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。 -- OTA 升级包的 `version` 字段 = `-ota`(见 `scripts/build-ota.mjs`), +- OTA 升级包的 `version` = `-ota`(见 `scripts/build-ota.mjs`), 所以版本号错位会直接导致线上 OTA 判新失准。 +- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本 + (CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬, + 否则装了 0.1.0 APK 的设备会直接收不到热更。 - `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。 - `ota-package/latest.json` 为运行时产物,不纳入版本管理。 diff --git a/scripts/ci/check-version.mjs b/scripts/ci/check-version.mjs index af76352..847ee95 100644 --- a/scripts/ci/check-version.mjs +++ b/scripts/ci/check-version.mjs @@ -1,15 +1,20 @@ #!/usr/bin/env node -// CI 专用: 校验分支名与 package.json 版本号一致 +// CI 专用: 校验版本号四处一致 + 与分支名一致 // // 背景: -// OTA 升级包的 version 直接取自 package.json (`-ota`), 而分支按版本命名 -// (dev-0.1 / dev-0.2 ...). 两者一旦错位 (例如 dev-0.2 上 package.json 还是 0.1.0), -// 线上 OTA 就会报成上一个版本的号, 客户端判新逻辑全部失准。 +// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生: +// - APK / 桌面安装包 version = (裸版本号, 无后缀) +// - OTA 热更包 version = -ota (多一个 -ota 标记) +// - 前端运行时 APP_VERSION = +// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。 // -// 规则: -// - 分支形如 dev-. → package.json 的 version 必须以 . 开头 -// - 分支形如 dev- → version 必须以 . 开头 -// - main / master 及其它分支 → 跳过校验 (生产分支不做约束, 只打印当前版本) +// 校验分两段: +// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock +// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加) +// B. 分支对应 (仅 dev-<版本> 分支): +// dev-. → version 必须以 . 开头 +// dev- → version 必须以 . 开头 +// main / master / 其它 → 跳过 // // 环境变量: // GITHUB_REF_NAME - Gitea Actions 提供的分支名 (本地运行可用 BRANCH 覆盖) @@ -23,27 +28,88 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const ROOT = path.resolve(__dirname, '..', '..') const branch = process.env.GITHUB_REF_NAME || process.env.BRANCH || '' -const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) -const version = String(pkg.version || '0.0.0') + +function readJson(rel) { + return JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8')) +} + +// Cargo.toml 的 [package].version 位于文件开头, 取第一个行首 version = "x" +function readCargoTomlVersion() { + const text = fs.readFileSync(path.join(ROOT, 'src-tauri/Cargo.toml'), 'utf8') + const m = /^version\s*=\s*"([^"]+)"/m.exec(text) + return m ? m[1] : null +} + +// Cargo.lock 里挑出 name = "iboard" 那个包的 version +function readCargoLockVersion() { + const text = fs.readFileSync(path.join(ROOT, 'src-tauri/Cargo.lock'), 'utf8') + const blocks = text.split(/^\[\[package\]\]$/m) + for (const b of blocks) { + if (/^name\s*=\s*"iboard"$/m.test(b)) { + const m = /^version\s*=\s*"([^"]+)"/m.exec(b) + if (m) return m[1] + } + } + return null +} + +const sources = [ + { file: 'package.json', version: String(readJson('package.json').version || '') }, + { file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.json').version || '') }, + { file: 'src-tauri/Cargo.toml', version: readCargoTomlVersion() }, + { file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() }, +] + +const version = sources[0].version const otaVersion = version + '-ota' -console.log('[check-version] 分支: ', branch || '(未知)') -console.log('[check-version] package.json version:', version) -console.log('[check-version] OTA 包版本将是:', otaVersion) +console.log('[check-version] 分支: ', branch || '(未知)') +for (const s of sources) { + console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`) +} +console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion) +let failed = false + +// ---- A. 四处一致性 + 裸版本号 ---- +if (/-ota$/i.test(version)) { + console.error('') + console.error(`[check-version] package.json 的 version 不能带 "-ota": ${version}`) + console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)') + failed = true +} + +const mismatched = sources.filter((s) => s.version !== version) +if (mismatched.length) { + console.error('') + console.error('============================================================') + console.error('[check-version] 版本号不一致, 构建中止') + console.error('============================================================') + for (const s of mismatched) { + console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`) + } + console.error('') + console.error(' 规则: APK / 桌面包版本 = 裸版本号 (无后缀);') + console.error(' OTA 热更包版本 = 同一个版本号 + "-ota"。') + console.error(' 改版本时四处必须一起改 (见 readme「版本管理」)。') + failed = true +} + +// ---- B. 分支对应 ---- const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch) if (!m) { - console.log('[check-version] 非 dev-<版本> 分支, 跳过一致性校验') - process.exit(0) + console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验') +} else { + const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}` + if (!version.startsWith(want)) { + console.error('') + console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`) + console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock') + failed = true + } else { + console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`) + } } -const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}` -if (!version.startsWith(want)) { - console.error('') - console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`) - console.error('[check-version] 请修改 package.json 的 version (唯一手动管理点),') - console.error('[check-version] 并同步 src-tauri/tauri.conf.json 与 src-tauri/Cargo.toml') - process.exit(1) -} - -console.log(`[check-version] 一致 (${branch} ↔ ${version})`) +if (failed) process.exit(1) +console.log('[check-version] 全部通过')