Lab 10.3 — Full Release Pipeline

Goal: stitch GitHub Actions + Fastlane into one pipeline that runs tests on every PR, ships to TestFlight on every main push, and submits to App Store on every v*.*.* tag.

Time: 120–180 minutes.

Prereqs: Lab 10.1 complete (working Fastfile + match repo), a GitHub repo, App Store Connect API key, paid Apple Developer subscription.

Setup

  1. Push your FastlaneLab repo from Lab 10.1 to GitHub.
  2. Convert the API key to base64 for GitHub secrets:
    base64 -i fastlane/AuthKey.p8 -o /tmp/asc_key.b64
    pbcopy < /tmp/asc_key.b64
    
  3. Generate a fine-grained PAT scoped to your certs repo with Contents: Read.
  4. Build the basic auth token for match:
    echo -n "x-access-token:$YOUR_PAT" | base64
    

Build (3 GitHub secrets, 3 Fastlane lanes, 1 workflow)

Step 1 — Add GitHub secrets

Repo → Settings → Secrets and variables → Actions → New secret:

NameValue
ASC_KEY_BASE64contents of /tmp/asc_key.b64
ASC_KEY_IDe.g. AAAA1111BB
ASC_ISSUER_IDe.g. 69a6de70-...
MATCH_PASSWORDthe passphrase you set in Lab 10.1
MATCH_GIT_TOKEN_B64the base64 you just produced
SLACK_WEBHOOKa Slack incoming webhook URL (or use webhook.site for testing)

Step 2 — Extend the Fastfile

Replace fastlane/Fastfile with:

fastlane_version "2.220.0"
default_platform :ios

APP_ID  = "com.yourname.fastlanelab"
SCHEME  = "FastlaneLab"
PROJECT = "FastlaneLab.xcodeproj"

def asc_api_key
  app_store_connect_api_key(
    key_id:       ENV["ASC_KEY_ID"],
    issuer_id:    ENV["ASC_ISSUER_ID"],
    key_content:  ENV["ASC_KEY_BASE64"],
    is_key_content_base64: true,
    in_house: false
  )
end

platform :ios do
  before_all do
    setup_ci if is_ci
  end

  desc "Run all tests"
  lane :test do
    run_tests(
      project: PROJECT,
      scheme: SCHEME,
      devices: ["iPhone 16 Pro"],
      clean: true
    )
  end

  desc "Sync App Store certs"
  lane :certs do
    match(type: "appstore", app_identifier: APP_ID, readonly: is_ci)
  end

  desc "Build and upload to TestFlight (internal)"
  lane :beta do
    certs
    increment_build_number(
      xcodeproj: PROJECT,
      build_number: ENV["GITHUB_RUN_NUMBER"] || latest_testflight_build_number(api_key: asc_api_key, app_identifier: APP_ID) + 1
    )
    build_app(
      project: PROJECT,
      scheme: SCHEME,
      export_method: "app-store",
      export_options: {
        signingStyle: "manual",
        provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
      },
      output_directory: "build",
      output_name: "FastlaneLab.ipa"
    )
    upload_to_testflight(
      api_key: asc_api_key,
      ipa: "build/FastlaneLab.ipa",
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )
    notify("📦 Beta #{lane_context[SharedValues::BUILD_NUMBER]} uploaded to TestFlight")
  end

  desc "Tag-triggered App Store release"
  lane :release do
    UI.user_error!("Not on a tag") unless ENV["GITHUB_REF"]&.start_with?("refs/tags/v")
    version = ENV["GITHUB_REF"].sub("refs/tags/v", "")

    test
    certs
    increment_version_number(version_number: version)
    increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"])

    build_app(
      project: PROJECT,
      scheme: SCHEME,
      export_method: "app-store",
      export_options: {
        signingStyle: "manual",
        provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
      },
      output_directory: "build",
      output_name: "FastlaneLab.ipa"
    )

    upload_to_app_store(
      api_key: asc_api_key,
      ipa: "build/FastlaneLab.ipa",
      skip_screenshots: true,
      skip_metadata: false,
      force: true,
      submit_for_review: true,
      automatic_release: true,
      phased_release: true,
      submission_information: {
        add_id_info_uses_idfa: false,
        export_compliance_uses_encryption: false
      }
    )
    notify("🚀 v#{version} submitted to App Store")
  end

  def notify(text)
    return unless ENV["SLACK_WEBHOOK"]
    sh "curl -s -X POST -H 'Content-Type: application/json' --data '{\"text\":\"#{text}\"}' \"$SLACK_WEBHOOK\""
  end

  error do |lane, exception|
    notify("❌ Lane #{lane} failed: #{exception.message}")
  end
end

Step 3 — Write the GitHub Actions workflow

.github/workflows/ios.yml:

name: iOS
on:
  push:
    branches: [main]
    tags: ['v*.*.*']
  pull_request:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: ios-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  XCODE_VERSION: "16.0"

jobs:
  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
            ~/.swiftpm
          key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
      - run: gem install fastlane -NV --no-document
      - run: fastlane test

  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
      - run: bundle install || true
      - name: Run beta lane
        env:
          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

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    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
      - name: Run release lane
        env:
          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 release

Step 4 — Test it

# 1. PR test
git checkout -b test/pr
echo "// touch" >> FastlaneLab/ContentView.swift
git commit -am "test PR pipeline"
git push -u origin test/pr
gh pr create --title "Test PR" --body "Pipeline verification"
# → GitHub Actions runs `test` job only

# 2. Beta
gh pr merge --merge
# → `test` + `beta` jobs run; check Slack + TestFlight

# 3. Release
git tag v1.0.1 -m "First automated release"
git push origin v1.0.1
# → `test` + `release` jobs run; check App Store Connect for submission

Stretch

  1. Phased release with metadata — set up fastlane/metadata/en-US/release_notes.txt, etc., remove skip_metadata: true, watch metadata sync alongside the binary.
  2. Reject “main” merges if test fails — set branch protection in GitHub: main requires test job to pass before merge.
  3. Approval gate before release — wrap the release job in a GitHub Environment with required reviewers; the workflow pauses until an admin approves.
  4. Auto-generate changelog — replace release_notes.txt content with git log --pretty=format:'- %s' $(git describe --tags --abbrev=0 HEAD^)..HEAD.
  5. Cost-cut PR runs — add a paths: filter so the test job only runs when *.swift files change.

Notes

  • The first release lane on a brand new app will fail because App Store Connect requires manual setup of pricing + age rating. Configure those one time in the UI, then automation takes over.
  • If match complains about “cert not in keychain” on CI, setup_ci wasn’t called. Confirm before_all runs.
  • latest_testflight_build_number makes builds idempotent — even if GitHub re-runs a job, the build number stays unique without collisions.
  • For real apps, add a notarize step for Mac apps and a validate_app step before upload_to_app_store to catch issues earlier.

Next: Lab 10.4 — Zero-Touch Pipeline