YiBi Dev Docs
架構決策 (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 注入。

觸發

「之後有需要再分離」的條件現在成立:

  1. 三方版本漂移:root VERSION0.6.5)、mobile/pubspec.yaml0.6.1+13)、 最新 git tag 已彼此不一致——「單一真相」在實務上並未成立,同步機制反而製造了 一個對不齊的中間檔。
  2. TestFlight build number 撞號git rev-list --count HEAD 對「同一 commit 重出 build」會回傳相同數字。內測常需對同一 commit 重新出包,TestFlight 對同一 marketing version 要求 build number 嚴格遞增,相同號會被拒絕;用空 commit 騙號污染歷史。
  3. 節奏分歧:mobile 進入 TestFlight 內測高頻出包,backend 走 Cloud Run 各自部署, 兩者升版節奏不再相同;強制同步升版讓任一端的 bump 都假裝動到另一端。

這不是修 bug,而是執行當初預留的分離——原設計已明文保留此演進路徑。

Decision

結論一:mobile 與 backend 版本脫鉤,各採 idiomatic 單一來源。

  • mobile marketing version 的單一真相 = mobile/pubspec.yamlversion: X.Y.Z+<BUILD>
  • backend version 的單一真相 = backend/pyproject.toml[project] version
  • 廢除根目錄 VERSION(已確認僅 bump-version.shcd-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-beta apk、android-play appbundle、ios-beta ipa)一致。
  • 為何納入 run_attempt(修正單純 run_number 的盲區,Keith review)github.run_number 只在「新的 workflow run」遞增;GitHub 原生「Re-run jobs / Re-run all jobs」重跑同一個 runrun_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.sh bump + commit,不打 tag;以 workflow_dispatch 手動觸發 cd-mobile 出內測 build。
  • 發行(release):QA 驗收 OK 才打 vX.Y.Z annotated 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)由 change 0035## MODIFIED Requirements 整段重述更新;Components follow semantic versioning 不變。
  • backend 動態讀版本與其版本政策為獨立工作(change 0036),本 ADR 為其前置依據。
  • Change 0035-testflight-internal-build-numbering — 本 ADR 的實作(mobile/release,PR1)
  • Change 0036(規劃中)— backend 動態讀 pyproject + process-backend-version-policy capability(PR2,引用本 ADR)
  • 來源設計 docs/openspec/changes/archive/2026-05-05-versioning-and-release-strategy/design.md(Non-Goal:24、取捨:112 預留分離路徑)

On this page