Skip to main content
mobile2026年3月1日5 分钟阅读

自动化 iOS 部署:App Store Connect 和 TestFlight

一份完整的 iOS 应用部署自动化指南——从 Fastlane 设置到 TestFlight 分发和 App Store 提交。

iosfastlanecicd
自动化 iOS 部署:App Store Connect 和 TestFlight

每个 iOS 开发者都经历过这样的仪式:打开 Xcode,增加构建版本号,选择归档方案,等待十五分钟,点击 Organizer,上传到 App Store Connect,再等待一段时间,然后手动在 TestFlight 中添加测试人员。如果每周对多个应用重复两次这样的操作,你就会开始损失本该用于编写代码的时间。最糟糕的不是时间——而是不一致性。一个被遗忘的步骤,一个错误的配置文件,都可能导致构建静默失败,或者在数小时后被 Apple 的自动化检查拒绝。

我为一款基于 Flutter 的 iOS 应用自动化了整个流程,并已将相同的模式应用于其他项目。这项投资在第三或第四个发布周期后就能收回成本。

为什么手动部署会失败

除了显而易见的时间浪费之外,手动部署过程还存在三个根本性问题。

首先,知识集中。当团队中只有一个人知道如何部署时,他们就成了瓶颈。休假、病假,或者仅仅是处于不同的时区,都意味着发布会停滞。文档有所帮助,但一份 15 步的部署指南本身就是一种负担——一旦有人更改了构建设置,它就会过时。

其次,在最糟糕的时候出现人为错误。发布往往在压力下进行。一个关键的错误修复,客户的截止日期,一个需要在竞争对手之前发布的功能。这些正是人们容易忘记增加构建版本号、选择错误方案或上传调试版本而非发布版本的情况。

第三,缺乏可审计性。当发布出现问题时,你希望确切地知道构建了什么,来自哪个提交,以及使用了什么配置。手动过程不会留下可靠的痕迹。

Fastlane:基础

Fastlane 是 iOS 部署自动化的事实标准,这并非没有道理。它通过基于 Ruby 的 DSL 处理代码签名、构建、上传和元数据管理,其内容读起来就像一份部署清单。

初始设置

在现有项目中设置 Fastlane 非常简单。

# Install Fastlane
brew install fastlane

# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init

在初始化过程中,Fastlane 会询问你想要自动化什么。我建议选择手动设置并明确配置所有内容——自动化设置会做出一些假设,这些假设并不总是与你的项目结构匹配,尤其是对于 Flutter 应用。

结果是生成一个包含 AppfileFastfilefastlane/ 目录。

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")

Appfile 存储你的 Apple Developer 账户详细信息。如果你的 Apple ID 属于多个团队,团队 ID 就很重要——没有它们,Fastlane 会以交互方式提示你,这会破坏 CI。

使用 Match 进行代码签名

代码签名是大多数 iOS 自动化工作失败的地方。配置文件、证书、权限——整个系统似乎旨在惩罚自动化。Fastlane 的 match 工具通过将你的证书和配置文件存储在私有 Git 仓库或云存储中来解决这个问题,使任何需要构建的机器都可以访问它们。

设置 Match

fastlane match init

这会创建一个 Matchfile,你可以在其中配置你的存储后端。

# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])

然后生成并存储你的证书:

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match 在推送到仓库之前会加密所有内容。你在设置过程中会设置一个密码,所有机器(包括 CI)都需要这个密码。

为什么选择 Match 而非手动签名

另一种选择——通过 Apple Developer 门户手动管理证书并分发 .p12 文件——在某些情况下可行,但并非总是如此。常见的故障模式包括:

  • 开发者证书过期,直到发布构建失败才有人注意到。
  • 有人在调试签名问题时撤销了证书,导致所有依赖该证书的配置文件失效。
  • 新的 CI 机器需要配置,而设置原始机器的人已经离开了团队。

Match 通过作为单一事实来源消除了所有这些问题。如果证书过期,你运行 fastlane match nuke 并重新生成。每台机器都从同一个仓库拉取。

CI 代码签名

在 CI 上,你需要处理 macOS 钥匙串,因为没有 GUI 可以解锁它。Fastlane 为此提供了帮助工具:

lane :ci_setup do
  create_keychain(
    name: "ci_keychain",
    password: ENV["KEYCHAIN_PASSWORD"],
    default_keychain: true,
    unlock: true,
    timeout: 3600,
    lock_when_sleeps: false
  )

  match(
    type: "appstore",
    keychain_name: "ci_keychain",
    keychain_password: ENV["KEYCHAIN_PASSWORD"],
    readonly: true
  )
end

readonly: true 标志对于 CI 至关重要。没有它,如果 match 找不到现有证书,它可能会尝试创建新证书,这将在没有交互式授权的情况下失败,并可能撤销现有证书。

使用 Gym 进行构建

Gym 是 Fastlane 的构建工具。它用合理的默认值和更好的错误输出封装了 xcodebuild

lane :build do
  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "YourApp.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        "com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
      }
    }
  )
end

对于 Flutter 项目,你有两种选择:运行 flutter build ipa 并处理输出,或者直接在 Flutter 生成的 Xcode 工作区上使用 gym。我更喜欢 gym,因为它对签名和导出选项有更多的控制,但两者都可行。

# Flutter-specific build lane
lane :build_flutter do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign")
  end

  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    clean: false,  # Flutter already built, no need to clean
    include_bitcode: false
  )
end

Flutter 构建步骤中的 --no-codesign 标志是故意的——我们让 gym 使用 match 管理的配置文件进行签名,而不是依赖 Xcode 的自动签名。

使用 Pilot 上传到 TestFlight

Pilot 处理 TestFlight 上传和 Beta 测试人员管理。

lane :beta do
  ci_setup
  build_flutter

  pilot(
    skip_waiting_for_build_processing: true,
    apple_id: "1234567890",
    distribute_external: false,
    notify_external_testers: false
  )
end

skip_waiting_for_build_processing 标志对 CI 非常重要。没有它,Fastlane 将会轮询 App Store Connect,直到 Apple 完成你的构建处理——这可能需要 15 到 45 分钟。你的 CI 运行器将在这段时间内一直处于空闲状态,消耗计算资源。更好的做法是上传并让处理异步进行。

管理 TestFlight 测试人员

你还可以自动化测试人员组管理:

lane :distribute_to_testers do
  pilot(
    distribute_external: true,
    groups: ["External Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    notify_external_testers: true
  )
end

对于外部测试人员,请记住 Apple 要求对发送给外部组的第一个构建以及每次添加新的外部测试组时进行 Beta 应用审核。这是一个你无法自动化的手动关卡——请为首次外部分发预留 24-48 小时的延迟。

使用 Deliver 提交到 App Store

一旦你的应用准备好投入生产,deliver 将处理 App Store 提交。

lane :release do
  ci_setup
  build_flutter

  deliver(
    submit_for_review: true,
    automatic_release: false,
    force: true,  # Skip HTML preview verification
    submission_information: {
      add_id_info_uses_idfa: false
    },
    precheck_include_in_app_purchases: false
  )
end

Deliver 还可以管理你的 App Store 元数据——截图、描述、关键词、发布说明——所有这些都作为文件存储在你的仓库中。

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt

fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│   ├── 01_home.png
│   ├── 02_detail.png
│   └── 03_settings.png
└── iPad Pro 12.9" Display/
    ├── 01_home.png
    └── 02_detail.png

这是 Fastlane 最被低估的功能之一。将你的 App Store 列表置于版本控制中意味着你可以在拉取请求中审查元数据更改,跟踪描述何时更新,并在文案人员出错时回滚。

版本递增策略

版本管理出人意料地具有争议性。我所采用的方法结合了营销版本的语义化版本控制和自动递增的构建版本号。

lane :bump_patch do
  increment_version_number(bump_type: "patch")
  increment_build_number
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

lane :bump_minor do
  increment_version_number(bump_type: "minor")
  increment_build_number(build_number: 1)
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

对于构建版本号,我特别建议使用 CI 构建版本号或时间戳,而不是简单的递增整数。这可以避免在同时构建多个分支时发生冲突。

lane :set_ci_build_number do
  build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
  increment_build_number(build_number: build_number)
end

对于 Flutter 项目,你还需要保持 pubspec.yaml 与 Xcode 项目同步:

lane :sync_flutter_version do
  version = get_version_number
  build = get_build_number
  Dir.chdir("..") do
    sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
  end
end

完整的 Fastfile

这是一个结合了所有功能的生产环境 Fastfile:

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV["CI"]
  end

  desc "Setup CI environment"
  lane :ci_setup do
    create_keychain(
      name: "ci_keychain",
      password: ENV["KEYCHAIN_PASSWORD"],
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: false
    )

    match(
      type: "appstore",
      keychain_name: "ci_keychain",
      keychain_password: ENV["KEYCHAIN_PASSWORD"],
      readonly: true
    )
  end

  desc "Build the Flutter iOS app"
  lane :build_flutter do
    Dir.chdir("..") do
      sh("flutter build ios --release --no-codesign")
    end

    gym(
      scheme: "Runner",
      workspace: "Runner.xcworkspace",
      export_method: "app-store",
      output_directory: "./build",
      clean: false,
      include_bitcode: false
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    pilot(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New TestFlight build uploaded!",
      slack_url: ENV["SLACK_WEBHOOK"]
    ) if ENV["SLACK_WEBHOOK"]
  end

  desc "Deploy to App Store"
  lane :release do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Set build number from CI"
  private_lane :set_ci_build_number do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    increment_build_number(build_number: build_number)
  end
end

GitHub Actions 集成

CI 工作流将所有内容联系在一起。这是一个完整的用于 TestFlight 部署的 GitHub Actions 工作流:

name: Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 45

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: Flutter dependencies
        run: flutter pub get

      - name: Deploy to TestFlight
        working-directory: ios
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

App Store Connect API 密钥

请注意,我使用的是 API 密钥而不是 Apple ID 凭据。这对于 CI 很重要——Apple ID 认证需要双重认证,这在非交互式环境中不起作用。API 密钥在 App Store Connect 的“用户和访问”下创建,除非你撤销它们,否则它们永不过期。

# In your Appfile or Fastfile
app_store_connect_api_key(
  key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
  key_content: ENV["APP_STORE_CONNECT_API_KEY"],
  is_key_content_base64: true,
  in_house: false
)

.p8 密钥文件内容作为 base64 编码的秘密存储在 GitHub Actions 中。这可以避免文件路径问题,并将密钥保留在你的仓库之外。

常见陷阱及解决方法

配置文件不匹配

最常见的构建失败是配置文件与应用中的权限不匹配。症状包括“没有匹配的配置文件”或“代码签名权限无效”等错误。

解决方法几乎总是重新生成你的 match 配置文件:

fastlane match nuke appstore
fastlane match appstore

然后验证你的 Xcode 项目的 bundle 标识符是否与 MatchfileAppfile 中的内容完全匹配。

权限问题

如果你的应用使用推送通知、关联域、通过 Apple 登录或其他功能,这些必须在 Apple Developer 门户和你的 Runner.entitlements 文件中进行配置。两者之间的不匹配会导致签名失败,并产生误导性的错误消息。

<!-- ios/Runner/Runner.entitlements -->
<?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>aps-environment</key>
    <string>production</string>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourdomain.com</string>
    </array>
</dict>
</plist>

构建版本号被拒绝

App Store Connect 会拒绝构建版本号不严格大于同一版本上次上传的构建。如果你使用 CI 构建版本号,这通常不是问题。但如果你重置 CI 或切换 CI 提供商,你可能会遇到这个问题。解决方法是使用 app_store_build_number 获取最新的构建版本号并在此基础上递增:

lane :smart_build_number do
  current = app_store_build_number(live: false)
  increment_build_number(build_number: current + 1)
end

Xcode 版本不匹配

Apple 会定期停止支持使用旧版 Xcode 构建的应用。你的 CI 运行器需要与你开发时使用的 Xcode 版本匹配。在 GitHub Actions 上,你可以这样指定:

- name: Select Xcode version
  run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer

查阅 Apple 的文档,了解 App Store 提交所需的最低 Xcode 版本——这会随着每个主要的 iOS 版本而变化。

环境特定配置

大多数应用需要针对不同环境进行不同的配置——不同的 API 端点、不同的 Firebase 项目、不同的功能标志。在你的 Fastfile 中处理这个问题:

lane :beta_staging do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=staging")
  end
  # ... rest of build and upload
end

lane :beta_production do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=production")
  end
  # ... rest of build and upload
end

对于 Firebase,为每个环境使用不同的 GoogleService-Info.plist 文件,并在构建期间复制正确的文件:

lane :configure_firebase do |options|
  env = options[:env] || "production"
  sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end

监控构建健康状况

一旦你的管道运行起来,你就会希望了解其健康状况。我为每个部署管道添加了一些东西:

构建时间跟踪——如果你的构建开始花费更长时间,你希望在它们达到超时限制之前就知道。

Slack 通知——成功和失败通知让团队随时了解情况,而无需任何人盯着 CI 仪表板。

error do |lane, exception|
  slack(
    message: "Build failed: #{exception.message}",
    success: false,
    slack_url: ENV["SLACK_WEBHOOK"]
  )
end

工件保留——保留 dSYM 文件用于崩溃符号化。自动将它们上传到你的崩溃报告服务:

lane :upload_symbols do
  upload_symbols_to_crashlytics(
    dsym_path: "./build/YourApp.app.dSYM.zip",
    gsp_path: "./Runner/GoogleService-Info.plist"
  )
end

投资回报

对于有经验的人来说,设置整个管道大约需要一天时间;对于初学者来说,则需要两到三天——大部分时间都花在解决代码签名问题上,而不是编写 Fastlane 配置。在最初的投资之后,每次后续发布都从 30-60 分钟的手动过程变为一次 git push。

真正的价值甚至不是节省的时间。它是信心。当部署是一个单一命令或一个自动触发器时,你部署的频率会更高。更小的部署意味着每次发布的风险更小,测试人员的反馈更快,以及功能的迭代速度更快。这个管道的回报不是节省了多少小时,而是一种从根本上更好的开发工作流程。

DU

Danil Ulmashev

Full Stack Developer

有兴趣一起合作吗?