架構決策 (ADR)
TestFlight 內測 build numbering 與 mobile/backend 版本脫鉤
Context
既有設計
docs/openspec/changes/archive/2026-05-05-versioning-and-release-strategy
(即 process-version-policy / process-release-tagging / process-bump-version-skill
三個 capability 的來源)刻意把 app 與 backend 版本綁在一起:
- Non-Goal(design.md:24):「不處理 monorepo app/backend 版本解耦(兩者保持同步升版策略)」
- 取捨(design.md:112):「app 與 backend 版本同步升版 vs. 各自獨立 → Beta 階段同步升版簡化管理,之後有需要再分離」
該設計以根目錄 VERSION 檔為跨元件單一真相,scripts/bump-version.sh
同步寫入 mobile/pubspec.yaml(marketing version)與 backend/pyproject.toml;
Flutter +BUILD 號由 CI 以 git rev-list --count HEAD 注入。
觸發
「之後有需要再分離」的條件現在成立:
- 三方版本漂移:root
VERSION(0.6.5)、mobile/pubspec.yaml(0.6.1+13)、 最新 git tag 已彼此不一致——「單一真相」在實務上並未成立,同步機制反而製造了 一個對不齊的中間檔。 - TestFlight build number 撞號:
git rev-list --count HEAD對「同一 commit 重出 build」會回傳相同數字。內測常需對同一 commit 重新出包,TestFlight 對同一 marketing version 要求 build number 嚴格遞增,相同號會被拒絕;用空 commit 騙號污染歷史。 - 節奏分歧:mobile 進入 TestFlight 內測高頻出包,backend 走 Cloud Run 各自部署, 兩者升版節奏不再相同;強制同步升版讓任一端的 bump 都假裝動到另一端。
這不是修 bug,而是執行當初預留的分離——原設計已明文保留此演進路徑。
Decision
結論一:mobile 與 backend 版本脫鉤,各採 idiomatic 單一來源。
- mobile marketing version 的單一真相 =
mobile/pubspec.yaml的version: X.Y.Z+<BUILD>。 - backend version 的單一真相 =
backend/pyproject.toml的[project] version。 - 廢除根目錄
VERSION檔(已確認僅bump-version.sh與cd-mobile.yml讀它, backend runtime 不讀)。 scripts/bump-version.sh只操作mobile/pubspec.yaml,不再寫backend/pyproject.toml。
結論二:TestFlight / Google Play build number 改用 (run_number + OFFSET) * 100 + run_attempt。
- CI 以
--build-number=$(( (GITHUB_RUN_NUMBER + OFFSET) * 100 + GITHUB_RUN_ATTEMPT ))注入, OFFSET 初值1000。三個 build job(android-betaapk、android-playappbundle、ios-betaipa)一致。 - 為何納入
run_attempt(修正單純run_number的盲區,Keith review):github.run_number只在「新的 workflow run」遞增;GitHub 原生「Re-run jobs / Re-run all jobs」重跑同一個 run 時run_number不變,只有github.run_attempt遞增。若只用run_number + OFFSET,原生 Re-run 會產生相同 build number → TestFlight / Play 撞號(正是本 ADR 要解的問題)。 - 嚴格遞增證明(
run_attempt < 100):新 run →run_number+1 → 乘積 +100,run_attempt重置為 1,仍大於前一 run 的任何 attempt(≤ 99);同 run 的 Re-run →run_attempt+1。兩條 重跑路徑皆嚴格遞增、不撞號、無需空 commit。run_attempt實務上遠小於 100。 - OFFSET 不可調小:TestFlight / Play 對同一 marketing version 拒絕「不嚴格大於」既有 build
的號;OFFSET 的作用是把起始號抬到高於已上傳歷史最大值。上線前須查該 marketing version 在
App Store Connect / Play Console 的最大 build number,確認
(run_number + OFFSET) * 100 + run_attempt較大、不倒退。
結論三:兩階段 bump/tag 流程。
- 內測(preparation):
bump-version.shbump + commit,不打 tag;以workflow_dispatch手動觸發cd-mobile出內測 build。 - 發行(release):QA 驗收 OK 才打
vX.Y.Zannotated tag + push +gh release, tag push 觸發正式 Beta CI build。tag 因此只代表真正發行的版本。
backend 的版本政策(runtime 動態讀 pyproject 修掉寫死的 0.1.0、獨立 capability
process-backend-version-policy)由後續 change 0036 處理,引用本 ADR。
Alternatives Considered
- 方案 A:維持 root
VERSION同步升版 — 考慮過,因為三方已漂移、「單一真相」名實不符, 且強制同步讓 mobile/backend 任一端 bump 都假裝動到另一端,與實際各自部署節奏不符而拒絕。 - 方案 B:build number 仍用
git rev-list --count HEAD— 考慮過,因為同 commit 重出 build 會撞號、TestFlight 拒絕,只能靠空 commit 騙號污染歷史而拒絕。 - 方案 C:build number 直接用
github.run_number(不加 OFFSET) — 考慮過,因為 App Store Connect 既有歷史 build number 已高於目前 run_number,不加 OFFSET 會落在歷史 之下被 TestFlight 拒絕;故以固定 OFFSET 抬高起點。 - 方案 C':
run_number + OFFSET(不納入run_attempt) — 初版採用,後於 review(Keith) 否決:GitHub 原生「Re-run」重跑同一個 run 時run_number不遞增,會撞號;改用方案 E 的公式 把run_attempt納入,公式層面解掉而非靠「請勿按 Re-run」的脆弱約定。 - 方案 E(採用):
(run_number + OFFSET) * 100 + run_attempt— 同時涵蓋「新 run」與 「原生 Re-run」兩條重跑路徑,嚴格遞增(run_attempt < 100),見結論二。 - 方案 D:導入獨立版本管理工具(如 release-please / changesets) — 考慮過,因為現階段
只需「mobile 單檔真相 + CI 注入 build 號」即可滿足,引入額外工具與本 repo 既有 git-cliff
- asdf 流程重疊,為維持單一機制而不採用。
Consequences
好:
- 消除三方漂移:mobile 與 backend 各自 idiomatic 單一來源,無中間同步檔。
- 內測同 commit 可重複出包且 build number 嚴格遞增,不撞號、不需空 commit。
- tag 只代表真正發行的版本,內測不污染 tag 命名空間。
- mobile/backend 升版節奏解耦,各自獨立發版。
壞 / 代償:
- 失去「一個檔看全 repo 版本」的單一視圖;查版本需分別看 pubspec 與 pyproject。
- OFFSET 是一次性人工校準值:上線前必須查 App Store Connect 既有最大 build number, 且永遠不可調小,否則 TestFlight 拒絕上傳。
- 兩階段流程比原本「一次 bump+tag」多一個心智步驟,需靠 SKILL.md 與本 ADR 維持紀律。
中性:
process-version-policy三個 Requirement(single source / sync / build number)由 change0035以## MODIFIED Requirements整段重述更新;Components follow semantic versioning不變。- backend 動態讀版本與其版本政策為獨立工作(change
0036),本 ADR 為其前置依據。
Links
- Change
0035-testflight-internal-build-numbering— 本 ADR 的實作(mobile/release,PR1) - Change
0036(規劃中)— backend 動態讀pyproject+process-backend-version-policycapability(PR2,引用本 ADR) - 來源設計
docs/openspec/changes/archive/2026-05-05-versioning-and-release-strategy/design.md(Non-Goal:24、取捨:112 預留分離路徑)