iOS 自動化遠程打包
說明:本文由原 Word 文檔整理為 Markdown。文中的本地圖片路徑為
media/xxx.png,發(fā)布到簡書時需要將圖片上傳到簡書后替換為對應圖片鏈接。
基于 Fastlane + Tailscale + SSH 的本地觸發(fā)、遠程打包、上傳 TestFlight
完整方案
1. 目的
當前 iOS
項目出于賬號和證書安全考慮,要求開發(fā)與打包環(huán)境隔離:本地開發(fā)機只配置個人開發(fā)賬號,正式打包統(tǒng)一在遠程打包機上完成。
因此,當前打包流程存在以下問題:
每次打包都需要通過 TeamViewer 遠程操作打包機;
需要手動執(zhí)行 SVN 更新,容易漏更新或更新不完整;
需要手動選擇 Debug / Release 環(huán)境,存在選錯風險;
Archive、導出 IPA、上傳 TestFlight 都依賴人工操作,流程耗時較長;
缺少統(tǒng)一打包入口,不方便標準化管理和問題排查。
打包完成后需要人工在群內(nèi)同步打包結果,通知不夠及時。
為了解決以上問題,本方案采用 fastlane + Tailscale + SSH
實現(xiàn)遠程自動化打包,主要優(yōu)勢如下:
本地只需執(zhí)行一條命令,即可觸發(fā)遠程打包;
遠程打包機自動完成 SVN 更新、Archive、IPA 導出和 TestFlight 上傳;
支持 Debug / Release 參數(shù)化打包,減少人為選擇錯誤;
不再需要每次通過 TeamViewer 手動操作打包機;
打包流程統(tǒng)一由 fastlane 管理,日志清晰,便于排查問題;
打包完成后通過飛書機器人自動通知群內(nèi)成員,及時同步打包結果;
在保證發(fā)布權限集中管理的同時,提高打包效率和發(fā)布安全性。
1.1 效率評估
原方案耗時:TeamViewer連接遠程打包機 + 手動 SVN 更新 + 選擇打包環(huán)境 +
打包 + 上傳 TestFlight + 群內(nèi)通知,完整流程約 12
分鐘左右,其中人工操作時間約 5 - 8 分鐘。
新方案耗時:本地終端執(zhí)行一條命令,完成后自動推送飛書群通知,人工操作時間小于
1 分鐘,整體等待時間約 9 分鐘左右。
一次性配置成本:首次配置本地和遠程環(huán)境、SSH、Tailscale、fastlane、飛書機器人等,預計耗時約
40 分鐘左右。配置完成后,后續(xù)可長期復用。
2. 方案原理
本方案基于以下工具實現(xiàn):
fastlane:負責自動化構建、導出 IPA、上傳 TestFlight
Tailscale:負責本地 Mac 與遠程 Mac 之間的虛擬內(nèi)網(wǎng)連接
SSH:負責本地遠程執(zhí)行命令
SVN:負責遠程機器更新最新代碼
Xcode / xcodebuild:負責真正的 iOS 編譯和 Archive
App Store Connect API Key:負責無交互上傳 TestFlight
Keychain:保存并授權訪問 iOS 發(fā)布證書私鑰
飛書自定義機器人:負責在打包成功或失敗后,將結果通知到指定飛書群
2.1 核心原理
本地 Mac 不直接執(zhí)行正式打包,而是通過 SSH 把打包任務轉(zhuǎn)發(fā)到遠程 Mac。
本地 Mac
|
| 執(zhí)行 ./scripts/remote_build.sh Release
|
Tailscale 虛擬內(nèi)網(wǎng)
|
| SSH
|
遠程 Mac 打包機
|
| svn update
| xcodebuild archive
| export ipa
| upload_to_testflight
| 發(fā)送飛書通知
|
App Store Connect / TestFlight
|
飛書群通知:打包成功,已上傳 TestFlight
2.2 本地觸發(fā)原理
本地開發(fā)機執(zhí)行:
./scripts/remote_build.sh Release
腳本會讀取本地配置,然后通過 SSH 登錄遠程機器,并執(zhí)行遠程 fastlane
命令。
2.3 遠程打包原理
遠程機器接收到命令后,執(zhí)行:
REMOTE_BUILD_SESSION=1 fastlane ios build configuration:Release
其中 REMOTE_BUILD_SESSION=1 用于標記當前已經(jīng)處于遠程打包會話,避免
Fastfile 再次遞歸轉(zhuǎn)發(fā),后續(xù)直接執(zhí)行真正的打包流程。
2.4 上傳 TestFlight 原理
上傳 TestFlight 不使用 Apple ID 密碼交互登錄,而是使用 App Store Connect
API Key。
需要配置:
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEY_FILEPATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
Fastfile 中通過:
app_store_connect_api_key
生成上傳憑據(jù),然后調(diào)用:
upload_to_testflight
完成上傳。
2.5 Keychain 解鎖原理
遠程 SSH 會話屬于非圖形化會話,即使證書已經(jīng)安裝在遠程 Mac 上,也可能因為
Keychain 未解鎖或私鑰訪問權限不足,導致簽名失敗。
常見錯誤:
errSecInternalComponent
Command CodeSign failed with a nonzero exit code
因此腳本在遠程執(zhí)行打包前,會先執(zhí)行:
security unlock-keychain
security set-key-partition-list
確保 codesign 可以訪問證書私鑰。
3. 實現(xiàn)效果
該方案最終實現(xiàn)的效果如下。
3.1 本地一條命令觸發(fā)遠程打包
本地執(zhí)行:
./scripts/remote_build.sh Debug
或:
./scripts/remote_build.sh Release
即可觸發(fā)遠程打包機完成完整構建流程。
3.2 支持 fastlane 自動轉(zhuǎn)發(fā)
本地也可以直接執(zhí)行:
fastlane ios build
fastlane ios build configuration:Debug
fastlane ios build configuration:Release
如果 Fastfile 檢測到當前機器不是遠程打包機,會自動轉(zhuǎn)發(fā)到遠程機器執(zhí)行。
3.3 遠程自動更新 SVN
遠程打包前會自動查找 .svn 根目錄,并執(zhí)行:
svn update
如果配置了 SVN 用戶名和密碼,則會以非交互方式執(zhí)行:
svn update --username xxx --password xxx --non-interactive
避免 SSH 遠程執(zhí)行時卡在認證輸入階段。
3.4 自動 Archive 和導出 IPA
Fastfile 中通過:
build_app
執(zhí)行 Archive 和 Export IPA。
3.5 自動上傳 TestFlight
打包完成后自動調(diào)用:
upload_to_testflight
上傳到 App Store Connect。
3.6 統(tǒng)一輸出 IPA 產(chǎn)物
IPA 輸出到:
./ipa
命名格式:
AIEndorser_<Configuration>_<時間戳>.ipa
3.7 證書和上傳權限集中管理
發(fā)布證書、Profile、App Store Connect API Key 全部保存在遠程打包機。
本地開發(fā)機不需要保存正式發(fā)布證書,也不需要具備完整發(fā)布權限。
3.8 自動發(fā)送飛書通知
打包完成并成功上傳 TestFlight 后,F(xiàn)astfile 會自動調(diào)用飛書自定義機器人
Webhook,將打包結果發(fā)送到指定飛書群。
通知內(nèi)容包括:
項目名稱:AIEndorser
打包環(huán)境:Debug / Release
上傳狀態(tài):成功
版本信息:Version / Build
IPA 名稱:AIEndorser_Release_時間戳.ipa
通知結果:已上傳 TestFlight
4. 安全風險評估
本方案采用 fastlane + Tailscale + SSH
實現(xiàn)遠程自動化打包,本質(zhì)上是對現(xiàn)有遠程打包流程的自動化改造,不改變賬號、證書和發(fā)布權限的管理方式。
4.1 發(fā)布證書未下發(fā)到本地開發(fā)機
本方案不會將發(fā)布證書、Provisioning Profile、App Store Connect API Key
等敏感信息分發(fā)到本地開發(fā)機。
正式發(fā)布能力仍然只保存在遠程打包機上,本地開發(fā)機僅負責觸發(fā)打包命令,不直接參與簽名、導出和上傳流程。
因此,不會增加發(fā)布證書泄露風險。
4.2 App Store Connect 權限仍集中在遠程打包機
TestFlight 上傳能力通過遠程打包機上的 App Store Connect API Key 完成。
API Key 不需要下發(fā)到每位開發(fā)人員本地,也不會暴露在普通開發(fā)環(huán)境中。
因此,App Store Connect 上傳權限仍然集中可控。
4.3 遠程訪問僅通過 Tailscale 內(nèi)網(wǎng)和 SSH 完成
本方案通過 Tailscale
建立本地開發(fā)機與遠程打包機之間的私有網(wǎng)絡連接,再通過 SSH 執(zhí)行遠程命令。
相比直接暴露公網(wǎng) IP 或長期使用 TeamViewer 手動操作,Tailscale + SSH
的訪問方式更可控,也更方便限制設備和用戶權限。
因此,遠程訪問風險可控。
4.4 自動化腳本不改變原有發(fā)布權限邊界
該方案只是將原本需要人工執(zhí)行的步驟自動化,包括:
SVN 更新代碼;
Archive;
導出 IPA;
上傳 TestFlight。
所有操作仍然發(fā)生在遠程打包機上,不會把發(fā)布權限轉(zhuǎn)移到本地開發(fā)機。
因此,不會改變公司現(xiàn)有的賬號和證書隔離策略。
4.5 敏感配置文件可通過規(guī)范管理控制風險
方案中涉及的 .env 文件可能包含遠程地址、SVN 憑據(jù)、Keychain 密碼、App
Store Connect API Key 路徑等配置。
該文件需要作為敏感文件管理:
.env 不提交代碼倉庫;
只提交 .env.example 模板;
僅授權人員維護遠程打包機配置。
在遵守以上規(guī)范的前提下,敏感信息泄露風險可控。
綜上評估
該方案符合開發(fā)與打包環(huán)境隔離要求,整體安全風險可控,可以在 iOS
團隊內(nèi)部接入使用,用于統(tǒng)一遠程打包、提升打包效率,并減少人工操作帶來的錯誤風險。
5. 具體方案
5.1 架構
5.1.1 關鍵依賴
本方案依賴以下工具與服務:

5.2 環(huán)境要求
5.2.1 遠程打包機要求
遠程打包機需要滿足以下條件:
已安裝 Homebrew
已安裝 Homebrew Ruby
已通過 gem 安裝 fastlane
已安裝 Subversion
已安裝可用 Xcode
已登錄 Apple Developer 相關賬號
已安裝發(fā)布證書
已安裝 Provisioning Profile
已開啟 SSH 遠程登錄
已安裝 Tailscale
與本地設備登錄同一 Tailscale 賬號
已配置 App Store Connect API Key
已配置 Keychain 簽名權限
已配置飛書自定義機器人 Webhook,如開啟簽名校驗,需配置飛書機器人簽名密鑰
5.2.2 本地開發(fā)機要求
本地開發(fā)機需要滿足以下條件:
已安裝 Homebrew
已安裝 Ruby
已安裝 fastlane
已安裝 SVN
已安裝 Tailscale
已配置到遠程打包機的 SSH 免密登錄
可訪問項目代碼目錄
可執(zhí)行 scripts/remote_build.sh
5.2.3 Xcode 版本要求
當前環(huán)境示例:
遠程機器當前使用:Xcode 26.2
本地機器當前使用:Xcode 26.3
推薦通過環(huán)境變量控制構建使用的 Xcode:
XCODE_PATH_FOR_BUILD = (ENV["XCODE_PATH"].to_s.strip.empty? ?
"/Users/zhengbao/Downloads/Xcode.app" : ENV["XCODE_PATH"].strip)
使用方式:
XCODE_PATH=/Applications/Xcode.app fastlane ios build
configuration:Release
5.3 遠程 Mac 環(huán)境搭建
| 以下步驟在遠程打包機執(zhí)行。 |
5.3.1 安裝 Homebrew
/bin/bash -c "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
安裝完成后,將 brew 加入 PATH:
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >>
~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
5.3.2 安裝 Ruby(必須使用 Homebrew 版本)
macOS 自帶 Ruby 版本通常過舊,例如 2.6.10,容易導致 fastlane 安裝失敗或
gem 擴展不可用。
Ignoring ffi-1.12.2 because its extensions are not built.
Ignoring json-2.6.2 because its extensions are not built.
You don't have write permissions for the /Library/Ruby/Gems/2.6.0
directory.
因此建議通過 Homebrew 安裝新版 Ruby:
brew install ruby
然后把 Homebrew Ruby 寫入環(huán)境變量。這里要同時配置 ~/.zshrc 和
~/.zshenv:
cat >> ~/.zshrc <<'EOF'
export PATH="/opt/homebrew/opt/ruby/bin:$PATH"
export PATH="$(/opt/homebrew/opt/ruby/bin/gem environment
gemdir)/bin:$PATH"
EOF
cat >> ~/.zshenv <<'EOF'
export PATH="/opt/homebrew/opt/ruby/bin:$PATH"
export PATH="$(/opt/homebrew/opt/ruby/bin/gem environment
gemdir)/bin:$PATH"
EOF
使配置生效:
source ~/.zshrc
which ruby
ruby -v
注意: ~/.zshenv 必須配置。因為通過 SSH 執(zhí)行遠程命令時,通常不會加載
~/.zshrc,如果只配 .zshrc,遠程腳本里很可能找不到 fastlane。
預期:
which ruby 返回 Homebrew Ruby 路徑
ruby -v 為 3.x
5.3.3 安裝 fastlane
如曾通過 brew 安裝 fastlane,建議先移除:
brew uninstall --force fastlane 2>/dev/null || true
安裝最新版:
gem install fastlane -NV
hash -r
fastlane --version
5.3.4 安裝 SVN
brew install subversion
svn --version | head -n 1
5.3.5 選擇 Xcode
確保當前激活的是完整 Xcode,而不是 Command Line Tools:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
xcodebuild -version
5.3.6 開啟遠程登錄
在遠程 Mac 上打開:


系統(tǒng)設置 → 通用 → 共享 → 遠程登錄
要求:
開啟“遠程登錄”
允許用戶 mac 登錄
本機可通過以下命令驗證:
ssh mac@localhost
5.3.7 飛書自定義機器人配置
創(chuàng)建自定義機器人流程:
飛書 PC 端 > 目標群聊 > 右上角「設置」 > 群機器人 > 添加機器人 >
自定義機器人 > 填寫機器人名稱 > 開啟簽名校驗 > 復制 Webhook
地址和密鑰

飛書機器人配置寫入
fastlane/.env。通過本地腳本觸發(fā)遠程打包時,remote_build.sh
會將飛書配置作為臨時環(huán)境變量傳遞給遠程 fastlane,用于發(fā)送打包結果通知。
相關配置如下:
FEISHU_WEBHOOK_URL='YOUR_FEISHU_WEBHOOK_URL'
FEISHU_WEBHOOK_SECRET='YOUR_FEISHU_WEBHOOK_SECRET'
5.4 本地 Mac 環(huán)境搭建
以下步驟在本地開發(fā)機執(zhí)行。
Homebrew
Ruby
fastlane
SVN
Tailscale
安裝方式可參考遠程 Mac 環(huán)境搭建。
5.5 Tailscale 配置
由于本地與遠程設備可能不處于同一局域網(wǎng),需要通過 Tailscale
建立可直連的私有網(wǎng)絡。
5.5.1 安裝 Tailscale
下載安裝:
https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg
本地和遠程機器都需要安裝。
5.5.2 登錄同一賬號
本地 Mac 和遠程 Mac 均需登錄同一個 Tailscale 賬號。
5.5.3 獲取設備地址
Tailscale 會為每臺設備分配:
MagicDNS 名稱
100.x.y.z 內(nèi)網(wǎng)地址
示例:
本地:100.115.253.94
遠程:100.121.8.61
5.5.4 驗證網(wǎng)絡連通
在本地執(zhí)行:
ping -c 3 100.121.8.61
如果可以 ping 通,說明本地與遠程已經(jīng)通過 Tailscale 連通。
5.6 SSH 免密登錄配置
5.6.1 生成密鑰
在本地執(zhí)行:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519
建議使用無 passphrase 密鑰,以便自動化腳本調(diào)用。
5.6.2 推送公鑰
ssh-copy-id -i ~/.ssh/id_ed25519.pub mac@100.121.8.61
5.6.3 配置 SSH Host
指定僅使用 ed25519,將以下內(nèi)容寫入 ~/.ssh/config:
cat >> ~/.ssh/config <<'EOF'
Host 100.121.8.61 macmac-mini macmac-mini.tail422e68.ts.net
User mac
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
EOF
設置權限:
chmod 600 ~/.ssh/config
5.6.4 驗證
ssh mac@100.121.8.61 "echo ok"
若能直接輸出 ok 且不要求輸入密碼,則說明配置成功。
5.7 App Store Connect API Key 配置
5.7.1 創(chuàng)建 API Key
在 App Store Connect 后臺創(chuàng)建 API Key:

路徑:
用戶和訪問 → 集成 → App Store Connect API / Team Keys
創(chuàng)建后記錄以下信息:
Key ID
Issuer ID
.p8 文件
注意:.p8 文件只能下載一次,必須妥善保存。
5.7.2 遠程機器存放路徑
例如:
/Users/mac/Desktop/builder/AuthKey_XXXXXXXXXX.p8
5.7.3 寫入 fastlane/.env文件
# App Store Connect API Key 配置
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEY_FILEPATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
5.8 SVN 憑據(jù)配置
5.8.1 首次手動認證
在遠程工作副本目錄執(zhí)行一次:
cd [工程根目錄]
svn update
根據(jù)提示輸入 SVN 用戶名和密碼,認證信息將緩存到本機。
5.8.2 在 .env 中配置
為了保證 SSH 非交互執(zhí)行時也能穩(wěn)定更新代碼,可在 fastlane/.env
文件中配置:
# SVN 憑據(jù)配置
SVN_USERNAME=YOUR_SVN_USERNAME
SVN_PASSWORD='YOUR_SVN_PASSWORD'
5.9 Keychain 與簽名權限配置
5.9.1 常見問題
遠程 SSH 打包時,常見報錯:
errSecInternalComponent
Command CodeSign failed with a nonzero exit code
根本原因在 SSH 非交互會話中,即使簽名證書已存在,codesign
也可能因為權限問題無法訪問私鑰:
login.keychain 可能未解鎖
私鑰 ACL 默認未授權 codesign
解決方案是在遠程 Mac 本機執(zhí)行一次:
security set-key-partition-list \
-S apple-tool:,apple:,codesign: -s \
-k '遠程mac登錄密碼' \
~/Library/Keychains/login.keychain-db
5.9.2 自動解鎖所需配置
在 fastlane/.env 中配置遠程 Mac 登錄密碼:
REMOTE_KEYCHAIN_PASSWORD='YOUR_MAC_LOGIN_PASSWORD'
該密碼為遠程打包機登錄用戶的系統(tǒng)密碼,用于在 SSH 非圖形會話中解鎖
login.keychain。
security unlock-keychain
security set-key-partition-list
5.9.3 鑰匙串中證書訪問控制權限打開

5.10 工程fastlane 配置
5.10.1 初始化Fastlane
cd [工程根目錄]
fastlane init

選 4. Manual setup(最干凈,不會自動塞 Apple ID
登錄邏輯),之后一直Enter 確認
5.10.2 創(chuàng)建 .env 和 .env.example
touch fastlane/.env fastlane/.env.example
5.10.3 創(chuàng)建 remote_build.sh
mkdir -p scripts # 創(chuàng)建 scripts 目錄
touch scripts/remote_build.sh # 創(chuàng)建遠程打包腳本
chmod +x scripts/remote_build.sh # 添加執(zhí)行權限
5.10.4 目錄結構
AIEndorser/
fastlane/
Appfile # 應用標識、團隊信息
Fastfile # 打包主流程
.env # 敏感配置
.env.example # 配置模板,可入庫
scripts/
remote_build.sh # 本地觸發(fā)遠程打包的 SSH 封裝腳本
ipa/ # 打包產(chǎn)物目錄
5.10.5 Appfile
app_identifier("")
team_id("")
字段說明:
app_identifier:發(fā)布使用的 Bundle ID,必須與 App Store Connect
中的應用記錄完全一致
team_id:Apple Developer Team ID
5.10.6 Fastfile
require "shellwords"
require "socket"
require "open3"
require "base64"
require "json"
require "net/http"
require "openssl"
require "uri"
default_platform(:ios)
APP_NAME = ENV["APP_NAME"].to_s.strip
WORKSPACE = ENV["WORKSPACE"].to_s.strip
OUTPUT_DIR = ENV["OUTPUT_DIR"].to_s.strip
EXPORT_METHOD = ENV["EXPORT_METHOD"].to_s.strip
# 默認統(tǒng)一使用 APP_NAME,需要特殊
Scheme、文件名前綴或通知標題時再單獨通過環(huán)境變量覆蓋。
SCHEME = (ENV["SCHEME"].to_s.strip.empty? ? APP_NAME :
ENV["SCHEME"].strip)
IPA_NAME_PREFIX = (ENV["IPA_NAME_PREFIX"].to_s.strip.empty? ? APP_NAME :
ENV["IPA_NAME_PREFIX"].strip)
FEISHU_PROJECT_NAME = (ENV["FEISHU_PROJECT_NAME"].to_s.strip.empty? ?
APP_NAME : ENV["FEISHU_PROJECT_NAME"].strip)
NOTIFICATION_TITLE = (ENV["NOTIFICATION_TITLE"].to_s.strip.empty? ?
APP_NAME : ENV["NOTIFICATION_TITLE"].strip)
XCODE_PATH_FOR_BUILD = ENV["XCODE_PATH"].to_s.strip
PROJECT_ROOT = File.expand_path("..", __dir__)
WORKSPACE_PATH = File.join(PROJECT_ROOT, WORKSPACE)
# 本地執(zhí)行 fastlane 時,按 fastlane/.env 中的 REMOTE_HOST 轉(zhuǎn)發(fā)到遠端
Mac 打包。
REMOTE_BUILD_HOST = ENV["REMOTE_HOST"].to_s.strip
# 兼容 IP、主機名、MagicDNS
名稱,統(tǒng)一解析成可比較的候選地址集合。
def resolve_host_candidates(host)
candidates = [host.to_s.strip].reject(&:empty?)
begin
candidates.concat(
Addrinfo.getaddrinfo(host, nil, :UNSPEC,
:STREAM).map(&:ip_address).compact
)
rescue SocketError
# 主機名無法解析時保留原值,后續(xù)仍可按字面值比較。
end
candidates.map(&:downcase).uniq
end
# 收集當前機器的主機名和非 loopback
IP,用來判斷當前是否已經(jīng)在遠端打包機上。
def local_host_candidates
candidates = []
begin
hostname = Socket.gethostname.to_s.strip
candidates << hostname unless hostname.empty?
rescue SocketError
# 忽略主機名讀取失敗,后續(xù)繼續(xù)用本機 IP 判斷。
end
Socket.ip_address_list.each do |addr|
ip = addr.ip_address.to_s.strip
next if ip.empty?
next if addr.ipv4_loopback? || addr.ipv6_loopback?
candidates << ip
end
candidates.map(&:downcase).uniq
end
# 本地 Mac 直接運行 fastlane 時,自動轉(zhuǎn)發(fā)到遠端;
# 遠端通過 REMOTE_BUILD_SESSION=1
再次進入時,則繼續(xù)執(zhí)行真正的打包流程。
def should_delegate_remote_build?
return false if ENV["REMOTE_BUILD_SESSION"] == "1"
return false if ENV["FORCE_LOCAL_BUILD"] == "1"
remote_candidates = resolve_host_candidates(REMOTE_BUILD_HOST)
!(remote_candidates & local_host_candidates).any?
end
# 配置項統(tǒng)一放在
fastlane/.env,缺少時提前終止,避免打包到半路才失敗。
def validate_required_env!(*keys)
missing_keys = keys.select { |key| ENV[key].to_s.strip.empty? }
return if missing_keys.empty?
UI.user_error!("缺少環(huán)境變量 #{missing_keys.join(", ")},請在
fastlane/.env 中配置")
end
# 從 xcodebuild 的 build settings
中讀取版本號,保證輸出和工程當前配置一致。
def fetch_version_info(configuration)
command = [
"xcodebuild",
"-workspace", WORKSPACE_PATH,
"-scheme", SCHEME,
"-configuration", configuration,
"-showBuildSettings"
]
# 這里只是為了打印 Version /
Build,不應該因為讀取失敗而阻斷真正的打包流程。
output, status = Open3.capture2e(*command, chdir: PROJECT_ROOT)
unless status.success?
UI.important("讀取版本號失敗,跳過版本信息打印: exit
#{status.exitstatus}")
UI.verbose(output)
return ["", ""]
end
marketing_version = output[/^\s*MARKETING_VERSION = (.+)$/,
1].to_s.strip
build_version = output[/^\s*CURRENT_PROJECT_VERSION = (.+)$/,
1].to_s.strip
[marketing_version, build_version]
end
# 當前工程目錄不一定就是 SVN 根目錄,所以向上查找最近的
.svn。
def find_svn_root(start_dir)
current_dir = File.expand_path(start_dir)
loop do
return current_dir if File.directory?(File.join(current_dir,
".svn"))
parent_dir = File.expand_path("..", current_dir)
return nil if parent_dir == current_dir
current_dir = parent_dir
end
end
# 根據(jù)環(huán)境變量拼裝 svn update 命令。
# 如果配置了用戶名密碼,就走非交互模式,避免遠端 SSH
會話里卡在認證提示。
def svn_update_command
username = ENV["SVN_USERNAME"].to_s.strip
password = ENV["SVN_PASSWORD"].to_s
if username.empty? && password.empty?
return ["svn", "update"]
end
if username.empty? || password.empty?
UI.user_error!("SVN_USERNAME 和 SVN_PASSWORD
需要同時配置,或者都不配置")
end
[
"svn", "update",
"--username", username,
"--password", password,
"--non-interactive"
]
end
# 用 Open3 執(zhí)行 svn update,避免 fastlane
把帶密碼的命令完整打印到日志里。
def run_svn_update!(svn_root)
command = svn_update_command
using_env_credentials = command.include?("--password")
UI.message("使用 fastlane/.env 中的 SVN 憑據(jù)執(zhí)行 svn update") if
using_env_credentials
output, status = Open3.capture2e(*command, chdir: svn_root)
output.to_s.each_line do |line|
line = line.rstrip
next if line.empty?
UI.message(line)
end
return if status.success?
UI.user_error!("svn update 失敗,退出碼: #{status.exitstatus}")
end
# 發(fā)送飛書群機器人通知;通知失敗不阻斷打包主流程。
def send_feishu_notification(status:, configuration:, marketing_version:
"", build_version: "", ipa_path: "", error_message: nil)
webhook_url = ENV["FEISHU_WEBHOOK_URL"].to_s.strip
return if webhook_url.empty?
lines = [
"#{FEISHU_PROJECT_NAME} iOS 自動化打包通知",
"狀態(tài):#{status}",
"環(huán)境:#{configuration}",
"版本:#{marketing_version.empty? ? "-" : marketing_version}",
"Build:#{build_version.empty? ? "-" : build_version}",
"機器:#{Socket.gethostname}",
"時間:#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}"
]
lines << "ipa:#{ipa_path}" unless ipa_path.to_s.empty?
lines << "錯誤:#{error_message}" unless
error_message.to_s.empty?
payload = {
msg_type: "text",
content: {
text: lines.join("\n")
}
}
secret = ENV["FEISHU_WEBHOOK_SECRET"].to_s.strip
unless secret.empty?
timestamp = Time.now.to_i.to_s
string_to_sign = "#{timestamp}\n#{secret}"
payload[:timestamp] = timestamp
payload[:sign] = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256",
string_to_sign, ""))
end
uri = URI(webhook_url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme ==
"https", open_timeout: 5, read_timeout: 10) do |http|
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request.body = JSON.generate(payload)
http.request(request)
end
body = JSON.parse(response.body) rescue {}
if !response.is_a?(Net::HTTPSuccess) || body["code"].to_i != 0
UI.important("飛書通知發(fā)送失敗: HTTP #{response.code}
#{body["msg"]}")
end
rescue => e
UI.important("飛書通知發(fā)送失敗: #{e.message}")
end
platform :ios do
desc <<~DESC
統(tǒng)一打包入口:本地執(zhí)行時自動轉(zhuǎn)發(fā)到遠端打包機;遠端執(zhí)行時本機打包并上傳
TestFlight
用法:
fastlane ios build # 交互式選擇 Debug /
Release,本地會自動判斷是否轉(zhuǎn)發(fā)遠端
fastlane ios build configuration:Debug # 打
Debug,本地默認自動轉(zhuǎn)發(fā)到遠端
fastlane ios build configuration:Release # 打
Release,本地默認自動轉(zhuǎn)發(fā)到遠端
指定 Xcode(覆蓋 fastlane/.env 中的 XCODE_PATH):
XCODE_PATH=/Applications/Xcode.app fastlane ios build
configuration:Release
指定遠端打包機(覆蓋 fastlane/.env 中的 REMOTE_HOST):
REMOTE_HOST=macmac-mini.tail422e68.ts.net fastlane ios build
configuration:Release
強制在當前機器本地執(zhí)行,不走遠端轉(zhuǎn)發(fā):
FORCE_LOCAL_BUILD=1 fastlane ios build configuration:Release
必須配置 fastlane/.env 或臨時環(huán)境變量(App Store Connect API
Key):
ASC_KEY_ID=XXXXXXXXXX # Key ID
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Issuer ID
ASC_KEY_FILEPATH=/path/to/AuthKey_XXXXXXXXXX.p8 # .p8 私鑰絕對路徑
如需在 fastlane 中自動執(zhí)行 svn update,可在 fastlane/.env 中配置:
SVN_USERNAME=你的SVN賬號
SVN_PASSWORD='你的SVN密碼'
如需讓本地 fastlane / remote_build.sh 自動解鎖遠端登錄鑰匙串,可在
fastlane/.env 中配置:
REMOTE_KEYCHAIN_PASSWORD='遠端Mac登錄密碼'
如果是本地轉(zhuǎn)發(fā)到遠端,首次執(zhí)行可能會提示輸入遠端 login.keychain
密碼,
用于 SSH 會話下解鎖鑰匙串,確保 codesign 和上傳流程可用。
無論 Debug 還是 Release,導出方式都按 fastlane/.env 中的 EXPORT_METHOD
執(zhí)行,
所以對應 build configuration 必須配置 distribution 簽名。
產(chǎn)物:
#{OUTPUT_DIR}/#{IPA_NAME_PREFIX}_<Configuration>_<時間戳>.ipa
DESC
lane :build do |options|
# 先確定打包環(huán)境,支持命令行直接傳,也支持交互式選擇。
configuration = (options[:configuration] || "").to_s.strip
if configuration.empty?
puts ""
puts "========================================"
puts "請選擇打包環(huán)境:"
puts " 1) Debug"
puts " 2) Release"
puts "========================================"
print "輸入 1 或 2: "
input = STDIN.gets.to_s.strip
case input
when "1" then configuration = "Debug"
when "2" then configuration = "Release"
else UI.user_error!("無效的選擇: #{input}")
end
end
unless %w[Debug Release].include?(configuration)
UI.user_error!("configuration 必須是 Debug 或 Release,當前:
#{configuration}")
end
validate_required_env!(
"APP_NAME",
"WORKSPACE",
"OUTPUT_DIR",
"EXPORT_METHOD",
"REMOTE_HOST"
)
# 當前機器不是遠端打包機時,直接復用現(xiàn)有 SSH
腳本把流程轉(zhuǎn)發(fā)過去。
if should_delegate_remote_build?
UI.important("當前不在遠端打包機上,轉(zhuǎn)發(fā)到遠端:
#{REMOTE_BUILD_HOST}")
Dir.chdir(PROJECT_ROOT) do
sh("./scripts/remote_build.sh
#{Shellwords.escape(configuration)}")
end
next
end
# 真正進入打包機后,再校驗上傳所需的 App Store Connect
憑據(jù)。
%w[XCODE_PATH ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_FILEPATH].each do
|k|
UI.user_error!("缺少環(huán)境變量 #{k},無法上傳 TestFlight") if
ENV[k].to_s.empty?
end
ENV["DEVELOPER_DIR"] =
"#{XCODE_PATH_FOR_BUILD}/Contents/Developer"
unless File.exist?(ENV["ASC_KEY_FILEPATH"])
UI.user_error!("ASC_KEY_FILEPATH 指向的文件不存在:
#{ENV["ASC_KEY_FILEPATH"]}")
end
xcode_ver = sh("xcodebuild -version | head -n 1", log:
false).to_s.strip
marketing_version, build_version =
fetch_version_info(configuration)
UI.important("========== 構建配置 ==========")
UI.important("App : #{APP_NAME}")
UI.important("Xcode : #{xcode_ver}")
UI.important("Workspace : #{WORKSPACE}")
UI.important("Scheme : #{SCHEME}")
UI.important("Config : #{configuration}")
UI.important("Version : #{marketing_version}") unless
marketing_version.empty?
UI.important("Build : #{build_version}") unless
build_version.empty?
UI.important("輸出目錄 : #{OUTPUT_DIR}")
UI.important("==============================")
# 先更新 SVN,避免遠端打包機拿到的是舊代碼。
svn_root = find_svn_root(PROJECT_ROOT)
if svn_root
Dir.chdir(svn_root) do
UI.message("========== [1/4] 檢查并更新 SVN ==========")
UI.important("SVN 根目錄: #{svn_root}")
status_before = sh("svn status || true", log: false).to_s.strip
UI.important("svn status(更新前):\n#{status_before}") unless
status_before.empty?
run_svn_update!(svn_root)
status_after = sh("svn status || true", log: false).to_s
if status_after.lines.any? { |line| line =~ /^(C|!|~)/ }
UI.user_error!("檢測到 SVN 沖突,終止打包:\n#{status_after}")
end
UI.success("SVN 更新完成")
end
else
UI.important("未檢測到 .svn 目錄,跳過 svn update")
end
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
ipa_name = "#{IPA_NAME_PREFIX}_#{configuration}_#{timestamp}"
# 主工程 archive/export。
UI.message("========== [2/4] 開始打包 ==========")
begin
build_app(
workspace: WORKSPACE_PATH,
scheme: SCHEME,
configuration: configuration,
export_method: EXPORT_METHOD,
output_directory: OUTPUT_DIR,
output_name: "#{ipa_name}.ipa",
clean: true,
silent: false,
include_bitcode: false,
include_symbols: true
)
rescue => e
UI.error("打包失敗: #{e.message}")
send_feishu_notification(
status: "打包失敗",
configuration: configuration,
marketing_version: marketing_version,
build_version: build_version,
error_message: e.message
)
raise
end
# 保留
ipa,刪除同目錄下其余臨時產(chǎn)物,方便后續(xù)定位最新包。
UI.message("========== [3/4] 清理非 ipa 產(chǎn)物 ==========")
abs_output = File.expand_path(OUTPUT_DIR, PROJECT_ROOT)
Dir.glob("#{abs_output}/*").each do |f|
next if f.end_with?(".ipa")
FileUtils.rm_rf(f)
UI.message("已刪除: #{File.basename(f)}")
end
ipa_path = "#{abs_output}/#{ipa_name}.ipa"
# 使用 App Store Connect API Key 上傳到 TestFlight。
UI.message("========== [4/4] 上傳 TestFlight ==========")
begin
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: ENV["ASC_KEY_FILEPATH"],
duration: 1200,
in_house: false
)
upload_to_testflight(
api_key: api_key,
ipa: ipa_path,
skip_waiting_for_build_processing: true,
skip_submission: true
)
UI.success("已上傳至 App Store Connect,進入 TestFlight 處理隊列")
rescue => e
UI.error("上傳失敗: #{e.message}")
send_feishu_notification(
status: "上傳失敗",
configuration: configuration,
marketing_version: marketing_version,
build_version: build_version,
ipa_path: ipa_path,
error_message: e.message
)
raise
end
send_feishu_notification(
status: "打包成功,已上傳 TestFlight",
configuration: configuration,
marketing_version: marketing_version,
build_version: build_version,
ipa_path: ipa_path
)
UI.success("==============================")
UI.success("打包并上傳成功")
UI.success("環(huán)境: #{configuration}")
UI.success("ipa : #{ipa_path}")
UI.success("==============================")
# 打包完成后給本機一個通知,便于后臺等待時感知結果。
notification(title: NOTIFICATION_TITLE, message: "#{configuration} 上傳
TestFlight 成功")
end
end
遠程轉(zhuǎn)發(fā)判斷
本地執(zhí)行 fastlane ios build
時,如果檢測到當前并非遠程打包機,則自動轉(zhuǎn)發(fā)到遠程。
跳過轉(zhuǎn)發(fā)條件:
REMOTE_BUILD_SESSION=1
FORCE_LOCAL_BUILD=1
SVN 更新
Fastfile 會:
自動查找 .svn 根目錄
根據(jù)是否存在 SVN 憑據(jù)拼接非交互更新命令
檢查沖突狀態(tài),發(fā)現(xiàn)異常立即終止
打包參數(shù)
核心參數(shù)包括:
YAML
workspace: WORKSPACE_PATH,
scheme: SCHEME,
configuration: configuration,
export_method: EXPORT_METHOD,
output_directory: OUTPUT_DIR,
output_name: "#{ipa_name}.ipa",
clean: true,
silent: false,
include_bitcode: false,
include_symbols: true
說明:
export_method 由 fastlane/.env 中的 EXPORT_METHOD 控制,當前推薦配置為
app-store。
因此對應 Build Configuration 必須配置 Distribution 簽名能力。
上傳 TestFlight
YAML
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: ENV["ASC_KEY_FILEPATH"],
duration: 1200,
in_house: false
)
upload_to_testflight(
api_key: api_key,
ipa: ipa_path,
skip_waiting_for_build_processing: true,
skip_submission: true
)
說明:
skip_waiting_for_build_processing:
true:上傳后不等待蘋果處理完成,縮短流水線時長
skip_submission: true:只上傳到 TestFlight,不自動提審
5.10.7 .env 示例
# App Store Connect API Key 配置
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEY_FILEPATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
# 項目打包配置
APP_NAME=YOUR_APP_NAME
WORKSPACE=YOUR_PROJECT.xcworkspace
OUTPUT_DIR=./ipa
EXPORT_METHOD=app-store
# SVN 憑據(jù)配置
SVN_USERNAME=YOUR_SVN_USERNAME
SVN_PASSWORD='YOUR_SVN_PASSWORD'
# 遠程打包機配置
REMOTE_USER=YOUR_REMOTE_MAC_USER
REMOTE_HOST=YOUR_REMOTE_HOST
REMOTE_DIR=/absolute/path/to/your/project
REMOTE_KEYCHAIN_PATH=/Users/YOUR_REMOTE_MAC_USER/Library/Keychains/login.keychain-db
REMOTE_KEYCHAIN_PASSWORD='YOUR_MAC_LOGIN_PASSWORD'
# 飛書機器人通知配置
FEISHU_WEBHOOK_URL='YOUR_FEISHU_WEBHOOK_URL'
FEISHU_WEBHOOK_SECRET='YOUR_FEISHU_WEBHOOK_SECRET'
# Xcode 路徑配置
XCODE_PATH=/Applications/Xcode.app
5.10.8 .env.example 示例
ASC_KEY_ID=替換成你的KeyID
ASC_ISSUER_ID=替換成你的IssuerID
ASC_KEY_FILEPATH=替換成本機上.p8文件的絕對路徑
APP_NAME=替換成應用名稱,例如AIEndorser
WORKSPACE=替換成工作區(qū)文件名,例如 AIEndorser.xcworkspace
OUTPUT_DIR=替換成ipa輸出目錄,例如 ./ipa
EXPORT_METHOD=替換成導出方式,例如 app-store
SVN_USERNAME=替換成你的SVN賬號
SVN_PASSWORD='替換成你的SVN密碼'
REMOTE_USER=替換成遠端Mac用戶名,例如mac
REMOTE_HOST=替換成遠端Mac的Tailscale IP或MagicDNS
,例如100.121.8.61
REMOTE_DIR=替換成遠端Mac上的項目絕對路徑
REMOTE_KEYCHAIN_PATH=替換成遠端Mac登錄鑰匙串路徑,例如/Users/mac/Library/Keychains/login.keychain-db
REMOTE_KEYCHAIN_PASSWORD='替換成遠端Mac登錄密碼'
FEISHU_WEBHOOK_URL='替換成飛書機器人Webhook地址'
FEISHU_WEBHOOK_SECRET='替換成飛書機器人簽名密鑰'
XCODE_PATH=替換成本機上要使用的Xcode.app絕對路徑,例如/Applications/Xcode.app
5.10.9 remote_build.sh 腳本
JavaScript
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_FASTLANE_ENV="${LOCAL_FASTLANE_ENV:-${SCRIPT_DIR}/../fastlane/.env}"
# 遠端打包機連接信息優(yōu)先從 fastlane/.env
讀取,也支持通過環(huán)境變量臨時覆蓋。
REMOTE_USER="${REMOTE_USER:-}"
REMOTE_HOST="${REMOTE_HOST:-}"
REMOTE_DIR="${REMOTE_DIR:-}"
REMOTE_KEYCHAIN_PATH="${REMOTE_KEYCHAIN_PATH:-}"
REMOTE_KEYCHAIN_PASSWORD="${REMOTE_KEYCHAIN_PASSWORD:-}"
CONFIG="${1:-Release}"
if [[ "$CONFIG" != "Debug" && "$CONFIG" != "Release" ]];
then
echo "CONFIG 必須是 Debug 或 Release,當前: $CONFIG"
exit 1
fi
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
# 只讀取當前腳本需要的少量 key,避免直接 source 整個 .env
帶來解析差異。
read_env_value() {
local key="$1"
local env_file="$2"
local line value
[[ -f "$env_file" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim_whitespace "$line")"
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
[[ "$line" == export\ * ]] && line="$(trim_whitespace
"${line#export }")"
[[ "$line" == "$key="* ]] || continue
value="${line#"$key="}"
value="$(trim_whitespace "$value")"
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
printf '%s' "$value"
return 0
done < "$env_file"
return 1
}
# 直接跑腳本時,也能復用 fastlane/.env 里的遠端鑰匙串密碼和 SVN
憑據(jù)。
load_local_defaults() {
local env_file="$1"
[[ -f "$env_file" ]] || return 0
if [[ -z "${REMOTE_USER:-}" ]]; then
REMOTE_USER="$(read_env_value "REMOTE_USER" "$env_file" || true)"
fi
if [[ -z "${REMOTE_HOST:-}" ]]; then
REMOTE_HOST="$(read_env_value "REMOTE_HOST" "$env_file" || true)"
fi
if [[ -z "${REMOTE_DIR:-}" ]]; then
REMOTE_DIR="$(read_env_value "REMOTE_DIR" "$env_file" || true)"
fi
if [[ -z "${REMOTE_KEYCHAIN_PATH:-}" ]]; then
REMOTE_KEYCHAIN_PATH="$(read_env_value "REMOTE_KEYCHAIN_PATH"
"$env_file" || true)"
fi
if [[ -z "${REMOTE_KEYCHAIN_PASSWORD:-}" ]]; then
REMOTE_KEYCHAIN_PASSWORD="$(read_env_value "REMOTE_KEYCHAIN_PASSWORD"
"$env_file" || true)"
fi
if [[ -z "${SVN_USERNAME:-}" ]]; then
SVN_USERNAME="$(read_env_value "SVN_USERNAME" "$env_file" ||
true)"
fi
if [[ -z "${SVN_PASSWORD:-}" ]]; then
SVN_PASSWORD="$(read_env_value "SVN_PASSWORD" "$env_file" ||
true)"
fi
if [[ -z "${FEISHU_WEBHOOK_URL:-}" ]]; then
FEISHU_WEBHOOK_URL="$(read_env_value "FEISHU_WEBHOOK_URL" "$env_file" ||
true)"
fi
if [[ -z "${FEISHU_WEBHOOK_SECRET:-}" ]]; then
FEISHU_WEBHOOK_SECRET="$(read_env_value "FEISHU_WEBHOOK_SECRET"
"$env_file" || true)"
fi
if [[ -z "${APP_NAME:-}" ]]; then
APP_NAME="$(read_env_value "APP_NAME" "$env_file" || true)"
fi
if [[ -z "${NOTIFICATION_TITLE:-}" ]]; then
NOTIFICATION_TITLE="$(read_env_value "NOTIFICATION_TITLE" "$env_file" ||
true)"
fi
}
load_local_defaults "$LOCAL_FASTLANE_ENV"
if [[ -z "${REMOTE_USER:-}" || -z "${REMOTE_HOST:-}" || -z
"${REMOTE_DIR:-}" ]]; then
echo "缺少遠端配置,請在 fastlane/.env 中配置
REMOTE_USER、REMOTE_HOST、REMOTE_DIR"
exit 1
fi
if [[ -z "${APP_NAME:-}" ]]; then
echo "缺少應用配置,請在 fastlane/.env 中配置 APP_NAME"
exit 1
fi
NOTIFICATION_TITLE="${NOTIFICATION_TITLE:-$APP_NAME}"
if [[ -n "${REMOTE_KEYCHAIN_PASSWORD:-}" && -z
"${REMOTE_KEYCHAIN_PATH:-}" ]]; then
echo "已配置 REMOTE_KEYCHAIN_PASSWORD,請同時在 fastlane/.env 中配置
REMOTE_KEYCHAIN_PATH"
exit 1
fi
#
本地手動觸發(fā)時,如果沒有提前傳密碼,就允許交互式輸入遠端鑰匙串密碼。
if [[ -z "$REMOTE_KEYCHAIN_PASSWORD" && -t 0 && -t 1 ]];
then
# 本地交互執(zhí)行時,允許先輸入遠端登錄鑰匙串密碼,避免 SSH
會話簽名失敗。
read -r -s -p "請輸入遠端 login.keychain 密碼(直接回車則跳過): "
REMOTE_KEYCHAIN_PASSWORD
echo
fi
if [[ -n "${REMOTE_KEYCHAIN_PASSWORD:-}" && -z
"${REMOTE_KEYCHAIN_PATH:-}" ]]; then
echo "需要解鎖遠端鑰匙串,請在 fastlane/.env 中配置
REMOTE_KEYCHAIN_PATH"
exit 1
fi
# 拼接遠端命令時統(tǒng)一做 shell 轉(zhuǎn)義,避免路徑里有空格時出錯。
shell_quote() {
printf '%q' "$1"
}
# SSH 返回后在本地電腦彈通知,讓觸發(fā)人不用盯著終端等結果。
local_notify() {
local title="$1"
local message="$2"
command -v osascript >/dev/null 2>&1 || return 0
osascript - "$title" "$message" <<'APPLESCRIPT' >/dev/null
2>&1 || true
on run argv
display notification (item 2 of argv) with title (item 1 of argv)
end run
APPLESCRIPT
}
echo "========================================"
echo "遠程打包觸發(fā)"
echo " 目標: ${REMOTE_USER}@${REMOTE_HOST}"
echo " 工作目錄: ${REMOTE_DIR}"
echo " 配置: ${CONFIG}"
if [[ -n "$REMOTE_KEYCHAIN_PASSWORD" ]]; then
echo " Keychain: ${REMOTE_KEYCHAIN_PATH}"
fi
echo "========================================"
# 按步驟拼裝一段遠端 bash 腳本,最后通過 ssh 一次性發(fā)送過去執(zhí)行。
REMOTE_SCRIPT=$'set -euo pipefail\n'
if [[ -n "$REMOTE_KEYCHAIN_PASSWORD" ]]; then
# SSH 非圖形會話下,先把目標鑰匙串加入搜索列表并設為默認,再解鎖和放通
codesign 訪問私鑰。
REMOTE_SCRIPT+=$'security list-keychains -d user -s '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_KEYCHAIN_PATH")"
REMOTE_SCRIPT+=$'\n'
REMOTE_SCRIPT+=$'security default-keychain -d user -s '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_KEYCHAIN_PATH")"
REMOTE_SCRIPT+=$'\n'
REMOTE_SCRIPT+=$'security unlock-keychain -p '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_KEYCHAIN_PASSWORD")"
REMOTE_SCRIPT+=$' '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_KEYCHAIN_PATH")"
REMOTE_SCRIPT+=$'\n'
REMOTE_SCRIPT+=$'security set-key-partition-list -S apple-tool:,apple:
-s -k '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_KEYCHAIN_PASSWORD")"
REMOTE_SCRIPT+=$' '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_KEYCHAIN_PATH")"
REMOTE_SCRIPT+=$'\n'
fi
# 切到遠端工程目錄后,進入 fastlane 主入口。
# REMOTE_BUILD_SESSION=1 用來告訴
Fastfile:當前已經(jīng)在遠端,不要再次遞歸轉(zhuǎn)發(fā)。
REMOTE_SCRIPT+=$'cd '
REMOTE_SCRIPT+="$(shell_quote "$REMOTE_DIR")"
REMOTE_SCRIPT+=$'\n'
if [[ -n "${SVN_USERNAME:-}" ]]; then
REMOTE_SCRIPT+=$'SVN_USERNAME='
REMOTE_SCRIPT+="$(shell_quote "$SVN_USERNAME")"
REMOTE_SCRIPT+=$' '
fi
if [[ -n "${SVN_PASSWORD:-}" ]]; then
REMOTE_SCRIPT+=$'SVN_PASSWORD='
REMOTE_SCRIPT+="$(shell_quote "$SVN_PASSWORD")"
REMOTE_SCRIPT+=$' '
fi
if [[ -n "${FEISHU_WEBHOOK_URL:-}" ]]; then
REMOTE_SCRIPT+=$'FEISHU_WEBHOOK_URL='
REMOTE_SCRIPT+="$(shell_quote "$FEISHU_WEBHOOK_URL")"
REMOTE_SCRIPT+=$' '
fi
if [[ -n "${FEISHU_WEBHOOK_SECRET:-}" ]]; then
REMOTE_SCRIPT+=$'FEISHU_WEBHOOK_SECRET='
REMOTE_SCRIPT+="$(shell_quote "$FEISHU_WEBHOOK_SECRET")"
REMOTE_SCRIPT+=$' '
fi
REMOTE_SCRIPT+=$'REMOTE_BUILD_SESSION=1 fastlane ios build
configuration:'
REMOTE_SCRIPT+="$(shell_quote "$CONFIG")"
# 強制分配 TTY,兼容從 fastlane 內(nèi)部調(diào)用腳本時的遠端 fastlane / security
/ codesign 場景。
set +e
ssh -tt "${REMOTE_USER}@${REMOTE_HOST}" \
"bash -lc $(shell_quote "$REMOTE_SCRIPT")"
SSH_STATUS=$?
set -e
if [[ "$SSH_STATUS" -eq 0 ]]; then
local_notify "$NOTIFICATION_TITLE" "${CONFIG} 打包并上傳成功"
else
local_notify "$NOTIFICATION_TITLE" "${CONFIG}
打包失敗,請查看終端日志"
fi
exit "$SSH_STATUS"
腳本執(zhí)行時會完成以下操作:
- 從本地 fastlane/.env 讀?。?/p>
- REMOTE_USER
- REMOTE_HOST
- REMOTE_DIR
- REMOTE_KEYCHAIN_PATH
- REMOTE_KEYCHAIN_PASSWORD
- SVN_USERNAME
- SVN_PASSWORD
- FEISHU_WEBHOOK_URL
- FEISHU_WEBHOOK_SECRET
- APP_NAME
- NOTIFICATION_TITLE
- 校驗遠程打包配置:
- 檢查 REMOTE_USER、REMOTE_HOST、REMOTE_DIR、APP_NAME
- 如果配置了 REMOTE_KEYCHAIN_PASSWORD,則必須同時配置
REMOTE_KEYCHAIN_PATH
- 如果缺少必要配置,則終止執(zhí)行
- 組裝遠程執(zhí)行腳本:
- 設置默認 keychain
- unlock-keychain
- set-key-partition-list
- 進入遠程工程目錄
- 傳遞 SVN 憑據(jù)
- 傳遞飛書機器人配置
- 帶上 REMOTE_BUILD_SESSION=1 執(zhí)行 fastlane ios build
- 使用 ssh -tt 強制分配 TTY,以兼容 security / codesign / fastlane
這類對終端環(huán)境敏感的命令
6. 使用方式
6.1 本地觸發(fā)遠程打包
cd [工程根目錄]
./scripts/remote_build.sh Debug
./scripts/remote_build.sh Release
6.2 通過 fastlane 自動轉(zhuǎn)發(fā)
cd [工程根目錄]
fastlane ios build
fastlane ios build configuration:Debug
fastlane ios build configuration:Release
6.3 在遠程機器本機執(zhí)行
cd [工程根目錄]
fastlane ios build configuration:Release
該方式適合排查遠程環(huán)境問題。
6.4 查看日志
ssh mac@100.121.8.61 "ls -lt /Users/mac/Library/Logs/gym/ | head"
ssh mac@100.121.8.61 "tail -n 200
/Users/mac/Library/Logs/gym/AIEndorser-AIEndorser.log"
7. 常見問題
7.1 gem 擴展無法加載
現(xiàn)象:
Ignoring ffi...
Ignoring json...
原因:使用了系統(tǒng) Ruby。
處理:切換到 Homebrew Ruby,并確保 ~/.zshenv 正確生效。
7.2 SSH 無法連接
現(xiàn)象:
Connection closed by ... port 22
原因:網(wǎng)絡不可達或未通過 Tailscale 建立連通。
處理:檢查 Tailscale 登錄狀態(tài)與內(nèi)網(wǎng)地址。
7.3 SSH 每次要求輸入密碼
原因:仍在使用帶 passphrase 的舊密鑰,或 SSH 配置未生效。
處理:重新生成無密碼 ed25519,并配置 IdentityFile。
7.4 簽名失敗
現(xiàn)象:
errSecInternalComponent
CodeSign failed
原因:Keychain 未解鎖或 codesign 無權限訪問私鑰。
處理:執(zhí)行 set-key-partition-list,并確保 .env 中已配置
REMOTE_KEYCHAIN_PASSWORD。
7.5 已上傳但 Organizer 未顯示記錄
原因:命令行上傳不會同步寫入 Organizer 歷史。
處理:以 App Store Connect 的 TestFlight 頁面為準。