Fix umask and permission drift in Cloud Mac CI

Fix umask and permission drift in Cloud Mac CI

A build script may run normally in an SSH terminal but fail with Permission denied when handed off to Cloud Mac CI. The most common misdiagnosis is that running chmod +x one more time will fix it. The real problem usually spans four layers: the umask at the job entry point, the executable bit stored in the Git index, the permission semantics of copy and extraction tools, and the interpreter actually used by the script. Fixing one file in the working tree is not enough—the problem will return after the next clean checkout.

Classify permission failures into four categories first

Before troubleshooting, record which object failed and what operation was attempted. A script that cannot execute, an unwritable directory, an overly permissive key, and mode changes after archive extraction all require completely different approaches.

Use stat to inspect the octal mode, owner, and file type together:

target="${1:?path required}"
stat -f 'mode=%Sp octal=%OLp owner=%Su group=%Sg type=%HT path=%N' "$target"
ls -ldeO@ "$target"

The second command also reveals ACLs, file flags, and extended attributes. If the standard permissions appear correct but the operation still fails, do not escalate privileges immediately. First confirm that every parent directory can be traversed, that the volume containing the file permits execution, and which account the process is actually using.

rwx is not the only criterion. Every directory in the path needs the appropriate permissions, and ACLs can override standard mode bits. Repeatedly using sudo only creates a mixed-ownership working tree, making later jobs harder to reproduce.

Capture the minimum diagnostic evidence

At a minimum, retain the output of id, umask, the current directory, the target file’s stat results, and mount information. Do not dump the entire environment into the logs because it may contain tokens. Diagnostic evidence should focus on identity, paths, and permissions without collecting credential contents.

Set umask explicitly at the job entry point

umask affects only newly created objects; it does not update existing files. A common baseline of 022 makes regular files default to 644 and directories to 755. A controlled directory shared by members of the same group may warrant 002, but that is not a universal fix. Set the value explicitly at the job entry point and verify it with newly created probes:

set -euo pipefail
umask 022

probe_dir="$(mktemp -d)"
probe_file="$probe_dir/probe"
: > "$probe_file"

file_mode="$(stat -f '%OLp' "$probe_file")"
dir_mode="$(stat -f '%OLp' "$probe_dir")"

test "$file_mode" = "644"
test "$dir_mode" = "700"
rm -rf "$probe_dir"

To protect temporary contents, mktemp -d normally creates a 700 directory. It therefore cannot be used to assert that an ordinary directory should have mode 755. To test the directory baseline, run mkdir under a known parent directory and inspect it separately. This distinction is also a frequent source of false positives in permission tests.

Make Git preserve the intended executable state

Git mainly tracks whether a file is executable; it does not preserve the full set of Unix permissions. Manually changing a script to 755 on one machine does not mean the index has recorded that state. Inspect the index rather than looking only at the working tree:

git ls-files --stage |
awk '$1 == "100755" {print $4}' |
sort

To commit the executable bit, use:

git update-index --chmod=+x scripts/build.sh
git diff --summary
git diff --cached --summary

Conversely, if a configuration file or document accidentally has the executable bit set, correct it with git update-index --chmod=-x. For .sh files, inspect the first line as well. Use an interpreter known to exist in the environment, such as #!/bin/zsh or #!/usr/bin/env bash, and make sure the script does not use CRLF line endings.

Validate scripts in the merge gate

Maintain an explicit allowlist of executable scripts and compare it with the index results. Do not impose a blanket rule that every .sh file must be executable, because library files loaded with source may not need the executable bit. The policy should express how a file is used rather than infer its purpose from the extension.

Control permission semantics during copying and extraction

cp, ditto, rsync, and different archive formats do not handle mode bits, ACLs, and extended attributes in the same way. Build caches should contain only reproducible data, while release packages should undergo another permission check after extraction.

Before copying a working tree, decide whether metadata needs to be preserved. If only the source contents are needed, avoid unintentionally inheriting old ownership, ACLs, or extended attributes. If the executable bit must be retained, perform a round-trip test in a temporary directory:

src="scripts/build.sh"
tmp="$(mktemp -d)"
ditto "$src" "$tmp/build.sh"

before="$(stat -f '%OLp' "$src")"
after="$(stat -f '%OLp' "$tmp/build.sh")"
test "$before" = "$after"

rm -rf "$tmp"

Archive validation must check more than whether files exist. At a minimum, verify that the entry-point script is executable, ordinary configuration files are not executable, and sensitive files cannot be read by group members or other users. Always extract into a new directory so that modes on existing files cannot conceal problems in the archive itself.

Turn the fix into an enforceable policy

A durable solution is not to run chmod -R 777 after every failure. Instead, define a small set of understandable assertions. The following check rejects files writable by other users and confirms that the entry-point script is executable:

set -euo pipefail

entry="scripts/build.sh"
test -f "$entry"
test -x "$entry"

while IFS= read -r -d '' file; do
  mode="$(stat -f '%OLp' "$file")"
  other_write=$((8#$mode & 2))
  if (( other_write != 0 )); then
    printf 'world-writable file: %s mode=%s\n' "$file" "$mode" >&2
    exit 1
  fi
done < <(find . -type f -not -path './.git/*' -print0)

Keep the policy script in the repository so that local preflight checks and CI use the same implementation. Failure output should include only the path, expected mode, and actual mode. That provides enough information for diagnosis without exposing file contents. When jobs run on RentMini dedicated physical nodes, rerun these assertions in every new working tree rather than assuming that the previous job left the environment unchanged.

Finish with a clean-checkout acceptance test: remove the temporary working tree, clone the repository again, apply no manual fixes, and run the permission checks and minimal build directly. The permission fix is truly under version control only when this path succeeds, rather than existing solely in one remote session.

Frequently asked questions

Why does CI still report Permission denied after chmod +x?

Verify that chmod targeted the file actually being executed, that its volume permits execution, that the shebang interpreter exists, and that a later copy or extraction step did not remove the executable bit.

Which umask should a Cloud Mac CI job use?

022 is a practical baseline for many single-user build jobs. Consider 002 only for controlled group-writable workspaces, and always validate the resulting file modes rather than trusting the setting alone.

How can permission drift be blocked before merge?

Check executable bits in the Git index, validate script shebangs, inspect sensitive file modes, and test extracted artifacts. Return a nonzero status whenever the declared policy is violated.

Use a cloud Mac on demand

Get a dedicated physical Mac mini ready for your next build

Choose an M4 configuration, rental term, and region. Each order includes a dedicated physical device; actual availability is confirmed in real time by the console.

Choose a configuration and rent