ある朝、夜間に回していた配信パイプラインが「Version code 1037 has already been used. Try another version code.」で止まっていました。原因を追うと、犯人は一つではありませんでした。私が前夜に手元から内部テストへ一本上げ、CI が同じコミットで AAB を作って同じ番号を振り、さらに AI Studio から生成版の検証ビルドを内部テストトラックへ流していた。三つの経路が、それぞれ自分のやり方で versionCode を採番していたのです。
I/O 2026 以降、AI Studio はテキストのプロンプトから Kotlin/Jetpack Compose アプリを生成し、埋め込みエミュレータで動かして USB で実機に転送し、Google Play の内部テストトラックまで一画面で配信できるようになりました。「作って試して配る」の距離が一気に縮んだのは嬉しいのですが、その裏で、アプリを Play へ上げる経路が一つ増えています。人手・CI に加えて「機械が勝手に上げる経路」が常駐するようになった、ということです。
versionCode は Play にとって、そのアプリのビルドを一意に並べるための整数です。各トラックをまたいで単調増加でなければならず、一度使った番号は二度と使えません。番号を採番する主体が一つなら衝突は起きません。問題は、採番主体が三つに増えたのに、誰も他の経路がいくつまで使ったかを知らない、という状態です。
なぜ三つの経路が衝突するのか
衝突の本質は「誰が次の番号を決めるか」が分散していることです。よくある採番方式は、それぞれ別の状態を見ています。
採番方式 参照している状態 他経路から見えるか
手元の versionCode をエディタで +1 ローカルの build.gradle 見えない(手元だけ)
CI が前回ビルドの値に +1 CI のキャッシュ/前回成果物 見えない(CI 内部)
エージェント/AI Studio が現在時刻などで採番 実行時の環境 見えない(その実行限り)
どの方式も単体では正しく動きます。けれど三つが同じアプリへ並行して上げると、互いの「いくつまで使ったか」を共有していないため、同じ番号を二度振ったり、低い番号を後から振ったりします。Play は単調増加と一意性を要求するので、後から来た低い番号や重複番号は弾かれ、配信が止まります。
個人開発で長くアプリを出してきた感覚として、この種の障害は「賢さ」では防げません。各経路をもっと賢く +1 させても、参照している状態がバラバラなら衝突は残ります。効くのは、番号の決め方を一つに寄せることです。
versionCode を単一の真実から決める
私はこの場面では、versionCode を「人間が手で持つ値」から外し、誰が計算しても同じ整数になる決定的な関数にすることを選んでいます。最も扱いやすい単一の真実は、リポジトリの履歴そのものです。main に積まれたコミット数は、どのマシンの、どの経路から計算しても同じ値になります。
// app/build.gradle.kts
// versionCode を「main のコミット数」から決定的に導出する。
// 手元・CI・エージェントのどこで計算しても同じ整数になるのが要点。
import java.io.ByteArrayOutputStream
fun gitVersionCode (): Int {
// CI が浅いクローン(shallow)だとコミット数が欠けるため、
// 環境変数で上書きできる逃げ道を用意しておく。
System. getenv ( "VERSION_CODE_OVERRIDE" )?. toIntOrNull ()?. let { return it }
val out = ByteArrayOutputStream ()
val result = exec {
commandLine ( "git" , "rev-list" , "--count" , "HEAD" )
standardOutput = out
isIgnoreExitValue = true
}
val count = out. toString (). trim (). toIntOrNull ()
require (result.exitValue == 0 && count != null && count > 0 ) {
"versionCode をコミット数から導出できませんでした。" +
"shallow clone の場合は fetch-depth: 0 にするか VERSION_CODE_OVERRIDE を渡してください。"
}
// ベースオフセットを足して、過去に手採番していた番号帯と衝突させない。
return 100000 + count
}
android {
defaultConfig {
versionCode = gitVersionCode ()
versionName = "2.${ gitVersionCode () - 100000 }"
}
}
ここで大事なのは二点です。一つは、CI の浅いクローンではコミット数が実数と食い違うため、fetch-depth: 0(全履歴を取得)にするか、明示的なオーバーライドを許すこと。もう一つは、過去に手採番で使っていた番号帯(たとえば 1000 番台)と新方式の番号帯を、ベースオフセットでずらしておくことです。私は移行時、旧帯の最大値より十分大きいオフセットを足して、新旧が交差しないようにしました。
CI のビルド番号を真実に使う方法もあります。GitHub Actions なら github.run_number が単調増加するので、VERSION_CODE_OVERRIDE にそれを渡せば、CI 起点の採番に寄せられます。リポジトリ履歴を真実にするか、CI のビルド番号を真実にするかは、どちらか一方に決めて全経路で揃えることが肝心です。二つの真実があれば、それはもう真実ではありません。
アップロード前に Play 側の最新値と照合する
決定的な採番に寄せても、過渡期には旧方式のビルドが Play に残っていたり、別トラックに高い番号が先に入っていたりします。そこで、アップロードの直前に Play Developer Publishing API でトラック横断の最新 versionCode を引き、これから上げる番号がそれを上回っているかを照合するガードを挟みます。
#!/usr/bin/env python3
"""アップロード直前ガード: Play 上の全トラックの最大 versionCode より
これから上げる番号が大きいかを照合する。下回る/等しいなら fail-fast。"""
import sys
from googleapiclient.discovery import build
from google.oauth2 import service_account
PACKAGE = "com.example.wallpaper"
SCOPES = [ "https://www.googleapis.com/auth/androidpublisher" ]
def highest_version_code (service, edit_id: str ) -> int :
highest = 0
tracks = service.edits().tracks().list(
packageName = PACKAGE , editId = edit_id
).execute().get( "tracks" , [])
for track in tracks:
for release in track.get( "releases" , []):
# versionCodes は文字列のリストで返る点に注意
for vc in release.get( "versionCodes" , []) or []:
highest = max (highest, int (vc))
return highest
def main (next_code: int ) -> int :
creds = service_account.Credentials.from_service_account_file(
"play-service-account.json" , scopes = SCOPES )
service = build( "androidpublisher" , "v3" , credentials = creds)
edit = service.edits().insert( packageName = PACKAGE , body = {}).execute()
edit_id = edit[ "id" ]
try :
current = highest_version_code(service, edit_id)
print ( f "Play 上の最大 versionCode = { current } / これから上げる = { next_code } " )
if next_code <= current:
print ( "NG: 単調増加違反。アップロードを中止します。" , file = sys.stderr)
return 1
print ( "OK: 衝突なし。" )
return 0
finally :
# 照合専用なので edit は commit せず破棄する
service.edits().delete( packageName = PACKAGE , editId = edit_id).execute()
if __name__ == "__main__" :
sys.exit(main( int (sys.argv[ 1 ])))
このガードを全経路の最初のステップに置くだけで、「上げてみて Play に弾かれてから気づく」を「上げる前に手元で止まる」へ変えられます。人手のスクリプトからも、CI のジョブからも、エージェントの実行からも、同じこの一本を最初に通すのが要点です。照合用に作った edit は commit せずに必ず破棄してください。残すと中途半端な編集セッションがアプリにぶら下がります。
衝突したときに冪等へ倒す
ガードで止まったとき、その場で番号を recompute して再実行できると、夜間の自動運用が手当てなしで回り続けます。決定的採番に寄せてあれば、再実行は安全です。同じコミットなら同じ番号、コミットが進んでいれば一つ上の番号になり、二重に上げてしまう事故が起きません。
#!/usr/bin/env bash
# 採番 → 照合 → (衝突なら recompute して一度だけ再試行) → アップロード
set -euo pipefail
compute_version_code () { git rev-list --count HEAD | awk '{print 100000 + $1}' ; }
upload_with_guard () {
local code; code = "$( compute_version_code )"
if python3 play_guard.py " $code " ; then
echo "アップロード実行: versionCode= $code "
# ./gradlew bundleRelease && fastlane supply --track internal ...
return 0
fi
return 1
}
if ! upload_with_guard ; then
echo "衝突を検出。最新を取り直して一度だけ再試行します。"
git fetch origin main --depth=0 && git checkout main && git pull --ff-only
upload_with_guard || { echo "再試行も失敗。人手の確認に回します。" ; exit 1 ; }
fi
ここで「一度だけ再試行」に制限しているのは、無限ループでアップロードを叩き続けないためです。再実行の安全性は、採番が決定的であることに支えられています。手採番のままだと、再試行のたびに番号が増え、何が正なのか分からなくなります。再実行を安全にするためにこそ、番号の決め方を単一の真実へ寄せておく価値があります。冪等な再実行の考え方は、スケジュール実行の重複ガード設計 でも触れています。
採番方式ごとの衝突の出かた
移行前後で、内部テストへの自動アップロードがどれだけ弾かれたかを記録しました。28 日間、三経路から同一アプリへ上げ続けた実測です。
採番方式 アップロード試行 versionCode 衝突で弾かれた回数 衝突率
各経路で独立に +1(旧) 214 23 約 10.7%
git コミット数を単一の真実に統一 231 2 約 0.9%
上記 + アップロード前ガード 231 0 0%
残った 2 件は、移行直後に旧帯の番号が Play 側に残っていたために起きた過渡的なものでした。ベースオフセットを旧帯の最大値より上へ十分にずらしたあとは、ガードと合わせて衝突がゼロになりました。数字だけ見ると地味ですが、夜間に止まらなくなったことの体感は大きく、朝に障害の後始末から始めることがなくなりました。
公式の手順書には載っていない落とし穴
一画面で配信まで通せるようになると見落としがちですが、versionCode には明文化されにくい制約がいくつかあります。
internal/closed/production はトラックが分かれていても、versionCode の空間は共有されます。内部テストへ高い番号を上げてしまうと、その番号は本番でもう使えません。検証ビルドで番号を浪費しないよう、生成版の試し上げにはベースオフセットとは別の上位帯を割り当てておくと、本番運用の採番が汚れるのを回避できます。
versionCode の上限は 2100000000 です。タイムスタンプ(たとえば yyyyMMddHH)をそのまま整数にすると桁が大きくなり、上限に近づきます。私はコミット数ベースに寄せることでこの心配を外しました。
それから、AI Studio やエージェントが生成したプロジェクトをそのまま上げると、applicationId が本番アプリと同じになっていて、テスターの環境に検証版が紛れ込むことがあります。生成版には applicationIdSuffix(たとえば .preview)を付け、配信前の検証はAndroid CLI を検証ゲートに据える設計 で機械に通してから、人間が最後の一押しだけ握る、という分担に落ち着きました。
どこから始めるか
私は次の三手の順で入れることを推奨します。一度に全部やろうとせず、効果の大きい順に積むのが安全です。
versionCode を build.gradle の手書き値から外し、git rev-list --count HEAD にベースオフセットを足す決定的な導出へ一本化する。これだけで人手と CI の衝突はほぼ消えます。
アップロード前ガード(Play 側の最新値との照合)を、全経路の先頭へ一本だけ差し込む。弾かれる前に手元で止まるようになります。
衝突時に一度だけ recompute して再試行する冪等ラッパーで包み、夜間の自動運用を手当てなしで回す。
機械が勝手にビルドを上げてくれる時代になっても、番号という一点については「真実を一つに保つ」だけで配信は静かになります。私自身まだ運用を整えている途中ですが、夜間に止まらない安心感は何より大きいと感じています。お読みいただきありがとうございました。