Skip to content

Commit 876fc8b

Browse files
committed
REFACTOR: 版本号方案重构 - tag 用纯 SemVer,versionCode 自动用 commit count
根因:Android 升降级只看 versionCode(整数),不看 versionName。 旧方案在 minor 升级时把 +N 重置回 1,导致 v1.0.10+4 (versionCode=4) 之后的 v1.0.11+1 (versionCode=1) 被系统拒绝安装。 改动: - scripts/lib/build_number.sh: calculate_build_number 用 git rev-list --count HEAD,TAG_NAME 改为纯 SemVer (v1.0.11);parse_tag 兼容 新旧两种格式 - scripts/release_{android,ios,macos}.sh: tag 正则放宽 +N 可选; 不带 +N 时 BUILD_NUMBER 兜底用 commit count - .github/workflows/release.yml: 重写 Parse version step,兼容新旧 tag 格式,加 versionCode 严格大于历史最大值的校验,保留 pubspec 一致性校验 - lib/screens/settings_screen.dart: 用户可见版本只显示 versionName, 不再拼接 +buildNumber - CLAUDE.md: 加 7.3 Android versionCode 单调递增踩坑记录
1 parent 819d990 commit 876fc8b

8 files changed

Lines changed: 109 additions & 51 deletions

File tree

.github/workflows/release.yml

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ name: Release
44
# 同时把 APK 作为 GitHub Release 附件挂出(IPA 不公开)。
55
#
66
# 触发:
7-
# - push tag 形如 v1.0.8+1
7+
# - push tag 形如 v1.0.11(推荐,新格式)或 v1.0.8+1(兼容旧格式)
88
# - 或在 Actions 页面手动 dispatch 选 tag
9+
#
10+
# 版本号约定:
11+
# - tag 用纯 SemVer(v1.0.11),不带 +N
12+
# - versionCode 由 CI 自动用 git rev-list --count <tag> 生成,全局单调递增
13+
# - 详见 CLAUDE.md "Android versionCode 单调递增" 章节
914
on:
1015
push:
1116
tags: ['v*']
1217
workflow_dispatch:
1318
inputs:
1419
tag:
15-
description: 'Tag to release (e.g. v1.0.8+1)'
20+
description: 'Tag to release (e.g. v1.0.11)'
1621
required: true
1722

1823
env:
@@ -61,23 +66,56 @@ jobs:
6166
env:
6267
TAG_REF: ${{ github.event.inputs.tag || github.ref_name }}
6368
run: |
64-
NAME="${TAG_REF#v}"
65-
NAME="${NAME%+*}"
66-
NUMBER="${TAG_REF##*+}"
67-
if [[ "$NAME" == "$TAG_REF" || "$NUMBER" == "$TAG_REF" || -z "$NAME" || -z "$NUMBER" ]]; then
68-
echo "ERROR: tag '$TAG_REF' is not in vX.Y.Z+N format" >&2
69+
# 兼容新格式 vX.Y.Z 和旧格式 vX.Y.Z+N
70+
if [[ "$TAG_REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
71+
NAME="${BASH_REMATCH[1]}"
72+
# versionCode = 该 tag 指向 commit 的 git rev-list count
73+
NUMBER="$(git rev-list --count "$TAG_REF")"
74+
elif [[ "$TAG_REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$ ]]; then
75+
NAME="${BASH_REMATCH[1]}"
76+
NUMBER="${BASH_REMATCH[2]}"
77+
else
78+
echo "ERROR: tag '$TAG_REF' is not in vX.Y.Z or vX.Y.Z+N format" >&2
79+
exit 1
80+
fi
81+
if [[ -z "$NUMBER" || "$NUMBER" -le 0 ]]; then
82+
echo "ERROR: invalid build number ($NUMBER) for tag $TAG_REF" >&2
6983
exit 1
7084
fi
85+
86+
# 校验 versionCode 严格大于所有历史 tag 的 versionCode
87+
# 防止人为意外打了倒退的 +N 旧格式 tag
88+
git fetch --tags --force
89+
MAX_CODE=0
90+
for t in $(git tag -l 'v*'); do
91+
if [[ "$t" == "$TAG_REF" ]]; then continue; fi
92+
if [[ "$t" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$ ]]; then
93+
CODE="${BASH_REMATCH[2]}"
94+
elif [[ "$t" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
95+
CODE="$(git rev-list --count "$t" 2>/dev/null || echo 0)"
96+
else
97+
continue
98+
fi
99+
if [[ "$CODE" -gt "$MAX_CODE" ]]; then MAX_CODE="$CODE"; fi
100+
done
101+
if [[ "$NUMBER" -le "$MAX_CODE" ]]; then
102+
echo "ERROR: versionCode $NUMBER must be strictly greater than max existing $MAX_CODE" >&2
103+
echo "如需重新构建已发布的 tag,请在最新 commit 上加空 commit 后再打新 tag" >&2
104+
exit 1
105+
fi
106+
107+
# 校验 pubspec.yaml 版本号一致
71108
PUBSPEC_VERSION="$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)"
72109
if [[ "$NAME" != "$PUBSPEC_VERSION" ]]; then
73110
echo "ERROR: tag version ($NAME) does not match pubspec.yaml ($PUBSPEC_VERSION)" >&2
74111
echo "请先升级 pubspec.yaml 的 version 字段,再打 tag。" >&2
75112
exit 1
76113
fi
114+
77115
echo "name=$NAME" >> "$GITHUB_OUTPUT"
78116
echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
79117
echo "version=${NAME}+${NUMBER}" >> "$GITHUB_OUTPUT"
80-
echo "Parsed: name=$NAME number=$NUMBER (pubspec OK)"
118+
echo "Parsed: name=$NAME number=$NUMBER (pubspec OK, max existing code=$MAX_CODE)"
81119
82120
# ---------- Android ----------
83121

CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,16 @@ flutter test integration_test -d macos
184184
- **规则**:TTS 的 stop 和 speak 之间不能依赖 flutter_tts 内部的完成信号隔离,必须自行管理
185185
- **相关代码**`lib/services/tts_service.dart`
186186
- **修复时间**:2026-04-06
187+
188+
### 7.3 Android versionCode 必须全局单调递增,与 versionName 解耦
189+
190+
- **现象**:已装 1.0.10+4 的设备无法安装 1.0.11+1/+2,系统提示「已存在更高版本」
191+
- **根因**:Android 升降级**只看 versionCode(整数)**,不看 versionName。Flutter `pubspec.yaml``+N` 直接映射到 Android versionCode。minor 升级时把 buildNumber 重置回 1,导致 versionCode 倒退(4 → 1)
192+
- **解法**
193+
- Git tag 改用纯 SemVer 格式 `v1.0.11`**不带 `+N`**
194+
- versionCode 由 CI 用 `git rev-list --count <tag>` 自动生成,全局单调递增
195+
- App 内"关于"页只显示 versionName,buildNumber 不暴露给用户
196+
- 同 versionName 内需重新构建时:加空 commit (`git commit --allow-empty`) → commit count +1
197+
- **规则**:所有平台(Android/iOS/macOS)的发布脚本和 CI workflow 都按"tag = 纯 SemVer,versionCode = commit count"约定走。打 tag 前必须先升级 `pubspec.yaml` 的 version 字段(workflow 会强制校验一致)
198+
- **相关代码**`scripts/lib/build_number.sh``scripts/release_*.sh``.github/workflows/release.yml``lib/screens/settings_screen.dart`
199+
- **修复时间**:2026-05-20

lib/screens/settings_screen.dart

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -315,12 +315,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
315315
AppLocalizations l10n,
316316
) {
317317
final packageInfo = ref.watch(packageInfoProvider);
318-
final version = packageInfo.version;
319-
final buildNumber = packageInfo.buildNumber;
320-
// 仅当 buildNumber 是纯数字时显示 +N(空时默认 +1)
321-
final buildNumberInt = int.tryParse(buildNumber);
322-
final effectiveBuildNumber = buildNumberInt?.toString() ?? '1';
323-
final versionDisplay = '$version+$effectiveBuildNumber';
318+
// 仅向用户展示 versionName。Android versionCode(commit count)
319+
// 是内部递增数字,对用户无意义,不显示。
320+
final versionDisplay = packageInfo.version;
324321
final updateState = ref.watch(appUpdateProvider);
325322
final isChecking = updateState is AppUpdateChecking;
326323

scripts/lib/build_number.sh

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
# calculate_build_number "1.0.8"
66
#
77
# 输出变量:
8-
# BUILD_NUMBER - 构建号(数字,首次构建为 1
9-
# TAG_NAME - 要创建的 tag 名
8+
# BUILD_NUMBER - 构建号(commit count,全局单调递增
9+
# TAG_NAME - 要创建的 tag 名(纯 SemVer,如 v1.0.11)
1010
# SKIP_TAG_CREATION - 是否跳过 tag 创建(当前 commit 已有同版本 tag)
11+
#
12+
# 设计原则:
13+
# - Android versionCode 必须单调递增。跨 versionName 重置 +N 会导致
14+
# 旧版本(如 v1.0.10+4)已被 Android 记下 versionCode=4,新版本
15+
# v1.0.11+1 的 versionCode=1 反而被系统拒绝安装。
16+
# - 改用 commit count 作为 versionCode:每次 commit 自动 +1,永不倒退。
17+
# - tag 只用纯 SemVer 形式(v1.0.11),用户和系统通过 versionName 识别版本。
1118

1219
# 从 pubspec.yaml 读取版本号(不含构建号)
1320
get_build_name() {
@@ -21,39 +28,21 @@ get_build_name() {
2128
}
2229

2330
# 计算构建号
24-
# 参数:BUILD_NAME - 版本号(如 1.0.8
31+
# 参数:BUILD_NAME - 版本号(如 1.0.11
2532
# 输出:设置 BUILD_NUMBER, TAG_NAME, SKIP_TAG_CREATION 变量
2633
calculate_build_number() {
2734
local BUILD_NAME="$1"
2835
BUILD_NUMBER=""
29-
TAG_NAME=""
36+
TAG_NAME="v${BUILD_NAME}" # 纯 SemVer,不带 +N
3037
SKIP_TAG_CREATION=0
3138

32-
# 1. 检查当前 commit 是否已有同版本 tag(格式:v版本号+构建号)
33-
local EXISTING_TAG="$(git tag --points-at HEAD | grep -E "^v${BUILD_NAME}[+][0-9]+$" || true)"
34-
if [[ -n "$EXISTING_TAG" ]]; then
35-
# 提取构建号
36-
BUILD_NUMBER="${EXISTING_TAG##*+}"
37-
SKIP_TAG_CREATION=1
38-
TAG_NAME="$EXISTING_TAG"
39-
return 0
40-
fi
41-
42-
# 2. 获取同版本号的最大构建号
43-
local MAX_BUILD="$(git tag -l "v${BUILD_NAME}+*" | grep -Eo '[+][0-9]+$' | grep -Eo '[0-9]+' | sort -n | tail -1 || true)"
39+
# versionCode 全局单调递增:直接用 commit count
40+
BUILD_NUMBER="$(git rev-list --count HEAD)"
4441

45-
# 3. 计算新构建号和 tag 名(统一格式:v版本号+构建号)
46-
# 构建号从 1 开始(Android versionCode 必须是正整数)
47-
if [[ -n "$MAX_BUILD" ]]; then
48-
# 有 +N tag,构建号递增
49-
BUILD_NUMBER=$((MAX_BUILD + 1))
50-
else
51-
# 无任何同版本 tag,第一次构建
52-
BUILD_NUMBER="1"
42+
# 当前 commit 已有同 tag → 跳过 tag 创建
43+
if git tag --points-at HEAD | grep -qx "$TAG_NAME"; then
44+
SKIP_TAG_CREATION=1
5345
fi
54-
TAG_NAME="v${BUILD_NAME}+${BUILD_NUMBER}"
55-
56-
SKIP_TAG_CREATION=0
5746
}
5847

5948
# 创建 tag(用于 CI 成功后)
@@ -69,17 +58,21 @@ create_build_tag() {
6958
}
7059

7160
# 从 tag 提取版本号和构建号
61+
# 支持两种格式:
62+
# v1.0.11 新格式 → BUILD_NUMBER 用 git rev-list --count <tag> 算
63+
# v1.0.11+5 旧格式 → BUILD_NUMBER=5(兼容历史 tag 回放)
7264
parse_tag() {
7365
local TAG="$1"
74-
# v1.0.8+1 → 提取 1.0.8 和构建号
7566
local build_name="${TAG#v}"
7667
build_name="${build_name%+*}"
77-
# 使用参数展开提取构建号(更可靠)
78-
local build_number="1" # 默认为 1(对应无 +N 的旧 tag)
68+
local build_number=""
7969
if [[ "$TAG" == *+* ]]; then
70+
# 旧格式:直接取 +N
8071
build_number="${TAG##*+}"
72+
else
73+
# 新格式:用 commit count(基于 tag 指向的 commit,不是 HEAD)
74+
build_number="$(git rev-list --count "$TAG" 2>/dev/null || echo "")"
8175
fi
82-
# 输出供 eval 解析
8376
echo "BUILD_NAME=${build_name}"
8477
echo "BUILD_NUMBER=${build_number}"
85-
}
78+
}

scripts/release_android.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,16 @@ fi
9595

9696
# 如果参数和环境变量都没提供,尝试从当前 commit 的 tag 或现有 APK 解析
9797
if [[ -z "$BUILD_NAME" && -z "$BUILD_NUMBER" ]]; then
98-
# 先尝试 tag
99-
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+[+][0-9]+$' | head -1 || true)"
98+
# 先尝试 tag(兼容新格式 vX.Y.Z 和旧格式 vX.Y.Z+N)
99+
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([+][0-9]+)?$' | head -1 || true)"
100100
if [[ -n "$TAG" ]]; then
101101
log "Using tag: $TAG"
102102
eval "$(parse_tag "$TAG")"
103+
# 新格式 tag 不带 +N,BUILD_NUMBER 兜底用 commit count
104+
if [[ -z "$BUILD_NUMBER" ]]; then
105+
BUILD_NUMBER="$(git rev-list --count HEAD)"
106+
log "BUILD_NUMBER from commit count: $BUILD_NUMBER"
107+
fi
103108
elif [[ "$SKIP_BUILD" == true ]]; then
104109
# --skip-build 时从现有 APK 文件名推断
105110
APK_PATTERN="build/release/Echo-Loop-*-arm64.apk"

scripts/release_ios.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,18 @@ fi
130130

131131
# 如果参数和环境变量都没提供,从当前 commit 的 tag 解析
132132
if [[ -z "$BUILD_NAME" || -z "$BUILD_NUMBER" ]]; then
133-
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+[+][0-9]+$' | head -1 || true)"
133+
# 兼容新格式 vX.Y.Z 和旧格式 vX.Y.Z+N
134+
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([+][0-9]+)?$' | head -1 || true)"
134135
if [[ -z "$TAG" ]]; then
135136
fail "No version tag on current commit. Run ci.sh first, or provide --build-name and --build-number."
136137
fi
137138
log "Using tag: $TAG"
138139
eval "$(parse_tag "$TAG")"
140+
# 新格式 tag 不带 +N,BUILD_NUMBER 兜底用 commit count
141+
if [[ -z "$BUILD_NUMBER" ]]; then
142+
BUILD_NUMBER="$(git rev-list --count HEAD)"
143+
log "BUILD_NUMBER from commit count: $BUILD_NUMBER"
144+
fi
139145
fi
140146

141147
log "Using build name: $BUILD_NAME"

scripts/release_macos.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,18 @@ fi
5555

5656
# 如果参数和环境变量都没提供,从当前 commit 的 tag 解析
5757
if [[ -z "$BUILD_NAME" || -z "$BUILD_NUMBER" ]]; then
58-
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+[+][0-9]+$' | head -1 || true)"
58+
# 兼容新格式 vX.Y.Z 和旧格式 vX.Y.Z+N
59+
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([+][0-9]+)?$' | head -1 || true)"
5960
if [[ -z "$TAG" ]]; then
6061
fail "No version tag on current commit. Run ci.sh first, or provide --build-name and --build-number."
6162
fi
6263
log "Using tag: $TAG"
6364
eval "$(parse_tag "$TAG")"
65+
# 新格式 tag 不带 +N,BUILD_NUMBER 兜底用 commit count
66+
if [[ -z "$BUILD_NUMBER" ]]; then
67+
BUILD_NUMBER="$(git rev-list --count HEAD)"
68+
log "BUILD_NUMBER from commit count: $BUILD_NUMBER"
69+
fi
6470
fi
6571

6672
# 安装包名字统一包含构建号

test/screens/settings_screen_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ void main() {
9898
expect(find.text('Terms of Service'), findsOneWidget);
9999
expect(find.text('Privacy Policy'), findsOneWidget);
100100
expect(find.text('Write Feedback'), findsOneWidget);
101-
expect(find.text('Version 1.0.0+1 (Debug)'), findsOneWidget);
101+
expect(find.text('Version 1.0.0 (Debug)'), findsOneWidget);
102102
});
103103

104104
testWidgets('显示外观标题', (tester) async {

0 commit comments

Comments
 (0)