The same CI script may work perfectly in an interactive terminal yet select the wrong Scheme on an unattended cloud Mac. The usual cause is not Xcode itself, but a script that treats human-readable text as an interface. The system language, tool version, placement of warnings, or even one extra space can break grep, cut, and fixed line-number assumptions. A more reliable approach is to prefer JSON or plist data and preserve the exit code, raw output, and parsed result separately.
Find fragile text parsing first
Start by searching the pipeline for high-risk combinations:
grep -RInE 'xcodebuild|simctl|system_profiler|sw_vers' ci scripts \
| grep -E 'grep|awk|sed|cut|tail|head'
Not every pipeline is wrong. The risky cases are those in which a script depends on natural-language output—for example, by searching for “Build Settings,” taking the second field after a colon, or assuming the target device is always on the third line. LC_ALL=C can reduce language differences, but it cannot lock down field order, warning content, or the output format of future versions.
If a command’s output is primarily intended for humans, do not assume it is also a stable machine interface.
During the audit, document three points for every read operation: which field is required, whether the command provides a structured format, and whether a missing field should cause failure or use an explicit default. Do not allow an empty string to flow into later archival steps.
Turn build settings into a JSON contract
xcodebuild -showBuildSettings -json can return the target’s build settings directly. Check the command’s exit code before parsing the file, and do not mix standard error into the 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
If the workspace contains multiple targets, do not force the array length to equal one. Select the intended target by its target field, and stop when there are zero or multiple matches. This prevents the script from silently reading the wrong configuration after a new extension target is added.
Make device selection deterministic with simctl JSON
Simulator lists are especially unsuitable for line-oriented parsing. Device names can contain spaces, and identically named devices may exist under different runtimes. Save the complete result first, then filter it by runtime, name, and availability:
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
Production scripts should also keep the expected runtime version in repository configuration rather than automatically selecting the “latest” one. Automatically choosing the newest runtime allows system component updates to change the test baseline directly. When the tests are ultimately run, passing the UDID is less ambiguous than specifying only the device name.
Do not ignore an empty device set
“No matching device” should be an explicit failure. Before creating a temporary simulator, confirm that the required runtime actually exists. When the job finishes, delete only the devices created by that job; do not clean up the shared device set. Parallel jobs should preferably use separate -derivedDataPath values and independent CoreSimulator device sets.
Read host facts from plist files
The macOS version can be read directly from the system plist, avoiding any need to parse the textual labels produced by 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
When reading a project plist, prefer plutil -extract KEY raw -o - FILE. If a field may be absent, check it first with plutil -type KEY FILE, then let the script decide whether the missing field is acceptable. Do not process XML with regular expressions: plist files may be binary, and their strings may contain escaped characters.
Establish a diagnosable data-collection boundary
A structured format does not guarantee that the command will succeed. A reliable collection step should preserve at least four kinds of evidence: the command’s exit code, standard error, the unmodified raw file, and the reduced parsed result. The raw file supports later investigation of tool behavior, while downstream jobs consume the reduced result.
Each collector should ideally handle only one object and write its output to a temporary file. After the syntax and all required fields have been validated, use mv to atomically replace the final file. This prevents an interrupted command from leaving behind a partial JSON document.
Pre-merge checklist
Before merging pipeline changes, verify at least the following:
- The script does not extract fields using fixed line numbers or natural-language labels.
- Standard output and standard error are written to separate files.
- JSON and plist syntax is validated before the data is read.
- Array filtering explicitly handles both zero matches and multiple matches.
- The Xcode version, macOS build number, and selected device UDID can be archived with the job.
- Logs record only environment facts and do not expose tokens, access credentials, or complete environment variables.
- After updating tool versions, run collection acceptance checks before enabling production build jobs.
The ultimate goal is not to convert every command to JSON, but to establish a clear boundary: machines consume structured data, while humans read diagnostic logs. When no structured interface exists, pin the command version, isolate the locale, validate the column count, and treat parsing failure as a failure instead of continuing with an empty value that merely looks plausible.
Frequently asked questions
Is setting LC_ALL=C enough for reliable parsing?
No. It reduces some language differences but does not stabilize field order, whitespace, warnings, or format changes across tool versions. Prefer JSON or plist interfaces.
What should be retained when a structured command fails?
Retain the exit code, stderr, raw JSON or plist, Xcode version, and macOS build number. Also verify that the output file is nonempty and syntactically valid before parsing it.
Move your next development task to a cloud Mac
Choose Berry M4 or Berry M4 Pro, review the five nodes, rental period, and add-ons, then place your order.