10.10 — Zero-Touch Automated Deployment
Opening scenario
You’re on vacation. Your phone lights up: a customer found a critical typo in the checkout flow. Your colleague pushes a one-character fix, opens a PR, your bot approves it (lint passes, tests green), the merge to main triggers CI which: bumps the patch version, archives, signs, uploads to App Store Connect, fills in metadata, submits for review, and pings Slack. You glance at your watch, see “✅ v2.4.7 submitted to App Store”, and go back to the beach.
This is what zero-touch deployment looks like in 2026. Every Xcode menu click that ships a binary is a place where the wrong person, wrong day, wrong action breaks production. Removing them is engineering work.
Context taxonomy
| Stage | Manual version | Automated version | Tool |
|---|---|---|---|
| Version bump | Edit MARKETING_VERSION in Xcode | agvtool new-marketing-version 1.2.3 | agvtool / PlistBuddy |
| Build number bump | Edit CURRENT_PROJECT_VERSION | agvtool new-version -all 42 | agvtool |
| Archive | Xcode → Product → Archive | xcodebuild archive | xcodebuild |
| Export IPA | Organizer → Distribute App | xcodebuild -exportArchive | xcodebuild |
| Upload | Organizer → Upload | xcrun altool (legacy) / xcrun notarytool, Transporter, pilot | notarytool / Transporter / Fastlane |
| Metadata update | App Store Connect UI | App Store Connect REST API | curl / fastlane deliver |
| Submit for review | App Store Connect → Submit | REST API POST /appStoreVersionSubmissions | REST API / Fastlane |
| Release | App Store Connect → Release | API or automatic_release: true | Fastlane |
| Notify | Slack message manually | Webhook in CI | curl |
Concept → Why → How → Code
Concept. Every artifact between commit and “Live in App Store” is producible by a CLI tool. Tie them together in a pipeline.
Why. Every manual step adds latency, requires a person, and introduces variance. Zero-touch deployment turns shipping from a weekly ritual into a commodity.
The full CLI toolchain
# Version manipulation
agvtool new-marketing-version 2.5.0
agvtool new-version -all 142
# Or via PlistBuddy for project formats that resist agvtool
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 2.5.0" Acme/Info.plist
# Archive
xcodebuild archive \
-workspace Acme.xcworkspace \
-scheme Acme \
-configuration Release \
-archivePath build/Acme.xcarchive \
-allowProvisioningUpdates
# Export IPA
cat > build/ExportOptions.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>method</key><string>app-store</string>
<key>uploadBitcode</key><false/>
<key>uploadSymbols</key><true/>
<key>signingStyle</key><string>manual</string>
<key>provisioningProfiles</key><dict>
<key>com.acme.notes</key><string>match AppStore com.acme.notes</string>
</dict></dict></plist>
EOF
xcodebuild -exportArchive \
-archivePath build/Acme.xcarchive \
-exportPath build/ \
-exportOptionsPlist build/ExportOptions.plist
# Notarize a Mac app
xcrun notarytool submit build/AcmeMac.dmg \
--key fastlane/AuthKey.p8 \
--key-id "$ASC_KEY_ID" \
--issuer "$ASC_ISSUER_ID" \
--wait
# Upload an iOS IPA via Transporter
xcrun iTMSTransporter -m upload \
-assetFile build/Acme.ipa \
-apiKey "$ASC_KEY_ID" \
-apiIssuer "$ASC_ISSUER_ID"
App Store Connect REST API — change pricing without UI
# Generate ASC JWT (10-min expiry, ES256)
TOKEN=$(asc-jwt --key-id "$ASC_KEY_ID" --issuer-id "$ASC_ISSUER_ID" --key "$(cat AuthKey.p8)")
APP_ID=1234567890
# Read current US price point for tier 8
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/appPricePoints?filter[priceTier]=8&filter[territory]=USA" \
| jq '.data[0].attributes.customerPrice'
# Schedule a new price tier effective immediately, USD territory
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://api.appstoreconnect.apple.com/v2/appPriceSchedules \
-d @<(cat <<EOF
{
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": { "data": { "type": "apps", "id": "$APP_ID" } },
"manualPrices": { "data": [{ "type": "appPrices", "id": "manual-price-id" }] },
"baseTerritory":{ "data": { "type": "territories", "id": "USA" } }
}
},
"included": [
{
"type": "appPrices",
"id": "manual-price-id",
"attributes": { "startDate": null },
"relationships": {
"appPricePoint": { "data": { "type": "appPricePoints", "id": "<tier-point-id>" } },
"territory": { "data": { "type": "territories", "id": "USA" } }
}
}
]
}
EOF
)
Complete zero-touch Fastfile
# fastlane/Fastfile — git push tag v*.*.* → App Store submission
default_platform :ios
platform :ios do
desc "Tag-triggered release: v*.*.* → App Store"
lane :tag_release do
# 1. Validate we're on a tag
UI.user_error!("Not on a tag commit") unless ENV["GITHUB_REF"]&.start_with?("refs/tags/v")
version = ENV["GITHUB_REF"].sub("refs/tags/v", "")
# 2. Set marketing version from tag
increment_version_number(version_number: version)
# 3. Build number = CI run number for uniqueness + traceability
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"])
# 4. Sign + archive
setup_ci
match(type: "appstore", readonly: true)
build_app(
scheme: "Acme",
export_method: "app-store",
output_directory: "build",
output_name: "Acme.ipa"
)
# 5. Upload + submit + release automatically
upload_to_app_store(
api_key_path: "fastlane/AuthKey.json",
ipa: "build/Acme.ipa",
skip_screenshots: true, # metadata only
skip_metadata: false,
force: true,
submit_for_review: true,
automatic_release: true,
phased_release: true,
release_notes: read_release_notes(version),
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false,
export_compliance_encryption_updated: false
}
)
# 6. Notify
slack(message: "🚀 v#{version} (build #{ENV['GITHUB_RUN_NUMBER']}) submitted to App Store")
end
def read_release_notes(version)
notes = {}
Dir.glob("metadata/*/release_notes.txt").each do |path|
locale = path.split("/")[-2]
notes[locale] = File.read(path)
end
notes
end
end
GitHub Actions workflow that triggers it
# .github/workflows/release.yml
name: Release on Tag
on:
push:
tags: ['v*.*.*']
jobs:
release:
runs-on: macos-15
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -switch /Applications/Xcode_16.0.app
- run: gem install fastlane -NV --no-document
- name: Decode API key
env: { ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }} }
run: |
mkdir -p fastlane
echo "$ASC_KEY_BASE64" | base64 --decode > fastlane/AuthKey.p8
- name: Release
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane tag_release
The release ritual (now a ritual of one)
git tag v2.5.0 -m "Checkout bug fix"
git push origin v2.5.0
# ... 8 minutes later, Slack notification fires; App Store review starts ...
In the wild
- Shopify runs near-zero-touch releases for their consumer apps — a tag is enough to trigger the full pipeline through to App Store submission.
- Apple’s own teams (per WWDC talks) push code to internal CI that handles signing, archive, and TestFlight without engineer interaction.
- Many crypto exchanges run zero-touch to TestFlight only, with the final “Submit for Review” gated behind a 2-of-3 multisig approval to comply with audit requirements.
- Open-source apps like Mastodon’s iOS client publish releases entirely via tag pushes, no manual App Store Connect interaction.
Common misconceptions
- “Zero-touch means risky.” It’s the opposite — every manual step is a source of human error. Automation enforces consistency, audit logs every action, and is rollback-able.
- “App Store review can be automated.” Submission can, but Apple’s review takes 1–48 hours of human (and ML) effort that no API affects.
- “
automatic_release: truereleases the moment review passes.” Yes — be intentional. For high-risk releases, setfalseand gate manual release with a Slack approval bot. - “You still need to log into App Store Connect for screenshots.” No —
fastlane deliver(nowupload_to_app_store) syncs screenshots, descriptions, keywords, age rating, everything. - “Phased release prevents review issues.” Phased release controls post-approval rollout. It doesn’t affect review timelines.
Seasoned engineer’s take
The goal isn’t to remove humans — it’s to put them at the right decision points.
| What humans should still decide | What machines should always do |
|---|---|
| Should we ship this version? | Bump version, build, sign, archive |
| What’s in the release notes? | Format, localize, upload notes |
| Approve cert/passphrase rotations | Apply the rotation |
| Authorize the actual “Release to users” toggle (sometimes) | Submit for review, upload metadata |
TIP. Build the pipeline incrementally. Start by automating just
xcodebuild archive. Then add upload. Then metadata. Then submission. Each layer should be reliable for two weeks before adding the next.
WARNING. A zero-touch pipeline that ships every
mainpush will eventually ship a regression. Always have a 24h soak in TestFlight before App Store submission — maketag_releasethe only path to production, not everymainmerge.
The transformative effect: shipping becomes uneventful. When release is a 15-second action, you ship more often, and small frequent releases have smaller blast radius than big quarterly ones. The first-order improvement is engineer time; the second-order improvement is product velocity.
Interview corner
Junior — “What’s the minimum CLI you need to ship an app?” xcodebuild archive, xcodebuild -exportArchive, then either xcrun iTMSTransporter or xcrun altool (deprecated) to upload. Plus an App Store Connect API key for auth.
Mid — “Walk me through automating a release from a Git tag.” Tag triggers CI; CI checks out, installs Fastlane, decodes API key, syncs certs via match, sets version from tag, builds + signs + archives, uploads via upload_to_app_store, submits for review, fires Slack notification. About 30 lines of Fastfile plus a small workflow YAML.
Senior — “What guardrails would you add to a zero-touch pipeline shipping to App Store on every main merge?” Mandatory 24h TestFlight bake before App Store submission (separate pipeline triggered by tag, not main); coverage + lint gates that block merge; required reviewers on main; runbook for emergency rollback (App Store Connect: “Remove from sale”); on-call rotation; metrics dashboard tracking submission → approval time; alerting on consecutive review rejections.
Red flag — “We have zero-touch but only one engineer knows the secrets.” That’s a bus factor of 1. Secrets should live in a shared password manager + CI; the runbook should let any senior engineer rotate and recover.
Lab preview
Lab 10.4 is exactly the workflow above — tag-triggered, end-to-end, with realistic guardrails — built from scratch on a sample repo.