1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発で Flutter アプリを Google Play に出すたびに、Android Studio を開いて aab をビルドし、Play Console にアップロードして、リリースノートを貼って公開ボタンを押す……という手作業を毎回やっていました。アプリが増えるとこれが地味に効いてきます。

そこで、Mac のターミナルで次の1コマンドにまとめました。

python3 store_submit.py android

fastlane の supply も使わず、Google Play Developer API(androidpublisher v3)を直接叩いて、ビルドから製品版(production トラック)へのコミットまでを通します。この記事では、その仕組みと、実際に踏んだ落とし穴を、動くコードごと全部出します。前提は Flutter(aab)です。

なぜ fastlane を使わず API を直叩きするのか

fastlane の supply でもアップロード自体はできます。ただ個人開発で使っていると、

  • Ruby / gem の依存が増える
  • 中で何をしているかがブラックボックスになりがち
  • Google 側の API 仕様が変わったとき、自分で追従しづらい

という不満がありました。

Google Play Developer API は Python の google-api-python-client から直接叩けます。「edit を作る → aab を上げる → track を更新する → commit する」という流れがそのままコードに出てくるので、落とし穴を踏んだときに自分のコードとして直せるのが大きいです。

(App Store 側も同じ思想で App Store Connect API を直叩きしています。これは最後に触れます。)

前提となる制約:新しい個人アカウントは「12人・14日」のクローズドテストが必要

自動化の話の前に、知らないと最初に詰まる制約を書いておきます。これはコードではなく Google Play 側のアカウント要件です。

  • 2023年11月13日以降に作成された個人デベロッパーアカウントは、本番(production)公開の前にクローズドテストの実施が必須になりました。
  • 条件は、12人以上のテスターが14日間連続でオプトインした状態を満たすこと。これを満たすと「製品版アクセス」を申請でき、審査を通ると本番公開ができるようになります。
  • 当初(2023年11月の発表時)は20人でしたが、2024年12月11日に12人へ緩和されました。
  • 「14日間オプトイン」は、一度オプトインしたテスターは途中でアンインストールしてもカウントされます。
  • 組織(Organization)アカウント、および2023年11月13日より前に作成された個人アカウントはこの要件の対象外で、最初から本番公開できます。

公式の要件はこちらです(数字や運用が変わることがあるので、最新は公式で確認してください)。

つまり、この記事の自動化(production トラックへのコミット)が効いてくるのは、この製品版アクセスを得たあとです。新規個人アカウントの「1本目」は、まずこのクローズドテストの壁を越える必要があります。逆に言えば、ここさえ越えてしまえば、以降のバージョンアップは1コマンドで回せます。

一番の難所:サービスアカウントの設定

Android 自動化で唯一めんどくさいのがここです。逆に言えば、一度やれば終わりです。

  1. GCP プロジェクトで「Google Play Android Developer API」を有効化する
  2. サービスアカウントを作成し、JSON 鍵をダウンロードする
  3. Google Play Console → ユーザーと権限で、そのサービスアカウントを招待し、リリースに必要な権限を付与する
  4. 権限の反映にラグがあることがある(設定直後に叩くと 403 で蹴られ、数分置くと通る、ということがありました)

JSON 鍵は秘密情報なので、リポジトリには入れないでください。設定ファイルにはパスだけを書き、鍵そのものはホーム配下などに置きます。

{
  "android": {
    "service_account_key": "~/.config/play/service_account.json",
    "package_name": "com.example.app"
  }
}

提出フローのコード

実際に動かしている関数がこれです。flutter build appbundle で aab を作り、Developer API で production トラックにコミットするところまで通します。

def do_android(cfg):
    version, build = get_version()
    meta = cfg["metadata"]

    # Pre-flight: ASO 誇張表現チェック
    if not preflight_aso_check(build=build):
        return False

    # Pre-flight: Google Play は releaseNotes 500字上限。超過するとアップロード後に
    # API error (HTTP 403) で失敗し build 時間を無駄にする。ビルド前に検出する。
    CHANGELOG_LIMIT = 500
    changelog_over = []
    for lang, tmpl_key in [("ja-JP", "android_ja_changelog"), ("en-US", "android_en_changelog")]:
        path_tmpl = meta.get(tmpl_key, "")
        if not path_tmpl:
            continue
        path = path_tmpl.replace("{build}", build)
        if os.path.exists(path):
            text = read_file(path)
            if text and len(text) > CHANGELOG_LIMIT:
                changelog_over.append((path, len(text)))
    if changelog_over:
        print("❌ Google Play changelog の 500 字上限を超過:")
        for p, n in changelog_over:
            print(f"   {p}: {n} 字 (上限 {CHANGELOG_LIMIT})")
        print("   短縮してから再実行してください。")
        return False

    print(f"🤖 Android 提出: {cfg['app_name']} v{version}+{build}")

    print("1️⃣  flutter build appbundle...")
    ok, _ = run("flutter build appbundle --release", timeout=1800)
    if not ok:
        return False

    print("2️⃣  Google Play 製品版にアップロード...")
    from google.oauth2 import service_account
    from googleapiclient.discovery import build as gp_build
    from googleapiclient.http import MediaFileUpload

    sa_key = os.path.expanduser(cfg["android"]["service_account_key"])
    package = cfg["android"]["package_name"]
    creds = service_account.Credentials.from_service_account_file(
        sa_key, scopes=["https://www.googleapis.com/auth/androidpublisher"]
    )
    service = gp_build("androidpublisher", "v3", credentials=creds)

    # edit はトランザクション。commit して初めて反映される。
    edit = service.edits().insert(body={}, packageName=package).execute()
    edit_id = edit["id"]

    aab_path = "build/app/outputs/bundle/release/app-release.aab"
    media = MediaFileUpload(aab_path, mimetype="application/octet-stream")
    upload = service.edits().bundles().upload(
        packageName=package, editId=edit_id, media_body=media
    ).execute()
    version_code = upload["versionCode"]  # versionCode は aab から自動で返る
    print(f"  Version Code: {version_code}")

    changelog_ja = read_file(meta.get("android_ja_changelog", "").replace("{build}", build))
    changelog_en = read_file(meta.get("android_en_changelog", "").replace("{build}", build))
    release_notes = []
    if changelog_ja:
        release_notes.append({"language": "ja-JP", "text": changelog_ja})
    if changelog_en:
        release_notes.append({"language": "en-US", "text": changelog_en})

    service.edits().tracks().update(
        packageName=package, editId=edit_id, track="production",
        body={"track": "production", "releases": [{
            "versionCodes": [str(version_code)],
            "status": "completed",
            "releaseNotes": release_notes,
        }]}
    ).execute()
    service.edits().commit(packageName=package, editId=edit_id).execute()
    print("  ✅ 製品版にコミット完了")
    return True

ポイントは Google Play API の edit がトランザクションになっていることです。insert で編集セッションを開き、aab のアップロードやトラック更新を積み上げて、最後に commit して初めて反映されます。途中で失敗しても commit していなければ何も変わらないので、リトライしやすい設計です。

踏んだ落とし穴

1. リリースノートは500字まで(超えるとアップロード後に弾かれる)

Google Play の releaseNotes は言語ごとに 500字上限です。超過していると、aab を上げたあとのトラック更新で HTTP 403 になって失敗します。重い flutter build appbundle を走らせたあとで弾かれると、その時間がまるごと無駄になります。

対策は、ビルドの前に changelog の文字数を数えて、超えていればその場で止めること(上のコードの Pre-flight 部分)。一番重い処理の前にチェックを置くのがコツです。

2. 1本目は手作業が必須(API だけでは完結しない)

正直に書くと、アプリの初回は API だけでは出せません。Play Console で次を手動でやる必要があります。

  • アプリの作成
  • ストア掲載情報(説明・スクリーンショット)
  • コンテンツのレーティング・対象年齢・データセーフティの申告
  • 最初のリリース(一度リリース実績を作る)

サービスアカウントによる API が効いてくるのは、アプリが「動き出したあと」です。逆に 2本目以降(バージョンアップ)は、バージョン番号を上げて1コマンドで終わります。この「最初だけ手動・あとは自動」という構造は、iOS でも同じです。

3. versionCode は aab から自動で決まる

bundles().upload のレスポンスに versionCode が入っています。これをそのままトラックに載せるので、versionCode を手で管理する必要はありません。

4. サービスアカウント権限の反映ラグ

権限を付与した直後に叩くと 403 で蹴られることがありました。設定したら数分置いてから実行すると通ります。「設定は合っているのに通らない」ときは、まずこれを疑うと早いです。

まとめ

  • Mac だけで、Android Studio も CI も使わず、flutter build appbundle から Google Play 製品版コミットまでを1コマンドにできる
  • 仕組みは Developer API の直叩き。難所はサービスアカウント設定だけで、一度やれば以降のリリースはバージョンを上げて実行するだけ
  • 新しい個人アカウントは、本番公開の前に「12人・14日」のクローズドテストを越える必要がある(ここは自動化の外の話)
  • リリースノートの500字上限と「初回は手動」だけ押さえておけば、運用でハマりにくい

おまけ:iOS(App Store)も同じ思想で1コマンドにしている

App Store 側も、fastlane を使わず App Store Connect API を直接叩いて、バージョン作成 → メタデータ同期 → ビルド → アップロード → 審査提出までを1コマンドにしています。

ただ iOS は Android より仕様のクセが多く、実運用で踏んだ落とし穴が12個ありました。公開済みバージョンへの二重提出の防止、ビルド番号の衝突、使用許諾契約の保留で VALID ビルドが現れない問題、PATCH の 409 属性ロック、暗号化コンプライアンスの自動回答、2026-05 の ASC スキーマ更新への追従……といったものです。

それらの「症状 → 原因 → 対策」と、スクリプト全文・AI エージェント用のセットアップガイド(AGENTS.md)・環境を自己診断する doctor サブコマンドまでをまとめたのが、こちらの本です。Flutter / Swift 両対応です。

Android の自動化が手に馴染んだら、iOS 側も同じやり方で一気に楽にできます。


この記事は Zenn にも掲載しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?