整合 GitHub Action 在 Self Host Runner - iOS 篇
CICD 建置 - 整合 GitHub Action - iOS 篇
準备 Runner 环境
- 取得 Apple Developer Account (Apple Developer)
- 安装 Xcode
- 安装 Xcode Command Line Tools
xcode-select --install
- 安装 Cocoapods
brew install cocoapods
準备及新增建置时候需要的 secrets 和 variable 到 GitHub 中
要能够单纯化 runner 建置的环境,避免要手动要放置各种档案到特定位置后才可以建置,所以把必要的档案、变数、密码等等都放到 GitHub 当作环境变数,然后在 Flow 执行的过程中能够动态自动的产生或取得
可以到 repo 的 Settings
中,左方的 Secrets and variables
点下后选 Actions
来管理 secrets 和
variable
secrets, variable 的名称均可以自行讨论决定
要取得及新增的项目如下
- 建置相关
- Apple Developer Certificate 档案 (p12) 及 档案密码
- [不需要了,保留记录用] Mobile provisioning profile for the app
- ExportOptions.plist
- 一个建立建置时使用的 Keychain 的密码
- worksapce name
- app name
- scheme name
- 上传到 Test flight 的 App Store Connect API 相关
- App Store Connect API Issuer ID
- App Store Connvet API Key ID
- App Store Connect API Private key 档案 (p8)
下面分别就各项东西怎么準备说明
Apple Developer Certificate 档案 (p12) 及 档案密码
取得档案步骤
汇出步骤
- 打开 Keychain App
- 点选左边
登入
- 切换到
凭证
页签 - 找到
Appple Development
开头的凭证 - 在上麵右键点选
输出
会出时会要求你设定密码,这个密码就是要使用该档案的 档案密码
然后成功会出的 p12 档案就是 Apple Developer Certificate 档案 (p12)
新增 Secrets
BUILD_CERTIFICATE_BASE64
假设汇出的凭证 p12 档案名称是 Certificates.p12
使用指令
base64 -i Certificates.p12 | pbcopy
将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容
P12_PASSWORD
将汇出凭证使用的密码键入
[不需要了,保留记录用] Mobile provisioning profile for the app
取得方式
- Apple Developer 里面产生
- Reference: Apple Developer — Certificates & Provisioning Profiles
- 从开发者本机取得
- 路径是:
~/Library/Developer/Xcode/UserData/Provisioning Profiles
- 旧一点版本的 xcode 会放置于:
~/Library/MobileDevice/Provisioning Profiles
- 路径是:
每个 App 会有各自的,所以不同 App 要使用不同的档案,所以要找取得对的档案,可以用用文字编辑器打开档案看里面的资讯,可以知道哪一个是你要的
上述的方式均可以取得 mobile provision 的档案,但其实 mobile provision 是有期限的,根据 Xcode Provisioning Profile Automation for CI 后来 Apple 有出一个可以自动更新 provision profile 的方式,只要在 project 档案打勾,并且在下指令 archive 的时候补上一个参数即可
备注:文章中说的参数是
-allowProvisioningUpdates
但文章中的範例有错误,这个参数只能下在 archive 的时候,如果下在 exportArchive 的话,CI 执行到最后会出现 export ipa 成功但那个 step 会失败因为不认得-allowProvisioningUpdates
是什么
新增 secrets
BUILD_PROVISION_PROFILE_BASE64
假设 mobileprovision 档案名称是 1d0e8da1-9eba-41c7-a308-931ba380c3b0.mobileprovision
base64 -i 1d0e8da1-9eba-41c7-a308-931ba380c3b0.mobileprovision | pbcopy
将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容
ExportOptions.plist
取得方式
ExportOptions.plist 需要先使用 xcode 产生,可以透过 xcode 执行 archive 然后 distribute,在 distribute 的时候选
Release Testing
( 主要是要使用 ad-hoc 的方式 ) 去产生 IPA 档案后,在输出的目录就会得到一个 ExportOptions.plist
不同的 scheme 有可能会需要不同的 ExportOptions.plist,因为 bundler id 是不同的,意思就是如果同一个用不同的 scheme 分正式版和开发版,并且里面的 bundler 是不同的,那就要产生两份 ExportOptions.plist 使用
新增 secrets
EXPORT_OPTIONS_PLIST
使用指令
base64 -i ExportOptions.plist | pbcopy
将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容
一个建立建置时使用的 Keychain 的密码
取得方式
自行决定一个强密码
新增 secrets
KEYCHAIN_PASSWORD
将密码键入
其他
新增 variables
IOS_APP_NAME
用于 build archive, export ipa 的名称
IOS_SCHEME_NAME
建置的时候要使用的 scheme name,仅需名称不用副档名
IOS_WORKSPACE_NAME
建置的时候要使用的 workspace name,仅需名称不用副档名
App Store Connect API 相关
取得方式
可以看 Generating Tokens for API Requests 以及 Creating API Keys for App Store Connect API 来产生及建立 Apple Store Connect API Key
大致上步骤如下
开启 App Store Connect 网站 后,点选 使用者与存取权限
,然后点上方的
整合
后就可以看到左边的一排的功能列出现有 App Store Connect API
(预设)
- 在画面上可以看到 Issuer ID 下面呈现的一串字串就是
App Store Connect API Issuer ID
- 下方的 API Key 的表格可上就可以看到 金钥 ID 栏位,该栏位所呈现的就是
App Store Connvet API Key ID
- App Store Connect API Private key 档案 (p8) 仅在刚建立 API Key 的最后步骤可以下载,建立之后就无法再次下载,所以请谨慎保存
Private key 预设的档名是
Auth_xxxx.p8
在上传到 test flight 的时候要有一模一样的名称的档案存在一些规範中的路径才可以被找到
新增 secrets
APP_STORE_CONNECT_API_ISSUER_ID
填入(複製贴上) App Store Connect API Issuer ID
的值
APP_STORE_CONNECT_API_KEY_ID
填入(複製贴上) App Store Connvet API Key ID
APP_STORE_CONNECT_API_PRIVATE_KEY
假设下载的 private key 档案名称是 Auth_1234.p8
使用指令
base64 -i Auth_1234.p8 | pbcopy
将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容
準备 GitHub Action 要用的 YAML 档案
新增 YAML 档案
- 在 repo 的根目录新增
.github/workflows
共 2 个资料夹 - 在
.github/workflows
的资料夹中新增{YOUR_WORKFLOW_NAME}.yaml
档案
要让 github action 有作用,需要在 default branch 新增这个 yaml 档案,所以开发测试角度来说可以先建立一个空的,然后再开 branch 去调整
可以根据 Writing workflows 来写 Workflow YAML,这边提供 iOS 的範例,可以再依照需求作调整
YAML 的内容
这边以我专案使用的 YAML 举例,说明 YAML 中哪些 step 对于建置 iOS App 时候哪些是必要的,以及额外补充说明一些用 Gtihub action 的心得技巧
name: "Build iOS app"
on:
# allow manual trigger
workflow_dispatch:
# trigger push to main branch with tag start with "v"
push:
branches:
- main
- develop
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
- 'dev[0-9]+.[0-9]+.[0-9]+*'
# trigger pull request for all branches
pull_request:
types: [opened, synchronize, reopened]
branches:
- '*'
jobs:
build_and_upload:
runs-on: [self-hosted, macOS]
steps:
# this was more debug as was curious what came pre-installed
# GitHub shares this online, e.g. https://github.com/actions/runner-images/blob/macOS-12/20230224.1/images/macos/macos-12-Readme.md
- name: Check Xcode version
run: /usr/bin/xcodebuild -version
- name: Checkout repository
uses: actions/checkout@v4
- name: Get branch name (push branch)
if: github.ref_type == 'branch'
run: |
echo "CURRENT_BRANCH=${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" >> $GITHUB_ENV
echo "CURRENT_TAG=" >> $GITHUB_ENV
- name: Get branch name (push tag)
if: github.ref_type == 'tag'
run: |
echo "CURRENT_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
BRANCHES=$(git branch -r --contains ${{ github.ref }} | grep -v HEAD | tr -d ' ' | sed 's/origin\///')
if echo "$BRANCHES" | grep -q "main"; then
CURRENT_BRANCH="main"
else
CURRENT_BRANCH=$(echo "$BRANCHES" | awk 'NR==1{print $1}')
fi
echo "CURRENT_BRANCH=$CURRENT_BRANCH" >> $GITHUB_ENV
- name: Current branch name and tag name
env:
CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
CURRENT_TAG: ${{ env.CURRENT_TAG }}
run: |
echo "Current Branch: $CURRENT_BRANCH. Current Tag: $CURRENT_TAG"
- name: Install the Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Store original keychain settings
ORIGINAL_KEYCHAIN=$(security default-keychain -d user | xargs)
echo "ORIGINAL_KEYCHAIN=$ORIGINAL_KEYCHAIN" >> $GITHUB_ENV
# create variables
BUILD_CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $BUILD_CERTIFICATE_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $BUILD_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# 避免弹出 Keychain UI 问你密码
security set-key-partition-list -S apple-tool:,apple: -s -k $KEYCHAIN_PASSWORD $KEYCHAIN_PATH
- name: Create App Store Connect API Key File
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
run: |
# 要把 p8 档案放置于 private_keys 资料夹下,才可以让 xcodebuild altool 的时候方便使用(不需要特别指定参数)
APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=./private_keys/AuthKey_$APP_STORE_CONNECT_API_KEY_ID.p8
# import app store connect api private key from secrets
mkdir -p private_keys
echo -n "$APP_STORE_CONNECT_API_PRIVATE_KEY" | base64 --decode -o $APP_STORE_CONNECT_API_PRIVATE_KEY_PATH
# transform relative path to absolute path
APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=$(realpath $APP_STORE_CONNECT_API_PRIVATE_KEY_PATH)
echo "APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" >> $GITHUB_ENV
- name: Clean Derived Data (before build)
run: rm -rf ~/Library/Developer/Xcode/DerivedData/*
- name: Build archive
env:
IOS_APP_NAME: ${{ vars.IOS_APP_NAME }}
IOS_SCHEME_NAME_PRODUCTION: ${{ vars.IOS_SCHEME_NAME }}
IOS_SCHEME_NAME_DEV: ${{ vars.IOS_SCHEME_NAME_DEV }}
IOS_WORKSPACE_NAME: ${{ vars.IOS_WORKSPACE_NAME }}
CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
CURRENT_TAG: ${{ env.CURRENT_TAG }}
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 }}
APP_STORE_CONNECT_API_PRIVATE_KEY_PATH: ${{ env.APP_STORE_CONNECT_API_PRIVATE_KEY_PATH }}
run: |
# setup scheme name
if [ $CURRENT_BRANCH == 'main' ] && echo "$CURRENT_TAG" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+$"; then
SCHEME_NAME=$IOS_SCHEME_NAME_PRODUCTION
else
SCHEME_NAME=$IOS_SCHEME_NAME_DEV
fi
echo "Building $SCHEME_NAME scheme"
# build archive
xcodebuild -workspace "$IOS_WORKSPACE_NAME.xcworkspace" \
-scheme "$SCHEME_NAME" \
-archivePath $RUNNER_TEMP/$IOS_APP_NAME.xcarchive \
-sdk iphoneos \
-configuration Release \
-allowProvisioningUpdates \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" \
clean archive || exit 1
- name: Export ipa
env:
IOS_APP_NAME: ${{ vars.IOS_APP_NAME }}
EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST }}
CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
CURRENT_TAG: ${{ env.CURRENT_TAG }}
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 }}
APP_STORE_CONNECT_API_PRIVATE_KEY_PATH: ${{ env.APP_STORE_CONNECT_API_PRIVATE_KEY_PATH }}
run: |
# setup export options plist
EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist
echo -n "$EXPORT_OPTIONS_PLIST" | base64 --decode -o $EXPORT_OPTS_PATH
# export ipa
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/$IOS_APP_NAME.xcarchive \
-exportOptionsPlist $EXPORT_OPTS_PATH \
-exportPath $RUNNER_TEMP/build \
-allowProvisioningUpdates \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" \
- name: Upload to TestFlight
if: github.ref_type == 'tag'
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 }}
CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
CURRENT_TAG: ${{ env.CURRENT_TAG }}
run: |
# check if branch name and tag name is what we want
if [ $CURRENT_BRANCH == 'main' ] && echo "$CURRENT_TAG" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+$"; then
OK_TO_UPLOAD=true
elif [ $CURRENT_BRANCH == 'develop' ] && echo "$CURRENT_TAG" | grep -Eq "^dev[0-9]+\.[0-9]+\.[0-9]+$"; then
OK_TO_UPLOAD=true
else
OK_TO_UPLOAD=false
fi
if ! $OK_TO_UPLOAD; then
echo 'Tag name not fits rule'
exit 1
fi
IPA_FILE_PATH=$(find $RUNNER_TEMP/build -name "*.ipa" -print0 | xargs -0 echo | head -n 1)
echo "IPA file path: $IPA_FILE_PATH"
xcrun altool --upload-app \
-t ios \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
-f "$IPA_FILE_PATH" || exit 1
- name: Clean up keychain and provisioning profile
if: ${{ always() }}
run: |
# Clean up keychain
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
# Clean Derived Data
rm -rf ~/Library/Developer/Xcode/DerivedData/*
- name: Restore original keychain
if: always() # This ensures it runs even if previous steps fail
env:
ORIGINAL_KEYCHAIN: ${{ env.ORIGINAL_KEYCHAIN }}
run: |
security default-keychain -s "$ORIGINAL_KEYCHAIN"
security list-keychains -s "$ORIGINAL_KEYCHAIN"
Install the Apple certificate and provisioning profile
这步骤的目的就是把放在 GitHub secrets 中的东西做下列事情
- 把
secrets.BUILD_CERTIFICATE_BASE64
(开发者凭证内容) 变成实体档案 (p12) - 把
secrets.BUILD_PROVISION_PROFILE_BASE64
(mobileprovision 档案内容) 变成实体档案 - 使用
secrets.KEYCHAIN_PASSWORD
来建立一个叫 app-signing 的 keychain - 使用
secrets.P12_PASSWORD
把开发者凭证档案汇入到 app-signing keychain 中
Create App Store Connect API Key File
把 App Store Connect API 的 private key (secrets.APP_STORE_CONNECT_API_PRIVATE_KEY
) 转化成实体档案 (p8) 并且放置于
<current_directory>/private_keys 的路径
此档案会在 xcbuild archive/exportArchive 使用到,并且于上传 Testflight 的 xcbuild altool 使用
altool 会自动寻找该档案,会自动寻找的路径其中一个地方就是
<current_directory>/private_keys
,所以这边放置于此,另外档案名称也要注意,要与当初下载时的名称一样
详细可以放置位置及工具使用说明请参考下方
altool 使用指南 1.3
连结,或者当出现错误的时候,错误讯息的内容会跟你说可以摆在哪一些路径中
Build archive
顾名思义,就是 archive 档案,这边的 scheme 是用动态决定的、有的东西是拿 GitHub variable 决定的,但没有需要可以写死也可以,这边会这样写是方便如果要直接拿这份 YAML 去改的话,这边就不用修改,只要在需使用的 repo 上面新增对应的 variable 即可
Export ipa
先把放在 GitHub secrets 中的 secrets.EXPORT_OPTIONS_PLIST
变成实体档案,然后再去执行 export 指令
在 Build archive 及 Export ipa 步骤的重点
都指定下面四个参数
-allowProvisioningUpdates \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" \
让 xcodebuild 可以与 server 沟通自动做 sign & 取得 mobile provision profile,加上这几个参数后,CI 机器上面的 XCode 就不需要登入
Upload to TestFlight
因为 xcbuild exportArchive 的时候只能指定输出的路径不能包含档名,所以不能确定最终的档名是什么,看起来是跟 archive 时候使用的 scheme name 一致,所以调整写法直接用指令去寻找 IPA 档案并上传
补充:其他 first party 上传 Test fligh 的方式
-
iTMSTransporter (CLI)
使用 CLI 上传 IPA 到 testflight 其实还有另外一个是
iTMSTransporter
/xcrun iTMSTransporter
根据官方文件 Transporter User Guide 3.3 中描述,在 xcode 14 之后就没有内建了需要另外下载
但我测试目前使用的 XCode 16 下 xcrun iTMSTransporter 还是可以执行,并且如果直接下 xcrun iTMSTransporter 还可以更新版本
iTMSTransporter 似乎是比 altool 似乎更早出现的工具,上传到 test flight 的时候虽然同 altool 一样会需要把 private key 放到指定的一些目录,但它有其他参数可以指定 JWT 的路径,这个 JWT 想当然有包含 private key (p8) 的内容,要组成 JWT 可以参考 Generating Tokens for API Requests
意思就是可以自己先把 private key 转换成一个 App Store Connect API 所认得得 JWT 档案,就不一定要把 private key 摆放在特定位置,但 JWT 必须动态产生,因为 JWT 是需要指定期限会过期
另外,iTMSTransporter 虽然跟 altool 一样可以寻找特定一些目录去找 private keys 档案 (p8) 来使用,但有遇到 2 个问题
- 不会找执行指令路径下的
<current_directory>/private_keys
- 用 GitHub Runner 执行的话也即便放到规範的
~/private_keys
里面会找不到,但如果自己直接下指令是正常的
所以要使用 iTMSTransporter 的话应该要使用 JWT 的方式比较合用
- 不会找执行指令路径下的
-
XCode (GUI)
-
Transporter App (GUI, Install from App Store)
Runner Keychain 的特别现象
手动执行完 workflow 后 Keychain 里面的 登入
会消失,这可能会导致后续无法正常 build code,自动执行的话似乎不会遇到这种情况
所以可以看到 step 里面有 保留 和 恢复 的步骤
TO-DO
-
xcrun altool --upload-app
已经被标示成 deprecated 需要找寻替代指令- 可能的替代方案
fast-lane
xcrun altool --upload-package
xcrun iTMSTransporter
- 使用别人写好的 apple-actions
- 可能的替代方案
参考资料
- 3 ways to install Xcode on macOS [2023]
- How to build an iOS app archive via command line
- How to build an iOS
app with GitHub Actions [2023]
- 此文件的根据文章
- [Day:27] GitHub Actions 懒人部署-ios CI 基础打包
- 可以参考写 build number 及有利用别人写好的几个 apple-actions
- Xcode Provisioning Profile Automation for CI
- 脚本打包所需ExportOptions.plist文件生成
- 自动化流程完成 打包 IPA 到 上传 AppStore 之 iOS IPA签名
- 里面有自己去下命令去 sign app 的,不确定为什么他需要自己去 sign,因为 archive 的命令应该就会 code sign 了
- 一行命令解决 Codesign wants to access key “access” in your keychain
- Distribute apps in Xcode with cloud signing
上传 Test Flight 相关
- 上传建置版本
- 官方文件,上传到 Test Fligh 用的指令範例是
xcrun altool --upload-app
- 官方文件,上传到 Test Fligh 用的指令範例是
- altool 使用指南 1.3
- Transporter User Guide 3.3
- 现在的版本已经到 3.4.x 了
- 利用 GitHub Actions 与 fastlane 将 iOS APP 发布到 Apple Store Connect 的 TestFlight
- Deploy iOS App to TestFlight with GitHub Actions & Fastlane