iOS 自动化远程打包
说明:本文由原 Word 文档整理为 Markdown。文中的本地图片路径为
media/xxx.png,发布到简书时需要将图片上传到简书后替换为对应图片链接。
基于 Fastlane + Tailscale + SSH 的本地触发、远程打包、上传 TestFlight
完整方案
1. 目的
当前 iOS
项目出于账号和证书安全考虑,要求开发与打包环境隔离:本地开发机只配置个人开发账号,正式打包统一在远程打包机上完成。
因此,当前打包流程存在以下问题:
每次打包都需要通过 TeamViewer 远程操作打包机;
需要手动执行 SVN 更新,容易漏更新或更新不完整;
需要手动选择 Debug / Release 环境,存在选错风险;
Archive、导出 IPA、上传 TestFlight 都依赖人工操作,流程耗时较长;
缺少统一打包入口,不方便标准化管理和问题排查。
打包完成后需要人工在群内同步打包结果,通知不够及时。
为了解决以上问题,本方案采用 fastlane + Tailscale + SSH
实现远程自动化打包,主要优势如下:
本地只需执行一条命令,即可触发远程打包;
远程打包机自动完成 SVN 更新、Archive、IPA 导出和 TestFlight 上传;
支持 Debug / Release 参数化打包,减少人为选择错误;
不再需要每次通过 TeamViewer 手动操作打包机;
打包流程统一由 fastlane 管理,日志清晰,便于排查问题;
打包完成后通过飞书机器人自动通知群内成员,及时同步打包结果;
在保证发布权限集中管理的同时,提高打包效率和发布安全性。
1.1 效率评估
原方案耗时:TeamViewer连接远程打包机 + 手动 SVN 更新 + 选择打包环境 +
打包 + 上传 TestFlight + 群内通知,完整流程约 12
分钟左右,其中人工操作时间约 5 - 8 分钟。
新方案耗时:本地终端执行一条命令,完成后自动推送飞书群通知,人工操作时间小于
1 分钟,整体等待时间约 9 分钟左右。
一次性配置成本:首次配置本地和远程环境、SSH、Tailscale、fastlane、飞书机器人等,预计耗时约
40 分钟左右。配置完成后,后续可长期复用。
2. 方案原理
本方案基于以下工具实现:
fastlane:负责自动化构建、导出 IPA、上传 TestFlight
Tailscale:负责本地 Mac 与远程 Mac 之间的虚拟内网连接
SSH:负责本地远程执行命令
SVN:负责远程机器更新最新代码
Xcode / xcodebuild:负责真正的 iOS 编译和 Archive
App Store Connect API Key:负责无交互上传 TestFlight
Keychain:保存并授权访问 iOS 发布证书私钥
飞书自定义机器人:负责在打包成功或失败后,将结果通知到指定飞书群
2.1 核心原理
本地 Mac 不直接执行正式打包,而是通过 SSH 把打包任务转发到远程 Mac。
本地 Mac
|
| 执行 ./scripts/remote_build.sh Release
|
Tailscale 虚拟内网
|
| SSH
|
远程 Mac 打包机
|
| svn update
| xcodebuild archive
| export ipa
| upload_to_testflight
| 发送飞书通知
|
App Store Connect / TestFlight
|
飞书群通知:打包成功,已上传 TestFlight
2.2 本地触发原理
本地开发机执行:
./scripts/remote_build.sh Release
脚本会读取本地配置,然后通过 SSH 登录远程机器,并执行远程 fastlane
命令。
2.3 远程打包原理
远程机器接收到命令后,执行:
REMOTE_BUILD_SESSION=1 fastlane ios build configuration:Release
其中 REMOTE_BUILD_SESSION=1 用于标记当前已经处于远程打包会话,避免
Fastfile 再次递归转发,后续直接执行真正的打包流程。
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
生成上传凭据,然后调用:
upload_to_testflight
完成上传。
2.5 Keychain 解锁原理
远程 SSH 会话属于非图形化会话,即使证书已经安装在远程 Mac 上,也可能因为
Keychain 未解锁或私钥访问权限不足,导致签名失败。
常见错误:
errSecInternalComponent
Command CodeSign failed with a nonzero exit code
因此脚本在远程执行打包前,会先执行:
security unlock-keychain
security set-key-partition-list
确保 codesign 可以访问证书私钥。
3. 实现效果
该方案最终实现的效果如下。
3.1 本地一条命令触发远程打包
本地执行:
./scripts/remote_build.sh Debug
或:
./scripts/remote_build.sh Release
即可触发远程打包机完成完整构建流程。
3.2 支持 fastlane 自动转发
本地也可以直接执行:
fastlane ios build
fastlane ios build configuration:Debug
fastlane ios build configuration:Release
如果 Fastfile 检测到当前机器不是远程打包机,会自动转发到远程机器执行。
3.3 远程自动更新 SVN
远程打包前会自动查找 .svn 根目录,并执行:
svn update
如果配置了 SVN 用户名和密码,则会以非交互方式执行:
svn update --username xxx --password xxx --non-interactive
避免 SSH 远程执行时卡在认证输入阶段。
3.4 自动 Archive 和导出 IPA
Fastfile 中通过:
build_app
执行 Archive 和 Export IPA。
3.5 自动上传 TestFlight
打包完成后自动调用:
upload_to_testflight
上传到 App Store Connect。
3.6 统一输出 IPA 产物
IPA 输出到:
./ipa
命名格式:
AIEndorser_<Configuration>_<时间戳>.ipa
3.7 证书和上传权限集中管理
发布证书、Profile、App Store Connect API Key 全部保存在远程打包机。
本地开发机不需要保存正式发布证书,也不需要具备完整发布权限。
3.8 自动发送飞书通知
打包完成并成功上传 TestFlight 后,Fastfile 会自动调用飞书自定义机器人
Webhook,将打包结果发送到指定飞书群。
通知内容包括:
项目名称:AIEndorser
打包环境:Debug / Release
上传状态:成功
版本信息:Version / Build
IPA 名称:AIEndorser_Release_时间戳.ipa
通知结果:已上传 TestFlight
4. 安全风险评估
本方案采用 fastlane + Tailscale + SSH
实现远程自动化打包,本质上是对现有远程打包流程的自动化改造,不改变账号、证书和发布权限的管理方式。
4.1 发布证书未下发到本地开发机
本方案不会将发布证书、Provisioning Profile、App Store Connect API Key
等敏感信息分发到本地开发机。
正式发布能力仍然只保存在远程打包机上,本地开发机仅负责触发打包命令,不直接参与签名、导出和上传流程。
因此,不会增加发布证书泄露风险。
4.2 App Store Connect 权限仍集中在远程打包机
TestFlight 上传能力通过远程打包机上的 App Store Connect API Key 完成。
API Key 不需要下发到每位开发人员本地,也不会暴露在普通开发环境中。
因此,App Store Connect 上传权限仍然集中可控。
4.3 远程访问仅通过 Tailscale 内网和 SSH 完成
本方案通过 Tailscale
建立本地开发机与远程打包机之间的私有网络连接,再通过 SSH 执行远程命令。
相比直接暴露公网 IP 或长期使用 TeamViewer 手动操作,Tailscale + SSH
的访问方式更可控,也更方便限制设备和用户权限。
因此,远程访问风险可控。
4.4 自动化脚本不改变原有发布权限边界
该方案只是将原本需要人工执行的步骤自动化,包括:
SVN 更新代码;
Archive;
导出 IPA;
上传 TestFlight。
所有操作仍然发生在远程打包机上,不会把发布权限转移到本地开发机。
因此,不会改变公司现有的账号和证书隔离策略。
4.5 敏感配置文件可通过规范管理控制风险
方案中涉及的 .env 文件可能包含远程地址、SVN 凭据、Keychain 密码、App
Store Connect API Key 路径等配置。
该文件需要作为敏感文件管理:
.env 不提交代码仓库;
只提交 .env.example 模板;
仅授权人员维护远程打包机配置。
在遵守以上规范的前提下,敏感信息泄露风险可控。
综上评估
该方案符合开发与打包环境隔离要求,整体安全风险可控,可以在 iOS
团队内部接入使用,用于统一远程打包、提升打包效率,并减少人工操作带来的错误风险。
5. 具体方案
5.1 架构
5.1.1 关键依赖
本方案依赖以下工具与服务:

5.2 环境要求
5.2.1 远程打包机要求
远程打包机需要满足以下条件:
已安装 Homebrew
已安装 Homebrew Ruby
已通过 gem 安装 fastlane
已安装 Subversion
已安装可用 Xcode
已登录 Apple Developer 相关账号
已安装发布证书
已安装 Provisioning Profile
已开启 SSH 远程登录
已安装 Tailscale
与本地设备登录同一 Tailscale 账号
已配置 App Store Connect API Key
已配置 Keychain 签名权限
已配置飞书自定义机器人 Webhook,如开启签名校验,需配置飞书机器人签名密钥
5.2.2 本地开发机要求
本地开发机需要满足以下条件:
已安装 Homebrew
已安装 Ruby
已安装 fastlane
已安装 SVN
已安装 Tailscale
已配置到远程打包机的 SSH 免密登录
可访问项目代码目录
可执行 scripts/remote_build.sh
5.2.3 Xcode 版本要求
当前环境示例:
远程机器当前使用:Xcode 26.2
本地机器当前使用:Xcode 26.3
推荐通过环境变量控制构建使用的 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 环境搭建
| 以下步骤在远程打包机执行。 |
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 写入环境变量。这里要同时配置 ~/.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 执行远程命令时,通常不会加载
~/.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 上打开:


系统设置 → 通用 → 共享 → 远程登录
要求:
开启“远程登录”
允许用户 mac 登录
本机可通过以下命令验证:
ssh mac@localhost
5.3.7 飞书自定义机器人配置
创建自定义机器人流程:
飞书 PC 端 > 目标群聊 > 右上角「设置」 > 群机器人 > 添加机器人 >
自定义机器人 > 填写机器人名称 > 开启签名校验 > 复制 Webhook
地址和密钥

飞书机器人配置写入
fastlane/.env。通过本地脚本触发远程打包时,remote_build.sh
会将飞书配置作为临时环境变量传递给远程 fastlane,用于发送打包结果通知。
相关配置如下:
FEISHU_WEBHOOK_URL='YOUR_FEISHU_WEBHOOK_URL'
FEISHU_WEBHOOK_SECRET='YOUR_FEISHU_WEBHOOK_SECRET'
5.4 本地 Mac 环境搭建
以下步骤在本地开发机执行。
Homebrew
Ruby
fastlane
SVN
Tailscale
安装方式可参考远程 Mac 环境搭建。
5.5 Tailscale 配置
由于本地与远程设备可能不处于同一局域网,需要通过 Tailscale
建立可直连的私有网络。
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 内网地址
示例:
本地:100.115.253.94
远程:100.121.8.61
5.5.4 验证网络连通
在本地执行:
ping -c 3 100.121.8.61
如果可以 ping 通,说明本地与远程已经通过 Tailscale 连通。
5.6 SSH 免密登录配置
5.6.1 生成密钥
在本地执行:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519
建议使用无 passphrase 密钥,以便自动化脚本调用。
5.6.2 推送公钥
ssh-copy-id -i ~/.ssh/id_ed25519.pub mac@100.121.8.61
5.6.3 配置 SSH Host
指定仅使用 ed25519,将以下内容写入 ~/.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 创建 API Key
在 App Store Connect 后台创建 API Key:

路径:
用户和访问 → 集成 → App Store Connect API / Team Keys
创建后记录以下信息:
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 凭据配置
5.8.1 首次手动认证
在远程工作副本目录执行一次:
cd [工程根目录]
svn update
根据提示输入 SVN 用户名和密码,认证信息将缓存到本机。
5.8.2 在 .env 中配置
为了保证 SSH 非交互执行时也能稳定更新代码,可在 fastlane/.env
文件中配置:
# SVN 凭据配置
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 本机执行一次:
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'
该密码为远程打包机登录用户的系统密码,用于在 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 创建 .env 和 .env.example
touch fastlane/.env fastlane/.env.example
5.10.3 创建 remote_build.sh
mkdir -p scripts # 创建 scripts 目录
touch scripts/remote_build.sh # 创建远程打包脚本
chmod +x scripts/remote_build.sh # 添加执行权限
5.10.4 目录结构
AIEndorser/
fastlane/
Appfile # 应用标识、团队信息
Fastfile # 打包主流程
.env # 敏感配置
.env.example # 配置模板,可入库
scripts/
remote_build.sh # 本地触发远程打包的 SSH 封装脚本
ipa/ # 打包产物目录
5.10.5 Appfile
app_identifier("")
team_id("")
字段说明:
app_identifier:发布使用的 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
# 默认统一使用 APP_NAME,需要特殊
Scheme、文件名前缀或通知标题时再单独通过环境变量覆盖。
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)
# 本地执行 fastlane 时,按 fastlane/.env 中的 REMOTE_HOST 转发到远端
Mac 打包。
REMOTE_BUILD_HOST = ENV["REMOTE_HOST"].to_s.strip
# 兼容 IP、主机名、MagicDNS
名称,统一解析成可比较的候选地址集合。
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
# 主机名无法解析时保留原值,后续仍可按字面值比较。
end
candidates.map(&:downcase).uniq
end
# 收集当前机器的主机名和非 loopback
IP,用来判断当前是否已经在远端打包机上。
def local_host_candidates
candidates = []
begin
hostname = Socket.gethostname.to_s.strip
candidates << hostname unless hostname.empty?
rescue SocketError
# 忽略主机名读取失败,后续继续用本机 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 时,自动转发到远端;
# 远端通过 REMOTE_BUILD_SESSION=1
再次进入时,则继续执行真正的打包流程。
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
# 配置项统一放在
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!("缺少环境变量 #{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
# 根据环境变量拼装 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 执行 svn update,避免 fastlane
把带密码的命令完整打印到日志里。
def run_svn_update!(svn_root)
command = svn_update_command
using_env_credentials = command.include?("--password")
UI.message("使用 fastlane/.env 中的 SVN 凭据执行 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
# 发送飞书群机器人通知;通知失败不阻断打包主流程。
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 自动化打包通知",
"状态:#{status}",
"环境:#{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("飞书通知发送失败: HTTP #{response.code}
#{body["msg"]}")
end
rescue => e
UI.important("飞书通知发送失败: #{e.message}")
end
platform :ios do
desc <<~DESC
统一打包入口:本地执行时自动转发到远端打包机;远端执行时本机打包并上传
TestFlight
用法:
fastlane ios build # 交互式选择 Debug /
Release,本地会自动判断是否转发远端
fastlane ios build configuration:Debug # 打
Debug,本地默认自动转发到远端
fastlane ios build configuration:Release # 打
Release,本地默认自动转发到远端
指定 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
强制在当前机器本地执行,不走远端转发:
FORCE_LOCAL_BUILD=1 fastlane ios build configuration:Release
必须配置 fastlane/.env 或临时环境变量(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 中自动执行 svn update,可在 fastlane/.env 中配置:
SVN_USERNAME=你的SVN账号
SVN_PASSWORD='你的SVN密码'
如需让本地 fastlane / remote_build.sh 自动解锁远端登录钥匙串,可在
fastlane/.env 中配置:
REMOTE_KEYCHAIN_PASSWORD='远端Mac登录密码'
如果是本地转发到远端,首次执行可能会提示输入远端 login.keychain
密码,
用于 SSH 会话下解锁钥匙串,确保 codesign 和上传流程可用。
无论 Debug 还是 Release,导出方式都按 fastlane/.env 中的 EXPORT_METHOD
执行,
所以对应 build configuration 必须配置 distribution 签名。
产物:
#{OUTPUT_DIR}/#{IPA_NAME_PREFIX}_<Configuration>_<时间戳>.ipa
DESC
lane :build do |options|
# 先确定打包环境,支持命令行直接传,也支持交互式选择。
configuration = (options[:configuration] || "").to_s.strip
if configuration.empty?
puts ""
puts "========================================"
puts "请选择打包环境:"
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"
)
# 当前机器不是远端打包机时,直接复用现有 SSH
脚本把流程转发过去。
if should_delegate_remote_build?
UI.important("当前不在远端打包机上,转发到远端:
#{REMOTE_BUILD_HOST}")
Dir.chdir(PROJECT_ROOT) do
sh("./scripts/remote_build.sh
#{Shellwords.escape(configuration)}")
end
next
end
# 真正进入打包机后,再校验上传所需的 App Store Connect
凭据。
%w[XCODE_PATH ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_FILEPATH].each do
|k|
UI.user_error!("缺少环境变量 #{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,删除同目录下其余临时产物,方便后续定位最新包。
UI.message("========== [3/4] 清理非 ipa 产物 ==========")
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("环境: #{configuration}")
UI.success("ipa : #{ipa_path}")
UI.success("==============================")
# 打包完成后给本机一个通知,便于后台等待时感知结果。
notification(title: NOTIFICATION_TITLE, message: "#{configuration} 上传
TestFlight 成功")
end
end
远程转发判断
本地执行 fastlane ios build
时,如果检测到当前并非远程打包机,则自动转发到远程。
跳过转发条件:
REMOTE_BUILD_SESSION=1
FORCE_LOCAL_BUILD=1
SVN 更新
Fastfile 会:
自动查找 .svn 根目录
根据是否存在 SVN 凭据拼接非交互更新命令
检查冲突状态,发现异常立即终止
打包参数
核心参数包括:
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 凭据配置
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=替换成工作区文件名,例如 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}"
# 远端打包机连接信息优先从 fastlane/.env
读取,也支持通过环境变量临时覆盖。
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
凭据。
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
#
本地手动触发时,如果没有提前传密码,就允许交互式输入远端钥匙串密码。
if [[ -z "$REMOTE_KEYCHAIN_PASSWORD" && -t 0 && -t 1 ]];
then
# 本地交互执行时,允许先输入远端登录钥匙串密码,避免 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
# 拼接远端命令时统一做 shell 转义,避免路径里有空格时出错。
shell_quote() {
printf '%q' "$1"
}
# SSH 返回后在本地电脑弹通知,让触发人不用盯着终端等结果。
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 "远程打包触发"
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 一次性发送过去执行。
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:当前已经在远端,不要再次递归转发。
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 内部调用脚本时的远端 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"
脚本执行时会完成以下操作:
- 从本地 fastlane/.env 读取:
- 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
- 如果缺少必要配置,则终止执行
- 组装远程执行脚本:
- 设置默认 keychain
- unlock-keychain
- set-key-partition-list
- 进入远程工程目录
- 传递 SVN 凭据
- 传递飞书机器人配置
- 带上 REMOTE_BUILD_SESSION=1 执行 fastlane ios build
- 使用 ssh -tt 强制分配 TTY,以兼容 security / codesign / fastlane
这类对终端环境敏感的命令
6. 使用方式
6.1 本地触发远程打包
cd [工程根目录]
./scripts/remote_build.sh Debug
./scripts/remote_build.sh Release
6.2 通过 fastlane 自动转发
cd [工程根目录]
fastlane ios build
fastlane ios build configuration:Debug
fastlane ios build configuration:Release
6.3 在远程机器本机执行
cd [工程根目录]
fastlane ios build configuration:Release
该方式适合排查远程环境问题。
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 扩展无法加载
现象:
Ignoring ffi...
Ignoring json...
原因:使用了系统 Ruby。
处理:切换到 Homebrew Ruby,并确保 ~/.zshenv 正确生效。
7.2 SSH 无法连接
现象:
Connection closed by ... port 22
原因:网络不可达或未通过 Tailscale 建立连通。
处理:检查 Tailscale 登录状态与内网地址。
7.3 SSH 每次要求输入密码
原因:仍在使用带 passphrase 的旧密钥,或 SSH 配置未生效。
处理:重新生成无密码 ed25519,并配置 IdentityFile。
7.4 签名失败
现象:
errSecInternalComponent
CodeSign failed
原因:Keychain 未解锁或 codesign 无权限访问私钥。
处理:执行 set-key-partition-list,并确保 .env 中已配置
REMOTE_KEYCHAIN_PASSWORD。
7.5 已上传但 Organizer 未显示记录
原因:命令行上传不会同步写入 Organizer 历史。
处理:以 App Store Connect 的 TestFlight 页面为准。