After adding tee to capture build logs, terminal output becomes complete, yet CI may occasionally report an obviously failed xcodebuild run as successful. The greatest risk is not a missing log, but the pipeline continuing to upload, notify, or deploy. The cause is usually straightforward: by default, the shell returns the exit code of the final command in a pipeline. If tee writes successfully, its status can mask the earlier xcodebuild failure.
First, confirm how the error is being swallowed
This pattern is common:
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "$CI_DESTINATION" \
2>&1 | tee build.log
The pipeline contains two processes. The process on the left runs the tests, while the one on the right writes the output to both a file and standard output. In Bash without pipefail enabled, the entire command uses the status returned by tee by default. Even if the tests return a nonzero value, the script may still see 0 as long as the log was written successfully.
Start with a minimal check on the cloud Mac:
/bin/bash -c 'exit 65' | tee /tmp/pipeline-check.log
printf 'pipeline=%s\n' "$?"
If the output is pipeline=0, the current script can produce a false success. Do not rely on checking the log for error: because output formats vary across compilers, test frameworks, and custom scripts. Exit codes are the stable interface CI uses to determine whether a job passed or failed.
Logs explain a failure; exit codes declare it. Preserve them separately rather than replacing process status with text searches.
Pin the shell, then handle pipeline status
The default interactive shell on macOS may differ from the interpreter that actually runs CI scripts. Bash uses PIPESTATUS, while zsh uses pipestatus, and their array indexes differ as well. The most reliable approach is to declare the interpreter in a repository script and have the pipeline execute that file directly instead of passing multiline commands to an unspecified shell.
#!/bin/bash
set -uo pipefail
The -u option catches undefined variables, while pipefail makes the whole pipeline return a nonzero status if any command in it fails. However, pipefail alone is not precise enough. If tee fails because of a disk or permission problem while xcodebuild succeeds, the final $? only shows that the pipeline failed; it does not identify which stage failed.
Copy PIPESTATUS immediately after the pipeline finishes. Any assignment, conditional check, or logging command can refresh it. Reading it even one command later may return the status of a different command.
Write a reusable xcodebuild wrapper
The following script preserves the raw log, the result bundle, and the actual xcodebuild exit code. It temporarily disables errexit so the shell does not exit before the statuses have been saved.
#!/bin/bash
set -uo pipefail
run_id="${CI_RUN_ID:-local-$(date +%s)}"
artifact_dir="${CI_ARTIFACT_DIR:-$PWD/artifacts/$run_id}"
result_path="$artifact_dir/TestResults.xcresult"
log_path="$artifact_dir/xcodebuild.log"
mkdir -p "$artifact_dir"
set +e
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$CI_DESTINATION" \
-resultBundlePath "$result_path" \
2>&1 | tee "$log_path"
pipeline_status=("${PIPESTATUS[@]}")
xcode_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"
set -e
printf 'xcodebuild_exit=%s\n' "$xcode_status"
printf 'tee_exit=%s\n' "$tee_status"
if (( tee_status != 0 )); then
printf 'Log capture failed\n' >&2
fi
exit "$xcode_status"
CI_DESTINATION should be passed explicitly by the job configuration, for example by binding it to a simulator destination that has already been validated. Do not loosely match the first available device in the script, because a runtime update could cause it to select a different system version. Quote every path variable so arguments are not split when the working directory contains spaces.
This version uses the xcodebuild status as the final job status. If a failure to save the log must also block the job, add a check before exiting: return failure when either status is nonzero, while writing both statuses separately to the job summary.
Treat the result bundle as diagnostic evidence
Console text is convenient for quick inspection, while xcresult preserves the test hierarchy, failure attachments, activity logs, and diagnostic information. They serve different purposes, so archive both.
| Artifact | Primary purpose | Preserve on failure |
|---|---|---|
xcodebuild.log |
Search compiler output and script logs | Yes |
TestResults.xcresult |
Inspect tests, attachments, and activity logs | Yes |
exit-status.txt |
Record the status of each pipeline stage | Yes |
| Temporary DerivedData | Retain briefly when an on-the-spot reproduction is needed | According to job policy |
To generate a machine-readable summary, use the system-provided xcresulttool. Check that the result bundle actually exists before parsing it so a parsing failure does not overwrite the original build status.
if [[ -d "$result_path" ]]; then
xcrun xcresulttool get test-results summary \
--path "$result_path" \
> "$artifact_dir/test-summary.json" || true
fi
printf 'xcodebuild=%s\ntee=%s\n' \
"$xcode_status" "$tee_status" \
> "$artifact_dir/exit-status.txt"
Here, || true allows archiving to continue only if summary generation fails. It must not be placed after the main xcodebuild command. A failure in an auxiliary report must not erase the saved primary status, and a failed primary build must not be rewritten as successful by an auxiliary command.
Validate failure scenarios, not just green builds
Before merging the wrapper, run at least these four categories of validation:
Compilation failure
Temporarily reference a symbol that does not exist. Expect xcodebuild to return a nonzero status, CI to fail, and the log to exist. Whether a result bundle is created depends on the stage at which the failure occurs.
Test failure
Add an assertion that always fails. Compilation should complete, but the test job should fail. The failing test case should be identifiable in xcresult, and the pipeline must not continue down the success path.
Log write failure
Point the artifact directory at a non-writable location and confirm that tee_status is nonzero. If the team requires complete diagnostic artifacts before delivery, this scenario should also block the job.
Cancellation and timeout
Have CI send a termination signal, confirm that no subsequent release steps run, and verify that child processes are cleaned up. Do not turn a timeout into an ordinary test failure; the two have different meanings for retry policies and incident analysis.
Finally, inspect the job definition itself. Diagnostic artifact upload steps may be configured to run after a failure, but deployment, distribution, and success notifications must depend on the real status returned by the wrapper. This ensures that even if the output filter, archiving tool, or summary generator changes, an xcodebuild failure still reaches CI through one verifiable path.
Frequently asked questions
Why read PIPESTATUS when pipefail is already enabled?
pipefail reports that a pipeline command failed, but it does not identify the xcodebuild status unambiguously. Reading PIPESTATUS[0] immediately preserves the first command’s exact exit code.
How should the wrapper be tested?
Run separate cases with a compiler error, a failing test assertion, and an unwritable log directory. Every case must fail the job while retaining the expected logs and xcresult bundle.
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.