Building a Fully Automated iOS App Release Pipeline with Antigravity — From Screenshot Generation to App Store Review Management
Use Antigravity, App Store Connect API, and GitHub Actions to automate every step from build to App Store submission. A complete advanced guide covering AI screenshot captions, metadata optimization, and rejection analysis.
If you're an independent iOS developer, you know the feeling: more time spent on release prep than on actual development. Screenshots for every device size, metadata updates in multiple languages, TestFlight uploads, review waiting — and then it all repeats for the next version.
I ran multiple apps simultaneously, and at one point, release preparation alone took one to two weeks per cycle. That's time I wanted to spend writing code. After building a pipeline combining Antigravity with the App Store Connect API, that changed.
This guide shares the full implementation: the architectural decisions, the working scripts, and the hard-won lessons from running this in production. This isn't just "add Fastlane to your project." We're covering AI-generated screenshot captions, keyword optimization based on competitive analysis, and an agent that identifies the root cause of review rejections and proposes code-level fixes.
Why Automating "Build → Submit" Isn't Enough
Most developers who automate their release workflow stop at "build → upload to TestFlight → submit to App Store." That automation is valuable, but it covers a surprisingly small portion of the actual work.
Here's how my time broke down for a new app launch across five languages and six device sizes:
Screenshot capture and editing: ~8 hours
App Store metadata per language (title, description, keywords): ~6 hours
Build, signing, and upload: ~2 hours (already automated)
The existing automation covered about 15% of total release effort. The remaining 85% was content creation work. That's where AI can make the real difference.
Pipeline Architecture and Component Design
The pipeline consists of three independent phases.
Phase 1 — Content Generation: An Antigravity agent reads the app's source code and design files, then orchestrates screenshot capture, caption generation, and metadata optimization.
Phase 2 — Automated Submission: Fastlane combined with the App Store Connect API takes the generated content and the build, then submits to TestFlight and the App Store.
Phase 3 — Review Monitoring: GitHub Actions detects rejection notifications, triggers an Antigravity agent, and generates a structured analysis with specific fix recommendations.
The key design choice here is independence between phases. You can re-run screenshot generation without triggering a new build, or update metadata without going through the full pipeline. This matters when you're iterating on App Store optimization after the initial launch.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Real before/after numbers measured while running several indie iOS apps in parallel: 14 days → 1.5 days per release, rejection rate 35% → 8%, monthly releases 3-4 → 10-12
✦Undocumented gotchas from running five iOS apps in parallel — JWT pre-rotation, screenshot color temperature alignment, and tone dictionaries that survived 6 months in production
✦A complete release-agent.md plus four explicit human-in-the-loop triggers, so the Antigravity agent stops at the exact moments you'd want it to
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The Small Step of Getting JSON Back Out of the Model
The first night I ran caption generation across a full batch, seven of ninety captions came back starting with "Sure, here's the JSON you asked for." It took me the better part of an hour to find the culprit, and the culprit was one line I had written myself: I was stripping fence markers off the response with a plain string replace before handing it to json.loads.
Models add a preamble sometimes, even when you ask for JSON only. The fence label comes back capitalized on some days; on others there's no fence at all and the object sits inline in a sentence. I wanted to know exactly where the naive versions gave up, so I built seven response shapes and ran both of them plus my helper against the same inputs.
Response shape
replace approach
split approach
extract_json
Bare JSON, no fence
✅
✅
✅
Fence labeled json
✅
✅
✅
Fence with no label
✅
✅
✅
Truncated — closing fence missing
✅
✅
✅
Fence label capitalized (JSON)
❌
❌
✅
Prose before and after the fence
❌
✅
✅
No fence, object embedded in prose
❌
❌
✅
Passing
4 / 7
5 / 7
7 / 7
The two that break both naive versions are the capitalized label and the fence-less inline object. Neither is common, but across ninety screenshots times five apps you hit them a few times a week. What bothered me most was that the failure never raised — it fell into a fallback, and the first thirty characters of the model's chatter went out as a real caption.
Try every candidate; fail loudly when none of them parse. That's the whole contract of the small module I pulled out, and it's the one I'd write first if I were starting this pipeline again.
# scripts/utils/json_extract.py"""Pull JSON out of a model response: fenced or not, prefixed or not, truncated or not."""import jsonimport refrom typing import Any, IteratorFENCE = "`" * 3 # built rather than written literally, so it can't collide with surrounding Markdown_FENCED = re.compile( FENCE + r"[ \t]*[A-Za-z0-9_+.-]*[ \t]*\r?\n(.*?)(?:\r?\n[ \t]*" + FENCE + r"|\Z)", re.DOTALL,)class ModelJSONError(ValueError): """Raised when no candidate in the response parses as JSON."""def _candidates(text: str) -> Iterator[str]: # 1) fenced bodies, whatever the label's case — and even if the fence never closes for m in _FENCED.finditer(text): yield m.group(1).strip() # 2) the whole response, for bare JSON with no fence at all yield text # 3) first bracket through last bracket, for JSON wrapped in prose starts = [i for i in (text.find("{"), text.find("[")) if i >= 0] end = max(text.rfind("}"), text.rfind("]")) if starts and min(starts) < end: yield text[min(starts):end + 1]def extract_json(raw: str) -> Any: if not raw or not raw.strip(): raise ModelJSONError("empty response") text = raw.strip() last_error = None for candidate in _candidates(text): if not candidate: continue try: return json.loads(candidate) except json.JSONDecodeError as e: last_error = e raise ModelJSONError( "could not extract JSON: %s / first 200 chars: %s" % (last_error, text[:200]) )
Since routing every call through this module, I haven't lost a caption to a malformed response. Callers only have to watch for ModelJSONError, which means the extraction code in Phase 2 and Phase 3 below collapses into the same single line.
Generating Captions with Gemini Vision API
This is where the real time savings happen. Rather than writing captions manually for each screenshot in each language, Gemini Vision analyzes the screenshot and generates App Store-appropriate copy:
# scripts/generate_captions.pyimport osimport base64import jsonfrom pathlib import Pathimport google.generativeai as genaifrom utils.json_extract import extract_json, ModelJSONError# IMPORTANT: Always load API keys from environment variablesgenai.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: """ Generate App Store caption from a screenshot. Args: image_path: Path to the screenshot PNG language: Target language code ("en-US", "ja", etc.) app_description: Brief app description for context Returns: {"caption": str, "alt_text": str} """ model = genai.GenerativeModel("gemini-2.5-pro") image_data = encode_image(image_path) prompt = f""" Look at this iOS app screenshot and write an App Store caption in {language}. App context: {app_description} Requirements: - Maximum 30 characters (App Store limit) - Focus on user benefit, not feature names - Sound natural to {language} native speakers - Action-oriented: what can users DO with this screen? Return JSON only: {{"caption": "caption text here", "alt_text": "accessibility description"}} """ response = model.generate_content([ {"mime_type": "image/png", "data": image_data}, prompt ]) # Extraction is delegated to json_extract — never invent copy from a failed parse return extract_json(response.text)def process_all_screenshots(screenshots_dir: str, app_description: str): """Process entire screenshots directory and generate captions.json""" screenshots_path = Path(screenshots_dir) captions = {} failed = [] 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: # Don't fail the entire batch on a single error, but always leave a marker print(f" ✗ Error: {e}") failed.append(f"{lang_code}/{screenshot.stem}") captions[lang_code][screenshot.stem] = { "caption": "", "alt_text": "", "needs_human": True } 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✅ Caption generation complete: {output_path}") if failed: # Never let blank captions reach deliver — exit non-zero and stop CI here raise SystemExit( "%d caption(s) missing: %s — fix these before submitting" % (len(failed), ", ".join(failed)) ) return captions
Common Pitfall #1: API Rate Limiting
With five languages, six devices, and three screens per combination, you're making 90 API calls. Hitting rate limits is nearly guaranteed without backoff logic:
# scripts/utils/rate_limiter.pyimport timeimport randomfrom functools import wrapsdef with_retry(max_retries: int = 3, base_delay: float = 1.0): """Exponential backoff retry decorator""" 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. Retrying in {delay:.1f}s...") time.sleep(delay) else: raise else: raise # Non-rate-limit errors fail immediately return None return wrapper return decorator
Phase 2: AI-Driven App Store Metadata Optimization
Screenshots get users to look; metadata gets them to download. This phase uses Gemini to generate and optimize title, subtitle, description, and keywords for each supported language.
Defining the Agent's Behavior in AGENTS.md
Create an AGENTS.md at the project root to define how the Antigravity agent should handle metadata optimization:
# AGENTS.md - App Store Metadata Optimizer## RoleYou are an App Store metadata optimization specialist.Read `app_info.json` and generate optimized metadata for each language.## Output FormatWrite files to `fastlane/metadata/{language}/`:- `name.txt` (30 chars max)- `subtitle.txt` (30 chars max)- `description.txt` (4,000 chars max)- `keywords.txt` (100 chars max, comma-separated, English only)## Optimization Principles1. Place highest-search-volume keywords first2. First 255 characters of description appear before "More" fold — treat as your hook3. Never mention competitor names (App Store guideline violation)4. Write each language as a native speaker, not a translation
Metadata Generation Script
# scripts/optimize_metadata.pyimport osimport jsonfrom pathlib import Pathimport google.generativeai as genaifrom utils.json_extract import extract_jsongenai.configure(api_key=os.environ["GEMINI_API_KEY"])def generate_metadata_for_language( app_info: dict, language: str, existing_metadata: dict = None) -> dict: """ Generate App Store metadata for a specific language. When existing_metadata is provided, the function acts as an improvement pass. """ model = genai.GenerativeModel("gemini-2.5-pro") improvement_context = "" if existing_metadata: improvement_context = f""" Current metadata (identify what to improve): {json.dumps(existing_metadata, ensure_ascii=False, indent=2)} """ prompt = f""" Optimize App Store metadata for the following iOS app, targeting {language} users. App information: {json.dumps(app_info, ensure_ascii=False, indent=2)} Hard constraints: - name: max 30 characters in {language} - subtitle: max 30 characters in {language} - keywords: max 100 characters total, comma-separated, English only - description: max 4,000 characters, first 255 are most important {improvement_context} Return only valid JSON: {{ "name": "...", "subtitle": "...", "keywords": "keyword1,keyword2,...", "description": "..." }} """ response = model.generate_content(prompt) # Handles missing fences, odd labels, and truncated output in one place return extract_json(response.text)def write_fastlane_metadata(metadata: dict, language: str, output_base: str): """Write metadata files in Fastlane deliver format""" lang_dir = Path(output_base) / "metadata" / language lang_dir.mkdir(parents=True, exist_ok=True) for field, filename in [ ("name", "name.txt"), ("subtitle", "subtitle.txt"), ("keywords", "keywords.txt"), ("description", "description.txt") ]: if field in metadata: (lang_dir / filename).write_text(metadata[field], encoding="utf-8") print(f" ✓ {lang_dir / filename}")
Phase 3: Automated Rejection Analysis
App Store rejections are expensive. When a rejection arrives, you typically spend hours identifying the issue, cross-referencing Apple's guidelines, and figuring out what exactly needs to change. This phase automates that initial triage.
Fetching Rejection Details via App Store Connect API
# scripts/monitor_review.pyimport osimport jwtimport timeimport httpxdef create_jwt_token(key_id: str, issuer_id: str, private_key: str) -> str: """Generate JWT for App Store Connect API authentication""" payload = { "iss": issuer_id, "iat": int(time.time()), "exp": int(time.time()) + 1200, # 20 minute expiry "aud": "appstoreconnect-v1" } return jwt.encode( payload, private_key, algorithm="ES256", headers={"alg": "ES256", "kid": key_id, "typ": "JWT"} )def get_app_review_status(app_id: str, token: str) -> dict: """Fetch the latest review status for the specified app""" url = f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/appStoreVersions" response = httpx.get( url, headers={"Authorization": f"Bearer {token}"}, params={ "filter[appStoreState]": "REJECTED,METADATA_REJECTED", "include": "appStoreReviewDetail", "limit": 1 }, timeout=30.0 ) response.raise_for_status() data = response.json() if not data.get("data"): return {"status": "no_rejection"} version = data["data"][0] review_detail = next( (item["attributes"] for item in data.get("included", []) if item["type"] == "appStoreReviewDetails"), None ) return { "status": version["attributes"]["appStoreState"], "version": version["attributes"]["versionString"], "rejection_reasons": review_detail.get("rejectReasonCode", []) if review_detail else [], "notes": review_detail.get("notes", "") if review_detail else "" }
Antigravity Agent for Guideline Analysis
# scripts/analyze_rejection.pyimport osimport jsonimport google.generativeai as genaifrom utils.json_extract import extract_json, ModelJSONErrorgenai.configure(api_key=os.environ["GEMINI_API_KEY"])COMMON_REJECTION_CODES = { "2.1.0": "App completeness — app doesn't perform as expected", "4.0.0": "Design — doesn't follow Human Interface Guidelines", "4.2.0": "Minimum functionality — too limited in features", "5.1.1": "Privacy — data collection and usage issues", "2.5.4": "Software requirements — inappropriate background execution"}def analyze_rejection_and_propose_fix( rejection_info: dict, project_context: str) -> dict: """ Analyze rejection and generate specific fix recommendations. Returns structured analysis with prioritized action items, file references, and estimated time to fix. """ model = genai.GenerativeModel("gemini-2.5-pro") rejection_codes = rejection_info.get("rejection_reasons", []) code_descriptions = [ f"- {code}: {COMMON_REJECTION_CODES.get(code, f'Code {code}')}" for code in rejection_codes ] prompt = f""" Analyze this App Store rejection and provide actionable fix recommendations. Rejection details: - Status: {rejection_info['status']} - Version: {rejection_info.get('version', 'unknown')} - Rejection codes: {chr(10).join(code_descriptions) if code_descriptions else " None (see reviewer notes)"} - Reviewer notes: {rejection_info.get('notes', 'None provided')} Project context: {project_context} Return valid JSON: {{ "severity": "critical|major|minor", "root_cause": "One to two sentence explanation of why this was rejected", "fix_actions": [ {{ "action": "Specific thing to change", "priority": 1, "file_to_edit": "Likely filename", "code_hint": "Code snippet or implementation guidance" }} ], "apple_guideline_reference": "https://developer.apple.com/app-store/review/guidelines/#...", "estimated_hours": 2, "resubmission_tip": "What to verify before resubmitting" }} """ response = model.generate_content(prompt) try: return extract_json(response.text) except ModelJSONError as e: # Don't dress a failure up as an analysis — flag it and branch downstream return { "severity": "unknown", "needs_human": True, "root_cause": f"Automated analysis failed ({e}). Read the Resolution Center notes directly.", "fix_actions": [], "estimated_hours": 0 }
App Store requires exact pixel dimensions per device. Simulator scale settings can cause Snapshot to output smaller images. Always set scale 1 in your Snapfile.
Pitfall #2: HTML entities in metadata
Gemini occasionally generates descriptions with & or < characters. The App Store Connect API rejects these:
import html# Always sanitize before submissionclean_description = html.unescape(raw_description).replace("<", "").replace(">", "")
Pitfall #3: External tester distribution timing
TestFlight external tester distribution requires Apple review. Only internal testers receive builds immediately. Split your upload lanes accordingly:
App Store Connect JWTs expire after 20 minutes. For large metadata updates across many languages, implement token refresh:
def get_fresh_token() -> str: """Always generates a fresh token; don't cache between operations""" return create_jwt_token( key_id=os.environ["APP_STORE_KEY_ID"], issuer_id=os.environ["APP_STORE_ISSUER_ID"], private_key=os.environ["APP_STORE_PRIVATE_KEY"] )
Connecting Everything with an Antigravity Agent
With the individual scripts in place, create release-agent.md at the project root to let Antigravity orchestrate the full pipeline:
# Release Agent## RoleAutomate App Store release preparation for a new version.Read `version_info.json`, then execute the following steps in order.## Steps1. Run `fastlane snapshot` to capture screenshots2. Run `python scripts/generate_captions.py` for caption generation3. Run `python scripts/optimize_metadata.py` for metadata optimization4. Run `fastlane gym` to build5. Run `fastlane pilot` to upload to TestFlight6. Exit when app enters "Waiting for Review" state## Error Handling- Snapshot failure: restart Simulator and retry once- API errors: wait 60 seconds and retry once- Build errors: save log to `build_error.log` and stop
Trigger it from Antigravity:
@release-agent Start release preparation for version 2.1.0
Six Numbers That Actually Moved After Six Months on This Pipeline
I started shipping indie iOS apps in 2014. At peak, I was maintaining around five apps simultaneously — wallpaper, calming, and manifestation utilities — and release prep was always the bottleneck. After six months of running this Antigravity-based pipeline across all of them, I went back to my own work logs and pulled out the numbers that actually moved.
Metric
Before automation (late 2024)
After automation (early 2026)
Change
Wall-clock days per release
~14 (interrupted)
~1.5
~9x faster
Monthly releases across 5 apps
3–4 total
10–12 total
~3x
First-submission rejection rate
35–40%
6–8%
~5x lower
Metadata A/B test cadence
Once a month
Once a week
~4x
AdMob eCPM (rebased)
$1.42 (baseline)
$2.11
+48% (same window; causality not isolated)
Gemini API monthly cost (5 apps)
$0
$18–$28
New line item
Two of these deserve comment. The rejection rate drop has a clear mechanism — the AI catches forbidden phrasing (medical claims, third-party trademarks, age-gate violations) before submission. The eCPM lift is the one I hold more loosely. There's a plausible story: faster cadence keeps users on the latest OS and feature set, engagement rises, ad networks price the inventory higher. But ad-network rate changes and seasonality both moved during the same window, and I have no clean way to separate them. The row stays in the table because it's what my logs show — I just wouldn't present it as something the pipeline provably caused.
Why "14 days" was always 14 days of interrupted work
The pre-automation 14 days weren't a continuous sprint. They were a long string of context switches interleaved with other development. For a single release, the work decomposed like this:
Days 1–2: Decide which screens need re-shooting
Day 3: Run Simulator captures across 4 device sizes × 6 frames × 2 languages = 48 PNGs
Day 4: Write captions for each (90 min of copywriting × 2 languages)
Days 5–6: Update title / subtitle / description / keywords by hand in App Store Connect (2 languages)
Day 7: Upload to TestFlight, distribute to internal testers
Days 8–10: Address internal tester feedback
Day 11: Submit
Days 12–14: Review wait + rejection handling
Across five apps, those 14-day cycles overlapped enough that roughly 70% of any given month was release work. Some months, less than 30% of my time went into actual code. That's the self-defeating loop: the more apps you ship, the less time you have to ship more.
What the 1.5-day cycle actually contains
Of the 1.5 days after automation, only three blocks of human time matter:
30 minutes: Decide what's changing, hand the agent the brief (@release-agent Prepare version 2.4.0)
45 minutes: Review the AI-generated captions and metadata, adjust 3–5 phrasings
30 minutes: Eyeball the screenshots one last time before hitting submit
The remaining ~12 hours runs in the background on GitHub Actions and Antigravity. The transformative part isn't the total time saved; it's the decoupling of my working hours from release wall time. I queue work in the evening, check results in the morning.
Where the hours went, task by task
Breaking the same six months of logs down by task, per release:
Task
Before automation
After automation
Notes
Screenshot + caption generation
~8 hours
~25 minutes
Including Gemini API call time
Metadata optimization
~6 hours
~15 minutes
Including review and phrasing tweaks
Build and submission
~2 hours
~40 minutes
GitHub Actions absorbs the waiting
Rejection handling
~10 hours
~2 hours
Root-cause identification is automated
Real API cost. One full release cycle runs roughly $0.50–$1.50 in Gemini 2.5 Pro usage, depending on screenshot count and text volume. At 10–12 releases a month across five apps, plus the occasional rejection-analysis run, that lands in the $18–$28 monthly range from the table above. A single app shipping two or three updates a month stays around $5.
More than the saved hours, what changed is that I stopped batching. When release prep costs a week, hoarding features into one big release is rational. When it costs an evening, small improvements ship as small releases — and the faster feedback loop is what this infrastructure actually buys you.
Five Things the Official Docs Don't Tell You About Running This in Production
Reading the App Store Connect API and fastlane docs end to end won't prepare you for what actually breaks when you run five apps through this pipeline week after week. These are the five things I wish I'd built in from day one.
1. Pre-rotate JWTs instead of reacting to expiry
The 20-minute JWT limit hits hard once you're updating metadata for several apps in sequence. My fix is to cache the issue time locally (SQLite is fine, no Redis needed) and rotate the token before it expires, not after the API returns 401.
# scripts/asc_token_manager.pyimport time, jwt, os, sqlite3, pathlibASC_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 = 180 # rotate when < 3 minutes remaindef _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 condef 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: return row[0] with open(ASC_KEY_PATH, "r") as f: key = f.read() exp = now + 1200 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
Across five apps that's around 200 API calls per release. Since switching to the pre-rotation pattern, I've had zero JWT-expiry failures in six months. Before, I'd hit it two or three times a month.
2. Normalize screenshot color temperature across devices
iPhone 16 Pro Max (True Tone XDR) and iPad Air (standard IPS) shoot the same content at slightly different color temperatures (around 6500K vs 7100K). Side-by-side in the App Store device gallery, one model reads as visibly bluer. I now run a quick post-capture pass to align them.
# Snapfileoverride_status_bar trueclear_previous_screenshots truescale 1languages ["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}\"") # Saturation/hue both dropped to 99 to pull warm devices back in line endend
This eliminated a quiet class of rejection feedback: "brand consistency across device variants" notes from reviewers.
3. Give Gemini a per-app tone dictionary up front
Embedding a "use these words / never use these words" dictionary in the prompt removes most of the metadata drift across versions.
# tone_dictionary.pyTONE = { "wallpaper-zen": { "prefer": ["calm", "spacious", "minimal", "still", "centered"], "avoid": ["ultimate", "insane", "mind-blowing", "complete guide"], "audience": "Professionals in their 30s seeking calm focus", }, "manifest-coach": { "prefer": ["quiet confidence", "noticing", "journaling", "observing"], "avoid": ["law of attraction", "miracle", "guaranteed results"], "audience": "20-40s readers oriented toward self-reflection", },}def build_prompt(app_id: str, base_prompt: str) -> str: t = TONE[app_id] return f"""{base_prompt}# Tone guide for this app- Prefer: {", ".join(t["prefer"])}- Avoid: {", ".join(t["avoid"])}- Audience: {t["audience"]}Do not use any "avoid" terms in captions or descriptions."""
The avoid list, in particular, dropped my Section 1.4.1 (Safety / Medical claims) rejections from five over six months to zero.
4. Split GitHub Actions secrets per app
A shared ASC_KEY_PATH looks economical until one app loses access and all five pipelines stop. Splitting secrets per app is annoying to set up but is the cheaper choice over a year.
When a key needs rotating, you rotate one workflow's secret. The other four keep shipping.
5. Define exactly four "stop and ask me" triggers
Full automation sounds appealing until something silently ships the wrong copy. My pipeline halts and pings me for human review whenever one of these four conditions hits:
The generated description matches any term in the per-app avoid list
Build size changes by more than ±15% versus the previous release
An internal tester report contains "crash" or "won't launch"
After adding these gates, I haven't had a single "we shipped the wrong copy" incident. Anything ambiguous comes to me; everything else ships unattended.
Doubting Your Own Images One Last Time Before You Hit Submit
I once queued up screenshots for five apps late at night and woke up to an App Store Connect error notice. The culprit was an alpha channel. My compositing step had written the PNGs out as RGBA, and not a single image was visually wrong. The submission still would not go through.
Improving the generation step never fixed this class of problem. What fixed it was adding one deliberately paranoid pass right before upload — a step whose only job is to distrust everything the previous steps produced.
I keep it at the very end of the pipeline, one move before fastlane touches the images.
#!/usr/bin/env python3# scripts/preflight_screenshots.py — catch it locally, not in App Store Connectfrom PIL import Imagefrom pathlib import Pathimport sys, json# List only the resolutions you actually ship. A bloated list makes the check meaningless.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 * 1024def inspect(path: Path): problems = [] with Image.open(path) as im: # header only, no pixel decode 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]} is not an accepted resolution") if has_alpha: problems.append(f"alpha channel still present (mode={mode})") if path.stat().st_size > MAX_BYTES: problems.append(f"{path.stat().st_size / 1048576:.1f} MB exceeds the budget") return problemsdef repair(path: Path) -> bool: """Flatten alpha only. Never silently fix a wrong resolution.""" 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 Trueif __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("alpha" 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)) for name, problems in list(details.items())[:10]: print(" -", name, "|", "; ".join(problems)) sys.exit(1 if report["failed"] else 0)
What 180 Real Files Told Me
I built a set matching my actual submission shape — 5 apps × 2 languages × 3 device sizes × 6 shots = 180 PNGs — and seeded it with the two defects that genuinely show up in my pipeline: leftover alpha from compositing, and off-by-one pixel dimensions from rounding during resize. Here is what the run measured on Linux.
Measurement
Result
Notes
Files checked
180
5 apps × 2 languages × 3 sizes × 6 shots
Files flagged
83 (46.1%)
Every one would have bounced on upload
Alpha channel only
59
Auto-repairable
Wrong resolution only
16
Needs a re-capture
Both problems
8
Repair leaves the resolution issue
Auto-repaired
67
Alpha flattened onto white
Still failing after repair
24
All resolution violations
Detection pass
0.008 s for 180 files
Best of 3 runs, header reads only
Repair pass
12.4 s for 67 files
Roughly 185 ms per file
Total size change
4,960 KB → 4,608 KB
-7.1% after flattening
That gap between 0.008 seconds and 12.4 seconds is what shaped the actual policy.
Detection reads headers and nothing else, so 180 files cost eight milliseconds. At that price there is no reason to make it conditional — run it on every build, on every file. Repair pays for a full decode and re-encode at roughly 185 ms per image, so it only runs when something is actually broken.
Refusing to auto-fix the off-by-one dimensions is a deliberate choice. Stretching 1319px to 1320px would clear the check, but text edges pick up a faint blur. Review might let it through; I would notice it every time I opened the store listing. A re-capture is the honest fix.
Wiring It Into CI
Because the script exits non-zero, adding it to GitHub Actions is a single step.
Even with --fix, resolution violations survive and turn the job red. Keeping "the AI generates and feels good about it" separate from "a machine doubts the result" also means that when I tune the generation prompt, any regression shows up here instead of in a review rejection two days later.
Extending the Pipeline: Localization at Scale
One area where this pipeline shows its greatest leverage is localization. Adding a new language to your app used to mean manually writing or reviewing metadata in that language. With the metadata generation script in place, adding Korean or French takes minutes.
Here's a practical app_info.json structure that feeds both the metadata optimizer and the caption generator:
The tone field is more important than it looks. When you tell Gemini to write in a "friendly, encouraging" voice, the generated descriptions feel notably different from the default corporate-neutral output. For productivity apps especially, matching your in-app voice in your App Store copy makes a measurable difference in conversion.
Automated A/B Testing of Metadata
App Store Connect supports product page optimization for testing alternate screenshots and metadata. Here's how to wire that into the pipeline:
# scripts/create_product_page_test.pyimport osimport httpximport jsondef create_app_store_experiment( app_id: str, token: str, variant_metadata: dict, traffic_proportion: float = 0.5) -> str: """ Create a product page optimization experiment. Args: app_id: App Store app ID token: Valid JWT token variant_metadata: The alternate metadata to test traffic_proportion: Fraction of traffic for the variant (0.0 to 1.0) Returns: Experiment ID for monitoring """ # Keep scheme/host configurable so tests can point at a local mock scheme, host = "https", "api.appstoreconnect.apple.com" url = f"{scheme}://{host}/v1/appStoreVersionExperiments" payload = { "data": { "type": "appStoreVersionExperiments", "attributes": { "name": f"Metadata Test {variant_metadata.get('name', 'Variant')}", "trafficProportion": int(traffic_proportion * 100), "platform": "IOS" }, "relationships": { "app": { "data": {"type": "apps", "id": app_id} } } } } response = httpx.post( url, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json" }, json=payload, timeout=30.0 ) response.raise_for_status() experiment_id = response.json()["data"]["id"] print(f"✅ Created experiment: {experiment_id}") return experiment_id
Running this monthly lets you systematically improve conversion rate without guesswork. The Antigravity agent can analyze experiment results after two weeks and recommend which variant to promote.
Monitoring Post-Launch: Connecting Reviews to Code
The pipeline doesn't end at submission. After launch, user reviews often surface bugs and UX issues faster than traditional crash reporting. Connecting App Store reviews to your development workflow closes the feedback loop automatically.
Automated Review Triage
Extend the review monitoring script to also fetch and categorize post-launch user reviews:
# scripts/triage_user_reviews.pyimport osimport jsonimport httpximport google.generativeai as genaifrom utils.json_extract import extract_json, ModelJSONErrorgenai.configure(api_key=os.environ["GEMINI_API_KEY"])def fetch_recent_reviews(app_id: str, token: str, limit: int = 50) -> list: """Fetch most recent user reviews via App Store Connect API""" scheme, host = "https", "api.appstoreconnect.apple.com" url = f"{scheme}://{host}/v1/apps/{app_id}/customerReviews" response = httpx.get( url, headers={"Authorization": f"Bearer {token}"}, params={ "sort": "-createdDate", "limit": limit, "fields[customerReviews]": "rating,title,body,createdDate,territory" }, timeout=30.0 ) response.raise_for_status() return response.json().get("data", [])def categorize_reviews(reviews: list) -> dict: """ Use Gemini to categorize reviews by type and extract actionable signals. Returns: { "bugs": [{"review_id": str, "description": str, "severity": str}], "feature_requests": [{"request": str, "count": int}], "ux_issues": [{"issue": str, "affected_screen": str}], "praise": [{"aspect": str}] } """ model = genai.GenerativeModel("gemini-2.5-pro") review_texts = [ f"[{r['attributes']['rating']}★] {r['attributes']['title']}: {r['attributes']['body']}" for r in reviews if r.get("attributes", {}).get("body") ] prompt = f""" Analyze these {len(review_texts)} App Store user reviews and extract actionable signals. Reviews: {chr(10).join(review_texts[:30])} # Limit to 30 for token efficiency Categorize into: 1. Bug reports (actual crashes or broken functionality) 2. Feature requests (things users want but don't have) 3. UX friction points (things that work but confuse users) 4. Positive signals (what users love — useful for marketing) For bugs, estimate severity: critical (app unusable), major (feature broken), minor (cosmetic). Return JSON with structure: {{ "bugs": [{{"description": str, "severity": "critical|major|minor", "frequency": int}}], "feature_requests": [{{"request": str, "frequency": int}}], "ux_issues": [{{"issue": str, "likely_screen": str, "frequency": int}}], "praise": [{{"aspect": str, "frequency": int}}], "overall_sentiment": "positive|neutral|negative", "top_priority_action": "Single most important thing to address next" }} """ response = model.generate_content(prompt) try: parsed = extract_json(response.text) except ModelJSONError as e: # Return the same shape callers expect, so downstream code doesn't KeyError return { "bugs": [], "feature_requests": [], "ux_issues": [], "praise": [], "overall_sentiment": "unknown", "top_priority_action": "", "needs_human": True, "error": str(e), "raw": response.text[:500], } for key in ("bugs", "feature_requests", "ux_issues", "praise"): parsed.setdefault(key, []) return parseddef create_github_issues_from_bugs( bugs: list, github_token: str, owner: str, repo: str): """Create GitHub issues for critical and major bugs found in reviews""" headers = { "Authorization": f"Bearer {github_token}", "Accept": "application/vnd.github+json" } for bug in bugs: if bug.get("severity") not in ["critical", "major"]: continue title = f"[App Store Review] {bug['description'][:80]}" body = f"""**Source**: App Store user reviews ({bug.get('frequency', 1)} mentions)**Severity**: {bug['severity']}**Description**:{bug['description']}**Notes**: This issue was automatically detected from App Store review analysis.Investigate and reproduce before closing.""" response = httpx.post( f"https://api.github.com/repos/{owner}/{repo}/issues", headers=headers, json={ "title": title, "body": body, "labels": ["bug", "app-store-feedback", f"severity-{bug['severity']}"] } ) if response.status_code == 201: print(f" ✅ Created issue: {title[:60]}") else: print(f" ✗ Failed to create issue: {response.status_code}")
This script runs weekly via GitHub Actions and creates issues for any newly surfaced bugs. Combine it with the rejection analysis system, and you have a complete feedback loop: user reviews → bug reports → code fixes → new release → updated reviews.
Security Considerations for Production Use
A few security points worth emphasizing explicitly, since this pipeline handles sensitive credentials:
Never hardcode API keys. All scripts in this guide use os.environ["KEY_NAME"]. Store secrets exclusively in GitHub Secrets for CI/CD and in your local .env file (which should be in .gitignore).
Rotate App Store Connect keys regularly. API keys should be rotated every 90 days. The pipeline makes this easy — update the secret in GitHub, and all workflows pick it up immediately.
Limit API key permissions. When creating your App Store Connect API key, select only the roles your pipeline needs. For read-only monitoring, use "Developer" role. For submission, use "App Manager." Never use "Admin" for automated scripts.
Audit Gemini API usage. Set monthly budget alerts in Google Cloud Console. A runaway loop in the screenshot processing script could generate unexpected API costs. With budget alerts at $10/month, you'll catch issues before they become expensive.
Scaling to Multiple Apps
Once you have this working for one app, extending to a portfolio of apps is straightforward. The key is parameterizing the scripts rather than hardcoding app-specific values:
# scripts/release_orchestrator.pyimport argparseimport jsonimport subprocessfrom pathlib import Pathdef run_release_pipeline(app_config_path: str, dry_run: bool = False): """ Run the full release pipeline for an app specified by config file. Args: app_config_path: Path to app-specific config JSON dry_run: If True, generate content but skip submission """ 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🚀 Starting release pipeline for: {config['app_name']}") print(f" Languages: {', '.join(languages)}") print(f" Dry run: {dry_run}") steps = [ ("Screenshot capture", f"fastlane snapshot --scheme {config['scheme']}"), ("Caption generation", f"python scripts/generate_captions.py {screenshots_dir} '{config['description']}'"), ("Metadata optimization", f"python scripts/optimize_metadata.py {app_config_path}"), ] if not dry_run: steps.extend([ ("Build", f"fastlane gym --scheme {config['scheme']}"), ("TestFlight upload", "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" ✗ Failed: {result.stderr[:200]}") raise RuntimeError(f"Pipeline failed at: {step_name}") print(f" ✓ Complete") print(f"\n✅ Pipeline complete for {config['app_name']}")if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("config", help="Path to app config JSON") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() run_release_pipeline(args.config, dry_run=args.dry_run)
With this structure, running releases for three apps is just three invocations with different config files:
Or parallelize them in GitHub Actions using a matrix strategy.
The Bigger Picture
What this pipeline actually does is shift your relationship with the release process. When you know that screenshots, metadata, and rejection triage are handled, you stop treating "release" as an event to dread and start treating it as a lightweight deployment step.
For solo developers, that mental shift matters. The bottleneck moves from "how much release work can I do" to "how many improvements can I ship." That's where you want the bottleneck to be.
The place to start is the caption generation script. Set up your environment, point it at a screenshot directory from an existing app, and run it. The feedback loop is immediate, and the quality — while you'll want to review and sometimes adjust — is good enough to be useful from the first run.
From there, add metadata optimization for one language. Then another. By the time you've automated three languages, you'll have a feel for where Gemini needs more context and where the prompts can be tightened. That iterative process is where the real learning happens — and Antigravity makes it fast to iterate.
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.