Automating the "Last Mile" of App Releases
For most app developers, release day is not a celebration—it is a checklist. Bump the version number across multiple files, write release notes in two or more languages, update screenshots for every device size, upload the build to TestFlight, wait for processing, submit for review... Each step is routine, yet any one of them can introduce a mistake that delays the release or confuses your users.
In 2026, combining Claude Code with the App Store Connect API makes it possible to automate nearly all of these steps. This guide walks through building a production-ready release pipeline with real, working code. By the end, pushing a release/v1.x.x branch to GitHub will trigger the entire release sequence automatically.
This guide assumes familiarity with Claude Code basics, Xcode, and terminal-based iOS development. Experience with Fastlane is helpful but not required—Claude Code will help you write the configuration files. For Xcode-specific Claude Code workflows, see the Claude Code × Xcode iOS Development Guide. For a primer on GitHub Actions integration, see Claude Code × GitHub Actions Complete Guide.
Understanding the Full Architecture
Before diving into individual components, it helps to see the full picture of what we are building.
The pipeline works like this: a git push origin release/v1.x.x (or creating a GitHub tag) triggers Claude Code to update version numbers across Info.plist and build.gradle. Next, the Claude API analyzes commit history and generates bilingual release notes. Fastlane then launches the Simulator, captures screenshots, and handles localization before uploading the build to TestFlight. A Slack notification alerts testers, who review and approve before the build proceeds to App Store review submission.
Each of these steps can function independently, so you can adopt them incrementally. Let us build from the foundation up.
Setting Up App Store Connect API Authentication
Generating Your API Key
The App Store Connect API requires a p8 private key issued through the Apple Developer portal. Here is how to generate one.
Log in to App Store Connect and navigate to Users and Access, then Integrations, then App Store Connect API. Click the plus button to add a new key, name it something like Release Automation, and set the access level to App Manager. Download the .p8 file immediately—this can only be downloaded once. Note your Issuer ID and Key ID from the portal.
Store these credentials as environment variables, never in source control:
# .env.local (keep this out of Git)
APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX
APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
APP_STORE_CONNECT_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8Installing and Initializing Fastlane
Fastlane is a Ruby-based automation toolkit that wraps the App Store Connect API in a developer-friendly interface.
# Install Fastlane
gem install fastlane
# Initialize in your project root
cd /path/to/YourApp
fastlane initAfter initialization, configure your Appfile with your credentials:
# fastlane/Appfile
app_identifier "com.example.yourapp"
apple_id "developer@example.com"
# Use App Store Connect API (recommended over username/password auth)
api_key_path "./fastlane/app_store_connect_api_key.json"The API key JSON file should look like this. Store it outside of your repository and reference it via environment variables in CI:
{
"key_id": "YOUR_KEY_ID",
"issuer_id": "YOUR_ISSUER_ID",
"key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
}Automating Version Management with Claude Code
Version bumps are deceptively simple but error-prone. The version number lives in multiple places—Info.plist, build.gradle, package.json for React Native projects—and keeping them synchronized manually invites mistakes.
Configure CLAUDE.md in your project root to give Claude Code a clear contract for version management:
## Version Management
Version numbers must be kept in sync across these four locations:
1. `ios/YourApp/Info.plist` — CFBundleShortVersionString and CFBundleVersion
2. `android/app/build.gradle` — versionName and versionCode
3. `package.json` — version field (React Native projects)
4. `CHANGELOG.md` — latest entry header
When bumping versions, always update all four locations in a single commit.
Use the commit message format: chore: bump version to X.Y.Z (build NNN)
The versionCode / build number must always increment by 1 from the previous value.With this configuration in place, Claude Code handles version bumps from a single instruction:
claude "Please update the version to 2.3.0 (build 231).
Follow the instructions in CLAUDE.md and update all four locations in one commit."Claude Code reads the current values, calculates the next build number, updates all files consistently, and creates a properly formatted commit. This prevents the common mistake of incrementing the version string but forgetting the build number—or updating iOS but leaving Android behind.
AI-Generated Release Notes with the Claude API
Release notes are a communication tool—a way to translate technical changes into user-facing value. Writing them thoughtfully in two languages takes time that most developers do not have on release day.
The following script pulls your commit history, sends it to the Claude API, and outputs formatted release notes into the Fastlane metadata directories.
#!/usr/bin/env python3
# scripts/generate_release_notes.py
import subprocess
import json
import os
import anthropic
def get_commits_since_last_tag():
"""Retrieve commits since the last release tag."""
try:
last_tag = subprocess.check_output(
["git", "describe", "--tags", "--abbrev=0", "HEAD^"],
text=True
).strip()
commits = subprocess.check_output(
["git", "log", f"{last_tag}..HEAD", "--oneline", "--no-merges"],
text=True
).strip()
return last_tag, commits
except subprocess.CalledProcessError:
# Fallback if no previous tags exist
commits = subprocess.check_output(
["git", "log", "--oneline", "--no-merges", "-20"],
text=True
).strip()
return "v0.0.0", commits
def generate_release_notes(commits, version, app_name):
"""Use the Claude API to generate bilingual release notes."""
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
prompt = f"""You are writing release notes for the mobile app "{app_name}" (version {version}).
Based on the commit history below, write App Store release notes in both Japanese and English.
# Commit History
{commits}
# Output Format (JSON only, no other text)
{{
"ja": {{
"whats_new": "What is new in Japanese (under 200 characters, user-focused, warm tone)"
}},
"en": {{
"whats_new": "What is new in English (under 200 characters, user-focused, warm tone)"
}}
}}
Rules:
- Skip technical commits (chore:, refactor:, ci:, docs:, test:) unless they have user impact
- Focus on user-visible changes and improvements
- Use friendly, accessible language — not developer jargon
- If there are no user-visible changes, write about stability improvements
- Keep each version under 200 characters to fit App Store display limits
"""
message = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}]
)
content = message.content[0].text
json_start = content.find("{")
json_end = content.rfind("}") + 1
return json.loads(content[json_start:json_end])
def main():
app_name = os.environ.get("APP_NAME", "MyApp")
version = os.environ.get("APP_VERSION", "1.0.0")
last_tag, commits = get_commits_since_last_tag()
print(f"Last tag: {last_tag}")
print(f"Commits to summarize: {len(commits.splitlines())}")
release_notes = generate_release_notes(commits, version, app_name)
# Write to Fastlane metadata directories
ja_dir = "fastlane/metadata/ja"
en_dir = "fastlane/metadata/en-US"
os.makedirs(ja_dir, exist_ok=True)
os.makedirs(en_dir, exist_ok=True)
with open(f"{ja_dir}/release_notes.txt", "w") as f:
f.write(release_notes["ja"]["whats_new"])
with open(f"{en_dir}/release_notes.txt", "w") as f:
f.write(release_notes["en"]["whats_new"])
print("Release notes generated successfully")
print(f"JA: {release_notes['ja']['whats_new']}")
print(f"EN: {release_notes['en']['whats_new']}")
if __name__ == "__main__":
main()Sample output from a real run:
Last tag: v2.2.1
Commits to summarize: 12
Release notes generated successfully
JA: ホーム画面の動作を改善し、検索の精度を向上させました。一部のユーザーに影響していたクラッシュ問題も修正済みです。
EN: We improved the home screen experience and enhanced search accuracy. Fixed a crash affecting some users.
The script writes directly into the directory structure that fastlane deliver expects, so no manual file management is required between note generation and upload.
Automating Screenshots with Fastlane Snapshot
App Store screenshots must cover multiple device sizes (iPhone 6.7-inch, 6.5-inch, 5.5-inch, and iPad Pro) across multiple languages. Maintaining these manually across releases is one of the highest-effort, lowest-value tasks in the release cycle.
Fastlane's snapshot tool integrates with Xcode UI Tests to automate screenshot generation entirely.
Configuring Snapfile
# fastlane/Snapfile
devices([
"iPhone 16 Pro Max",
"iPhone 16",
"iPhone SE (3rd generation)",
"iPad Pro 13-inch (M4)"
])
languages([
"ja",
"en-US"
])
scheme "YourAppUITests"
output_directory "./fastlane/screenshots"
clear_previous_screenshots trueWriting the Screenshot UI Tests
// YourAppUITests/SnapshotTests.swift
import XCTest
class SnapshotTests: XCTestCase {
func testCaptureScreenshots() throws {
let app = XCUIApplication()
setupSnapshot(app)
app.launch()
// Capture the home screen
snapshot("01_HomeScreen")
// Navigate to search and capture
app.tabBars.buttons.matching(identifier: "search").firstMatch.tap()
snapshot("02_SearchScreen")
// Navigate to settings and capture
app.tabBars.buttons.matching(identifier: "settings").firstMatch.tap()
snapshot("03_SettingsScreen")
}
}Once these tests are in place, running fastlane snapshot regenerates all screenshots automatically for every device and language combination. Claude Code is particularly helpful when you add new screens—tell it to add a screenshot for the new onboarding screen and it extends the test file following the existing pattern.
Automating TestFlight Distribution
With screenshots and release notes ready, the final Fastlane piece is uploading the build.
# fastlane/Fastfile
platform :ios do
desc "Build and distribute to TestFlight beta testers"
lane :beta do
version = ENV["APP_VERSION"] || get_version_number
build_num = ENV["BUILD_NUMBER"] || get_build_number
# Build the app for release
gym(
scheme: "YourApp",
configuration: "Release",
export_method: "app-store",
output_directory: "./build",
output_name: "YourApp.ipa"
)
# Upload to TestFlight
pilot(
ipa: "./build/YourApp.ipa",
skip_waiting_for_build_processing: false,
distribute_external: true,
groups: ["Beta Testers"],
changelog: File.read("fastlane/metadata/en-US/release_notes.txt"),
notify_external_testers: true
)
# Notify the team on Slack
slack(
message: "TestFlight build #{version} (#{build_num}) is live and ready for review",
slack_url: ENV["SLACK_WEBHOOK_URL"],
success: true
)
end
desc "Submit to App Store review"
lane :release do
# Capture fresh screenshots before submitting
snapshot
# Upload metadata, screenshots, and submit for review
deliver(
submit_for_review: true,
automatic_release: false,
force: true,
metadata_path: "./fastlane/metadata",
screenshots_path: "./fastlane/screenshots"
)
puts "Submitted for review. Apple typically responds within 24 to 48 hours."
end
endRunning fastlane beta from the terminal now handles the full build-and-distribute cycle automatically. The release lane adds screenshot capture and App Store metadata upload on top.
Full CI/CD Pipeline with GitHub Actions
The final step connects everything through GitHub Actions so the pipeline runs automatically on branch push.
# .github/workflows/release.yml
name: Release Pipeline
on:
push:
branches:
- 'release/*'
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., 2.3.0)'
required: true
jobs:
release:
name: Build and Deploy to TestFlight
runs-on: macos-15
steps:
- name: Checkout with full history for release notes
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Ruby and Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install Python dependencies
run: pip install anthropic
- name: Extract version from branch name
id: version
run: |
BRANCH="${{ github.ref_name }}"
VERSION=$(echo "$BRANCH" | sed 's/release\///')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Generate AI release notes
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
APP_VERSION: ${{ steps.version.outputs.version }}
APP_NAME: "YourApp"
run: python scripts/generate_release_notes.py
- name: Import code signing certificates
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.IOS_P12_BASE64 }}
p12-password: ${{ secrets.IOS_P12_PASSWORD }}
- name: Run Fastlane beta lane
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
APP_VERSION: ${{ steps.version.outputs.version }}
BUILD_NUMBER: ${{ github.run_number }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: bundle exec fastlane betaWith this workflow in place, pushing release/v2.3.0 to GitHub automatically generates bilingual release notes from commit history using Claude AI, builds the iOS app and uploads it to TestFlight, and notifies your team via Slack—all without human intervention.
Extending the Pipeline to Android with Google Play
The same pipeline architecture applies to Android apps through Fastlane's supply plugin, which communicates with the Google Play Developer API. Setting up Android automation requires a service account rather than a p8 key, but the overall structure is identical.
Creating a Google Play Service Account
To authenticate with the Google Play API, you need a Google service account with the appropriate permissions.
In the Google Cloud Console, create a new project (or use your existing one) and enable the Google Play Android Developer API. Create a service account under IAM and Admin, assign it the Editor role, and download the JSON key file. In the Google Play Console, go to Setup, then API access, and grant the service account access to your app with the Release Manager role.
Store the JSON key content as a GitHub Secret called GOOGLE_PLAY_JSON_KEY_DATA. Never commit it to your repository.
Android Fastlane Configuration
# fastlane/Fastfile (Android section)
platform :android do
desc "Build and upload to Google Play internal testing"
lane :beta do
version = ENV["APP_VERSION"] || android_get_version_name
build_num = ENV["BUILD_NUMBER"] || android_get_version_code.to_i
# Build a signed release APK or AAB
gradle(
task: "bundle",
build_type: "Release",
project_dir: "android/"
)
# Upload to Google Play internal testing track
supply(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
json_key_data: ENV["GOOGLE_PLAY_JSON_KEY_DATA"],
skip_upload_metadata: false,
metadata_path: "./fastlane/metadata/android"
)
# Notify the team
slack(
message: "Google Play internal build #{version} (#{build_num}) uploaded",
slack_url: ENV["SLACK_WEBHOOK_URL"],
success: true
)
end
endAndroid Release Notes Structure
The release notes generated by the Python script can be written to the Android metadata path:
# Add to generate_release_notes.py
def write_android_release_notes(release_notes, version_code):
"""Write release notes to the Android metadata structure."""
android_ja = f"fastlane/metadata/android/ja-JP/changelogs/{version_code}.txt"
android_en = f"fastlane/metadata/android/en-US/changelogs/{version_code}.txt"
os.makedirs(os.path.dirname(android_ja), exist_ok=True)
os.makedirs(os.path.dirname(android_en), exist_ok=True)
with open(android_ja, "w") as f:
f.write(release_notes["ja"]["whats_new"])
with open(android_en, "w") as f:
f.write(release_notes["en"]["whats_new"])
print(f"Android release notes written for version code {version_code}")With both platforms covered, a single run of the Python script generates release notes for iOS and Android simultaneously.
Advanced Pattern: Multi-Environment Release Management
Production apps typically need more than a single release track. You might want an internal build, a beta track for trusted testers, and a phased rollout to production. Here is how to structure a Fastfile that handles all three environments cleanly.
# fastlane/Fastfile (multi-environment)
platform :ios do
# Shared configuration applied to all lanes
before_all do |lane|
# Ensure the workspace is clean before building
ensure_git_status_clean unless lane == :fix
end
desc "Internal development build"
lane :internal do
gym(scheme: "YourApp-Dev", configuration: "Debug", export_method: "development")
pilot(distribute_external: false, groups: ["Internal Team"])
end
desc "Beta distribution to external testers"
lane :beta do
gym(scheme: "YourApp", configuration: "Release", export_method: "app-store")
pilot(
distribute_external: true,
groups: ["Beta Testers"],
changelog: File.read("fastlane/metadata/en-US/release_notes.txt")
)
slack(message: "Beta build ready for external testers", slack_url: ENV["SLACK_WEBHOOK_URL"])
end
desc "Production release with phased rollout"
lane :production do
# Run screenshot capture before production release
snapshot
# Deliver with phased release (10% rollout initially)
deliver(
submit_for_review: true,
phased_release: true,
automatic_release: false
)
end
endThis structure means your team can run fastlane internal for a quick dev build, fastlane beta for a customer-facing test, and fastlane production when you are ready to ship—each with appropriate settings and notifications.
Intelligent Release Note Customization
The base script generates general release notes from commits, but you can make the output significantly more useful with targeted prompting.
Categorizing Changes by Type
Rather than a single paragraph, structured notes with distinct sections for new features, improvements, and bug fixes communicate more clearly to users:
def generate_structured_release_notes(commits, version, app_name):
"""Generate release notes with explicit feature/fix sections."""
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
prompt = f"""Write App Store release notes for "{app_name}" version {version}.
Commits since last release:
{commits}
Output JSON with this structure:
{{
"ja": {{
"summary": "One sentence summary in Japanese (under 100 chars)",
"new_features": ["Feature 1 in Japanese", "Feature 2"],
"improvements": ["Improvement 1", "Improvement 2"],
"bug_fixes": ["Fix 1", "Fix 2"]
}},
"en": {{
"summary": "One sentence summary in English (under 100 chars)",
"new_features": ["Feature 1", "Feature 2"],
"improvements": ["Improvement 1", "Improvement 2"],
"bug_fixes": ["Fix 1", "Fix 2"]
}}
}}
Only include sections that have actual changes.
Skip sections with no relevant commits.
Use user-friendly language, not commit message syntax.
"""
message = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": prompt}]
)
content = message.content[0].text
json_start = content.find("{")
json_end = content.rfind("}") + 1
return json.loads(content[json_start:json_end])
def format_structured_notes(notes_data, locale="en"):
"""Format structured notes into App Store-compatible text."""
parts = []
data = notes_data[locale]
if data.get("summary"):
parts.append(data["summary"])
parts.append("")
if data.get("new_features"):
parts.append("What's New:" if locale == "en" else "新機能:")
for feature in data["new_features"]:
parts.append(f"- {feature}")
parts.append("")
if data.get("improvements"):
parts.append("Improvements:" if locale == "en" else "改善:")
for item in data["improvements"]:
parts.append(f"- {item}")
parts.append("")
if data.get("bug_fixes"):
parts.append("Bug Fixes:" if locale == "en" else "バグ修正:")
for fix in data["bug_fixes"]:
parts.append(f"- {fix}")
return "\n".join(parts).strip()This structured format produces release notes that users actually read, rather than a wall of text that gets skimmed past.
Tone Customization by App Category
The tone appropriate for a productivity app is different from that of a casual game or a health app. You can configure the prompt per project by adding a release_notes_tone.txt file to your project:
# release_notes_tone.txt
Tone: Warm and encouraging, like talking to a friend.
Avoid: Technical jargon, passive voice, negative framing.
Emphasize: How changes make the user's day better.
App category: Productivity
Target user: Freelancers and solo creators
Read this file in your script and pass it to the Claude prompt as additional context. This small addition dramatically improves the consistency and quality of AI-generated notes across multiple developers on the same team.
Common Errors and How to Fix Them
No profiles for 'com.example.yourapp' were found
This is the most common failure point in iOS CI pipelines and is caused by code signing configuration. The most reliable fix is using Fastlane match to centralize certificate management in a private repository or S3 bucket. Run fastlane match appstore --readonly on your CI machine to sync certificates without modifying them.
Invalid JWT token from the App Store Connect API
Either the API key has been revoked, the Issuer ID is incorrect, or the p8 file content has formatting issues. Private key content stored in environment variables often loses newlines during transfer. Normalize them at runtime:
# Normalize newlines when reading key content from environment variables
key_content = os.environ["APP_STORE_CONNECT_KEY_CONTENT"].replace("\\n", "\n")Xcode simulator not found during snapshot
The device name in Snapfile does not match any installed Simulator on the CI runner. Run xcrun simctl list devices on the runner to see what is available, and update the devices array to match. GitHub Actions macOS runners ship with specific Xcode versions—pin your runner to a known macOS version to avoid unexpected device list changes.
Changelog is too long on TestFlight upload
The what_to_test field has a 4,000-character limit. Add an explicit constraint to the Claude prompt, such as "Keep the response under 400 characters." For App Store release_notes.txt, the same limit applies but displayed text is typically much shorter in practice.
Build already exists on re-run
This happens when re-running a workflow without incrementing the build number. Use the GitHub Actions run number as the build number so it is always unique: BUILD_NUMBER: ${{ github.run_number }}.
Rollback Strategy and Release Safety Nets
Automation is powerful but increases the risk of shipping a mistake quickly. Building rollback mechanisms and safety nets into your pipeline is not optional—it is what separates a production-grade pipeline from a fragile script.
Version Control Tagging as a Safety Net
Every successful TestFlight upload should create a corresponding Git tag. This gives you a precise checkpoint to roll back to if a problem is discovered after upload.
# Add to .github/workflows/release.yml after the Fastlane step
- name: Tag the release commit on success
if: success()
run: |
git tag "v${{ steps.version.outputs.version }}-build-${{ github.run_number }}"
git push origin "v${{ steps.version.outputs.version }}-build-${{ github.run_number }}"If a critical bug is discovered after TestFlight distribution but before App Store submission, you can check out the previous tag, create a hotfix branch, and release a patched build without disrupting the main release branch.
Automated Pre-Upload Validation
Before uploading to TestFlight, add a validation step that catches common mistakes automatically.
#!/usr/bin/env python3
# scripts/validate_release.py
import os
import re
import sys
def check_release_notes_length():
"""Verify release notes are within App Store limits."""
for locale, path in [("ja", "fastlane/metadata/ja/release_notes.txt"),
("en-US", "fastlane/metadata/en-US/release_notes.txt")]:
if not os.path.exists(path):
print(f"FAIL: Missing release notes for {locale}")
return False
with open(path) as f:
content = f.read()
if len(content) > 4000:
print(f"FAIL: {locale} release notes exceed 4000 characters ({len(content)} chars)")
return False
if len(content) < 10:
print(f"FAIL: {locale} release notes appear empty")
return False
return True
def check_version_consistency():
"""Check that iOS and Android version numbers match."""
version = os.environ.get("APP_VERSION", "")
if not re.match(r'^\d+\.\d+\.\d+$', version):
print(f"FAIL: APP_VERSION '{version}' does not match X.Y.Z format")
return False
return True
def main():
checks = [
("Release notes length", check_release_notes_length),
("Version format", check_version_consistency),
]
failed = []
for name, check in checks:
if check():
print(f"PASS: {name}")
else:
failed.append(name)
if failed:
print(f"\nValidation failed: {', '.join(failed)}")
sys.exit(1)
else:
print("\nAll pre-release checks passed.")
if __name__ == "__main__":
main()Add this validation step to the GitHub Actions workflow before the Fastlane step. A failed validation aborts the pipeline and posts a clear error message to the Actions log, rather than letting a malformed build reach TestFlight.
Staged Rollout Strategy
For apps with a large user base, use phased rollout in App Store Connect to limit the blast radius of any issue. The deliver command supports this natively:
deliver(
submit_for_review: true,
phased_release: true, # Enables staged rollout (7% → 100% over 7 days)
automatic_release: false # Keep manual release control
)Pair staged rollout with automated crash rate monitoring (via Firebase Crashlytics or Datadog). If crash rates spike in the first phase, pause the rollout manually through App Store Connect before it reaches the full user base.
Wrapping Up
The release pipeline described in this guide transforms a multi-hour manual process into something that runs in the background while you focus on building.
The highest-leverage pieces are the AI release notes generator, which eliminates the blank-page problem and the dual-language requirement in a single step; screenshot automation, which removes one of the most repetitive tasks in mobile development once written as UI tests; and the GitHub Actions integration, which makes the release process consistent, documented, and auditable by default.
Start with the release notes generator. It is the easiest component to add to an existing workflow and delivers immediate value from the first release. Then layer in screenshot automation and the full pipeline as your confidence in the automation grows.