2025-12-05

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 关键依赖

本方案依赖以下工具与服务:

image1.png

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 上打开:

image2.png

image3.png

系统设置 → 通用 → 共享 → 远程登录

要求:

开启“远程登录”

允许用户 mac 登录

本机可通过以下命令验证:

ssh mac@localhost

5.3.7 飞书自定义机器人配置

创建自定义机器人流程:

飞书 PC 端 > 目标群聊 > 右上角「设置」 > 群机器人 > 添加机器人 >
自定义机器人 > 填写机器人名称 > 开启签名校验 > 复制 Webhook
地址和密钥


image4.png

飞书机器人配置写入
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:

image5.png

路径:

用户和访问 → 集成 → 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 钥匙串中证书访问控制权限打开

image6.png

5.10 工程fastlane 配置

5.10.1 初始化Fastlane

cd [工程根目录]

fastlane init
image7.png

选 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 页面为准。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容