同一条 CI 脚本在交互式终端运行正常,换到无人值守的云端 Mac 后却选错 Scheme,常见原因不是 Xcode 本身,而是脚本把“给人看的文字”当成了接口。系统语言、工具版本、警告插入位置甚至一个额外空格,都可能让 grep、cut 和固定行号失效。更稳妥的做法是优先读取 JSON 或 plist,把退出码、原始输出和解析结果分别保存。
先找出不稳定的文本解析
先搜索流水线中的高风险组合:
grep -RInE 'xcodebuild|simctl|system_profiler|sw_vers' ci scripts \
| grep -E 'grep|awk|sed|cut|tail|head'
并非所有管道都错误。危险的是脚本依赖自然语言,例如查找“Build Settings”、截取冒号后的第二列,或假设目标设备总在第三行。LC_ALL=C 可以减少语言差异,却不能固定字段顺序、警告内容和未来版本格式。
如果命令输出主要服务于人类阅读,就不要默认它也是稳定的机器接口。
审计时为每个读取动作写清三个问题:需要哪个字段,命令是否提供结构化格式,字段缺失时应当失败还是使用明确默认值。不要让空字符串继续流入后续归档步骤。
把构建设置转换为 JSON 契约
xcodebuild -showBuildSettings -json 可以直接返回目标构建设置。应先检查命令退出码,再解析文件,不能把标准错误混入 JSON。
set -euo pipefail
mkdir -p artifacts/environment
if ! xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings \
-json \
> artifacts/environment/build-settings.json \
2> artifacts/environment/build-settings.stderr; then
cat artifacts/environment/build-settings.stderr >&2
exit 1
fi
python3 - <<'PY'
import json
from pathlib import Path
path = Path("artifacts/environment/build-settings.json")
if not path.exists() or path.stat().st_size == 0:
raise SystemExit("build settings output is empty")
items = json.loads(path.read_text(encoding="utf-8"))
if len(items) != 1:
raise SystemExit(f"expected one target, got {len(items)}")
settings = items[0].get("buildSettings", {})
required = ["PRODUCT_BUNDLE_IDENTIFIER", "SDKROOT", "CONFIGURATION_BUILD_DIR"]
missing = [key for key in required if not settings.get(key)]
if missing:
raise SystemExit("missing build settings: " + ", ".join(missing))
print(settings["PRODUCT_BUNDLE_IDENTIFIER"])
PY
若工作区包含多个目标,不要强行要求数组长度为一。应按 target 字段选择目标,并在零匹配或多匹配时终止。这样新增扩展目标后,脚本不会悄悄读取错误配置。
用 simctl JSON 固定设备选择
模拟器列表尤其不适合按行解析。设备名称可能包含空格,同名设备也可能分布在不同运行时。先保存完整结果,再按运行时、名称和可用状态筛选:
xcrun simctl list -j devices available \
> artifacts/environment/simulators.json
python3 - <<'PY'
import json
from pathlib import Path
data = json.loads(
Path("artifacts/environment/simulators.json").read_text(encoding="utf-8")
)
matches = []
for runtime, devices in data.get("devices", {}).items():
if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
continue
for device in devices:
if device.get("isAvailable") and device.get("name") == "iPhone 16":
matches.append((runtime, device["udid"]))
if len(matches) != 1:
raise SystemExit(f"expected one matching simulator, got {len(matches)}")
print(matches[0][1])
PY
生产脚本还应把期望的运行时版本写进仓库配置,而不是自动选择“最新”。自动取最新会让系统组件更新直接改变测试基线。最终执行测试时传入 UDID,比只写设备名称更少歧义。
不要忽略空集合
“没有匹配设备”应当是明确失败。临时创建模拟器之前,先确认所需运行时确实存在;任务结束后只删除本任务创建的设备,不要清理共享设备集。并行任务最好分别设置 -derivedDataPath 和独立的 CoreSimulator 设备集。
从 plist 读取主机事实
macOS 版本信息可以直接从系统 plist 读取,避免解析 sw_vers 的标签文本:
SYSTEM_PLIST=/System/Library/CoreServices/SystemVersion.plist
product_version=$(
/usr/libexec/PlistBuddy -c 'Print :ProductVersion' "$SYSTEM_PLIST"
)
build_version=$(
/usr/libexec/PlistBuddy -c 'Print :ProductBuildVersion' "$SYSTEM_PLIST"
)
printf '%s\n' "$product_version" > artifacts/environment/macos-version.txt
printf '%s\n' "$build_version" > artifacts/environment/macos-build.txt
xcodebuild -version > artifacts/environment/xcode-version.txt
读取项目 plist 时优先使用 plutil -extract KEY raw -o - FILE。字段可能不存在时,先用 plutil -type KEY FILE 检查,并由脚本决定缺失是否允许。不要用正则处理 XML;plist 可能是二进制格式,字符串也可能经过转义。
建立可诊断的数据采集边界
结构化格式并不意味着命令必然成功。一个可靠采集步骤至少应保留四类证据:命令退出码、标准错误、未修改的原始文件,以及解析后的精简结果。原始文件用于复盘工具行为,精简结果供后续任务消费。
建议让每个采集器只负责一个对象,并把输出写入临时文件。语法与必填字段全部通过后,再用 mv 原子替换正式文件。这样命令中断时不会留下半截 JSON。
提交前检查项
合并流水线改动前,至少验证以下事项:
- 脚本没有按固定行号或自然语言标签提取字段。
- 标准输出与标准错误写入不同文件。
- JSON 与 plist 在读取前经过语法检查。
- 数组筛选对零匹配和多匹配都有明确处理。
- Xcode、macOS 构建号和选择出的设备 UDID能够随任务归档。
- 日志只记录环境事实,不输出令牌、访问资料或完整环境变量。
- 更新工具版本后先运行采集验收,再开放正式构建任务。
最终目标不是把所有命令都改成 JSON,而是建立清晰边界:机器消费结构化数据,人类阅读诊断日志。确实没有结构化接口时,也应固定命令版本、隔离语言环境、检查列数,并把解析失败当成失败,而不是继续使用一个看似合理的空值。
常见问题
设置 LC_ALL=C 后还需要结构化输出吗?
需要。固定语言只能减少部分文本差异,不能保证字段顺序、空格、提示信息和未来版本保持不变;JSON 或 plist 才适合作为自动化接口。
结构化命令失败时应该保留哪些文件?
至少保留退出码、标准错误、原始 JSON 或 plist、Xcode 版本和 macOS 构建号,并在解析前检查文件非空且语法有效。
把下一项开发任务放到云端 Mac 上
选择 Berry M4 或 Berry M4 Pro,核对五个节点、租用周期和附加选项后提交订单。