Engineering guide

Verifiable iOS Build Artifacts on Cloud Macs

Verifiable iOS Build Artifacts on Cloud Macs

A pipeline can report success even when the downloaded IPA cannot be tied back to the commit that triggered the build. A more dangerous failure occurs when an upload is interrupted and a downstream job picks up a partially written file. On a cloud Mac, build, export, and upload steps are often handled by separate scripts. If “the command exited with status 0” is the only delivery criterion, neither problem is likely to surface during the job that created it.

The solution is not another compression command. Instead, define the artifact as an indivisible set: the IPA, SHA-256 manifest, build context, and verification results must be generated together and published only after every check passes.

Define the Deliverable First

Every job must use a unique BUILD_ID. At a minimum, the release directory should contain the following files:

File Purpose Acceptance check
App.ipa Installation and distribution artifact Hash matches and the export command succeeded
SHA256SUMS Content integrity manifest shasum -a 256 -c succeeds
build-context.tsv Trace the artifact to its source build Commit, Xcode version, and job identifier are not empty
export.log Diagnose export failures Preserve complete output and restrict access

Build context should come from variables already resolved by the pipeline, not from branch information guessed inside the script. Record at least the full commit hash, job identifier, Scheme, Xcode version, and generation time. The timestamp is useful for correlating logs, but artifact identity should still be determined by the commit hash and checksum.

A hash can prove that a file did not change between two checkpoints, but it cannot prove that the file originally came from a trusted process. Code-signing verification and build context are both required.

Verify the Archive Before Exporting

First confirm that the archive exists, then locate its .app and verify the code signature. Do not use --deep to re-sign it; the goal here is only to inspect the existing signature structure. If the app contains extensions, the verification command should cover nested code, and any failure must block the release.

Keep the archive, export directory, and final directory on the same working volume. This is what makes the final directory rename atomic. When running on a MiniRent cloud Mac, give every concurrent job its own root directory as well. Two jobs must never share a fixed path such as out/latest.

A Script You Can Adapt Directly

set -euo pipefail

: "${BUILD_ID:?BUILD_ID is required}"
: "${GIT_COMMIT:?GIT_COMMIT is required}"
: "${SCHEME:?SCHEME is required}"

ROOT="${ARTIFACT_ROOT:-$PWD/out}"
ARCHIVE="$ROOT/input/App.xcarchive"
EXPORT_OPTIONS="$ROOT/input/ExportOptions.plist"
STAGE="$ROOT/.stage-$BUILD_ID"
FINAL="$ROOT/releases/$BUILD_ID"

rm -rf "$STAGE"
mkdir -p "$STAGE" "$(dirname "$FINAL")"
trap 'rm -rf "$STAGE"' EXIT

test -d "$ARCHIVE"
test -f "$EXPORT_OPTIONS"
test ! -e "$FINAL"

APP_PATH="$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)"
test -n "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"

xcodebuild -exportArchive \
  -archivePath "$ARCHIVE" \
  -exportPath "$STAGE/export" \
  -exportOptionsPlist "$EXPORT_OPTIONS" \
  >"$STAGE/export.log" 2>&1

IPA_PATH="$(find "$STAGE/export" -maxdepth 1 -name '*.ipa' -print -quit)"
test -n "$IPA_PATH"
cp "$IPA_PATH" "$STAGE/App.ipa"

DIGEST="$(shasum -a 256 "$STAGE/App.ipa" | awk '{print $1}')"
printf '%s  %s
' "$DIGEST" "App.ipa" >"$STAGE/SHA256SUMS"

XCODE_VERSION="$(xcodebuild -version | paste -sd ' ' -)"
printf 'build_id	%s
commit	%s
scheme	%s
xcode	%s
' \
  "$BUILD_ID" "$GIT_COMMIT" "$SCHEME" "$XCODE_VERSION" \
  >"$STAGE/build-context.tsv"

(
  cd "$STAGE"
  shasum -a 256 -c SHA256SUMS
)

mv "$STAGE" "$FINAL"

(
  cd "$FINAL"
  shasum -a 256 -c SHA256SUMS
)

The script’s ExportOptions.plist should come from a repository-controlled configuration. Do not write passwords, private keys, temporary tokens, or a complete environment-variable dump to logs or context files.

Isolate Partial Output with Atomic Publication

The essential pattern is to complete every write and validation step in a hidden staging directory, then expose the final directory with a single mv. Consumers scan only releases/ and never access .stage-*, so they cannot retrieve files that are still being generated.

This guarantee holds only within a single file system. If the release directory is mounted on another volume, mv may degrade into a copy followed by deletion. Instead, create the temporary directory on the destination volume, copy every file, rerun the hash check at the destination, and then rename the directory within that same volume. If an object store or artifact service has no directory-renaming semantics, upload to temporary keys containing the job identifier, verify the objects, and then write a small completion marker. Consumers must check for that marker before reading any artifact.

Do Not Overwrite latest

Using latest allows a retried job to overwrite a newer successful result. A more reliable design stores artifacts under immutable BUILD_ID values and maintains a separate pointer file containing only the target job identifier. Before updating the pointer, confirm that the target directory has passed validation. A rollback should update only the pointer and must not modify historical artifacts.

Keep Failures Within the Correct Stage

A signature error should stop the process before export. An export error should remain in the staging directory, and a hash error should stop publication. Verification after upload is a separate checkpoint for detecting changes caused by transfer failures, disk issues, or a script selecting the wrong file.

Common mistakes include taking the “first file” from the export directory without restricting the extension, sharing one output path across multiple Schemes, and reusing a staging directory left behind by an earlier attempt. The script should clean the current job’s staging directory when it starts, but it must not use a broad wildcard that could delete directories belonging to other jobs.

Logs need boundaries as well. Preserve the complete exit status and output from xcodebuild, but avoid printing sensitive environment data. Before submitting a support request, extract only the relevant time range and remove repository tokens, internal identifiers embedded in signing-material paths, and connection credentials.

Pre-Release Checklist

Before the pipeline marks the job as successful, verify each item:

  1. The App in the archive has passed strict code-signing verification.
  2. The IPA was exported from the current job’s archive rather than reused from an old directory.
  3. GIT_COMMIT is a full commit hash and matches the trigger record.
  4. SHA256SUMS was verified once in the staging directory and once in the final directory.
  5. The final directory is named with a unique job identifier and cannot be overwritten by a retry.
  6. Consumers read only published directories or objects with a completion marker.
  7. Logs identify the stage that failed without containing credentials or signing-material contents.

This process will not eliminate every build failure, but it separates “did the build succeed?” from “can the deliverable be trusted?” and makes both questions independently verifiable. Once the archive, validation steps, build context, and publication boundary are fixed, retries and rollbacks operate on explicitly identified objects instead of relying on a shared directory whose contents keep changing.

Frequently asked questions

Does a SHA-256 checksum prove that an iOS artifact came from a trusted builder?

No. It proves only that the file stayed unchanged between verification points. Verify the code signature as well, then retain the commit, Xcode version, job identifier, and checksum manifest together.

Why publish through a temporary directory and rename it?

A rename on the same file system exposes the completed directory in one step, so consumers cannot read a partial copy. For cross-volume transfers, copy first, verify again, and rename within the destination volume.

Dedicated cloud Mac

Run build tasks on a dedicated physical node

Choose MiniRents M4 or MiniRents M4 Pro by day, week, month, or quarter, and select a node based on your team and code repository location. Each device is a dedicated physical machine, not a virtual machine.

Choose a model and order