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

  1. Crash-free-session-rate gate — query Sentry’s API for the last 24h of TestFlight builds; fail the pipeline if rate < 99.5%.
  2. Auto-generated screenshots — wire capture_screenshots + frame_screenshots into a nightly job; PR-bot opens a metadata PR if screenshots change.
  3. Branch-protection lockdown — require the test job + 1 reviewer on main; block force-pushes to release tags.
  4. Beta diff in Slack — after beta succeeds, post the git log diff from the previous TestFlight build into Slack so QA knows what changed.
  5. 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: production gate 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.