The same project may archive successfully on a developer’s Mac but fail in cloud Mac CI with a missing-resource error. One of the easiest causes to overlook is a symbolic link. What appears to be an ordinary file in the repository may actually point to a local directory, an uncommitted target, or a location outside the workspace. Xcode may not access it until copying resources, running scripts, or packaging dependencies, so the issue often surfaces late in the build while the log reports it as a routine “file not found” error.
Define Which Links Are Allowed
Before writing a scanning script, define the policy. A practical default for continuous builds usually has only three rules: links must use relative paths, resolved targets must exist, and targets must remain within the current workspace. Internal links generated by specific tools may be exempted, but each exception should match a complete relative path.
A symlink gate is not intended to eliminate links. Its purpose is to eliminate implicit dependencies on the directory layout of a particular machine.
For example, Config/current.json -> release.json can be checked out consistently with the repository, while SDK/current -> /Users/dev/SDK carries local machine state into the pipeline. Nor can ../Shared/file be judged from its text alone: it may still be inside the repository, or it may escape the working directory assigned by CI. The path must therefore be resolved and normalized before its boundary is checked.
| Check | Allow | Block |
|---|---|---|
| Link form | Relative path within the repository | Absolute path |
| Target state | Target exists and is readable | Broken or cyclic link |
| Path boundary | Resolves inside the workspace | Points outside the workspace |
| Exception rule | Complete relative path | Broad directory prefix |
Confirm Link Identity from the Git Index
Check File Modes, Not Extensions
Git records symbolic links with mode 120000, storing the link target as the file content. List them from the index first so ordinary text files and links generated during the build are not mixed into the audit.
git ls-files -s | awk '$1 == "120000" { print $4 }'
git config --show-origin --get core.symlinks || true
git status --porcelain=v1
On macOS, each checked-out object should actually be a link. If the repository status suddenly shows a link replaced by a regular file, first inspect packaging, extraction, and synchronization steps for anything that may have changed the file type. The gate should also run git diff --exit-code after a clean checkout to verify that initialization scripts have not silently modified the link text recorded in the index.
Find Untracked Targets
Committing the link itself does not mean its target has also been committed. For every Git-tracked link, confirm that the target exists, then use git ls-files --error-unmatch to determine whether an in-repository target is under version control. If the target is intentionally generated during the build, move this check after the generation step while maintaining an exact allowlist of targets permitted to be temporarily absent beforehand.
Block Broken and Out-of-Bounds Links with a Boundary Script
The following script recursively checks a specified directory. It rejects absolute links, targets that cannot be resolved, and targets that resolve outside the root directory. Save it as ci/check_symlinks.py and run it after dependency installation but before invoking xcodebuild.
import os
import sys
from pathlib import Path
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
failures = []
for path in root.rglob("*"):
if not path.is_symlink():
continue
raw = os.readlink(path)
relative = path.relative_to(root)
if os.path.isabs(raw):
failures.append((relative, "absolute", raw))
continue
try:
target = path.resolve(strict=True)
except (FileNotFoundError, RuntimeError, OSError) as error:
failures.append((relative, "broken", str(error)))
continue
try:
target.relative_to(root)
except ValueError:
failures.append((relative, "outside", str(target)))
for item in failures:
print("\t".join(map(str, item)))
sys.exit(1 if failures else 0)
Run it as follows:
python3 ci/check_symlinks.py "$PWD"
Do not compare paths with a simple string-prefix test. /tmp/job-10 has /tmp/job-1 as a string prefix, but it is not a child directory of /tmp/job-1. Normalize paths and compare their hierarchy to avoid this type of false result.
Check Once Before the Build and Again Afterward
A source scan can only detect problems already present in the repository. Dependency managers, code generators, and custom Build Phases may create additional links, so two checkpoints are recommended.
Check the Workspace Before the Build
After dependency resolution and code generation finish, scan the entire workspace while excluding cache directories that are clearly internal to tools and cannot enter the final artifact. Exclusion rules must also be committed to the repository; temporary environment variables on the runner must not be able to broaden them arbitrarily. Then record the link inventory so any links added during the build can be identified.
find "$PWD" -type l -print | LC_ALL=C sort > "$TMPDIR/symlinks-before.txt"
python3 ci/check_symlinks.py "$PWD"
Check the Artifact After the Build
After archiving, run the same script again against the exported .app or the root of the unpacked artifact. A valid link must both keep its target inside the artifact and match an allowed path. If a third-party framework legitimately contains internal links, allow the specific paths rather than permitting the entire Frameworks directory.
Next, compare the link inventories from before and after the build. Any new link created by a script phase should map to a specific generation command. If a new entry cannot be explained, block the release first and then use the build log to identify what created it.
Handle Common False Positives and Enforce the Gate
The most common false positives come from temporary directories, dependency caches, and internal archive structures. Handle them in this order: narrow the scan root first, add precise exceptions second, and only then consider excluding directories. Do not skip an entire dependency tree merely because one dependency creates links, or genuine broken links will be hidden as well.
When the gate fails, it should output at least the link’s relative path, the failure type, and the resolved target, and it should pass the scanning script’s exit code directly to CI. After fixing the issue, verify it from a fresh checkout rather than rerunning only in a workspace where initialization scripts have already run. The final checklist can be reduced to six items: correct Git mode, version-controlled targets, relative link paths, no boundary escapes after resolution, explainable links added during the build, and exact matches for archive exceptions. Once all six are enforced, symbolic links stop being intermittent environment issues and become an auditable engineering contract.
Frequently asked questions
Why can a symlink work locally but fail on a cloud Mac runner?
The link may depend on an absolute local path, point to an untracked file, or assume a checkout directory that differs on the runner. Validate both the stored link text and its resolved destination.
Should a CI policy reject every symlink in the repository?
No. Stable relative links that resolve inside the repository can be valid. Reject broken links, absolute targets, and workspace escapes, then allow only narrowly defined exceptions.
How should legitimate symlinks inside an archive be handled?
Require every resolved target to remain inside the archive root and match exceptions by exact relative path. Broad filename or directory-prefix allowlists make the gate too easy to bypass.
Choose a Cloud Mac for Your Workload
Review the chip, memory, storage, rental term, and node, then order a dedicated Apple Silicon physical machine.