配信の瞬間だけは、いまも少し息を止めます。コードはあとから直せます。クラッシュも、レビューの指摘も、ストアの説明文も、気づいた時点で巻き戻せます。けれど「製品版トラックへ何万人かのもとへ届いた」という事実だけは、押した瞬間に引き返せません。個人開発で複数のアプリを抱えていると、この一点だけは、どれだけ自動化が進んでも気持ちが軽くなりませんでした。
2026年6月24日、Google AI Studio がテキストのプロンプトから Kotlin と Jetpack Compose のアプリを生成し、埋め込みのエミュレータで動かし、USB で実機に転送し、Google Play の内部テストトラックまで一つの画面から配信できるようになりました。「作って、試して、配る」の距離が一気に縮まったわけです。私はこの更新を歓迎しながら、同時に一つだけ自分に問いを立てました。これだけ滑らかにつながると、どこで立ち止まるかを自分で決めておかないと、立ち止まれなくなる、と。
この記事は、その一気通貫のフローを個人開発の配信に据えるとき、私がどこまでを機械に預け、どこを手で握り続けるかという線引きと、その境界を支える実装の話です。
一手でつながったのは「可逆な工程」の連結だった
まず冷静に見たいのは、AI Studio が短くしたのは何の距離か、ということです。生成・エミュレータ実行・実機転送・内部テスト配信。この四つはどれも、やり直しがきく工程です。生成し直せますし、エミュレータは何度でも起動できますし、実機への転送は上書きできます。内部テストトラックへの配信さえ、対象は招待した自分とごく少数のテスターだけですから、間違えても被害は閉じています。
つまり、一画面につながったのは「可逆な工程の連結」でした。便利さの本質はここにあります。可逆な作業を何度も往復するコストが、ほぼゼロになったのです。私の手元でも、ある壁紙アプリの設定画面を作り直しては実機で眺める、という往復が一晩で十数回まで増えました。以前なら、ビルドを待ち、ケーブルを挿し、インストールを待つ各ステップで集中が切れていたところです。
問題は、この滑らかさが「不可逆な工程」にまで地続きに見えてしまうことです。内部テストの次には、クローズドテスト、オープンテスト、そして製品版があります。同じ画面の同じ操作感のまま、最後の一押しだけが性質を変えます。私が最初に決めたのは、この性質の変わり目に、意図的な段差を作っておくことでした。
自動化する工程と、手で握り続ける工程
線引きの基準は単純です。やり直しがきくなら機械へ、引き返せないなら自分の手へ。これを工程ごとに当てはめると、次のように分かれました。
工程 性質 担い手 理由
コード生成・修正 可逆 AI Studio 何度でも作り直せる。差分は git で追える
エミュレータ確認 可逆 自動 主要画面のスクリーンショット差分で機械判定できる
実機転送 可逆 自動 上書きインストール。被害は自分の端末に閉じる
配信前サニティチェック 可逆 自動(契約化) 版番号・署名・難読化マップの整合は機械が確実
内部テストへの配信 ほぼ可逆 自動+承認 対象は招待者のみ。ただし台帳に記録する
製品版への昇格 不可逆 自分の手 一般ユーザーへ到達する。ここだけは押さない
ここで強調したいのは、「内部テストへの配信」を自動と承認の中間に置いたことです。内部テストは性質としてはほぼ可逆ですが、ここを完全自動にすると、製品版への昇格との心理的な段差が消えてしまいます。私はあえて、内部テストへの配信に「一行のコミットメッセージを書く」という小さな手間を残しました。何のための配信かを自分の言葉で残すことで、次のクローズドや製品版へ進むときに、自分が何を確認したのかを思い出せるようにしています。
配信前サニティチェックを契約にする
生成されたアプリを内部テストへ引き渡す前に、機械が確実に守れる約束事だけを集めて、終了コードで返すスクリプトにしました。ここでの設計の勘所は、「機械が判断できることだけを機械に任せ、判断が要ることは持ち込まない」という割り切りです。
具体的には、版コード(versionCode)が前回配信より単調増加しているか、applicationId が想定どおりか、リリースビルドに難読化マップ(mapping.txt)が存在するか、署名構成が debug になっていないか。どれも主観の入らない、真偽がはっきりする項目です。
#!/usr/bin/env python3
"""preflight.py — 内部テスト配信前の不可逆チェック。
真偽がはっきりする項目だけを集め、終了コードで契約する。
exit 0 通過
exit 1 機械が落とすべき違反(配信を止める)
exit 2 入力不備(設定の読み取りに失敗)
"""
import json
import re
import sys
from pathlib import Path
EXPECTED_APP_ID = "net.dolice.wallpaper" # 自分のアプリに合わせる
LEDGER = Path( "release_ledger.jsonl" ) # 過去の配信記録
def read_gradle_version (gradle: Path):
text = gradle.read_text( encoding = "utf-8" )
code = re.search( r "versionCode \s * [ = \s]\s * (\d + ) " , text)
name = re.search( r 'versionName \s * [ = \s]\s * " ([ ^ " ] + ) "' , text)
app = re.search( r 'applicationId \s * [ = \s]\s * " ([ ^ " ] + ) "' , text)
if not (code and name and app):
print ( "読み取り失敗: versionCode / versionName / applicationId" )
sys.exit( 2 )
return int (code.group( 1 )), name.group( 1 ), app.group( 1 )
def last_released_code () -> int :
if not LEDGER .exists():
return 0
codes = [json.loads(l)[ "versionCode" ] for l in LEDGER .read_text().splitlines() if l.strip()]
return max (codes) if codes else 0
def main ():
gradle = Path( "app/build.gradle.kts" )
if not gradle.exists():
print ( "app/build.gradle.kts が見つかりません" ); sys.exit( 2 )
version_code, version_name, app_id = read_gradle_version(gradle)
failures = []
if app_id != EXPECTED_APP_ID :
failures.append( f "applicationId 不一致: { app_id } != { EXPECTED_APP_ID } " )
if version_code <= last_released_code():
failures.append( f "versionCode が単調増加していません: { version_code } <= { last_released_code() } " )
mapping = Path( "app/build/outputs/mapping/release/mapping.txt" )
if not mapping.exists():
failures.append( "難読化マップ mapping.txt がありません(R8 未適用の疑い)" )
bundle = Path( "app/build/outputs/bundle/release/app-release.aab" )
if not bundle.exists():
failures.append( "リリース用 .aab が見つかりません" )
if failures:
print ( "配信を止めます:" )
for f in failures:
print ( f " - { f } " )
sys.exit( 1 )
print ( f "OK: { app_id } versionCode= { version_code } ( { version_name } )" )
if __name__ == "__main__" :
main()
このスクリプトに「デザインが整っているか」「文言が自然か」を判定させようとは思いません。それは人間の領分です。機械には、人間が見落としやすい単調増加や署名構成のような、退屈で確実な項目だけを預けます。退屈な確認こそ機械が得意で、人間が最も疲れて見落とす部分だからです。
内部テストトラックへの引き渡しを実装する
サニティチェックを通ったら、Play Developer API で内部テストトラックへ引き渡します。AI Studio の画面からも同じことができますが、私は配信の最後の数手だけは、自分のスクリプトとして手元に置いておきたいと考えています。理由は後半の「所有」の話につながります。
#!/usr/bin/env python3
"""publish_internal.py — 内部テストトラックへ .aab を引き渡す。
サービスアカウント鍵は環境変数 PLAY_SA_JSON のパスから読む。
"""
import os
import sys
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
PACKAGE = "net.dolice.wallpaper"
AAB = "app/build/outputs/bundle/release/app-release.aab"
SCOPES = [ "https://www.googleapis.com/auth/androidpublisher" ]
def service ():
key_path = os.environ[ "PLAY_SA_JSON" ] # サービスアカウント鍵のパス
creds = service_account.Credentials.from_service_account_file(key_path, scopes = SCOPES )
return build( "androidpublisher" , "v3" , credentials = creds, cache_discovery = False )
def main ():
note = sys.argv[ 1 ] if len (sys.argv) > 1 else ""
if not note:
print ( "配信メモを引数に渡してください(何のための配信か)" ); sys.exit( 2 )
svc = service()
edit = svc.edits().insert( packageName = PACKAGE , body = {}).execute()
edit_id = edit[ "id" ]
uploaded = svc.edits().bundles().upload(
packageName = PACKAGE , editId = edit_id,
media_body = MediaFileUpload( AAB , mimetype = "application/octet-stream" ),
).execute()
version_code = uploaded[ "versionCode" ]
svc.edits().tracks().update(
packageName = PACKAGE , editId = edit_id, track = "internal" ,
body = { "releases" : [{
"versionCodes" : [version_code],
"status" : "completed" ,
"releaseNotes" : [{ "language" : "ja-JP" , "text" : note}],
}]},
).execute()
svc.edits().commit( packageName = PACKAGE , editId = edit_id).execute()
print ( f "内部テストへ配信: versionCode= { version_code } note= { note !r } " )
if __name__ == "__main__" :
main()
ここで track="internal" を別の値、たとえば "production" に変えれば、同じスクリプトで製品版へも配信できてしまいます。だからこそ私は、製品版のトラック名をスクリプトに書きません。内部テストまでをスクリプトの守備範囲とし、製品版への昇格は Play Console の画面から、自分の指で行うと決めています。コードに書けることと、コードに書くべきことは別だと考えているからです。
段階的公開を、一人でも安全に回す
内部テストで数日寝かせ、自分の主要端末で日常的に使い、AdMob の表示や課金導線が壊れていないかを実際の操作で確かめます。ここは自動化しません。自分が一人のユーザーとして触る時間こそ、生成されたコードと自分のあいだの距離を測る時間だからです。
昇格は、内部 → クローズド → 製品版の順に、必ず一段ずつ上げます。クローズドや製品版では、Play Console の段階的公開(staged rollout)で最初は数パーセントから始め、クラッシュ率と ANR を見ながら広げます。下の表は、私が一人で回すときに各段で見ている指標と、止める判断のしきい値です。
トラック 対象 滞留の目安 主に見る指標 差し戻すしきい値
内部テスト 自分+数名 2〜3日 起動・主要導線・課金 主要導線で1件でも再現
クローズド 招待20〜50名 3〜5日 クラッシュ率・ANR クラッシュ率 1% 超
製品版(段階) 5% → 20% → 100% 各1〜2日 クラッシュ率・低評価 クラッシュ率 0.5% 超で停止
一人で運用する強みは、判断の往復がないことです。弱みは、見落としを指摘してくれる他者がいないことです。だから私は、しきい値を数字で先に決めておきます。配信の高揚のなかで「もう少し様子を見れば大丈夫だろう」と甘く判断しないための、自分への先回りの約束です。実際、ある引き寄せ系アプリの更新では、製品版5%の段階でクラッシュ率が 0.6% に触れ、決めておいたしきい値に従って迷わず公開を止めました。後から原因は古い端末でのレイアウト崩れだと分かり、止めた判断は正しかったと胸をなでおろしました。
生成物の所有を、手放さないための台帳
最後に、私が一気通貫のフローでいちばん大事にしている工程の話です。それは、配信のたびに台帳へ一行を記録することです。
AI Studio が生成からコードを書いてくれるほど、「このアプリの今のかたちを、自分はどこまで把握しているか」という感覚が薄れていきます。生成は便利ですが、便利さに任せきると、自分のアプリなのに中身を説明できなくなる瞬間が来ます。それを防ぐために、配信の節目ごとに、版コード・git の commit・配信メモ・トラックを、自分の言葉も添えて残しています。
#!/usr/bin/env bash
# ledger.sh — 配信のたびに release_ledger.jsonl へ一行追記する
set -euo pipefail
VERSION_CODE = " $1 " # publish_internal.py が出力した値
TRACK = " $2 " # internal / closed / production
NOTE = " $3 " # 自分の言葉で「何のための配信か」
SHA = "$( git rev-parse --short HEAD)"
TS = "$( TZ = Asia/Tokyo date '+%Y-%m-%dT%H:%M:%S%z')"
printf '{"versionCode":%s,"track":"%s","sha":"%s","ts":"%s","note":%s}\n' \
" $VERSION_CODE " " $TRACK " " $SHA " " $TS " "$( printf '%s' " $NOTE " | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
>> release_ledger.jsonl
echo "台帳に記録しました: code= $VERSION_CODE track= $TRACK sha= $SHA "
この台帳は、preflight.py が単調増加を確認するための入力でもあり、後から「いつ、何を、なぜ配信したか」を自分にだけ説明する日記でもあります。生成されたコードがどれだけ増えても、配信の意思決定だけは自分の言葉で残っている。この一行が、生成物を自分の作品として握り続けるための、最後のよりどころになっています。
便利な一気通貫は、立ち止まる場所を自分で設計してこそ、本当に安心して使えるものになると感じています。次に新しいアプリを AI Studio で立ち上げるときは、まずこの preflight.py と台帳から用意して、生成の前に「どこで止まるか」を決めておくつもりです。お読みいただき、ありがとうございました。