フェーズ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}\nResponse: {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がファイルを読み込み、各スクリプトを順番に実行します。作業中に別の開発を進めることができます。
複数アプリを並行運用して気づいた、自動化前後で本当に変わった 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 の +48% は、因果を切り分けられていません。リリース頻度が上がればアクティブ率や最新 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 アプリぶんを連続処理すると 1 リリースあたり合計約 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 条件を入れてから、本番に通った後で気づくレベルの事故(例: 説明文が前バージョンの古いコピー)はゼロになりました。
生成した画像を、提出ボタンの前でもう一度だけ疑う
深夜に 5 アプリぶんのスクリーンショットをまとめて流し込み、翌朝 App Store Connect からのエラー通知で起こされたことがあります。原因は透過チャンネルでした。合成処理が RGBA のまま PNG を書き出していただけで、絵そのものは一枚も間違っていません。それでも提出は通りません。
生成の精度を上げても、この種の事故は減りませんでした。減ったのは、生成の質ではなく「アップロードする直前に一度だけ全数を疑う」工程を足したときです。
私はパイプラインの最後、fastlane に画像を渡す一歩手前にこれを置いています。
#!/usr/bin/env python3
# scripts/preflight_screenshots.py — アップロード前にローカルで弾く
from PIL import Image
from pathlib import Path
import sys, json
# 自分が提出するデバイスの解像度だけを列挙する(増やしすぎると検査が形骸化します)
ACCEPTED = {
(1320, 2868), (2868, 1320), # iPhone 6.9"
(1242, 2688), (2688, 1242), # iPhone 6.5"
(2064, 2752), (2752, 2064), # iPad 13"
}
MAX_BYTES = 8 * 1024 * 1024
def inspect(path: Path):
problems = []
with Image.open(path) as im: # 画素は展開せずヘッダだけ読む
size, mode = im.size, im.mode
has_alpha = ("A" in mode) or (im.info.get("transparency") is not None)
if size not in ACCEPTED:
problems.append(f"{size[0]}x{size[1]} は受理対象の解像度ではありません")
if has_alpha:
problems.append(f"アルファチャンネルが残っています (mode={mode})")
if path.stat().st_size > MAX_BYTES:
problems.append(f"{path.stat().st_size / 1048576:.1f} MB は上限を超えています")
return problems
def repair(path: Path) -> bool:
"""アルファだけ自動修復する。解像度違反は直さない。"""
with Image.open(path) as im:
if "A" not in im.mode and im.info.get("transparency") is None:
return False
rgba = im.convert("RGBA")
flat = Image.new("RGB", im.size, (255, 255, 255))
flat.paste(rgba, mask=rgba.split()[-1])
flat.save(path, "PNG")
return True
if __name__ == "__main__":
root, autofix = Path(sys.argv[1]), "--fix" in sys.argv
report = {"checked": 0, "failed": 0, "repaired": 0}
details = {}
for p in sorted(root.rglob("*.png")):
report["checked"] += 1
problems = inspect(p)
if autofix and any("アルファ" in x for x in problems) and repair(p):
report["repaired"] += 1
problems = inspect(p)
if problems:
report["failed"] += 1
details[p.name] = problems
print(json.dumps(report, ensure_ascii=False))
for name, problems in list(details.items())[:10]:
print(" -", name, "|", "; ".join(problems))
sys.exit(1 if report["failed"] else 0)
180 枚を実際に通して測った数字
本番と同じ構成(5 アプリ × 2 言語 × 3 デバイスサイズ × 6 枚 = 180 枚)の PNG を用意し、現場で実際に混ざる不備 — 合成由来のアルファ残りと、リサイズの丸め誤差による 1px ずれ — を含んだ状態で走らせました。以下は Linux 上で実測した値です。
| 項目 | 実測値 | 備考 |
| 検査対象 | 180 枚 | 5 アプリ × 2 言語 × 3 サイズ × 6 枚 |
| 不備を検出 | 83 枚(46.1%) | 提出すれば確実に差し戻される枚数 |
| アルファ残りのみ | 59 枚 | 自動修復の対象 |
| 解像度違反のみ | 16 枚 | 撮り直しが必要 |
| 両方に該当 | 8 枚 | 修復しても解像度は残る |
| 自動修復できた枚数 | 67 枚 | アルファを白背景で合成 |
| 修復後も残った不備 | 24 枚 | すべて解像度違反 |
| 検査のみの所要時間 | 0.008 秒 / 180 枚 | 3 回計測の最小値・ヘッダのみ読取 |
| 修復込みの所要時間 | 12.4 秒 / 67 枚 | 1 枚あたり約 185 ミリ秒 |
| 総容量の変化 | 4,960 KB → 4,608 KB | アルファ合成に伴う -7.1% |
この差が、運用の設計をそのまま決めてくれました。
検査はヘッダを読むだけなので、180 枚でも 0.008 秒しかかかりません。実質ゼロコストですから、条件を付けず毎ビルドで全数に掛けます。一方の修復はデコードと再エンコードが走るぶん 1 枚 185 ミリ秒ほどかかるので、違反が出たときにだけ動かします。
1px ずれを自動で直さないのは意図的です。1319px を 1320px へ引き伸ばせば検査は通りますが、文字の輪郭がわずかににじみます。審査は通っても、ストアで見比べたときに自分が納得できません。ここは撮り直しが正しい対処だと考えています。
CI に置くときの形
戻り値で落ちるようにしてあるので、GitHub Actions では素直にステップを 1 つ足すだけで済みます。
- name: Preflight screenshots
run: |
python3 scripts/preflight_screenshots.py fastlane/screenshots --fix
- name: Upload to App Store Connect
run: bundle exec fastlane deliver --skip_binary_upload
--fix を付けても解像度違反は残るため、そこで確実に赤くなります。前段の「AI が生成して満足する」工程と、後段の「機械が疑う」工程を分けておくと、生成側のプロンプトを触ったときの回帰にも気づけるようになりました。
工程別の時間と API コストの内訳
「6 つの数字」で挙げた 1.5 日の内側を、工程別に分解するとこうなります。6 ヶ月の運用ログから、1 リリースあたりの中央値に近い値を拾いました。
| 工程 | 自動化前 | 自動化後 | 備考 |
| スクリーンショット + キャプション生成 | 約 8 時間 | 約 25 分 | Gemini API の呼び出し時間を含む |
| メタデータ最適化 | 約 6 時間 | 約 15 分 | レビューと語感調整を含む |
| ビルド・提出 | 約 2 時間 | 約 40 分 | GitHub Actions が待ち時間を引き受ける |
| リジェクト対応 | 約 10 時間 | 約 2 時間 | 原因特定が自動化されたため |
API コストの実額: Gemini 2.5 Pro で 1 回のリリース準備にかかるのは概算 $0.50〜$1.50 です(画像枚数・テキスト量で変動)。5 アプリで月 10〜12 本のリリースに、リジェクト分析ぶんの追加呼び出しを足すと、冒頭の表に載せた月 $18〜$28 に落ち着きます。1 本のアプリを月 2〜3 回更新する規模なら、月 $5 前後で収まる計算です。
工数の合計より効いているのは、リリースを「まとめ待ち」しなくなったことでした。準備に 1 週間かかるうちは、機能をため込んでから一度に出すのが合理的です。準備が一晩で済むようになると、小さな改善を小さいまま出せます。フィードバックの回転が速くなること。それがこの構成の買ってくれる一番の資産だと感じています。
運用に載せる前に確認しておきたいセキュリティの要点
このパイプラインは認証情報を複数扱うため、公開リポジトリ化やチーム共有の前に、次の 4 点だけは押さえておくことをお勧めします。
API キーをコードに書かない。 本記事のスクリプトはすべて os.environ["KEY_NAME"] 経由で読み込みます。CI/CD では GitHub Secrets、ローカルでは .gitignore 済みの .env ファイルに限定します。
App Store Connect のキーは 90 日を目安にローテーションする。 GitHub Secrets を更新すれば全ワークフローが即座に新しいキーを拾うため、ローテーションの手間はほとんどかかりません。
キーの権限を絞る。 読み取り専用の監視には「Developer」、提出には「App Manager」。自動化スクリプトに「Admin」を渡さないことは、事故が起きたときの被害半径を決める一線です。
Gemini API に予算アラートを張る。 スクリーンショット処理でループが暴走すると、呼び出し課金は静かに積み上がります。Google Cloud Console で月 $10 のアラートを設定しておけば、高くつく前に気づけます。
5 アプリへの展開: 設定ファイル駆動のオーケストレータ
1 本のアプリで動いたら、複数アプリへの展開は「スクリプトのパラメータ化」だけで済みます。アプリ固有の値をコードに書かず、設定 JSON に寄せるのが要点です。
# scripts/release_orchestrator.py
import argparse
import json
import subprocess
from pathlib import Path
def run_release_pipeline(app_config_path: str, dry_run: bool = False):
"""設定 JSON で指定されたアプリのリリースパイプラインを一括実行する。
Args:
app_config_path: アプリ固有の設定 JSON のパス
dry_run: True の場合、生成のみ行い提出はスキップする
"""
config = json.loads(Path(app_config_path).read_text())
app_id = config["app_store_id"]
languages = config.get("languages", ["en-US"])
screenshots_dir = config.get("screenshots_dir", "./screenshots")
print(f"\n🚀 リリースパイプライン開始: {config['app_name']}")
print(f" 言語: {', '.join(languages)}")
print(f" dry run: {dry_run}")
steps = [
("スクリーンショット撮影", f"fastlane snapshot --scheme {config['scheme']}"),
("キャプション生成", f"python scripts/generate_captions.py {screenshots_dir} '{config['description']}'"),
("メタデータ最適化", f"python scripts/optimize_metadata.py {app_config_path}"),
]
if not dry_run:
steps.extend([
("ビルド", f"fastlane gym --scheme {config['scheme']}"),
("TestFlight アップロード", "fastlane pilot upload"),
])
for step_name, command in steps:
print(f"\n📍 {step_name}...")
result = subprocess.run(command, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f" ✗ 失敗: {result.stderr[:200]}")
raise RuntimeError(f"パイプライン失敗: {step_name}")
print(f" ✓ 完了")
print(f"\n✅ パイプライン完了: {config['app_name']}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("config", help="アプリ設定 JSON のパス")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
run_release_pipeline(args.config, dry_run=args.dry_run)
これで 3 アプリのリリースは、設定ファイルを替えた 3 回の呼び出しになります。
python scripts/release_orchestrator.py apps/app1/config.json
python scripts/release_orchestrator.py apps/app2/config.json
python scripts/release_orchestrator.py apps/app3/config.json
GitHub Actions の matrix 戦略で並列化すれば、5 アプリぶんの準備が同じ夜のうちに片づきます。
パイプライン全体を一気に組む必要はありません。まず generate_captions.py だけを、手元にある既存アプリのスクリーンショットで動かしてみてください。画像を渡すと数十秒でキャプションが返ってくる手応えを確かめてから、メタデータ最適化、リジェクト分析、と一段ずつ足していく。それが私自身の辿った順路でもあります。認証まわりの詳細は Antigravity × App Store Connect API 実装ガイド に、GitHub Actions 側の構成は Antigravity × GitHub Actions 上級CI/CDパイプライン構築 に分けてまとめています。夜のうちに準備が終わっている朝の静けさを、一度味わっていただければ幸いです。