壁紙アプリの更新を出そうとして、Play Console のアップロード画面で止まりました。返ってきたのは一行です。
Version code 1 has already been used. Try another version code.
手元の app.json は、たしかに versionCode を 2 に上げてありました。ですので最初は Play 側の表示が追いついていないのだろうと考え、しばらく待ってから同じファイルを上げ直しました。結果は変わりません。
原因は Play ではなく、私の手元にありました。上げたはずの値が、そもそもビルドに入っていなかったのです。
この番号は、置き場所が複数あります。どこに書いたかではなく、どこが持ち主になっているかで結果が決まります。今日はその見分け方を、実際に動かせる形で整理します。
その番号は、公開していなくても使われています
まず Play 側の事実を確かめます。versionCode は Android アプリの更新順序を決める整数で、Play は一度受け取った番号を再利用させません。ここで見落としやすいのは、公開していなくても消費されることです。
- 製品版に出していないドラフトのリリースに添付しただけ
- 内部テストやクローズドテストのトラックに上げただけ
- アップロード後に「破棄」せず、リリースを保存したまま放置した
いずれの場合も、その番号は使用済みとして扱われます。「まだ誰にも配っていないのだから空いているはず」という感覚は通りません。
現状は Play Console の リリース > アプリバンドルエクスプローラ で確認できます。ここには受け取り済みの AAB が版ごとに並んでいますので、いま何番まで消費しているのかが一目で分かります。エラーに驚いて番号を適当に上げる前に、まずこの画面を開くのが早道です。
対処は二択です。
- ドラフトのリリースに添付された AAB を外し、アプリバンドルエクスプローラ側でも削除して、その番号を空ける
- 番号を上げて、ビルドし直す
2 を選ぶ場合、番号は連番である必要がありません。2 の次が 10 でも 100 でも、前より大きければ受け付けられます。私は原因調査で番号を何度か無駄にしたとき、切りのよい数まで飛ばして仕切り直すようにしています。あとから履歴を見たときに「ここで何かあった」と分かる方が、詰めて並べるより読みやすいためです。
なお、番号を変えたら必ずビルドし直します。versionCode は AAB の中に焼き込まれているため、設定ファイルを書き換えただけの状態で同じ成果物を上げ直しても、Play には古い番号が届きます。私が最初にはまったのは、まさにこの往復でした。
上げたはずの番号が、ビルドに入っていない
ここからが本題です。Expo のプロジェクトでは、versionCode を決めうる場所が三つあります。
app.json(またはapp.config.js)のexpo.android.versionCode- EAS のサーバー側に保管された値(
eas.jsonのcli.appVersionSourceがremoteのとき) android/app/build.gradleのversionCode(androidディレクトリをリポジトリに置いているとき)
三つのうちどれが効くかは、プロジェクトの構成で変わります。書いた場所と効く場所がずれていると、「上げたのに同じ番号で提出される」が起こります。
宣言した値と実際にビルドへ入る値が割れる現象は、versionCode に限りません。同じ日に扱った targetSdkVersion 36 を通すまでに直した3か所 でも、原因の構造はよく似ています。設定ファイルの記述は宣言であって、ビルドが読む値そのものではない、という一点です。
目で追うと間違えますので、読み取りを機械にやらせます。依存を増やさずに済むよう、Node の標準モジュールだけで書きました。
#!/usr/bin/env node
// 提出前に「Play へ実際に届く versionCode」を1コマンドで確かめる
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const root = process.argv[2] ?? ".";
const readJson = (p) => (existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null);
const appJson = readJson(join(root, "app.json"));
const easJson = readJson(join(root, "eas.json"));
const gradlePath = join(root, "android", "app", "build.gradle");
const hasNativeDir = existsSync(gradlePath);
const expo = appJson?.expo ?? {};
const declared = expo.android?.versionCode ?? null;
const versionName = expo.version ?? null;
let gradleCode = null;
if (hasNativeDir) {
const m = readFileSync(gradlePath, "utf8").match(/versionCode\s+(\d+)/);
gradleCode = m ? Number(m[1]) : null;
}
const source = easJson?.cli?.appVersionSource ?? "(未設定)";
const profile = process.argv[3] ?? "production";
const autoIncrement = easJson?.build?.[profile]?.autoIncrement ?? false;
const notes = [];
let effective;
if (hasNativeDir) {
effective = gradleCode;
notes.push("android/ をリポジトリに置いているため、app.json ではなく build.gradle の値がビルドに入ります");
} else if (source === "remote") {
effective = "EAS サーバー管理(ローカルの値は初期化にのみ使われます)";
notes.push("値はローカルファイルではなく EAS 側にあります。eas build:version:get で確認してください");
} else {
effective = declared;
notes.push("app.json の値がそのまま入ります。手で上げ忘れると同じ番号で提出することになります");
}
if (source === "(未設定)") notes.push("eas.json に cli.appVersionSource がありません。どちらで管理するかを先に決めてください");
if (!autoIncrement) notes.push(`profile "${profile}" の autoIncrement が false です。番号は自動では上がりません`);
console.log(`project : ${root}`);
console.log(`versionName : ${versionName ?? "(未設定)"}`);
console.log(`app.json 宣言値 : ${declared ?? "(未設定)"}`);
console.log(`build.gradle : ${hasNativeDir ? gradleCode : "(android/ なし)"}`);
console.log(`appVersionSource: ${source} / autoIncrement(${profile}): ${autoIncrement}`);
console.log(`実際に入る値 : ${effective ?? "(判定不能)"}`);
notes.forEach((n) => console.log(` - ${n}`));なぜこう書いたかというと、判定に必要なのは「値」ではなく「経路」だからです。versionCode が 2 と表示されても、その 2 がビルドに届くかどうかは別の話です。ですのでこのスクリプトは値を並べるだけでなく、どの経路が採用されたのかを最後の一行で言い切るようにしてあります。
三通りの構成を用意して、実際に走らせた出力がこちらです。
==========
project : a
versionName : 1.0.0
app.json 宣言値 : 1
build.gradle : (android/ なし)
appVersionSource: (未設定) / autoIncrement(production): false
実際に入る値 : 1
- app.json の値がそのまま入ります。手で上げ忘れると同じ番号で提出することになります
- eas.json に cli.appVersionSource がありません。どちらで管理するかを先に決めてください
- profile "production" の autoIncrement が false です。番号は自動では上がりません
==========
project : b
versionName : 1.2.0
app.json 宣言値 : 7
build.gradle : (android/ なし)
appVersionSource: remote / autoIncrement(production): true
実際に入る値 : EAS サーバー管理(ローカルの値は初期化にのみ使われます)
- 値はローカルファイルではなく EAS 側にあります。eas build:version:get で確認してください
==========
project : c
versionName : 1.3.0
app.json 宣言値 : 12
build.gradle : 9
appVersionSource: local / autoIncrement(production): true
実際に入る値 : 9
- android/ をリポジトリに置いているため、app.json ではなく build.gradle の値がビルドに入ります
c の構成が、私が踏んだものです。app.json には 12 と書いてあるのに、ビルドに入るのは 9 でした。一度 prebuild して android ディレクトリをリポジトリに置いたまま、その後の更新を app.json 側だけで続けていたためです。数字が並んで見えている限り、これは目視では見つけられません。
b の構成では、ローカルの 7 という値は初期値として使われるだけで、以後の実体は EAS 側にあります。ローカルのファイルをいくら読んでも次の番号は分かりませんので、eas build:version:get で問い合わせます。
番号の持ち主を、先に一つ決める
エラーが出てから慌てるのは、持ち主が決まっていないからです。EAS では eas.json の cli.appVersionSource で、ローカルとリモートのどちらが版番号を管理するかを指定します。
| 観点 | local(設定ファイルが持つ) | remote(EAS が持つ) |
|---|---|---|
| 値の置き場所 | app.json / build.gradle | EAS のサーバー側 |
| 次の番号の確認 | ファイルを開けば分かる | eas build:version:get で問い合わせる |
| 上げ忘れ | 起きうる(手で上げる運用なら特に) | autoIncrement を有効にすれば起きにくい |
| 複数の端末・CI から出す | 取り違えが起きやすい | 一箇所で連番が保たれる |
| 手元だけで完結するか | する | しない(EAS への問い合わせが要る) |
remote を選び、本番のビルドプロファイルで autoIncrement を有効にすると、eas.json は次のようになります。
{
"cli": {
"appVersionSource": "remote"
},
"build": {
"production": {
"autoIncrement": true
}
}
}一点だけ補足します。remote にしても autoIncrement を有効にしなければ、番号は自動では上がりません。持ち主を移すことと、自動で増やすことは別の設定です。私は最初これを一つの機能だと思い込んでいて、remote にしたのに同じ番号でビルドが出てきて首をかしげました。上のスクリプトが autoIncrement を必ず出力しているのは、そのときの反省からです。
remote に切り替えた直後は、ローカルに書いてあった値が初期値として引き継がれます。ですので移行のタイミングで、Play 側の消費済み番号より小さい値がローカルに残っていないかだけは見ておくと安全です。
六本を並べて出すときに決めたこと
私は壁紙・癒し系のアプリを複数本、同じコードベースの構成違いとして運用しています。素材の追加をまとめて反映する日は、六本を同じ日に出すことになります。ここで番号の付け方が効いてきました。
最初は versionName から機械的に導く方式を試しました。1.4.2 なら 10402 というように、桁を割り当てて番号にします。人間が見て対応が分かるのは利点でしたが、同じ日に修正版をもう一度出すと桁が足りなくなります。末尾に一桁足すと、今度は過去の番号との大小関係を毎回頭で確かめることになりました。
いまは分けています。
versionName(expo.version)は手で決める。読者に見せる番号なので、意味のある単位で上げるversionCodeは EAS のautoIncrementに任せる。単調増加でありさえすればよく、意味を持たせない
意味を持たせないと決めてから、リリース作業で番号のことを考える時間がなくなりました。六本それぞれが独立したカウンタを持ちますので、アプリ間で番号が揃わないことは受け入れています。揃っていて嬉しい場面が、実際には一度もなかったためです。
この判断は、個人開発で複数本を回している方には合うと思います。逆に、社内の別チームが番号を見て何かを判断しているような運用では、意味を持たせたまま local で管理する方が噛み合うはずです。
提出前に、三行だけ通す
個人開発では、リリース作業の待ち時間がそのまま自分の時間になります。いまは AAB を作る前に、次の三行を通しています。
node audit-version.mjs . # 実際に入る値の経路を確認
eas build:version:get --platform android # remote 管理なら現在値を問い合わせ
# Play Console > リリース > アプリバンドルエクスプローラ で消費済みの最大値を目視一行目でローカルの構成、二行目でサーバー側の値、三行目で Play 側の実績を押さえます。三つが噛み合っていれば、アップロード画面で止まることはまずありません。所要は一分ほどです。
エラーが出てからやり直すと、ビルドの待ち時間がまるごと無駄になります。EAS のビルドは短くありませんので、この一分は十分に元が取れています。
この記事の次にやること
まず eas.json を開いて、cli.appVersionSource が書かれているかどうかだけ確かめてください。書かれていなければ、番号の持ち主が決まっていない状態です。remote と local のどちらを選ぶかを決めて明示するところから始めると、次のリリースで同じ画面を見ずに済みます。
私自身、この一行を書き忘れたまま何本もアプリを出していました。同じところで時間を溶かす方が一人でも減れば嬉しく思います。お読みいただきありがとうございました。