A build script may run correctly on a developer’s machine but fail on a cloud Mac with Permission denied, bad interpreter, or an inexplicable $'\r' at the end of a line. These failures usually have nothing to do with application code. Instead, the Git file mode, line endings, and actual interpreter do not form a consistent contract. First record the failed command, commit hash, and script path. Then work through the checks below in order; this is usually faster than repeatedly clearing caches.
Identify Which Layer Is Failing First
Do not immediately add chmod +x whenever a script fails. A shell script has at least four relevant layers of state: permissions in the working tree, the mode recorded in the Git index, the file’s actual bytes, and the way the runner invokes it.
script="ci/build.sh"
ls -l "$script"
git ls-files -s -- "$script"
file "$script"
head -n 1 "$script" | cat -vet
In the output of git ls-files -s, 100755 in the first column means Git records the file as executable, while 100644 means it is a regular file. If cat -vet shows ^M at line endings, the file contains CRLF. Also verify that the first line is a valid shebang, such as #!/bin/zsh or #!/usr/bin/env bash.
The invocation method must also be recorded. bash ci/build.sh does not require the file to have its executable bit set, whereas ./ci/build.sh checks its permissions and reads the shebang. If an Xcode build phase, a task orchestration script, and a local terminal invoke the file differently, the problem may appear to occur “only in CI.”
The goal is not to make one job pass by chance. The repository should explicitly define which interpreter runs each script, whether the script may be executed directly, and which line endings it must use.
Correct the Executable Bit in the Git Index
Entrypoint scripts that are run directly should have their executable bit recorded. Fragments that are sourced or passed explicitly to an interpreter should normally remain 100644. Start by listing the entrypoints so that you do not make every .sh file executable.
git update-index --chmod=+x ci/build.sh
git update-index --chmod=+x ci/test.sh
git diff --summary
git ls-files -s -- ci/build.sh ci/test.sh
If git diff --summary does not show a mode change, check whether permission tracking is disabled in the local repository:
git config --get core.fileMode
git config core.fileMode true
core.fileMode only affects how the current working tree detects changes. The Git index is what actually propagates through commits. Do not treat chmod -R +x . at runner startup as a long-term solution. It hides repository errors and may also grant unnecessary permissions to configuration files, secret material, or ordinary data files.
You can list scripts that may be executed directly, one per line, in ci/entrypoints.txt. Validation then needs to inspect only that list. Every new entrypoint must update the registry at the same time, allowing code reviewers to see why the permission boundary changed.
Enforce LF and Normalize Existing Files Carefully
The .gitattributes file at the repository root should be the single source of truth for line-ending rules. The following baseline works for common build-related files:
*.sh text eol=lf
*.command text eol=lf
*.zsh text eol=lf
*.xcconfig text eol=lf
*.pbxproj text eol=lf
*.png binary
*.zip binary
Adding these rules does not automatically rewrite files that have already been committed. Run the following commands on a clean branch:
git add --renormalize .
git status --short
git diff --check
git diff --cached --stat
Review the changes first, then commit the normalization separately instead of mixing it with functional changes. If a large number of project files change at once, spot-check them to confirm that only line endings were modified. Archives, code generators, and external scripts may still reintroduce CRLF, so .gitattributes is not a substitute for a validation gate.
| Check result | Common cause | Correct action |
|---|---|---|
100644 and executed directly |
The index does not record the executable bit | Correct it with git update-index --chmod=+x |
^M appears at line endings |
The file uses CRLF | Normalize it to LF and commit the change |
bad interpreter |
The shebang contains CR or specifies an invalid path | Correct the first line and verify that the interpreter exists |
| Works locally but fails in CI | The invocation method or shell differs | Print the invocation command and select the shell explicitly |
Make Interpreter Differences an Explicit Contract
The default interactive shell on macOS is not necessarily the shell that CI actually uses. A script that relies on Bash arrays, [[ ]], or process substitution should not be interpreted implicitly by sh. Scripts that depend on zsh syntax should declare that directly as well.
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
Do not rely on interactive configuration files to provide PATH, aliases, or environment variables. A CI entrypoint should set its own minimal PATH and verify that required tools exist with commands such as command -v xcodebuild and command -v ruby. Required environment variables should be injected by the task configuration; the script should check only their names and never echo sensitive values.
Also avoid invoking a file with a Bash shebang by running sh script.sh, because an explicit sh bypasses the shebang. Jobs on DebugMac nodes should likewise follow the repository’s declarations rather than infer the execution environment from whichever shell happened to be available in a particular session.
Add Both Pre-Commit and CI Gates
Local hooks shorten the feedback cycle, but they cannot be the only defense because ordinary clones do not enable them automatically. CI should run lightweight checks before dependency installation and the Xcode build begin.
#!/usr/bin/env bash
set -euo pipefail
while IFS= read -r script; do
[[ -z "$script" ]] && continue
mode="$(git ls-files -s -- "$script" | awk '{print $1}')"
[[ "$mode" == "100755" ]] || {
printf 'entrypoint is not executable: %s\n' "$script" >&2
exit 1
}
done < ci/entrypoints.txt
if git grep -Il $'\r' -- '*.sh' '*.zsh' '*.command' > /tmp/crlf-files; then
printf 'CRLF detected:\n' >&2
cat /tmp/crlf-files >&2
exit 1
fi
The gate should also verify that every path in the registry exists and reject uncommitted normalization changes. Final acceptance can follow four checks: start from a fresh checkout, run the entrypoint script directly, confirm that git status --porcelain is empty, and complete a minimal build in a non-interactive session. This exposes permission and line-ending problems before expensive dependency downloads and compilation begin, without relying on ad hoc runner modifications to keep jobs working.
Frequently asked questions
Does a script need the executable bit when CI invokes it with bash?
Not for bash script.sh, but direct execution, build phases, and some runners require it. Track real entry points as mode 100755 in the Git index.
Will adding .gitattributes repair existing CRLF files?
No. Run git add --renormalize ., review the diff, and commit the normalized files. Keep a CI scan because generated or imported scripts can reintroduce CRLF.
Run your next build on a dedicated Apple Silicon physical node
Choose a rental term and node region, review the Mac Mini M4, 16GB RAM, 256GB SSD, and USD amount due, then place your order.