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

StageManual versionAutomated versionTool
Version bumpEdit MARKETING_VERSION in Xcodeagvtool new-marketing-version 1.2.3agvtool / PlistBuddy
Build number bumpEdit CURRENT_PROJECT_VERSIONagvtool new-version -all 42agvtool
ArchiveXcode → Product → Archivexcodebuild archivexcodebuild
Export IPAOrganizer → Distribute Appxcodebuild -exportArchivexcodebuild
UploadOrganizer → Uploadxcrun altool (legacy) / xcrun notarytool, Transporter, pilotnotarytool / Transporter / Fastlane
Metadata updateApp Store Connect UIApp Store Connect REST APIcurl / fastlane deliver
Submit for reviewApp Store Connect → SubmitREST API POST /appStoreVersionSubmissionsREST API / Fastlane
ReleaseApp Store Connect → ReleaseAPI or automatic_release: trueFastlane
NotifySlack message manuallyWebhook in CIcurl

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

  1. “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.
  2. “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.
  3. automatic_release: true releases the moment review passes.” Yes — be intentional. For high-risk releases, set false and gate manual release with a Slack approval bot.
  4. “You still need to log into App Store Connect for screenshots.” No — fastlane deliver (now upload_to_app_store) syncs screenshots, descriptions, keywords, age rating, everything.
  5. “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 decideWhat 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 rotationsApply 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 main push will eventually ship a regression. Always have a 24h soak in TestFlight before App Store submission — make tag_release the only path to production, not every main merge.

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.


Next: 10.11 — App Store Review Strategy