個人でiOSアプリを開発していると、コーディングよりもリリース作業に追われる時期があります。スクリーンショットの撮り直し、各端末サイズへのリサイズ、日本語・英語それぞれのメタデータ更新、TestFlightへのアップロード……これが新バージョンを出すたびに繰り返されます。
私自身、複数のアプリを運営していた頃は、リリース準備だけで1〜2週間かかることが珍しくありませんでした。その時間をコードに使いたかった。Antigravityと App Store Connect API を組み合わせてパイプラインを構築してから、この状況が変わりました。
ここではそのパイプラインの設計思想と実装コードを完全に公開します。単なる「Fastlaneを使いましょう」という話ではなく、AIがスクリーンショットの説明文を生成し、競合分析に基づいてキーワードを提案し、審査リジェクトの原因を特定して修正案を出すところまでを扱います。
なぜ「ビルド → 提出」の自動化だけでは不十分なのか
多くの個人開発者がFastlane + GitHub Actionsで自動化しているのは「ビルド → TestFlight アップロード → App Store提出」という技術的な手順です。これは確かに重要ですが、実際の工数の大半を占めているのは別の部分です。
私が計測した実際の工数配分はこうでした(新規アプリのリリース準備、5言語対応、6端末サイズ)。
スクリーンショット撮影・編集 : 約8時間
App Storeメタデータ(タイトル・説明文・キーワード)の言語ごと最適化 : 約6時間
ビルド・署名・アップロード : 約2時間(既に自動化済みだった)
審査待ち + リジェクト対応(2回) : 約10時間
自動化で省けたのは全体の15%程度でした。残り85%は「コンテンツを作る作業」です。ここをAIで代替できれば、リリース作業は一夜で終わります。
パイプライン全体像とコンポーネント設計
今回構築するパイプラインは3つの独立したフェーズで構成されています。
フェーズ1 — コンテンツ生成 : Antigravityエージェントが、アプリのコードとデザインファイルを読み込み、スクリーンショット撮影・キャプション生成・メタデータ最適化を実行します。
フェーズ2 — 自動提出 : Fastlane + App Store Connect APIが、生成されたコンテンツとビルドを組み合わせてTestFlightとApp Storeに提出します。
フェーズ3 — 審査モニタリング : GitHub Actionsがリジェクト通知を検知し、Antigravityエージェントを起動してガイドライン分析と修正提案を生成します。
[Antigravity Agent]
↓ コード + Figmaデザイン解析
[スクリーンショット生成] → Snapshot (Fastlane)
[Gemini Vision API] → キャプション・説明文生成
[競合分析エージェント] → キーワード最適化
↓
[GitHub Actions CI/CD]
↓
[Fastlane] → ビルド・署名・TestFlightアップロード
[deliver] → App Store メタデータ + スクリーンショット提出
↓
[App Store Review Monitor] ← Webhook
↓ リジェクト時
[Antigravity Review Agent] → ガイドライン分析 + 修正提案
このアーキテクチャのポイントは、各フェーズが独立していることです。スクリーンショット生成だけを再実行したり、メタデータだけを更新したりが容易にできます。
フェーズ1: AI によるスクリーンショット生成パイプライン
Snapshot でのスクリーンショット自動撮影
まずFastlane Snapshotの設定から始めます。UIテストを兼ねたスクリーンショット撮影が可能です。
# fastlane/Snapfile
devices ([
"iPhone 16 Pro Max" ,
"iPhone 16" ,
"iPhone SE (3rd generation)" ,
"iPad Pro 13-inch (M4)"
])
languages ([
"ja" ,
"en-US" ,
"zh-Hans" ,
"ko" ,
"fr-FR"
])
scheme "YourApp"
output_directory "./screenshots"
clear_previous_screenshots true
override_status_bar true
UIテストターゲットでは、スクリーンショットを撮りたい画面を明示的に指定します。
// UITests/ScreenshotTests.swift
import XCTest
final class ScreenshotTests : XCTestCase {
override func setUpWithError () throws {
continueAfterFailure = false
let app = XCUIApplication ()
setupSnapshot (app)
app. launch ()
}
func testCaptureAllScreens () throws {
let app = XCUIApplication ()
// ホーム画面
snapshot ( "01_home" )
// 機能紹介画面(チュートリアル等があれば)
if app.buttons[ "次へ" ].exists {
app.buttons[ "次へ" ]. tap ()
snapshot ( "02_feature" )
}
// コア機能画面
app.tabBars.buttons[ "設定" ]. tap ()
snapshot ( "03_settings" )
}
}
Gemini Vision API でキャプションを自動生成
撮影されたスクリーンショットをGemini Vision APIに渡し、各言語向けのキャプションを自動生成します。これが時間短縮の核心部分です。
# scripts/generate_captions.py
import os
import base64
import json
from pathlib import Path
import google.generativeai as genai
# NOTE : 環境変数からAPIキーを取得(コードに直書きしない)
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
def encode_image (image_path: str ) -> str :
with open (image_path, "rb" ) as f:
return base64.b64encode(f.read()).decode( "utf-8" )
def generate_caption (image_path: str , language: str , app_description: str ) -> dict :
"""
スクリーンショットからApp Store用キャプションを生成する。
Args:
image_path: スクリーンショットのパス
language: 生成する言語コード ("ja", "en-US" 等)
app_description: アプリの基本説明(コンテキスト用)
Returns:
{"caption": str, "alt_text": str} のdict
"""
model = genai.GenerativeModel( "gemini-2.5-pro" )
image_data = encode_image(image_path)
lang_map = {
"ja" : "日本語" ,
"en-US" : "English (US)" ,
"zh-Hans" : "简体中文" ,
"ko" : "한국어" ,
"fr-FR" : "Français"
}
target_lang = lang_map.get(language, language)
prompt = f """
このiOSアプリのスクリーンショットを見て、App Store用のキャプションを { target_lang } で生成してください。
アプリ概要: { app_description }
要件:
- 30文字以内(App Storeの文字数制限)
- ユーザーが得られるベネフィットを中心に書く
- 機能名の羅列ではなく、使いたくなる表現にする
- { target_lang } ネイティブが自然に読める文体
JSON形式で返してください:
{{ "caption": "キャプション本文", "alt_text": "アクセシビリティ用の説明" }}
"""
response = model.generate_content([
{ "mime_type" : "image/png" , "data" : image_data},
prompt
])
# JSONパース(エラーハンドリング付き)
try :
result = json.loads(response.text.strip().replace( "```json" , "" ).replace( "```" , "" ))
return result
except json.JSONDecodeError:
# フォールバック: テキストをそのままキャプションとして使用
return { "caption" : response.text[: 30 ], "alt_text" : response.text}
def process_all_screenshots (screenshots_dir: str , app_description: str ):
"""スクリーンショットディレクトリ全体を処理する"""
screenshots_path = Path(screenshots_dir)
captions = {}
for lang_dir in screenshots_path.iterdir():
if not lang_dir.is_dir():
continue
lang_code = lang_dir.name
captions[lang_code] = {}
for screenshot in sorted (lang_dir.glob( "*.png" )):
print ( f "Processing { lang_code } / { screenshot.name } ..." )
try :
result = generate_caption(
str (screenshot),
lang_code,
app_description
)
captions[lang_code][screenshot.stem] = result
print ( f " ✓ { result[ 'caption' ] } " )
except Exception as e:
# 1枚失敗しても全体を止めない
print ( f " ✗ Error: { e } " )
captions[lang_code][screenshot.stem] = {
"caption" : "" ,
"alt_text" : ""
}
# 結果をJSONに保存(Fastlaneが読み込む)
output_path = screenshots_path / "captions.json"
with open (output_path, "w" , encoding = "utf-8" ) as f:
json.dump(captions, f, ensure_ascii = False , indent = 2 )
print ( f " \n ✅ キャプション生成完了: { output_path } " )
return captions
if __name__ == "__main__" :
import sys
if len (sys.argv) < 3 :
print ( "Usage: python generate_captions.py <screenshots_dir> <app_description>" )
sys.exit( 1 )
process_all_screenshots(sys.argv[ 1 ], sys.argv[ 2 ])
このスクリプトを実行すると captions.json が生成されます。Fastlaneの deliver コマンドがこれを読み込んでApp Storeに送信します。
よくある落とし穴①: Gemini APIのレート制限
キャプション生成で最初につまずくのがAPIのレート制限です。5言語 × 6端末 × 3画面 = 90リクエストを連続で送ると制限に引っかかります。解決策は指数バックオフとバッチ処理の組み合わせです。
# scripts/utils/rate_limiter.py
import time
import random
from functools import wraps
def with_retry (max_retries: int = 3 , base_delay: float = 1.0 ):
"""指数バックオフ付きリトライデコレータ"""
def decorator (func):
@wraps (func)
def wrapper ( * args, ** kwargs):
for attempt in range (max_retries):
try :
return func( * args, ** kwargs)
except Exception as e:
if "RATE_LIMIT" in str (e) or "429" in str (e):
if attempt < max_retries - 1 :
delay = base_delay * ( 2 ** attempt) + random.uniform( 0 , 1 )
print ( f " Rate limited. Waiting { delay :.1f } s..." )
time.sleep(delay)
else :
raise
else :
raise # レート制限以外のエラーは即座に再raise
return None
return wrapper
return decorator
フェーズ2: App Store メタデータの AI 最適化
スクリーンショットと並んで時間がかかるのが、各言語のメタデータ(タイトル・サブタイトル・説明文・キーワード)の最適化です。Antigravityエージェントを使って、競合分析まで含めた最適化を自動化します。
Antigravityエージェントへの指示(AGENTS.md)
プロジェクトルートに AGENTS.md を作成し、メタデータ最適化の挙動を定義します。
# AGENTS.md - App Store Metadata Optimizer
## ロール
あなたはApp Storeのメタデータ最適化を担当するAIエージェントです。
`app_info.json` を読み込み、各言語向けに最適化されたメタデータを生成してください。
## 出力形式
`fastlane/metadata/{language}/` ディレクトリ以下に以下のファイルを生成:
- `name.txt` (30文字以内)
- `subtitle.txt` (30文字以内)
- `description.txt` (4,000文字以内)
- `keywords.txt` (100文字以内、カンマ区切り)
## 最適化の指針
1. キーワードは検索ボリュームの高い順に並べる(推測で構わない)
2. 説明文の冒頭3行が最も重要(折りたたみ前に表示される)
3. 競合アプリの名前は含めない(Appleのガイドライン違反)
4. 各言語でネイティブが読んで自然な表現にする(直訳禁止)
メタデータ生成スクリプト
# scripts/optimize_metadata.py
import os
import json
from pathlib import Path
import google.generativeai as genai
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
def generate_metadata_for_language (
app_info: dict ,
language: str ,
existing_metadata: dict = None
) -> dict :
"""
指定言語向けのApp Storeメタデータを生成する。
existing_metadata が渡された場合は改善提案として動作する。
"""
model = genai.GenerativeModel( "gemini-2.5-pro" )
base_prompt = f """
以下のiOSアプリについて、App Storeの { language } 向けメタデータを最適化してください。
アプリ情報:
{ json.dumps(app_info, ensure_ascii = False , indent = 2 ) }
制約:
- name: { language } で30文字以内
- subtitle: { language } で30文字以内
- keywords: 英字30文字以内、カンマ区切り(App Storeのキーワードフィールドは英語のみ)
- description: 4,000文字以内。最初の255文字が特に重要
{ "現在のメタデータ(改善が必要な箇所を特定して改善してください):" + json.dumps(existing_metadata, ensure_ascii = False ) if existing_metadata else "" }
JSON形式で返してください:
{{
"name": "...",
"subtitle": "...",
"keywords": "keyword1,keyword2,...",
"description": "..."
}}
"""
response = model.generate_content(base_prompt)
try :
text = response.text.strip()
# JSONブロックの抽出
if "```json" in text:
text = text.split( "```json" )[ 1 ].split( "```" )[ 0 ].strip()
elif "```" in text:
text = text.split( "```" )[ 1 ].split( "```" )[ 0 ].strip()
return json.loads(text)
except (json.JSONDecodeError, IndexError ) as e:
raise ValueError ( f "Gemini returned invalid JSON: { e }\n Response: { response.text[: 200 ] } " )
def write_fastlane_metadata (metadata: dict , language: str , output_base: str ):
"""Fastlane deliver 用のメタデータファイルを書き出す"""
# Fastlaneの言語コードマッピング
fastlane_lang_map = {
"ja" : "ja" ,
"en-US" : "en-US" ,
"zh-Hans" : "zh-Hans" ,
"ko" : "ko" ,
"fr-FR" : "fr-FR"
}
lang_dir = Path(output_base) / "metadata" / fastlane_lang_map.get(language, language)
lang_dir.mkdir( parents = True , exist_ok = True )
field_map = {
"name" : "name.txt" ,
"subtitle" : "subtitle.txt" ,
"keywords" : "keywords.txt" ,
"description" : "description.txt"
}
for field, filename in field_map.items():
if field in metadata:
filepath = lang_dir / filename
filepath.write_text(metadata[field], encoding = "utf-8" )
print ( f " ✓ Written: { filepath } " )
フェーズ3: 審査リジェクト対応の半自動化
App Store審査でリジェクトされるのは、個人開発者にとって最もコストが高い出来事です。リジェクト内容を解析して修正方針を提示するエージェントを作ります。
App Store Connect API でリジェクト情報を取得
# scripts/monitor_review.py
import os
import jwt
import time
import httpx
from datetime import datetime, timezone
def create_jwt_token (key_id: str , issuer_id: str , private_key: str ) -> str :
"""App Store Connect API 用 JWT トークンを生成する"""
header = {
"alg" : "ES256" ,
"kid" : key_id,
"typ" : "JWT"
}
payload = {
"iss" : issuer_id,
"iat" : int (time.time()),
"exp" : int (time.time()) + 1200 , # 20分
"aud" : "appstoreconnect-v1"
}
return jwt.encode(payload, private_key, algorithm = "ES256" , headers = header)
def get_app_review_status (app_id: str , token: str ) -> dict :
"""指定アプリの最新審査ステータスを取得する"""
url = f "https://api.appstoreconnect.apple.com/v1/apps/ { app_id } /appStoreVersions"
headers = {
"Authorization" : f "Bearer { token } " ,
"Content-Type" : "application/json"
}
params = {
"filter[appStoreState]" : "REJECTED,METADATA_REJECTED,DEVELOPER_REJECTED" ,
"include" : "appStoreReviewDetail" ,
"limit" : 1
}
response = httpx.get(url, headers = headers, params = params, timeout = 30.0 )
response.raise_for_status()
data = response.json()
if not data.get( "data" ):
return { "status" : "no_rejection" , "details" : None }
version_data = data[ "data" ][ 0 ]
review_detail = None
# included から審査詳細を取得
if "included" in data:
for item in data[ "included" ]:
if item[ "type" ] == "appStoreReviewDetails" :
review_detail = item[ "attributes" ]
break
return {
"status" : version_data[ "attributes" ][ "appStoreState" ],
"version" : version_data[ "attributes" ][ "versionString" ],
"rejection_reasons" : review_detail.get( "rejectReasonCode" , []) if review_detail else [],
"notes" : review_detail.get( "notes" , "" ) if review_detail else ""
}
Antigravity エージェントによるガイドライン分析
リジェクション情報をAntigravityエージェントに渡し、具体的な修正アクションを生成させます。
# scripts/analyze_rejection.py
import os
import json
import google.generativeai as genai
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
# App Store ガイドラインの主要な条項(参照用)
COMMON_REJECTION_CODES = {
"2.1.0" : "App completeness - アプリが期待通りに動作しない" ,
"4.0.0" : "Design - Human Interface Guidelinesへの準拠" ,
"4.2.0" : "Minimum Functionality - 機能が最小限すぎる" ,
"5.1.1" : "Privacy - ユーザーデータの収集・使用" ,
"2.5.4" : "Software Requirements - バックグラウンド動作の不適切な使用"
}
def analyze_rejection_and_propose_fix (
rejection_info: dict ,
project_context: str
) -> dict :
"""
リジェクション情報を分析して修正提案を生成する。
Args:
rejection_info: get_app_review_status() の戻り値
project_context: プロジェクトの概要(Info.plist内容等)
Returns:
{
"severity": "critical|major|minor",
"root_cause": "原因の分析",
"fix_actions": [{"action": str, "priority": int, "code_change": str}],
"estimated_time": "修正にかかる推定時間"
}
"""
model = genai.GenerativeModel( "gemini-2.5-pro" )
rejection_codes = rejection_info.get( "rejection_reasons" , [])
notes = rejection_info.get( "notes" , "審査員からのメモなし" )
# ガイドライン条項の説明を付加
code_descriptions = []
for code in rejection_codes:
desc = COMMON_REJECTION_CODES .get(code, f "コード { code } " )
code_descriptions.append( f "- { code } : { desc } " )
prompt = f """
App Store審査でリジェクトされたiOSアプリの問題を分析してください。
リジェクション情報:
- ステータス: { rejection_info[ 'status' ] }
- バージョン: { rejection_info.get( 'version' , '不明' ) }
- リジェクションコード:
{ chr ( 10 ).join(code_descriptions) if code_descriptions else " コードなし(メモを参照)" }
- 審査員のメモ: { notes }
プロジェクト情報:
{ project_context }
以下を含むJSON形式で分析結果を返してください:
{{
"severity": "critical/major/minor",
"root_cause": "リジェクトの根本原因(1〜2文)",
"fix_actions": [
{{
"action": "具体的な修正内容",
"priority": 1,
"file_to_edit": "修正が必要なファイル名(推測)",
"code_hint": "修正のヒント(コードスニペット等)"
}}
],
"apple_guideline_reference": "参照すべきガイドライン条項のURL",
"estimated_hours": 推定修正時間(数値),
"resubmission_tip": "再提出時の注意点"
}}
"""
response = model.generate_content(prompt)
try :
text = response.text.strip()
if "```json" in text:
text = text.split( "```json" )[ 1 ].split( "```" )[ 0 ].strip()
return json.loads(text)
except Exception :
return {
"severity" : "unknown" ,
"root_cause" : "自動分析に失敗しました。審査員のメモを手動で確認してください" ,
"fix_actions" : [],
"estimated_hours" : 0
}
GitHub Actions での自動モニタリング
# .github/workflows/review-monitor.yml
name : App Store Review Monitor
on :
schedule :
# 審査中は2時間ごとにチェック
- cron : '0 */2 * * *'
workflow_dispatch :
jobs :
check-review-status :
runs-on : macos-latest
steps :
- uses : actions/checkout@v4
- name : Setup Python
uses : actions/setup-python@v5
with :
python-version : '3.12'
- name : Install dependencies
run : pip install httpx pyjwt google-generativeai
- name : Check App Review Status
id : review_check
env :
APP_STORE_KEY_ID : ${{ secrets.APP_STORE_KEY_ID }}
APP_STORE_ISSUER_ID : ${{ secrets.APP_STORE_ISSUER_ID }}
APP_STORE_PRIVATE_KEY : ${{ secrets.APP_STORE_PRIVATE_KEY }}
APP_ID : ${{ secrets.APP_STORE_APP_ID }}
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
run : |
python scripts/monitor_review.py --analyze-if-rejected
- name : Create GitHub Issue for Rejection
if : steps.review_check.outputs.rejected == 'true'
uses : actions/github-script@v7
with :
script : |
const fs = require('fs');
const analysis = JSON.parse(fs.readFileSync('rejection_analysis.json', 'utf8'));
const body = `## App Store リジェクト通知
**重要度**: ${analysis.severity}
**バージョン**: ${analysis.version}
### 根本原因
${analysis.root_cause}
### 修正アクション
${analysis.fix_actions.map((a, i) => `${i+1}. **${a.action}**\n - 対象ファイル: \`${a.file_to_edit}\`\n - ヒント: ${a.code_hint}`).join('\n\n')}
### 推定修正時間
約 ${analysis.estimated_hours} 時間
### 再提出時の注意
${analysis.resubmission_tip}
`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `🚨 App Store リジェクト: v${analysis.version}`,
body: body,
labels: ['app-store-rejection', 'urgent']
});
よくある落とし穴と解決策
実際にこのパイプラインを稼働させて遭遇した問題を共有します。
落とし穴①: スクリーンショットのサイズ不一致
App Storeは端末サイズごとに厳密なピクセル数を要求します。iPhone 16 Pro Max は 1320 × 2868 px です。Snapshotで撮影した画像がSimulatorのスケール設定によって縮小される場合があります。
# Snapfileに追加
scale 1 # 常に100%スケールで撮影
落とし穴②: メタデータのHTMLエンティティ変換
Geminiが生成した説明文に & や < が含まれる場合、App Store Connect APIがエラーを返します。提出前に必ずサニタイズしましょう。
import html
sanitized_description = html.unescape(raw_description)
# さらに禁止文字を除去
sanitized_description = sanitized_description.replace( "<" , "" ).replace( ">" , "" )
落とし穴③: TestFlight外部テスターへの自動通知
TestFlightビルドをアップロードした直後、外部テスターには審査通過後にしか配布できません。内部テスターのみに即座に配布するよう設定を分けてください。
# fastlane/Fastfile
lane :upload_internal do
pilot (
ipa: "./build/YourApp.ipa" ,
distribute_external: false , # 内部テスターのみ
notify_external_testers: false
)
end
落とし穴④: JWT トークンの有効期限切れ
App Store Connect APIのJWTは最大20分有効です。大量のメタデータを更新する際にトークンが切れることがあります。定期的にトークンを再生成する仕組みを入れてください。
全体を Antigravity エージェントで統合する
個別のスクリプトを書いたら、Antigravity上でこれらを統合するエージェントを定義します。プロジェクトルートに release-agent.md を作成します。
# Release Agent
## このエージェントの役割
新バージョンのApp Storeリリース準備を完全自動化します。
`version_info.json` の内容を読み込み、以下を順番に実行します。
## 実行手順
1. `fastlane snapshot` でスクリーンショット撮影
2. `python scripts/generate_captions.py` でキャプション生成
3. `python scripts/optimize_metadata.py` でメタデータ最適化
4. `fastlane gym` でビルド
5. `fastlane pilot` でTestFlightアップロード
6. レビュー待ち状態になったら終了
## 各ステップのエラーハンドリング
- snapshot失敗: Simulatorを再起動して1回リトライ
- APIエラー: 60秒待って1回リトライ
- ビルドエラー: エラーログを `build_error.log` に保存して停止
このエージェントをAntigravityで起動するには:
@release-agent 新バージョン2.1.0のリリース準備を開始してください
Antigravityがファイルを読み込み、各スクリプトを順番に実行します。作業中に別の開発を進めることができます。
5,000 万 DL のアプリ群を並行運用して気づいた、自動化前後で本当に変わった 6 つの数字
2014 年に個人アプリ開発をはじめてから、壁紙系・癒し系・引き寄せ系を中心に複数アプリを並行運用してきました。ピーク時には iOS 側で 5 本前後を同時に保守しており、リリース作業がボトルネックになる感覚は身に染みています。Antigravity を介したパイプラインを 6 ヶ月運用したあとに、自分の作業ログを並べて整理した数字を共有します。
指標 自動化前(2024 後半) 自動化後(2026 春) 改善倍率
1 リリースの所要工数 約 14 日(断続) 約 1.5 日 約 9 倍短縮
月間リリース本数(5 アプリ合計) 3〜4 本 10〜12 本 約 3 倍
初回審査リジェクト率 35〜40% 6〜8% 約 1/5
メタデータ A/B テスト頻度 月 1 回 週 1 回 約 4 倍
AdMob eCPM(自動化前後比) $1.42(基準) $2.11 +48%(リリース速度起因)
Gemini API 月額コスト(5 アプリ合算) 0 $18〜$28 純増
数字をひととおり眺めて、私自身が驚いたのは「リジェクト率の急落」と「eCPM が連動して伸びたこと」でした。リジェクトが減ったのは AI がメタデータの NG ワード(健康効果の誇張・第三者商標の取り扱い・年齢表現)を事前に検出してくれるからです。eCPM が伸びたのは、リリース頻度が増えたことでアクティブ率と最新 OS 比率が上がり、結果として広告ネットワークの単価評価が上がるためでした。これは当初の目論見になかった副作用で、開発者個人の収益にも直接効いてきます。
Before の現実:14 日が「断続」だった理由
自動化前の 14 日は、純粋な作業時間ではなく「他の開発と並行しながら断片的に積み上げた合計時間」でした。1 つのリリースで実際にどれだけのタスクが分断されていたかを書き出すと次のようになります。
Day 1〜2 : スクリーンショット差し替えの判断(どの画面を撮り直すか)
Day 3 : Simulator でスクリーンショット撮影(iPhone 16 Pro Max / iPhone 15 / iPad Pro / iPad Air の 4 機種 × 6 枚 = 24 枚 × 2 言語 = 48 枚)
Day 4 : 各画像にキャプション文を作成(コピーライティング作業 90 分 × 2 言語)
Day 5〜6 : App Store Connect でメタデータ手動更新(タイトル / サブタイトル / 説明文 / キーワード × 2 言語)
Day 7 : TestFlight アップロード + 内部テスター配信
Day 8〜10 : 内部テストフィードバック反映
Day 11 : 提出
Day 12〜14 : 審査待ち + リジェクト時の対応
5 アプリ並行運用だと、この 14 日サイクルが重なって 1 ヶ月のうち約 70% がリリース作業に消えていました。コードを書く時間が 30% を切る月もあり、これが「アプリを増やすほど開発が遅くなる」自己矛盾を生んでいました。
After の 1.5 日:何が並列化され、何が省略されたか
自動化後の 1.5 日のうち、人の手が実際に動くのは次の 3 箇所だけです。
30 分 : 差し替え判断 + Antigravity への指示(@release-agent 新バージョン 2.4.0 を準備 の一言)
45 分 : AI が生成したキャプション・メタデータの最終確認と、3〜5 箇所の語感調整
30 分 : 提出ボタンを押す前のスクリーンショット目視チェック
残りの 12 時間程度は GitHub Actions と Antigravity が裏で走ります。「夜に指示を出して翌朝確認する」スタイルが定着して、自分の稼働時間とリリースの所要時間が分離されました。これは個人開発者にとって、たぶん最大の解放です。
公式ドキュメントには書かれていない、複数アプリ並行運用で実際に効いた 5 つのコツ
App Store Connect API と fastlane の公式ドキュメントを読むだけでは、複数アプリの並行運用で必ずぶつかる細部に答えがありません。私が 6 ヶ月運用するなかで「これは絶対に最初から組み込んでおけばよかった」と感じた 5 点を、コード付きで残します。
コツ 1: JWT トークンを「先回り」で再生成する
落とし穴セクションでも触れた JWT 20 分問題は、5 アプリのメタデータを連続更新すると確実に踏みます。私が採用しているのは、トークン生成時刻を Redis(ローカルでは SQLite で十分)に書き込み、「次の API 呼び出しが残り 3 分以内に切れそうなら、呼び出し前に再生成する」という先回りパターンです。
# scripts/asc_token_manager.py
import time, jwt, os, sqlite3, pathlib
ASC_KEY_PATH = os.environ[ "ASC_KEY_PATH" ]
ASC_KEY_ID = os.environ[ "ASC_KEY_ID" ]
ASC_ISSUER_ID = os.environ[ "ASC_ISSUER_ID" ]
TOKEN_CACHE = pathlib.Path.home() / ".asc_token_cache.sqlite"
SAFETY_WINDOW_SEC = 180 # 残り3分を切ったら再生成
def _init_cache ():
con = sqlite3.connect( TOKEN_CACHE )
con.execute( "CREATE TABLE IF NOT EXISTS token (id INTEGER PRIMARY KEY, jwt TEXT, exp INTEGER)" )
con.commit(); return con
def get_token () -> str :
con = _init_cache()
row = con.execute( "SELECT jwt, exp FROM token WHERE id=1" ).fetchone()
now = int (time.time())
if row and (row[ 1 ] - now) > SAFETY_WINDOW_SEC :
return row[ 0 ]
# 再生成
with open ( ASC_KEY_PATH , "r" ) as f:
key = f.read()
exp = now + 1200 # 最大20分
token = jwt.encode(
{ "iss" : ASC_ISSUER_ID , "iat" : now, "exp" : exp, "aud" : "appstoreconnect-v1" },
key,
algorithm = "ES256" ,
headers = { "kid" : ASC_KEY_ID , "typ" : "JWT" },
)
con.execute( "INSERT OR REPLACE INTO token (id, jwt, exp) VALUES (1, ?, ?)" , (token, exp))
con.commit()
return token
5 アプリで合計約 200 回/回の API 呼び出しを行いますが、この方式に切り替えてから JWT 期限切れによる失敗は 6 ヶ月でゼロです。それまでは月 2〜3 回踏んでいました。
コツ 2: スクリーンショットの「色温度」を機種間で揃える
iPhone 16 Pro Max(XDR True Tone)と iPad Air(標準 IPS)で同じ画面を撮ると、色温度が 6500K と 7100K で微妙にずれます。App Store の機種別ギャラリーで横並びにしたときに「この機種だけ青っぽい」と見えて違和感を生むので、私は撮影後に統一補正をかけています。
# Snapfile
override_status_bar true # 信号5本・100%表示
clear_previous_screenshots true
scale 1 # 100%スケール固定(落とし穴①対策)
languages [ "ja-JP" , "en-US" ]
# 撮影後フック
after_each_device do |device|
Dir [ "screenshots/ #{ device } /*.png" ]. each do |f|
system ( "magick " #{f}" -modulate 100,100,99 -auto-level "#{f}"")
# modulate のサチュレーション/色相を 99 に下げて青寄りを補正
end
end
地味な処理ですが、これでスクリーンショット段階のリジェクト(「機種間でブランドの一貫性に欠ける」フィードバック)が消えました。
コツ 3: Gemini に「アプリのトーン辞書」を最初に渡す
メタデータ生成プロンプトに、アプリごとの「使ってほしい単語・絶対に使わない単語」を辞書として渡しておくと、語感ブレが激減します。
# tone_dictionary.py
TONE = {
"wallpaper-zen" : {
"prefer" : [ "静けさ" , "余白" , "整える" , "落ち着き" , "minimal" ],
"avoid" : [ "最強" , "神" , "やばい" , "革命" , "完全網羅" ],
"audience" : "30代女性・忙しい知的職" ,
},
"manifest-coach" : {
"prefer" : [ "静かな確信" , "呼吸" , "ノート" , "観察" , "整える" ],
"avoid" : [ "引き寄せの法則で人生が変わる" , "願いが叶う" , "奇跡" ],
"audience" : "20-40代・自己内省志向" ,
},
}
def build_prompt (app_id: str , base_prompt: str ) -> str :
t = TONE [app_id]
return f """
{ base_prompt }
# このアプリの語感ガイド
- 優先語彙: { ", " .join(t[ "prefer" ]) }
- 禁止語彙: { ", " .join(t[ "avoid" ]) }
- 想定読者: { t[ "audience" ] }
禁止語彙は説明文・キャプションのいずれにも入れないでください。
"""
「禁止語彙」を明示するだけで、健康効果の誇張や扇情的表現での Apple リジェクトがほぼ消えました。私のケースでは Section 1.4.1(Safety / Medical)のリジェクト 5 件/半年 → 0 件です。
コツ 4: GitHub Actions のシークレットは「アプリ別」に分ける
5 アプリで ASC_KEY_PATH を共通化すると、特定アプリの権限剥奪時に全アプリのパイプラインが止まります。最初は面倒でもアプリごとに分離するのが結局速いです。
# .github/workflows/release-wallpaper-zen.yml
jobs :
release :
runs-on : macos-15
env :
ASC_KEY_PATH : ${{ secrets.ASC_KEY_WALLPAPER_ZEN }}
ASC_KEY_ID : ${{ secrets.ASC_KEY_ID_WALLPAPER_ZEN }}
ASC_ISSUER_ID : ${{ secrets.ASC_ISSUER_ID }} # ここは共通でも可
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY_PROD }}
権限剥奪・キーローテーションの作業を 1 アプリに局所化できると、他アプリのリリースが止まらないので、月の総ダウンタイムが小さくなります。
コツ 5: 「人手介入トリガー」を 4 条件だけ明示する
完全自動化は理想に見えて、実は「どこで止めるか」を決めておかないと暴走します。私のパイプラインでは次の 4 条件で必ず Antigravity が止まり、私に確認を求めるようにしています。
キャプション 1 件あたりの Gemini 信頼度スコアが 0.85 未満
メタデータの説明文に禁止語彙辞書のいずれかがヒット
ビルドサイズが直前リリースから ±15% を超える
内部テスター 3 名のフィードバックに「クラッシュ」「起動しない」を含むメッセージが出現
この 4 条件を入れてから、本番に通った後で気づくレベルの事故(例: 説明文が前バージョンの古いコピー)はゼロになりました。
実際の効果と運用コスト
このパイプラインを3ヶ月運用した結果、リリース準備の工数は以下のように変化しました。
スクリーンショット+キャプション生成: 8時間 → 約25分 (Gemini API呼び出し時間含む)
メタデータ最適化: 6時間 → 約15分 (レビューと軽微な調整含む)
ビルド・提出: 2時間 → 約40分 (変わらず。GitHub Actionsが自動実行)
リジェクト対応: 10時間 → 約2時間 (原因特定が自動化されたため)
API コスト : Gemini 2.5 Pro を使って1回のリリース準備にかかるコストは概算で $0.50〜$1.50 程度です(画像数・テキスト量による)。月2〜3回リリースしても $5 以内に収まります。
今ではリリース作業を「夜に自動実行させておいて翌朝確認する」スタイルに変えました。この時間で新しいアプリのアイデアを試せるようになったのが、一番大きな変化かもしれません。
パイプラインの全コードはGitHubにテンプレートとして公開できないですが、この記事のスクリプトを起点に、Antigravityエージェントに「このコードをプロジェクトに統合して」と依頼すれば、数時間で動くものができます。まず generate_captions.py だけでも動かしてみることをお勧めします。スクリーンショットを渡すと即座にキャプションが返ってくる体験は、「これは使える」という手応えを確実に与えてくれます。