After a CI node finishes archiving a build, the artifact must be handed off to testing, release, or artifact-retention jobs. The obvious approach is to use an SMB share directly as the workspace so that multiple cloud Macs can read and write the same project. This may appear to work for small repositories, but once the network becomes unstable, file locking is delayed, or the mount fails, Xcode intermediate files can be corrupted. Output may even be written to an empty local directory that has the same name as the mount point.
A safer boundary is to use SMB only for receiving input snapshots and final artifacts. Source extraction, dependency resolution, DerivedData, and temporary files should all remain on local storage.
Separate the three storage layers
Each job needs at least three paths with clearly separated roles:
- Read-only input area: stores source snapshots or dependency packages pinned to specific versions.
- Local workspace: contains the checked-out source, DerivedData, test results, and temporary files.
- Remote delivery area: stores only archives, log summaries, and manifests that have already passed verification.
Never let two concurrent jobs share DerivedData. Even when they build the same commit, they may update the index, module cache, and build database at the same time. Include a unique job identifier in the path, such as ~/ci-work/$RUN_ID, and clean it up according to the retention policy after the job finishes.
Treat the network share as a delivery boundary, not as a transparent replacement for local storage. Build success and upload success must also be tracked as two separate states.
Manage the SMB mount with a persistent process
Unattended jobs should not construct command-line arguments containing usernames and passwords. Instead, a controlled persistent process should mount SMB in advance and read credentials from a separate keychain or restricted configuration. Regular build jobs should receive only the minimum write permissions required for the destination directory.
At job startup, checking whether the directory exists is not enough. After SMB disconnects, the mount point may fall back to an ordinary local directory. At a minimum, verify the file system type, write access, and a small-file round trip:
set -euo pipefail
SHARE_ROOT="/Volumes/ci-artifacts"
PROBE="$SHARE_ROOT/.probe-${RUN_ID}"
fs_type="$(stat -f '%T' "$SHARE_ROOT")"
test "$fs_type" = "smbfs"
test -w "$SHARE_ROOT"
printf '%s\n' "$RUN_ID" > "$PROBE"
test "$(cat "$PROBE")" = "$RUN_ID"
rm -f "$PROBE"
Any failed check should terminate the job. Do not automatically create the mount point and continue, because a single share disconnection could otherwise become a “successful build” whose artifact exists only on the local machine.
Keep build activity on local storage
First sync the pinned commit into a local temporary directory, then resolve dependencies and run the build. The job directory, DerivedData, and result bundle should all include a unique job identifier so that parallel jobs cannot overwrite one another.
LOCAL_ROOT="$(mktemp -d "$TMPDIR/vmcache-ci.XXXXXX")"
trap 'rm -rf "$LOCAL_ROOT"' EXIT
rsync -a --delete "$SOURCE_SNAPSHOT/" "$LOCAL_ROOT/repo/"
xcodebuild \
-workspace "$LOCAL_ROOT/repo/App.xcworkspace" \
-scheme App \
-configuration Release \
-derivedDataPath "$LOCAL_ROOT/DerivedData" \
-resultBundlePath "$LOCAL_ROOT/TestResults.xcresult" \
build
SOURCE_SNAPSHOT should refer to an immutable commit, not an active directory that other jobs can continue modifying. If the input comes from a shared volume, verify the commit ID or manifest before starting the sync.
Do not cache all of DerivedData
Copying an entire DerivedData directory between jobs is usually not worth the cost. The cache is large, while internal paths and build settings may change. A more maintainable approach is to cache only dependency download directories that are explicitly safe to reuse, and include the Xcode version, architecture, and lockfile digest in the cache key. Regenerate the cache when the key does not match instead of attempting an approximate reuse.
Publish atomically through a temporary name
Copying directly to the final filename exposes an incomplete artifact. A downstream job may begin reading as soon as the file appears, even though the transfer is still in progress. The correct sequence is to package locally, calculate the digest locally, copy to a temporary name on the shared volume, verify it there, and finally rename it within the same directory.
ARTIFACT="$LOCAL_ROOT/App-release.zip"
ditto -c -k --norsrc "$LOCAL_ROOT/DerivedData/Build/Products/Release" "$ARTIFACT"
REMOTE_DIR="$SHARE_ROOT/releases/$GIT_COMMIT"
REMOTE_TMP="$REMOTE_DIR/.App-release.zip.${RUN_ID}.partial"
REMOTE_FINAL="$REMOTE_DIR/App-release.zip"
mkdir -p "$REMOTE_DIR"
cp "$ARTIFACT" "$REMOTE_TMP"
local_hash="$(shasum -a 256 "$ARTIFACT" | awk '{print $1}')"
remote_hash="$(shasum -a 256 "$REMOTE_TMP" | awk '{print $1}')"
test "$local_hash" = "$remote_hash"
mv "$REMOTE_TMP" "$REMOTE_FINAL"
printf '%s %s\n' "$remote_hash" "App-release.zip" \
> "$REMOTE_DIR/SHA256SUMS.${RUN_ID}"
For the rename to be atomic, the temporary and final files must reside on the same shared volume, preferably in the same directory. Do not write locally and then use mv across volumes, because a cross-volume move degrades into a copy followed by a deletion.
Make failures observable and cleanable
If an upload fails, retain the local build output for a controlled period so that the transfer can be retried, but do not mark the job as fully delivered. Record build_completed, artifact_verified, and publish_completed separately, and allow downstream jobs to accept only the final state.
Also remove .partial files that have exceeded the retention period on a regular schedule. The cleanup job must validate the filename format and modification time, and it should process only the temporary suffix without scanning or deleting final artifacts. If the shared volume becomes read-only, verification repeatedly fails, or the connection keeps dropping, pause the publishing queue and preserve the local output and mount diagnostics first.
When implementing this workflow on VMCache cloud Macs, the key is not any specific mount command but a clear boundary: the network volume handles exchange, local storage handles builds, and a final filename represents only an artifact that has completed verification. With these roles separated, even a brief SMB failure will not mix build state with delivery state.
Frequently asked questions
Should Xcode DerivedData live on an SMB share?
No. DerivedData creates many small files, locks, and metadata updates that are sensitive to network latency. Keep it in a job-specific local directory and apply an explicit cleanup or retention policy.
How can downstream jobs avoid reading a partially copied artifact?
Copy the artifact to a temporary name on the share, write its checksum and completion manifest, then rename it within the same directory. Consumers should accept only the final name and matching manifest.
What should the job do when the mount directory exists but SMB is disconnected?
Verify the filesystem type and perform a small write-and-read test before building. Fail immediately if either check fails, preventing output from being written into a local directory that merely has the expected path.
Choose a cloud Mac for builds, testing, and MLX inference
Choose your M4 model, memory, storage, node, and rental term for your workload. Every rental includes a dedicated physical machine, not a virtual machine.