前言
每次发布 iOS 应用,你是否都需要在本地 Xcode 上手动执行 Archive → Distribute... 操作?
这不仅耗时,还容易造成构建环境与个人绑定,出现"在我的 Mac 上能构建,换台 Mac 就失败"的问题。
本文将介绍如何使用 GitHub Actions 构建、签名 iOS 应用并自动上传至 TestFlight 的完整流水线搭建步骤。
很多教程会使用 fastlane,但本文采用不同方案:不依赖 fastlane,仅使用 Apple 原生的 xcodebuild 和 xcrun altool。无需引入 Ruby 环境,工作流程更简洁,也不再受 Xcode 升级时 fastlane 兼容性问题的困扰。
认证方式采用更安全、不受双重认证影响的 App Store Connect API 密钥,而非 Apple ID + 密码。
前提条件
- 已注册 Apple Developer Program(付费)
- 已在 App Store Connect 中创建应用记录
- iOS 项目已推送至 GitHub 仓库
- 已确认本地 macOS / Xcode 构建成功
整体流程
要构建的流水线概览如下:
- 推送至
main分支或手动触发,启动工作流 - 在 GitHub Actions 的 macOS runner 上准备 Xcode
- 将证书和描述文件临时导入钥匙串
- 更新构建号
- 使用
xcodebuild archive生成.xcarchive - 使用
xcodebuild -exportArchive导出.ipa - 使用
xcrun altool上传至 TestFlight - 删除临时钥匙串并清理
push → 触发 Actions → 安装证书 → archive → export → altool → TestFlight
Step 1:准备证书和描述文件
在 CI 中执行代码签名,需要将以下两个文件进行 Base64 编码后存入 GitHub Secrets。
1-1. 导出发布证书(.p12)
打开本地"钥匙串访问.app",右键点击 Apple Distribution 或 iPhone 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 密钥。
- 登录 App Store Connect
- 选择"用户和访问权限" → "Integrations" → "App Store Connect API"
- 点击"+"按钮生成密钥(角色需为 App Manager 或以上)
- 保存下载的
AuthKey_XXXXXXXXXX.p8文件(无法重新下载,请妥善保管) - 记下生成时显示的 Key ID 和 Issuer ID
同样对 .p8 文件进行 Base64 编码:
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
Step 3:在 GitHub Secrets 中注册
在仓库的 Settings → Secrets and variables → Actions 中,注册以下 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 提交到仓库(不包含敏感信息)。
<?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:
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 的原始日志非常难以阅读,因此通过管道传入 xcbeautify。macos-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:
{
"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 权限:
{
"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 令牌。
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:
{
"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 解码后写入文件:
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:ParameterNotFound 或 AccessDenied
AWS 集成时发生的错误。按以下顺序排查:
- 参数路径是否有拼写错误(
/ios/yourapp/...的层级结构) - IAM 角色的权限策略是否对目标路径授予了
ssm:GetParameter - 使用 SecureString 时是否添加了
--with-decryption标志 - IAM 角色是否具有 KMS 密钥的
kms:Decrypt权限 - 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 是更现实的选择。
初始配置最大的难关无疑是证书相关配置,希望本文以及"证书管理方案"部分能帮助你顺利突破这道难关。
