Lab 10.4 — Zero-Touch Pipeline
Goal: a fully automated pipeline where git tag v1.2.3 && git push --tags is the only human action between “code committed” and “App Store submission”. Plus guardrails: pre-submission validation, soak period, approval gate.
Time: 120–240 minutes.
Prereqs: Lab 10.3 complete and working.
Setup
Continue from Lab 10.3. We’ll add:
- A pre-submission validator
- A 24-hour TestFlight soak before App Store submission
- A required-reviewer approval gate
- An emergency rollback runbook
- Symbolicated crash report uploading
Build
Step 1 — Pre-submission validator
scripts/validate-release.sh:
#!/bin/sh
set -euo pipefail
echo "🔍 Pre-submission validation"
# 1. Privacy plist exists and is non-empty
PRIVACY_FILE="FastlaneLab/PrivacyInfo.xcprivacy"
if [ ! -s "$PRIVACY_FILE" ]; then
echo "❌ Missing or empty $PRIVACY_FILE"
exit 1
fi
echo "✓ Privacy manifest present"
# 2. Required Info.plist keys
INFO="FastlaneLab/Info.plist"
for key in CFBundleShortVersionString CFBundleVersion ITSAppUsesNonExemptEncryption; do
if ! /usr/libexec/PlistBuddy -c "Print :$key" "$INFO" >/dev/null 2>&1; then
echo "❌ Missing Info.plist key: $key"
exit 1
fi
done
echo "✓ Required Info.plist keys present"
# 3. Demo account uptime check
DEMO_LOGIN_URL="${DEMO_LOGIN_URL:-https://api.acme.com/health}"
if [ "$(curl -s -o /dev/null -w '%{http_code}' "$DEMO_LOGIN_URL")" != "200" ]; then
echo "❌ Demo API unhealthy: $DEMO_LOGIN_URL"
exit 1
fi
echo "✓ Demo API healthy"
# 4. Release notes exist for primary locale
NOTES="fastlane/metadata/en-US/release_notes.txt"
if [ ! -s "$NOTES" ]; then
echo "❌ Missing $NOTES"
exit 1
fi
echo "✓ Release notes present"
# 5. Tag matches semver
TAG="${GITHUB_REF#refs/tags/}"
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Tag must match v<MAJOR>.<MINOR>.<PATCH>, got: $TAG"
exit 1
fi
echo "✓ Tag $TAG is valid semver"
echo "✅ All pre-submission checks passed"
chmod +x scripts/validate-release.sh
Step 2 — Three-stage pipeline
Replace .github/workflows/ios.yml:
name: iOS Zero-Touch
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
XCODE_VERSION: "16.0"
jobs:
# ─── 1. Tests on every PR + main push ────────────────────────
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
- run: gem install fastlane -NV --no-document
- run: fastlane test
# ─── 2. Beta: any push to main goes to TestFlight Internal ───
beta:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- env: &secrets
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane beta
# ─── 3. Validate: tag pushed but nothing ships yet ───────────
validate:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest # cheap Linux for validation
steps:
- uses: actions/checkout@v4
- env:
DEMO_LOGIN_URL: ${{ secrets.DEMO_LOGIN_URL }}
run: ./scripts/validate-release.sh
# ─── 4. Approval gate: human required for App Store push ─────
approve:
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, validate]
runs-on: ubuntu-latest
environment:
name: production
url: https://appstoreconnect.apple.com
steps:
- run: echo "Approved by ${{ github.actor }} — proceeding to App Store"
# ─── 5. Release to App Store ─────────────────────────────────
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: approve
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- env: *secrets
run: fastlane release
# Upload dSYMs for symbolication after release
- name: Upload dSYMs to Sentry
if: env.SENTRY_AUTH_TOKEN != ''
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: acme
SENTRY_PROJECT: ios
run: |
curl -sL https://sentry.io/get-cli/ | bash
sentry-cli debug-files upload --include-sources build/
Step 3 — Configure the production environment
GitHub repo → Settings → Environments → New environment → name: production.
- Required reviewers: add yourself + a designated approver
- Wait timer: optionally 1440 (24 hours) for the soak
This means: pushing a v*.*.* tag now triggers test → validate, then pauses awaiting human approval before release runs. Click “Review deployments” in the Actions UI, approve, and submission proceeds.
Step 4 — Emergency rollback runbook
docs/runbook-rollback.md:
# Emergency rollback
## Symptom: a released version has a critical bug
### Option A — Halt new downloads (within minutes)
1. App Store Connect → My Apps → Acme → Pricing and Availability
2. Set "Availability" to "Remove from sale" → save
3. Existing users still have the app; no new downloads possible
### Option B — Revert to a previous version (within hours)
1. App Store Connect → Acme → App Store → Versions
2. Click the previous "Ready for Sale" version
3. Click "..." → "Re-submit for review"
4. Apple typically processes re-submits within 4–8h
5. Once approved, set to "Manually release" if you want to control the rollout
### Option C — Hot-fix (within ~24h)
1. Branch from the tag: `git checkout v<bad> && git checkout -b hotfix/v<bad>-1`
2. Cherry-pick the fix or write a one-liner
3. Tag `v<bad>-1` and push
4. Pipeline runs; approve in `production` env
5. Expedite request in App Store Connect with reason "critical bug affecting users"
### Communication template (Slack)
> 🚨 Production incident: <one-line description>
> Affected versions: v<x.y.z>
> Action: <Option A/B/C above>
> Owner: @<handle>
> ETA: <time>
Commit it. Practice the rollback at least once per quarter in a dry run.
Step 5 — Test the full flow
# 1. Merge to main → triggers test + beta automatically (no human action)
git checkout main && git pull
git push origin main
# 2. Wait for TestFlight Internal build to appear (~10 min)
# QA validates manually for ~24h
# 3. Tag the release after QA approval
git tag v1.0.2 -m "QA-approved release"
git push origin v1.0.2
# 4. Pipeline: test → validate (~3 min)
# Pipeline pauses at approve job
# You receive Slack notification "Approval needed"
# 5. Visit GitHub → Actions → Review deployments → Approve
# 6. release job runs → App Store submission triggered
# Slack confirms "🚀 v1.0.2 submitted to App Store"
Stretch
- Crash-free-session-rate gate — query Sentry’s API for the last 24h of TestFlight builds; fail the pipeline if rate < 99.5%.
- Auto-generated screenshots — wire
capture_screenshots+frame_screenshotsinto a nightly job; PR-bot opens a metadata PR if screenshots change. - Branch-protection lockdown — require the
testjob + 1 reviewer onmain; block force-pushes to release tags. - Beta diff in Slack — after
betasucceeds, post the git log diff from the previous TestFlight build into Slack so QA knows what changed. - Multi-region phased release — first release to NZ only (24h), then to EU (24h), then worldwide. Encode the phasing in App Store Connect API calls.
Notes
- The
environment: productiongate is the single most important guardrail. It transforms “tag = release” into “tag + human nod = release” without removing automation. - For solo dev shops, you can self-approve — the audit log still records the action.
- TestFlight Internal can hold many builds simultaneously; older ones still expire at 90 days. Add a cleanup script if needed.
- For real production apps add: Datadog/Honeycomb pipeline monitoring, Sentry dSYM upload (shown), App Store Connect API rate-limit handling, and Slack ack for failures.
Phase 10 complete. Phase 11 (Monetization & Business Strategy) explores StoreKit business patterns, subscription design, App Store pricing automation, and how companies like Spotify and Netflix navigate Apple’s payment rules.