iOSGitHub ActionsTestFlightXcodeCI/CDCode Signing

使用 GitHub Actions 构建、签名 iOS 应用并自动上传至 TestFlight(无需 fastlane)

Sloth255
Sloth255
·9 min read·1,820 words

前言

每次发布 iOS 应用,你是否都需要在本地 Xcode 上手动执行 Archive → Distribute... 操作?
这不仅耗时,还容易造成构建环境与个人绑定,出现"在我的 Mac 上能构建,换台 Mac 就失败"的问题。

本文将介绍如何使用 GitHub Actions 构建、签名 iOS 应用并自动上传至 TestFlight 的完整流水线搭建步骤。

很多教程会使用 fastlane,但本文采用不同方案:不依赖 fastlane,仅使用 Apple 原生的 xcodebuildxcrun altool。无需引入 Ruby 环境,工作流程更简洁,也不再受 Xcode 升级时 fastlane 兼容性问题的困扰。

认证方式采用更安全、不受双重认证影响的 App Store Connect API 密钥,而非 Apple ID + 密码。

前提条件

  • 已注册 Apple Developer Program(付费)
  • 已在 App Store Connect 中创建应用记录
  • iOS 项目已推送至 GitHub 仓库
  • 已确认本地 macOS / Xcode 构建成功

整体流程

要构建的流水线概览如下:

  1. 推送至 main 分支或手动触发,启动工作流
  2. 在 GitHub Actions 的 macOS runner 上准备 Xcode
  3. 将证书和描述文件临时导入钥匙串
  4. 更新构建号
  5. 使用 xcodebuild archive 生成 .xcarchive
  6. 使用 xcodebuild -exportArchive 导出 .ipa
  7. 使用 xcrun altool 上传至 TestFlight
  8. 删除临时钥匙串并清理
push → 触发 Actions → 安装证书 → archive → export → altool → TestFlight

Step 1:准备证书和描述文件

在 CI 中执行代码签名,需要将以下两个文件进行 Base64 编码后存入 GitHub Secrets。

1-1. 导出发布证书(.p12)

打开本地"钥匙串访问.app",右键点击 Apple DistributioniPhone Distribution 证书,以 .p12 格式导出。导出时设置的密码请务必记好,后续需要使用。

如果没有证书,请在 Apple Developer 网站的 Certificates 页面新建。

1-2. 下载描述文件(.mobileprovision)

从 Apple Developer 网站的 Profiles 页面下载 App Store 发布用的描述文件。同时,务必记下描述文件名称(例如:YourApp AppStore),后续在 ExportOptions.plist 中需要填写。

1-3. 编码为 Base64

在终端执行以下命令,将文件转换为 Base64 字符串:

# 证书
base64 -i Certificates.p12 | pbcopy

# 描述文件
base64 -i YourApp_AppStore.mobileprovision | pbcopy

通过 pbcopy 将内容复制到剪贴板,可直接粘贴到 GitHub Secrets 中。

Step 2:生成 App Store Connect API 密钥

为了避免双重认证的干扰,我们使用 API 密钥。

  1. 登录 App Store Connect
  2. 选择"用户和访问权限" → "Integrations" → "App Store Connect API"
  3. 点击"+"按钮生成密钥(角色需为 App Manager 或以上)
  4. 保存下载的 AuthKey_XXXXXXXXXX.p8 文件(无法重新下载,请妥善保管)
  5. 记下生成时显示的 Key IDIssuer ID

同样对 .p8 文件进行 Base64 编码:

base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy

Step 3:在 GitHub Secrets 中注册

在仓库的 SettingsSecrets and variablesActions 中,注册以下 Secrets:

Secret 名称 内容
BUILD_CERTIFICATE_BASE64 .p12 证书的 Base64 字符串
P12_PASSWORD 导出 .p12 时设置的密码
BUILD_PROVISION_PROFILE_BASE64 .mobileprovision 的 Base64 字符串
KEYCHAIN_PASSWORD 临时钥匙串的任意密码
APP_STORE_CONNECT_API_KEY_ID API 密钥的 Key ID
APP_STORE_CONNECT_API_ISSUER_ID Issuer ID
APP_STORE_CONNECT_API_KEY_BASE64 .p8 的 Base64 字符串

Step 4:创建 ExportOptions.plist

这是从 .xcarchive 生成 .ipa 时所需的配置文件。将其作为 ios/ExportOptions.plist 提交到仓库(不包含敏感信息)。

ios/ExportOptions.plist
<?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>teamID</key>
    <string>ABCD123456</string>

    <key>signingStyle</key>
    <string>manual</string>

    <key>signingCertificate</key>
    <string>Apple Distribution</string>

    <key>provisioningProfiles</key>
    <dict>
        <key>com.example.yourapp</key>
        <string>YourApp AppStore</string>
    </dict>

    <key>uploadSymbols</key>
    <true/>

    <key>uploadBitcode</key>
    <false/>
</dict>
</plist>

Step 5:创建 GitHub Actions 工作流

终于到了核心部分。创建 .github/workflows/ios-testflight.yml

.github/workflows/ios-testflight.yml
name: iOS TestFlight Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  SCHEME: YourApp
  WORKSPACE: YourApp.xcworkspace # 如果是 .xcodeproj 请使用 PROJECT
  CONFIGURATION: Release
  EXPORT_OPTIONS_PLIST: ios/ExportOptions.plist

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

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.4.app

      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # 解码 Base64
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # 创建临时钥匙串
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # 导入证书
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: \
            -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # 放置描述文件
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      - name: Set build number
        run: |
          # 使用 GitHub Actions 的运行编号作为构建号(保证唯一性)
          BUILD_NUMBER=${{ github.run_number }}
          /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
            YourApp/Info.plist
          echo "Build number set to $BUILD_NUMBER"

      - name: Build archive
        run: |
          xcodebuild archive \
            -workspace "$WORKSPACE" \
            -scheme "$SCHEME" \
            -configuration "$CONFIGURATION" \
            -archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
            -destination "generic/platform=iOS" \
            -allowProvisioningUpdates \
            CODE_SIGNING_ALLOWED=YES \
            | xcbeautify

      - name: Export IPA
        run: |
          xcodebuild -exportArchive \
            -archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
            -exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
            -exportPath "$RUNNER_TEMP/export" \
            | xcbeautify

      - name: Prepare API key for altool
        env:
          APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
        run: |
          # altool 会从 ~/.appstoreconnect/private_keys/ 或 ./private_keys/ 读取
          mkdir -p ~/.appstoreconnect/private_keys
          echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode \
            -o ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8

      - name: Upload to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
        run: |
          xcrun altool --upload-app \
            --type ios \
            --file "$RUNNER_TEMP/export/YourApp.ipa" \
            --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
            --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"

      - name: Clean up keychain and profiles
        if: always()
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
          rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
          rm -rf ~/.appstoreconnect/private_keys

要点说明

使用 github.run_number 作为构建号
无需使用 fastlane 的 increment_build_number,直接使用 GitHub Actions 自动分配的运行编号。该编号保证单调递增,满足 TestFlight 要求的"构建号不重复"条件。也可以使用基于日期的方式($(date +%Y%m%d%H%M))。

使用 xcbeautify 格式化日志
xcodebuild 的原始日志非常难以阅读,因此通过管道传入 xcbeautifymacos-14 runner 已预装该工具。如果未安装,请额外添加 brew install xcbeautify 步骤。

API 密钥文件的存放位置
xcrun altool 会自动从以下路径之一检测 API 密钥。由于没有显式指定路径的参数,必须将文件放在指定目录中:

  • ./private_keys/AuthKey_<KEY_ID>.p8
  • ~/private_keys/AuthKey_<KEY_ID>.p8
  • ~/.private_keys/AuthKey_<KEY_ID>.p8
  • ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8

使用临时钥匙串的原因
将证书导入 CI 环境的默认钥匙串不仅清理麻烦,安全性也存在隐患。最佳实践是每个作业创建临时钥匙串,用完即删。

使用 if: always() 进行清理
即使构建失败,也会确保临时钥匙串和密钥文件被删除。在使用 self-hosted runner 时尤为重要。

Step 6:验证运行

main 分支推送代码,或在 GitHub Actions 标签页通过 Run workflow 手动触发。

大约 5~10 分钟后完成,新构建将出现在 App Store Connect 的 TestFlight 标签页中。从"处理中(Processing)"状态到完成还需要约 10~30 分钟。

证书管理方案与推荐配置

本文采用将 .p12 编码为 Base64 后存入 GitHub Secrets 的方式,但 GitHub Actions 还提供其他证书管理选项。请根据团队规模和需求选择最合适的方案。

方案一:Base64 存入 Secrets(本文方式)

原理:将 .p12.mobileprovision 编码为 Base64,以字符串形式保存在 GitHub Secrets 中。

优点

  • 无外部依赖,仅在 GitHub 内完成
  • 初始配置最为简单
  • .p12 通常只有几 KB 到几十 KB,远在 Secrets 的 64KB 限制以内

缺点

  • 证书轮换时需手动更新 Secrets
  • 管理多个应用时 Secrets 数量增多,维护繁琐
  • Secrets 无法像 Azure DevOps Secure Files 那样以文件形式操作

适用场景:个人开发、1~3 人小团队、单应用

方案二:加密文件提交至仓库

原理:使用 GPG 或 OpenSSL 加密 .p12 并提交到仓库,仅将解密密码存入 Secrets。

# 本地加密
gpg --symmetric --cipher-algo AES256 Certificates.p12
# → 将 Certificates.p12.gpg 提交到仓库

工作流中:

- name: Decrypt certificate
  run: |
    gpg --quiet --batch --yes --decrypt \
      --passphrase="$CERT_PASSPHRASE" \
      --output $RUNNER_TEMP/cert.p12 \
      secrets/Certificates.p12.gpg
  env:
    CERT_PASSPHRASE: ${{ secrets.CERT_PASSPHRASE }}

优点

  • 可作为文件管理,证书轮换历史可通过 Git 追踪
  • 不受 Secrets 大小限制
  • 最接近 Azure DevOps Secure Files 的使用体验

缺点

  • 仓库泄露时,密码强度成为最后一道防线
  • .gpg 文件出现在仓库中,观感因人而异

适用场景:从 Azure DevOps 迁移、希望以文件形式管理、私有仓库运营

方案三:fastlane match

原理:将加密的证书存储在专用私有仓库(或 S3/GCS),由 fastlane 自动获取、安装和解密。

优点

  • 可统一管理多应用、多环境(dev/adhoc/appstore)的证书
  • 新成员只需运行一次 fastlane match 即可完成本地环境配置
  • 支持证书自动重新生成

缺点

  • 需要 Ruby 和 fastlane
  • Xcode 升级时可能存在 fastlane 兼容性问题
  • 与本文脱离 fastlane 的主旨相悖

适用场景:多应用运营、5 人以上 iOS 团队、频繁轮换证书

方案四:AWS Parameter Store + OIDC 集成

原理:将 .p12 的 Base64 字符串和密码以 SecureString 形式存入 AWS Systems Manager Parameter Store,通过 GitHub Actions 的 OIDC 获取。无需长期凭证是最大优势。

Parameter Store 与 Secrets Manager 的区别

AWS 有两个类似服务,整理如下:

项目 Parameter Store Secrets Manager
费用(标准) 免费 $0.40/密钥/月 + API 费用
费用(高级) $0.05/10,000 次 API 调用 同上
值大小上限 标准 4KB / 高级 8KB 64KB
自动轮换
KMS 加密 SecureString 类型支持 原生支持

Base64 编码后的 .p12 通常为几十 KB 到 30KB,这是方案选择的关键。若在 8KB 以内,Parameter Store 明显更经济;若超出,则适合使用 Secrets Manager 或下文介绍的 S3 混合方案。

文件大于 8KB 时的处理方式

若大小超限,将加密的 .p12 存入 S3,仅在 Parameter Store 中保存 S3 键名和解密密码的混合方案是实践中较为简洁的选择,兼顾了 Parameter Store 的简单性和 S3 的容量灵活性。

AWS 侧配置

首先在 IAM 中为 GitHub Actions 创建 OIDC 身份提供商(已创建可跳过):

  • Provider URL:https://token.actions.githubusercontent.com
  • Audience:sts.amazonaws.com

然后在 Parameter Store 中保存参数(AWS CLI 示例):

# 证书本体
aws ssm put-parameter \
  --name /ios/yourapp/dist-cert-base64 \
  --type SecureString \
  --value "$(base64 -i Certificates.p12)"

# .p12 密码
aws ssm put-parameter \
  --name /ios/yourapp/p12-password \
  --type SecureString \
  --value "your-p12-password"

# 描述文件
aws ssm put-parameter \
  --name /ios/yourapp/provisioning-profile-base64 \
  --type SecureString \
  --value "$(base64 -i YourApp_AppStore.mobileprovision)"

# App Store Connect API 密钥(.p8)
aws ssm put-parameter \
  --name /ios/yourapp/asc-api-key-base64 \
  --type SecureString \
  --value "$(base64 -i AuthKey_XXXXXXXXXX.p8)"

创建 GitHub Actions 用 IAM 角色,在 Trust Policy 中限制仅特定仓库的特定分支可以 Assume Role:

trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

权限策略遵循最小权限原则,仅授予目标参数路径的 GetParameter 权限:

permission-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ssm:GetParameter", "ssm:GetParameters"],
      "Resource": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/ios/yourapp/*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
    }
  ]
}

GitHub Actions 侧工作流

不要忘记在 permissions 块中设置 id-token: write,否则无法获取 OIDC 令牌。

.github/workflows/ios-testflight.yml
permissions:
  id-token: write   # 获取 OIDC 令牌所需
  contents: read

jobs:
  deploy:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
          aws-region: ap-northeast-1

      - name: Fetch secrets from Parameter Store
        env:
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # 从 Parameter Store 获取(SecureString 需要 --with-decryption)
          aws ssm get-parameter \
            --name /ios/yourapp/dist-cert-base64 \
            --with-decryption \
            --query Parameter.Value --output text | base64 --decode > $CERTIFICATE_PATH

          aws ssm get-parameter \
            --name /ios/yourapp/provisioning-profile-base64 \
            --with-decryption \
            --query Parameter.Value --output text | base64 --decode > $PP_PATH

          P12_PASSWORD=$(aws ssm get-parameter \
            --name /ios/yourapp/p12-password \
            --with-decryption \
            --query Parameter.Value --output text)

          # 创建钥匙串并导入证书
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: \
            -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      # 后续的 archive / export / altool 步骤与 Step 5 相同

优点

  • 成本几乎为零:标准参数完全免费,高级参数也极为低廉
  • OIDC 集成无需长期凭证:无需在 GitHub 中保存访问密钥(最大优势)
  • CloudTrail 审计日志:完整记录谁在何时获取了证书
  • IAM/KMS 精细权限控制:可灵活区分开发和生产环境的角色
  • 多 CI/CD 系统共用:GitHub Actions、CodeBuild、Jenkins 及本地开发均可引用同一证书
  • 版本历史:可回滚至历史版本

缺点

  • 需要 AWS 账户(对未使用 AWS 的团队初始构建成本较高)
  • .p12 超过 8KB 时需要额外处理(结合 S3 或使用 Secrets Manager)
  • OIDC 配置有一定学习成本(仅需一次)

适用场景:已使用 AWS 的团队、有审计日志要求、需从多个 CI/CD 工具引用同一证书、追求低成本企业级管理

方案五:AWS Secrets Manager + OIDC 集成

原理:将 .p12二进制(SecretBinary)形式直接存入 AWS Secrets Manager,通过 GitHub Actions 的 OIDC 获取。与 Parameter Store 不同,最大特点是可以像操作文件一样使用,无需关心 Base64 转换。

与 Parameter Store 的区别

项目 Parameter Store Secrets Manager
值的存储格式 仅字符串(二进制需 Base64) 直接支持二进制(SecretBinary 类型)
大小上限 4KB(标准)/ 8KB(高级) 64KB
费用 免费至极低 $0.40/月/密钥 + API 费用
自动轮换 (Lambda 集成)
适用方向 配置值、小型机密 较大机密、生产级运营

.p12 超过 8KB,或优先考虑文件操作便利性,Secrets Manager 更为直接。1~2 个证书的月费不足 1 美元,成本基本不是问题。

AWS 侧配置

OIDC 身份提供商配置与方案四相同,此处省略。

使用 fileb:// 前缀以二进制形式注册到 Secrets Manager,AWS CLI 会直接将文件作为二进制发送:

# 证书本体(可直接以二进制注册)
aws secretsmanager create-secret \
  --name ios/yourapp/dist-cert \
  --secret-binary fileb://Certificates.p12

# 描述文件也以二进制形式注册
aws secretsmanager create-secret \
  --name ios/yourapp/provisioning-profile \
  --secret-binary fileb://YourApp_AppStore.mobileprovision

# App Store Connect API 密钥(.p8)也以二进制形式注册
aws secretsmanager create-secret \
  --name ios/yourapp/asc-api-key \
  --secret-binary fileb://AuthKey_XXXXXXXXXX.p8

# 密码类以字符串形式注册
aws secretsmanager create-secret \
  --name ios/yourapp/p12-password \
  --secret-string "your-p12-password"

更新时使用 update-secret

aws secretsmanager update-secret \
  --secret-id ios/yourapp/dist-cert \
  --secret-binary fileb://Certificates_new.p12

IAM 角色权限策略改为允许 Secrets Manager:

permission-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue"],
      "Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:ios/yourapp/*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
    }
  ]
}

GitHub Actions 侧工作流

通过 SecretBinary 获取的值在 API 响应中以 Base64 编码返回,需使用 base64 --decode 解码后写入文件:

.github/workflows/ios-testflight.yml
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
          aws-region: ap-northeast-1

      - name: Fetch certificates from Secrets Manager
        env:
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # SecretBinary 以 Base64 编码返回,解码后写入文件
          aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/dist-cert \
            --query SecretBinary --output text | base64 --decode > $CERTIFICATE_PATH

          aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/provisioning-profile \
            --query SecretBinary --output text | base64 --decode > $PP_PATH

          # SecretString 可直接获取
          P12_PASSWORD=$(aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/p12-password \
            --query SecretString --output text)

          # 将 API 密钥(.p8)放置到 altool 期望的路径
          API_KEY_ID="${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}"
          mkdir -p ~/.appstoreconnect/private_keys
          aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/asc-api-key \
            --query SecretBinary --output text | base64 --decode \
            > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8

          # 创建钥匙串并导入证书
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: \
            -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      # 后续的 archive / export / altool 步骤与 Step 5 相同

优点

  • 可直接以文件形式注册和获取 .p12(最接近 Azure DevOps Secure Files 的体验)
  • 支持最大 64KB:无需担心大小限制
  • OIDC 集成无需长期凭证:与方案四具有同等安全性
  • CloudTrail 审计日志标准可用
  • 自动轮换:可通过 Lambda 实现证书自动更新(但构建 Apple 侧 API 集成的成本较高)
  • IAM 精细权限控制

缺点

  • 产生费用:约 $0.40/密钥/月 + API 费用;3~5 个证书每月约数美元
  • 需要 AWS 账户
  • OIDC 配置学习成本(与方案四共通)

适用场景:优先考虑文件操作便利性、.p12 文件较大、多应用管理大量证书文件、未来考虑自动轮换、希望获得 Azure DevOps Secure Files 同等运维体验

方案六:其他云端密钥管理服务

对于未使用 AWS 的团队,以下方案同样适用,均支持 OIDC 集成:

服务 费用参考 大小上限 二进制支持
Azure Key Vault $0.03/10,000 次操作 25KB ○(证书类型)
Google Secret Manager $0.06/月/密钥 64KB
HashiCorp Vault(OSS) 仅服务器费用 无限制

Azure Key Vault 特别提供了 Certificate 类型,官方支持以 .p12 格式直接导入/导出。对于使用 Azure 体系的团队来说兼容性极好。

方案七:GitHub Environments 附加保护

该选项可与上述任一方案组合使用。通过配置 GitHub Environments,可实现仅允许特定分支的部署访问证书,或增加手动审批步骤

jobs:
  deploy:
    runs-on: macos-14
    environment: production # ← 可使用绑定到该 Environment 的 Secrets

由于可以按 Environment 隔离 Secrets,能有效防止误用生产发布证书进行开发构建。

按规模推荐配置

规模 推荐配置
个人开发 / 兴趣项目 方案一(Base64 + Secrets) 简单至上
初创 / 小团队(1~3 人) 方案一方案二(加密文件)
中型团队(多应用或 5 人以上) 方案三(fastlane match)方案五(Secrets Manager)
已使用 AWS,注重成本 方案四(Parameter Store + OIDC)
已使用 AWS,注重运维 方案五(Secrets Manager + OIDC) 可像文件一样操作
企业级 / 有审计要求 方案四或五 + 方案七(Environments) 组合使用
从 Azure DevOps 迁移 方案二(加密文件)方案五(Secrets Manager) 最接近 Secure Files

常见问题与解决方法

No signing certificate "iOS Distribution" found

通常是钥匙串证书导入失败,或缺少 set-key-partition-list 步骤。在步骤中添加 security find-identity -v -p codesigning $KEYCHAIN_PATH 以确认证书是否可见,便于定位问题。

error: exportArchive: "YourApp.app" requires a provisioning profile

描述文件未正确放置,或 ExportOptions.plist 中 provisioningProfiles 的 Bundle ID 和描述文件名称不匹配。请注意描述文件名称是"Apple Developer 网站上显示的名称",而非文件名。

altool: Invalid API key

多数情况是 Base64 编码 .p8 文件时混入了换行符或多余空格。使用 base64 -i file.p8 | pbcopy 生成并直接粘贴是最可靠的方式。另外,文件名若不符合 AuthKey_<KEY_ID>.p8 的命名规则,altool 将无法识别,请通过环境变量精确拼接。

xcodebuild: error: The operation couldn't be completed. No such file or directory

使用了 .xcworkspace 却指定了 -project(或反之)的情况。若使用了 CocoaPods 或 Swift Package Manager 的工作区,请指定 -workspace

Parameter Store:ParameterNotFoundAccessDenied

AWS 集成时发生的错误。按以下顺序排查:

  1. 参数路径是否有拼写错误(/ios/yourapp/... 的层级结构)
  2. IAM 角色的权限策略是否对目标路径授予了 ssm:GetParameter
  3. 使用 SecureString 时是否添加了 --with-decryption 标志
  4. IAM 角色是否具有 KMS 密钥的 kms:Decrypt 权限
  5. Trust Policy 中的 sub 条件是否与工作流执行分支匹配

进一步优化

本文构建了最小化配置的流水线,以下是一些可能的扩展方向:

  • Slack 通知:集成 slackapi/slack-github-action,上传完成后自动发送通知。
  • 自动生成发布说明:从 git log 提取变更内容并自动填入 TestFlight 的"测试须知"字段(altool 本身难以实现,需直接调用 App Store Connect API)。
  • Tag push 触发:将 main 分支推送改为 v* 标签推送触发,实现更有意图的发布流程。
  • 预先运行单元测试:在 archive 前执行 xcodebuild test,测试失败时中止部署。

总结

不使用 fastlane 的方案具有工作流直观易懂、无需 Ruby 环境、不受 Xcode 升级时 fastlane 兼容性问题影响等优势。但代价是构建号管理等需要自行处理,扩展到多应用运营规模时管理成本也会增加。届时迁移到 fastlane match 或 AWS Secrets Manager / Parameter Store 是更现实的选择。

初始配置最大的难关无疑是证书相关配置,希望本文以及"证书管理方案"部分能帮助你顺利突破这道难关。